mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-06 22:14:51 +00:00
Compare commits
121 Commits
refactor/e
...
brendan/el
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3444c6e9f5 | ||
|
|
0d28d22289 | ||
|
|
517e6c9aa4 | ||
|
|
a4a9ea4ab0 | ||
|
|
eaa272ef7f | ||
|
|
70b636a360 | ||
|
|
a8fd0159be | ||
|
|
bd9a6a2f5d | ||
|
|
6a28a5f63c | ||
|
|
6073a630c8 | ||
|
|
9649146641 | ||
|
|
342436dfc4 | ||
|
|
77a462c930 | ||
|
|
9965d385de | ||
|
|
f0f1e51c5c | ||
|
|
4712c18a58 | ||
|
|
9e156ea168 | ||
|
|
68f4aa220e | ||
|
|
3a0e00dd7f | ||
|
|
66b4e5e020 | ||
|
|
8b8d4fa066 | ||
|
|
529a4d5e72 | ||
|
|
751d42d6a6 | ||
|
|
0b93d2d8fb | ||
|
|
5079bf863e | ||
|
|
57f7d39281 | ||
|
|
282ab0f67d | ||
|
|
ada7b11fd6 | ||
|
|
a6bff14a78 | ||
|
|
37ff5aaa5c | ||
|
|
b77c797c0f | ||
|
|
3eeeec359a | ||
|
|
65e786258a | ||
|
|
cbc40a5981 | ||
|
|
b9b210a864 | ||
|
|
d473b7e971 | ||
|
|
f5783c4313 | ||
|
|
9439a5647e | ||
|
|
2bfe81ee5c | ||
|
|
fcf1bb010c | ||
|
|
08b6d9c6dc | ||
|
|
0293a8bb80 | ||
|
|
850dbb93eb | ||
|
|
48e867ee20 | ||
|
|
b5ebc541b9 | ||
|
|
bd7a4cec90 | ||
|
|
63af295a17 | ||
|
|
0a53f8e084 | ||
|
|
6f5b2f786e | ||
|
|
04954a9620 | ||
|
|
fb63fd79a3 | ||
|
|
2e04b66eab | ||
|
|
f0b7c8c374 | ||
|
|
be6f59035a | ||
|
|
27ab51f490 | ||
|
|
bca723e8fe | ||
|
|
1ac39718d8 | ||
|
|
190319fb56 | ||
|
|
3154f0a61c | ||
|
|
0b686b8178 | ||
|
|
4cba56171b | ||
|
|
66342acd31 | ||
|
|
88dae67549 | ||
|
|
0ec42582f3 | ||
|
|
4f82248a68 | ||
|
|
5e069aab97 | ||
|
|
5325b2ec99 | ||
|
|
2a98920922 | ||
|
|
5ea92ea6cb | ||
|
|
a18528a7ee | ||
|
|
ced125a974 | ||
|
|
655fe20beb | ||
|
|
dd0c258e23 | ||
|
|
791e27d289 | ||
|
|
fac0aec69f | ||
|
|
ca26e639f6 | ||
|
|
0b5d54f2cb | ||
|
|
1b408cf06b | ||
|
|
8e102d19ed | ||
|
|
721b2406e9 | ||
|
|
4a6a18cd79 | ||
|
|
c10b5880cc | ||
|
|
e6bf83084c | ||
|
|
6722ee22ee | ||
|
|
870a5731ac | ||
|
|
7910ce5d36 | ||
|
|
6ad171dba9 | ||
|
|
cb5674edc7 | ||
|
|
b99de4118e | ||
|
|
040700dbc4 | ||
|
|
4d5da9697e | ||
|
|
a28648f530 | ||
|
|
4d81e2d4d9 | ||
|
|
21e72cbf42 | ||
|
|
5f277d1e62 | ||
|
|
d67e877e28 | ||
|
|
d4e51e04b3 | ||
|
|
070c1679e4 | ||
|
|
406d216cd2 | ||
|
|
5dc8b4ef29 | ||
|
|
2f41d89163 | ||
|
|
b2eae867a1 | ||
|
|
3c2fda4d91 | ||
|
|
2678ceb45e | ||
|
|
58a4cd00b6 | ||
|
|
0faa191b6d | ||
|
|
58cf092105 | ||
|
|
0ff8bfe1d9 | ||
|
|
ceb79c786a | ||
|
|
b1a15d559b | ||
|
|
124a8abf9b | ||
|
|
85c2bb342b | ||
|
|
4c57e39466 | ||
|
|
0cdd4e4e16 | ||
|
|
a9b01be0c2 | ||
|
|
528daf5490 | ||
|
|
0e176d3ac3 | ||
|
|
27f359852e | ||
|
|
173128d431 | ||
|
|
e8ee1e239f | ||
|
|
656fa191c1 |
94
bun.lock
94
bun.lock
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -80,7 +80,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -114,7 +114,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -141,7 +141,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -165,7 +165,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -189,7 +189,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -222,7 +222,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -230,6 +230,7 @@
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@valibot/to-json-schema": "1.6.0",
|
||||
"effect": "catalog:",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-log": "^5",
|
||||
@@ -238,10 +239,13 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"marked": "^15",
|
||||
"solid-js": "catalog:",
|
||||
"sury": "11.0.0-alpha.4",
|
||||
"tree-kill": "^1.2.2",
|
||||
"zod-openapi": "5.4.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "4.0.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -251,10 +255,18 @@
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "catalog:",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.10",
|
||||
"@lydell/node-pty-darwin-x64": "1.2.0-beta.10",
|
||||
"@lydell/node-pty-linux-arm64": "1.2.0-beta.10",
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.10",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.10",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.10",
|
||||
},
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -283,7 +295,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -299,7 +311,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -329,8 +341,13 @@
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
@@ -371,6 +388,7 @@
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.1",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
@@ -412,6 +430,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/cross-spawn": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
@@ -428,7 +447,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -462,7 +481,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -477,7 +496,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -512,7 +531,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -560,7 +579,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -571,7 +590,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -626,6 +645,7 @@
|
||||
"@effect/platform-node": "4.0.0-beta.43",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@lydell/node-pty": "1.2.0-beta.10",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
@@ -1142,6 +1162,10 @@
|
||||
|
||||
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
|
||||
|
||||
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
|
||||
|
||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||
|
||||
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
|
||||
@@ -1154,7 +1178,9 @@
|
||||
|
||||
"@hey-api/types": ["@hey-api/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
|
||||
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||
|
||||
"@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
|
||||
|
||||
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
|
||||
|
||||
@@ -1346,6 +1372,20 @@
|
||||
|
||||
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
|
||||
|
||||
"@lydell/node-pty": ["@lydell/node-pty@1.2.0-beta.10", "", { "optionalDependencies": { "@lydell/node-pty-darwin-arm64": "1.2.0-beta.10", "@lydell/node-pty-darwin-x64": "1.2.0-beta.10", "@lydell/node-pty-linux-arm64": "1.2.0-beta.10", "@lydell/node-pty-linux-x64": "1.2.0-beta.10", "@lydell/node-pty-win32-arm64": "1.2.0-beta.10", "@lydell/node-pty-win32-x64": "1.2.0-beta.10" } }, "sha512-Fv+A3+MZVA8qhkBIZsM1E6dCdHNMyXXz22mAYiMWd03LlyK///F3OH6CKPX9mj4id7LUlxpr45yPzyBVy9aDPw=="],
|
||||
|
||||
"@lydell/node-pty-darwin-arm64": ["@lydell/node-pty-darwin-arm64@1.2.0-beta.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-C+eqDyRNHRYvx7RaHj6VVCx6nCpRBPuuxhTcc3JH3GuBMoxTsYeY4GkWH2XOktrgbAq1BG8e/Y8bu/wNQreCEw=="],
|
||||
|
||||
"@lydell/node-pty-darwin-x64": ["@lydell/node-pty-darwin-x64@1.2.0-beta.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-aZoIK6HtJO5BiT4ELm683U4dyHtt8b7wNgq3NJqYAQwSXrcPv576Z8vY3BIulVxfcFkht/SPLKou9TtdFXdNpg=="],
|
||||
|
||||
"@lydell/node-pty-linux-arm64": ["@lydell/node-pty-linux-arm64@1.2.0-beta.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-0cKX2iMyXFNBE4fGtGK6B7IkdXcDMZajyEDoGMOgQQs/DDtoI5tSPcBcqNY9VitVrsRQA8+gFt6eKYU9Ye/lUA=="],
|
||||
|
||||
"@lydell/node-pty-linux-x64": ["@lydell/node-pty-linux-x64@1.2.0-beta.10", "", { "os": "linux", "cpu": "x64" }, "sha512-J9HnxvSzEeMH748+Ul1VrmCLWMo7iCVJy9EGijRR62+YO/Yk5GaCydUTZ+KzlH0/X5aTrgt5cfiof4vx45tRRg=="],
|
||||
|
||||
"@lydell/node-pty-win32-arm64": ["@lydell/node-pty-win32-arm64@1.2.0-beta.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-PlDJpJX/pnKyy6OmADKzhf+INZDDnzTBGaI0LT4laVNc6NblZNqUSkCMjLFWbeakeuQp0VG37M49WQSN9FDfeA=="],
|
||||
|
||||
"@lydell/node-pty-win32-x64": ["@lydell/node-pty-win32-x64@1.2.0-beta.10", "", { "os": "win32", "cpu": "x64" }, "sha512-ExFgWrzyldNAMi45U9PLIOu+g/RatP+f0c/dZxaooifME6yLW32BoHveH26/TtoAjZyJrc2iL0u48pgnR1fzmg=="],
|
||||
|
||||
"@malept/cross-spawn-promise": ["@malept/cross-spawn-promise@2.0.0", "", { "dependencies": { "cross-spawn": "^7.0.1" } }, "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg=="],
|
||||
|
||||
"@malept/flatpak-bundler": ["@malept/flatpak-bundler@0.4.0", "", { "dependencies": { "debug": "^4.1.1", "fs-extra": "^9.0.0", "lodash": "^4.17.15", "tmp-promise": "^3.0.2" } }, "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q=="],
|
||||
@@ -2286,6 +2326,8 @@
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
@@ -4548,6 +4590,8 @@
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"sury": ["sury@11.0.0-alpha.4", "", { "peerDependencies": { "rescript": "12.x" }, "optionalPeers": ["rescript"] }, "sha512-oeG/GJWZvQCKtGPpLbu0yCZudfr5LxycDo5kh7SJmKHDPCsEPJssIZL2Eb4Tl7g9aPEvIDuRrkS+L0pybsMEMA=="],
|
||||
|
||||
"system-architecture": ["system-architecture@0.1.0", "", {}, "sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.11", "", {}, "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA=="],
|
||||
@@ -4782,6 +4826,8 @@
|
||||
|
||||
"uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
|
||||
|
||||
"valibot": ["valibot@1.3.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg=="],
|
||||
|
||||
"validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
@@ -4938,6 +4984,8 @@
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
"zod-openapi": ["zod-openapi@5.4.6", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
|
||||
|
||||
"zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="],
|
||||
@@ -5192,10 +5240,18 @@
|
||||
|
||||
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/openai": ["openai@6.33.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="],
|
||||
|
||||
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
|
||||
|
||||
"@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@hono/node-ws/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
@@ -5248,6 +5304,8 @@
|
||||
|
||||
"@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
@@ -6082,6 +6140,8 @@
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
|
||||
|
||||
"@gitlab/opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"@jsx-email/cli/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
|
||||
|
||||
"@jsx-email/cli/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-0jwPCu2Lod433GPQLHN8eEkhfpPviDFfkFJmuvkRdlE=",
|
||||
"aarch64-linux": "sha256-Qi0IkGkaIBKZsPLTO8kaTbCVL0cEfVOm/Y/6VUVI9TY=",
|
||||
"aarch64-darwin": "sha256-1eZBBLgYVkjg5RYN/etR1Mb5UjU3VelElBB5ug5hQdc=",
|
||||
"x86_64-darwin": "sha256-jdXgA+kZb/foFHR40UiPif6rsA2GDVCCVHnJR3jBUGI="
|
||||
"x86_64-linux": "sha256-LRhPPrOKCGUSCEWTpAxPdWKTKVNkg82WrvD25cP3jts=",
|
||||
"aarch64-linux": "sha256-sbNxkil47n+B7v6ds5EYFybLytXUyRlu0Cpka0ZmDx4=",
|
||||
"aarch64-darwin": "sha256-5+99gtpIHGygMW3VBAexNhmaORgI8LCxPk/Gf1fW/ds=",
|
||||
"x86_64-darwin": "sha256-LqnvZGGnQaRxIoowOr5gf6lFgDhbgQhVPiAcRTtU6fE="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||
"typecheck": "bun turbo typecheck",
|
||||
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"random": "echo 'Random script'",
|
||||
"hello": "echo 'Hello World!'",
|
||||
@@ -70,7 +71,8 @@
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||
"solid-js": "1.9.10",
|
||||
"vite-plugin-solid": "2.11.10"
|
||||
"vite-plugin-solid": "2.11.10",
|
||||
"@lydell/node-pty": "1.2.0-beta.10"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -103,6 +105,7 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"esbuild",
|
||||
"node-pty",
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -90,7 +90,8 @@ export async function handler(
|
||||
const body = await input.request.json()
|
||||
const model = opts.parseModel(url, body)
|
||||
const isStream = opts.parseIsStream(url, body)
|
||||
const ip = input.request.headers.get("x-real-ip") ?? ""
|
||||
const rawIp = input.request.headers.get("x-real-ip") ?? ""
|
||||
const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp
|
||||
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
|
||||
const requestId = input.request.headers.get("x-opencode-request") ?? ""
|
||||
const projectId = input.request.headers.get("x-opencode-project") ?? ""
|
||||
|
||||
@@ -17,9 +17,8 @@ export function createRateLimiter(
|
||||
const dict = i18n(localeFromRequest(request))
|
||||
|
||||
const limits = Subscription.getFreeLimits()
|
||||
const headerExists = request.headers.has(limits.checkHeader)
|
||||
const dailyLimit = !headerExists ? limits.fallbackValue : (rateLimit ?? limits.dailyRequests)
|
||||
const isDefaultModel = headerExists && !rateLimit
|
||||
const dailyLimit = rateLimit ?? limits.dailyRequests
|
||||
const isDefaultModel = !rateLimit
|
||||
|
||||
const ip = !rawIp.length ? "unknown" : rawIp
|
||||
const now = Date.now()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -9,8 +9,6 @@ export namespace Subscription {
|
||||
free: z.object({
|
||||
promoTokens: z.number().int(),
|
||||
dailyRequests: z.number().int(),
|
||||
checkHeader: z.string(),
|
||||
fallbackValue: z.number().int(),
|
||||
}),
|
||||
lite: z.object({
|
||||
rollingLimit: z.number().int(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -34,11 +34,6 @@ const getBase = (): Configuration => ({
|
||||
},
|
||||
files: ["out/**/*", "resources/**/*"],
|
||||
extraResources: [
|
||||
{
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli*"],
|
||||
},
|
||||
{
|
||||
from: "native/",
|
||||
to: "native/",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from "electron-vite"
|
||||
import appPlugin from "@opencode-ai/app/vite"
|
||||
import * as fs from "node:fs/promises"
|
||||
|
||||
const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
@@ -7,6 +8,10 @@ const channel = (() => {
|
||||
return "dev"
|
||||
})()
|
||||
|
||||
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
|
||||
|
||||
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
define: {
|
||||
@@ -16,7 +21,34 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: { index: "src/main/index.ts" },
|
||||
},
|
||||
externalizeDeps: { include: [nodePtyPkg] },
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: "opencode:node-pty-narrower",
|
||||
enforce: "pre",
|
||||
resolveId(s) {
|
||||
if (s === "@lydell/node-pty") return nodePtyPkg
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "opencode:virtual-server-module",
|
||||
enforce: "pre",
|
||||
resolveId(id) {
|
||||
if (id === "virtual:opencode-server") return this.resolve(`${OPENCODE_SERVER_DIST}/node.js`)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "opencode:copy-server-assets",
|
||||
enforce: "post",
|
||||
async closeBundle() {
|
||||
for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) {
|
||||
if (l.endsWith(".js")) continue
|
||||
await fs.writeFile(`./out/main/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`))
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
preload: {
|
||||
build: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
@@ -13,7 +13,7 @@
|
||||
"typecheck": "tsgo -b",
|
||||
"predev": "bun ./scripts/predev.ts",
|
||||
"dev": "electron-vite dev",
|
||||
"prebuild": "bun ./scripts/copy-icons.ts",
|
||||
"prebuild": "bun ./scripts/prebuild.ts",
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "electron-builder --config electron-builder.config.ts",
|
||||
@@ -30,6 +30,7 @@
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@valibot/to-json-schema": "1.6.0",
|
||||
"effect": "catalog:",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-log": "^5",
|
||||
@@ -38,10 +39,13 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"marked": "^15",
|
||||
"solid-js": "catalog:",
|
||||
"tree-kill": "^1.2.2"
|
||||
"sury": "11.0.0-alpha.4",
|
||||
"tree-kill": "^1.2.2",
|
||||
"zod-openapi": "5.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "4.0.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
@@ -50,5 +54,13 @@
|
||||
"electron-vite": "^5",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.10",
|
||||
"@lydell/node-pty-darwin-x64": "1.2.0-beta.10",
|
||||
"@lydell/node-pty-linux-arm64": "1.2.0-beta.10",
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.10",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.10",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.10"
|
||||
}
|
||||
}
|
||||
|
||||
9
packages/desktop-electron/scripts/prebuild.ts
Normal file
9
packages/desktop-electron/scripts/prebuild.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
import { $ } from "bun"
|
||||
|
||||
import { resolveChannel } from "./utils"
|
||||
|
||||
const channel = resolveChannel()
|
||||
await $`bun ./scripts/copy-icons.ts ${channel}`
|
||||
|
||||
await $`cd ../opencode && bun script/build-node.ts`
|
||||
@@ -1,17 +1,5 @@
|
||||
import { $ } from "bun"
|
||||
|
||||
import { copyBinaryToSidecarFolder, getCurrentSidecar, windowsify } from "./utils"
|
||||
|
||||
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
|
||||
|
||||
const RUST_TARGET = Bun.env.RUST_TARGET
|
||||
|
||||
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
|
||||
|
||||
const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`)
|
||||
|
||||
await (sidecarConfig.ocBinary.includes("-baseline")
|
||||
? $`cd ../opencode && bun run build --single --baseline`
|
||||
: $`cd ../opencode && bun run build --single`)
|
||||
|
||||
await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET)
|
||||
await $`cd ../opencode && bun script/build-node.ts`
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
#!/usr/bin/env bun
|
||||
import { $ } from "bun"
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import { copyBinaryToSidecarFolder, getCurrentSidecar, resolveChannel, windowsify } from "./utils"
|
||||
|
||||
const channel = resolveChannel()
|
||||
await $`bun ./scripts/copy-icons.ts ${channel}`
|
||||
await import("./prebuild")
|
||||
|
||||
const pkg = await Bun.file("./package.json").json()
|
||||
pkg.version = Script.version
|
||||
await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
console.log(`Updated package.json version to ${Script.version}`)
|
||||
|
||||
const sidecarConfig = getCurrentSidecar()
|
||||
const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
|
||||
|
||||
const dir = "resources/opencode-binaries"
|
||||
|
||||
await $`mkdir -p ${dir}`
|
||||
await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
|
||||
|
||||
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))
|
||||
|
||||
await $`rm -rf ${dir}`
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import { execFileSync, spawn } from "node:child_process"
|
||||
import { EventEmitter } from "node:events"
|
||||
import { chmodSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { dirname, join } from "node:path"
|
||||
import readline from "node:readline"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { app } from "electron"
|
||||
import treeKill from "tree-kill"
|
||||
|
||||
import { WSL_ENABLED_KEY } from "./constants"
|
||||
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
|
||||
import { store } from "./store"
|
||||
|
||||
const CLI_INSTALL_DIR = ".opencode/bin"
|
||||
const CLI_BINARY_NAME = "opencode"
|
||||
|
||||
export type ServerConfig = {
|
||||
hostname?: string
|
||||
port?: number
|
||||
}
|
||||
|
||||
export type Config = {
|
||||
server?: ServerConfig
|
||||
}
|
||||
|
||||
export type TerminatedPayload = { code: number | null; signal: number | null }
|
||||
|
||||
export type CommandEvent =
|
||||
| { type: "stdout"; value: string }
|
||||
| { type: "stderr"; value: string }
|
||||
| { type: "error"; value: string }
|
||||
| { type: "terminated"; value: TerminatedPayload }
|
||||
| { type: "sqlite"; value: SqliteMigrationProgress }
|
||||
|
||||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
|
||||
|
||||
export type CommandChild = {
|
||||
pid: number | undefined
|
||||
kill: () => void
|
||||
}
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export function getSidecarPath() {
|
||||
const suffix = process.platform === "win32" ? ".exe" : ""
|
||||
const path = app.isPackaged
|
||||
? join(process.resourcesPath, `opencode-cli${suffix}`)
|
||||
: join(root, "../../resources", `opencode-cli${suffix}`)
|
||||
console.log(`[cli] Sidecar path resolved: ${path} (isPackaged: ${app.isPackaged})`)
|
||||
return path
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<Config | null> {
|
||||
const { events } = spawnCommand("debug config", {})
|
||||
let output = ""
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
events.on("stdout", (line: string) => {
|
||||
output += line
|
||||
})
|
||||
events.on("stderr", (line: string) => {
|
||||
output += line
|
||||
})
|
||||
events.on("terminated", () => resolve())
|
||||
events.on("error", () => resolve())
|
||||
})
|
||||
|
||||
try {
|
||||
return JSON.parse(output) as Config
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function installCli(): Promise<string> {
|
||||
if (process.platform === "win32") {
|
||||
throw new Error("CLI installation is only supported on macOS & Linux")
|
||||
}
|
||||
|
||||
const sidecar = getSidecarPath()
|
||||
const scriptPath = join(app.getAppPath(), "install")
|
||||
const script = readFileSync(scriptPath, "utf8")
|
||||
const tempScript = join(tmpdir(), "opencode-install.sh")
|
||||
|
||||
writeFileSync(tempScript, script, "utf8")
|
||||
chmodSync(tempScript, 0o755)
|
||||
|
||||
const cmd = spawn(tempScript, ["--binary", sidecar], { stdio: "pipe" })
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
cmd.on("exit", (code: number | null) => {
|
||||
try {
|
||||
unlinkSync(tempScript)
|
||||
} catch {}
|
||||
if (code === 0) {
|
||||
const installPath = getCliInstallPath()
|
||||
if (installPath) return resolve(installPath)
|
||||
return reject(new Error("Could not determine install path"))
|
||||
}
|
||||
reject(new Error("Install script failed"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function syncCli() {
|
||||
if (!app.isPackaged) return
|
||||
const installPath = getCliInstallPath()
|
||||
if (!installPath) return
|
||||
|
||||
let version = ""
|
||||
try {
|
||||
version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const cli = parseVersion(version)
|
||||
const appVersion = parseVersion(app.getVersion())
|
||||
if (!cli || !appVersion) return
|
||||
if (compareVersions(cli, appVersion) >= 0) return
|
||||
void installCli().catch(() => undefined)
|
||||
}
|
||||
|
||||
export function serve(hostname: string, port: number, password: string) {
|
||||
const args = `--print-logs --log-level WARN serve --hostname ${hostname} --port ${port}`
|
||||
const env = {
|
||||
OPENCODE_SERVER_USERNAME: "opencode",
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
}
|
||||
|
||||
return spawnCommand(args, env)
|
||||
}
|
||||
|
||||
export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
||||
console.log(`[cli] Spawning command with args: ${args}`)
|
||||
const base = Object.fromEntries(
|
||||
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
const env = {
|
||||
...base,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
XDG_STATE_HOME: app.getPath("userData"),
|
||||
...extraEnv,
|
||||
}
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
|
||||
|
||||
const { cmd, cmdArgs } = buildCommand(args, envs, shell)
|
||||
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
|
||||
const child = spawn(cmd, cmdArgs, {
|
||||
env: envs,
|
||||
detached: process.platform !== "win32",
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
console.log(`[cli] Spawned process with PID: ${child.pid}`)
|
||||
|
||||
const events = new EventEmitter()
|
||||
const exit = new Promise<TerminatedPayload>((resolve) => {
|
||||
child.on("exit", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
console.log(`[cli] Process exited with code: ${code}, signal: ${signal}`)
|
||||
resolve({ code: code ?? null, signal: null })
|
||||
})
|
||||
child.on("error", (error: Error) => {
|
||||
console.error(`[cli] Process error: ${error.message}`)
|
||||
events.emit("error", error.message)
|
||||
})
|
||||
})
|
||||
|
||||
const stdout = child.stdout
|
||||
const stderr = child.stderr
|
||||
|
||||
if (stdout) {
|
||||
readline.createInterface({ input: stdout }).on("line", (line: string) => {
|
||||
if (handleSqliteProgress(events, line)) return
|
||||
events.emit("stdout", `${line}\n`)
|
||||
})
|
||||
}
|
||||
|
||||
if (stderr) {
|
||||
readline.createInterface({ input: stderr }).on("line", (line: string) => {
|
||||
if (handleSqliteProgress(events, line)) return
|
||||
events.emit("stderr", `${line}\n`)
|
||||
})
|
||||
}
|
||||
|
||||
exit.then((payload) => {
|
||||
events.emit("terminated", payload)
|
||||
})
|
||||
|
||||
const kill = () => {
|
||||
if (!child.pid) return
|
||||
treeKill(child.pid)
|
||||
}
|
||||
|
||||
return { events, child: { pid: child.pid, kill }, exit }
|
||||
}
|
||||
|
||||
function handleSqliteProgress(events: EventEmitter, line: string) {
|
||||
const stripped = line.startsWith("sqlite-migration:") ? line.slice("sqlite-migration:".length).trim() : null
|
||||
if (!stripped) return false
|
||||
if (stripped === "done") {
|
||||
events.emit("sqlite", { type: "Done" })
|
||||
return true
|
||||
}
|
||||
const value = Number.parseInt(stripped, 10)
|
||||
if (!Number.isNaN(value)) {
|
||||
events.emit("sqlite", { type: "InProgress", value })
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
|
||||
if (process.platform === "win32" && isWslEnabled()) {
|
||||
console.log(`[cli] Using WSL mode`)
|
||||
const version = app.getVersion()
|
||||
const script = [
|
||||
"set -e",
|
||||
'BIN="$HOME/.opencode/bin/opencode"',
|
||||
'if [ ! -x "$BIN" ]; then',
|
||||
` curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)} --no-modify-path`,
|
||||
"fi",
|
||||
`${envPrefix(env)} exec "$BIN" ${args}`,
|
||||
].join("\n")
|
||||
|
||||
return { cmd: "wsl", cmdArgs: ["-e", "bash", "-lc", script] }
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const sidecar = getSidecarPath()
|
||||
console.log(`[cli] Windows direct mode, sidecar: ${sidecar}`)
|
||||
return { cmd: sidecar, cmdArgs: args.split(" ") }
|
||||
}
|
||||
|
||||
const sidecar = getSidecarPath()
|
||||
const user = shell || getUserShell()
|
||||
const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
|
||||
console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
|
||||
return { cmd: user, cmdArgs: ["-l", "-c", line] }
|
||||
}
|
||||
|
||||
function envPrefix(env: Record<string, string>) {
|
||||
const entries = Object.entries(env).map(([key, value]) => `${key}=${shellEscape(value)}`)
|
||||
return entries.join(" ")
|
||||
}
|
||||
|
||||
function shellEscape(input: string) {
|
||||
if (!input) return "''"
|
||||
return `'${input.replace(/'/g, `'"'"'`)}'`
|
||||
}
|
||||
|
||||
function getCliInstallPath() {
|
||||
const home = process.env.HOME
|
||||
if (!home) return null
|
||||
return join(home, CLI_INSTALL_DIR, CLI_BINARY_NAME)
|
||||
}
|
||||
|
||||
function isWslEnabled() {
|
||||
return store.get(WSL_ENABLED_KEY) === true
|
||||
}
|
||||
|
||||
function parseVersion(value: string) {
|
||||
const parts = value
|
||||
.replace(/^v/, "")
|
||||
.split(".")
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
if (parts.some((part) => Number.isNaN(part))) return null
|
||||
return parts
|
||||
}
|
||||
|
||||
function compareVersions(a: number[], b: number[]) {
|
||||
const len = Math.max(a.length, b.length)
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
const left = a[i] ?? 0
|
||||
const right = b[i] ?? 0
|
||||
if (left > right) return 1
|
||||
if (left < right) return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
22
packages/desktop-electron/src/main/env.d.ts
vendored
22
packages/desktop-electron/src/main/env.d.ts
vendored
@@ -5,3 +5,25 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
declare module "virtual:opencode-server" {
|
||||
export namespace Server {
|
||||
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
|
||||
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
|
||||
}
|
||||
export namespace Config {
|
||||
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
|
||||
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
|
||||
}
|
||||
export namespace Log {
|
||||
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
|
||||
}
|
||||
export namespace Database {
|
||||
export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
|
||||
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
|
||||
}
|
||||
export namespace JsonMigration {
|
||||
export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress
|
||||
export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run
|
||||
}
|
||||
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import pkg from "electron-updater"
|
||||
import contextMenu from "electron-context-menu"
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
const APP_NAMES: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
beta: "OpenCode Beta",
|
||||
@@ -27,8 +29,6 @@ const { autoUpdater } = pkg
|
||||
|
||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
||||
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
||||
import type { CommandChild } from "./cli"
|
||||
import { installCli, syncCli } from "./cli"
|
||||
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
|
||||
import { initLogging } from "./logging"
|
||||
@@ -36,12 +36,13 @@ import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
||||
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
|
||||
import { Server } from "virtual:opencode-server"
|
||||
|
||||
const initEmitter = new EventEmitter()
|
||||
let initStep: InitStep = { phase: "server_waiting" }
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let sidecar: CommandChild | null = null
|
||||
let server: Server.Listener | null = null
|
||||
const loadingComplete = defer<void>()
|
||||
|
||||
const pendingDeepLinks: string[] = []
|
||||
@@ -96,11 +97,9 @@ function setupApp() {
|
||||
}
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
// migrate()
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
setDockIcon()
|
||||
setupAutoUpdater()
|
||||
syncCli()
|
||||
await initialize()
|
||||
})
|
||||
}
|
||||
@@ -134,8 +133,8 @@ async function initialize() {
|
||||
const password = randomUUID()
|
||||
|
||||
logger.log("spawning sidecar", { url })
|
||||
const { child, health, events } = spawnLocalServer(hostname, port, password)
|
||||
sidecar = child
|
||||
const { listener, health } = await spawnLocalServer(hostname, port, password)
|
||||
server = listener
|
||||
serverReady.resolve({
|
||||
url,
|
||||
username: "opencode",
|
||||
@@ -145,7 +144,7 @@ async function initialize() {
|
||||
const loadingTask = (async () => {
|
||||
logger.log("sidecar connection started", { url })
|
||||
|
||||
events.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||
setInitStep({ phase: "sqlite_waiting" })
|
||||
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
||||
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
||||
@@ -198,9 +197,6 @@ function wireMenu() {
|
||||
if (!mainWindow) return
|
||||
createMenu({
|
||||
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
|
||||
installCli: () => {
|
||||
void installCli()
|
||||
},
|
||||
checkForUpdates: () => {
|
||||
void checkForUpdates(true)
|
||||
},
|
||||
@@ -215,7 +211,6 @@ function wireMenu() {
|
||||
|
||||
registerIpcHandlers({
|
||||
killSidecar: () => killSidecar(),
|
||||
installCli: async () => installCli(),
|
||||
awaitInitialization: async (sendStep) => {
|
||||
sendStep(initStep)
|
||||
const listener = (step: InitStep) => sendStep(step)
|
||||
@@ -247,16 +242,9 @@ registerIpcHandlers({
|
||||
})
|
||||
|
||||
function killSidecar() {
|
||||
if (!sidecar) return
|
||||
const pid = sidecar.pid
|
||||
sidecar.kill()
|
||||
sidecar = null
|
||||
// tree-kill is async; also send process group signal as immediate fallback
|
||||
if (pid && process.platform !== "win32") {
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM")
|
||||
} catch {}
|
||||
}
|
||||
if (!server) return
|
||||
server.stop()
|
||||
server = null
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
|
||||
@@ -13,7 +13,6 @@ const pickerFilters = (ext?: string[]) => {
|
||||
|
||||
type Deps = {
|
||||
killSidecar: () => void
|
||||
installCli: () => Promise<string>
|
||||
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||
getDefaultServerUrl: () => Promise<string | null> | string | null
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
||||
@@ -34,7 +33,6 @@ type Deps = {
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
||||
ipcMain.handle("install-cli", () => deps.installCli())
|
||||
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
|
||||
const send = (step: InitStep) => event.sender.send("init-step", step)
|
||||
return deps.awaitInitialization(send)
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createMainWindow } from "./windows"
|
||||
|
||||
type Deps = {
|
||||
trigger: (id: string) => void
|
||||
installCli: () => void
|
||||
checkForUpdates: () => void
|
||||
reload: () => void
|
||||
relaunch: () => void
|
||||
@@ -24,10 +23,6 @@ export function createMenu(deps: Deps) {
|
||||
enabled: UPDATER_ENABLED,
|
||||
click: () => deps.checkForUpdates(),
|
||||
},
|
||||
{
|
||||
label: "Install CLI...",
|
||||
click: () => deps.installCli(),
|
||||
},
|
||||
{
|
||||
label: "Reload Webview",
|
||||
click: () => deps.reload(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { serve, type CommandChild } from "./cli"
|
||||
import { Server, Log } from "virtual:opencode-server"
|
||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||
import { store } from "./store"
|
||||
|
||||
@@ -29,8 +29,14 @@ export function setWslConfig(config: WslConfig) {
|
||||
store.set(WSL_ENABLED_KEY, config.enabled)
|
||||
}
|
||||
|
||||
export function spawnLocalServer(hostname: string, port: number, password: string) {
|
||||
const { child, exit, events } = serve(hostname, port, password)
|
||||
export async function spawnLocalServer(hostname: string, port: number, password: string) {
|
||||
await Log.init({ level: "WARN" })
|
||||
const listener = await Server.listen({
|
||||
port,
|
||||
hostname,
|
||||
username: "opencode",
|
||||
password,
|
||||
})
|
||||
|
||||
const wait = (async () => {
|
||||
const url = `http://${hostname}:${port}`
|
||||
@@ -42,19 +48,10 @@ export function spawnLocalServer(hostname: string, port: number, password: strin
|
||||
}
|
||||
}
|
||||
|
||||
const terminated = async () => {
|
||||
const payload = await exit
|
||||
throw new Error(
|
||||
`Sidecar terminated before becoming healthy (code=${payload.code ?? "unknown"} signal=${
|
||||
payload.signal ?? "unknown"
|
||||
})`,
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.race([ready(), terminated()])
|
||||
await ready()
|
||||
})()
|
||||
|
||||
return { child, health: { wait }, events }
|
||||
return { listener, health: { wait } }
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
@@ -82,5 +79,3 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export type { CommandChild }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.3.15"
|
||||
version = "1.3.17"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.15/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -11,6 +11,7 @@
|
||||
"test": "bun test --timeout 30000",
|
||||
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||
"build": "bun run script/build.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"dev": "bun run --conditions=browser ./src/index.ts",
|
||||
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
|
||||
@@ -33,6 +34,11 @@
|
||||
"bun": "./src/storage/db.bun.ts",
|
||||
"node": "./src/storage/db.node.ts",
|
||||
"default": "./src/storage/db.bun.ts"
|
||||
},
|
||||
"#pty": {
|
||||
"bun": "./src/pty/pty.bun.ts",
|
||||
"node": "./src/pty/pty.node.ts",
|
||||
"default": "./src/pty/pty.bun.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -54,6 +60,7 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/cross-spawn": "catalog:",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
@@ -93,8 +100,13 @@
|
||||
"@aws-sdk/credential-providers": "3.993.0",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||
"@hono/node-server": "1.19.11",
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
@@ -135,6 +147,7 @@
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.0.3",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.1",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
|
||||
8
packages/opencode/script/build-node.ts
Normal file → Executable file
8
packages/opencode/script/build-node.ts
Normal file → Executable file
@@ -44,13 +44,17 @@ console.log(`Loaded ${migrations.length} migrations`)
|
||||
await Bun.build({
|
||||
target: "node",
|
||||
entrypoints: ["./src/node.ts"],
|
||||
outdir: "./dist",
|
||||
outdir: "./dist/node",
|
||||
format: "esm",
|
||||
external: ["jsonc-parser"],
|
||||
sourcemap: "linked",
|
||||
external: ["jsonc-parser", "@lydell/node-pty"],
|
||||
define: {
|
||||
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
},
|
||||
files: {
|
||||
"opencode-web-ui.gen.ts": "",
|
||||
},
|
||||
})
|
||||
|
||||
console.log("Build complete")
|
||||
|
||||
28
packages/opencode/script/fix-node-pty.ts
Executable file
28
packages/opencode/script/fix-node-pty.ts
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const root = path.join(dir, "node_modules", "node-pty", "prebuilds")
|
||||
const dirs = await fs.readdir(root, { withFileTypes: true }).catch(() => [])
|
||||
const files = dirs.filter((x) => x.isDirectory()).map((x) => path.join(root, x.name, "spawn-helper"))
|
||||
const result = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const stat = await fs.stat(file).catch(() => undefined)
|
||||
if (!stat) return
|
||||
if ((stat.mode & 0o111) === 0o111) return
|
||||
await fs.chmod(file, stat.mode | 0o755)
|
||||
return file
|
||||
}),
|
||||
)
|
||||
const fixed = result.filter(Boolean)
|
||||
if (fixed.length) {
|
||||
console.log(`fixed node-pty permissions for ${fixed.length} helper${fixed.length === 1 ? "" : "s"}`)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
type Role,
|
||||
type SessionInfo,
|
||||
type SetSessionModelRequest,
|
||||
type SessionConfigOption,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionConfigOptionResponse,
|
||||
type SetSessionModeRequest,
|
||||
type SetSessionModeResponse,
|
||||
type ToolCallContent,
|
||||
@@ -601,6 +604,7 @@ export namespace ACP {
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
configOptions: load.configOptions,
|
||||
models: load.models,
|
||||
modes: load.modes,
|
||||
_meta: load._meta,
|
||||
@@ -660,6 +664,11 @@ export namespace ACP {
|
||||
result.modes.currentModeId = lastUser.agent
|
||||
this.sessionManager.setMode(sessionId, lastUser.agent)
|
||||
}
|
||||
result.configOptions = buildConfigOptions({
|
||||
currentModelId: result.models.currentModelId,
|
||||
availableModels: result.models.availableModels,
|
||||
modes: result.modes,
|
||||
})
|
||||
}
|
||||
|
||||
for (const msg of messages ?? []) {
|
||||
@@ -1266,6 +1275,11 @@ export namespace ACP {
|
||||
availableModels,
|
||||
},
|
||||
modes,
|
||||
configOptions: buildConfigOptions({
|
||||
currentModelId: formatModelIdWithVariant(model, currentVariant, availableVariants, true),
|
||||
availableModels,
|
||||
modes,
|
||||
}),
|
||||
_meta: buildVariantMeta({
|
||||
model,
|
||||
variant: this.sessionManager.getVariant(sessionId),
|
||||
@@ -1305,6 +1319,44 @@ export namespace ACP {
|
||||
this.sessionManager.setMode(params.sessionId, params.modeId)
|
||||
}
|
||||
|
||||
async setSessionConfigOption(params: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse> {
|
||||
const session = this.sessionManager.get(params.sessionId)
|
||||
const providers = await this.sdk.config
|
||||
.providers({ directory: session.cwd }, { throwOnError: true })
|
||||
.then((x) => x.data!.providers)
|
||||
const entries = sortProvidersByName(providers)
|
||||
|
||||
if (params.configId === "model") {
|
||||
if (typeof params.value !== "string") throw RequestError.invalidParams("model value must be a string")
|
||||
const selection = parseModelSelection(params.value, providers)
|
||||
this.sessionManager.setModel(session.id, selection.model)
|
||||
this.sessionManager.setVariant(session.id, selection.variant)
|
||||
} else if (params.configId === "mode") {
|
||||
if (typeof params.value !== "string") throw RequestError.invalidParams("mode value must be a string")
|
||||
const availableModes = await this.loadAvailableModes(session.cwd)
|
||||
if (!availableModes.some((mode) => mode.id === params.value)) {
|
||||
throw RequestError.invalidParams(JSON.stringify({ error: `Mode not found: ${params.value}` }))
|
||||
}
|
||||
this.sessionManager.setMode(session.id, params.value)
|
||||
} else {
|
||||
throw RequestError.invalidParams(JSON.stringify({ error: `Unknown config option: ${params.configId}` }))
|
||||
}
|
||||
|
||||
const updatedSession = this.sessionManager.get(session.id)
|
||||
const model = updatedSession.model ?? (await defaultModel(this.config, session.cwd))
|
||||
const availableVariants = modelVariantsFromProviders(entries, model)
|
||||
const currentModelId = formatModelIdWithVariant(model, updatedSession.variant, availableVariants, true)
|
||||
const availableModels = buildAvailableModels(entries, { includeVariants: true })
|
||||
const modeState = await this.resolveModeState(session.cwd, session.id)
|
||||
const modes = modeState.currentModeId
|
||||
? { availableModes: modeState.availableModes, currentModeId: modeState.currentModeId }
|
||||
: undefined
|
||||
|
||||
return {
|
||||
configOptions: buildConfigOptions({ currentModelId, availableModels, modes }),
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(params: PromptRequest) {
|
||||
const sessionID = params.sessionId
|
||||
const session = this.sessionManager.get(sessionID)
|
||||
@@ -1760,4 +1812,36 @@ export namespace ACP {
|
||||
|
||||
return { model: parsed, variant: undefined }
|
||||
}
|
||||
|
||||
function buildConfigOptions(input: {
|
||||
currentModelId: string
|
||||
availableModels: ModelOption[]
|
||||
modes?: { availableModes: ModeOption[]; currentModeId: string } | undefined
|
||||
}): SessionConfigOption[] {
|
||||
const options: SessionConfigOption[] = [
|
||||
{
|
||||
id: "model",
|
||||
name: "Model",
|
||||
category: "model",
|
||||
type: "select",
|
||||
currentValue: input.currentModelId,
|
||||
options: input.availableModels.map((m) => ({ value: m.modelId, name: m.name })),
|
||||
},
|
||||
]
|
||||
if (input.modes) {
|
||||
options.push({
|
||||
id: "mode",
|
||||
name: "Session Mode",
|
||||
category: "mode",
|
||||
type: "select",
|
||||
currentValue: input.modes.currentModeId,
|
||||
options: input.modes.availableModes.map((m) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
...(m.description ? { description: m.description } : {}),
|
||||
})),
|
||||
})
|
||||
}
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export namespace Auth {
|
||||
export class Api extends Schema.Class<Api>("ApiAuth")({
|
||||
type: Schema.Literal("api"),
|
||||
key: Schema.String,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
}) {}
|
||||
|
||||
export class WellKnown extends Schema.Class<WellKnown>("WellKnownAuth")({
|
||||
|
||||
@@ -23,7 +23,7 @@ export const AcpCommand = cmd({
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: `http://${server.hostname}:${server.port}`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Argv } from "yargs"
|
||||
import { spawn } from "child_process"
|
||||
import { Database } from "../../storage/db"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { Database as BunDatabase } from "bun:sqlite"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
@@ -74,7 +75,7 @@ const MigrateCommand = cmd({
|
||||
let last = -1
|
||||
if (tty) process.stderr.write("\x1b[?25l")
|
||||
try {
|
||||
const stats = await JsonMigration.run(sqlite, {
|
||||
const stats = await JsonMigration.run(drizzle({ client: sqlite }), {
|
||||
progress: (event) => {
|
||||
const percent = Math.floor((event.current / event.total) * 100)
|
||||
if (percent === last) return
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Flag } from "../../flag/flag"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { EOL } from "os"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { createOpencodeClient, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { Server } from "../../server/server"
|
||||
import { Provider } from "../../provider/provider"
|
||||
import { Agent } from "../../agent/agent"
|
||||
@@ -667,7 +667,7 @@ export const RunCommand = cmd({
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
return Server.Default().fetch(request)
|
||||
return Server.Default().app.fetch(request)
|
||||
}) as typeof globalThis.fetch
|
||||
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
|
||||
await execute(sdk)
|
||||
|
||||
@@ -15,7 +15,7 @@ export const ServeCommand = cmd({
|
||||
console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
await new Promise(() => {})
|
||||
|
||||
@@ -125,14 +125,17 @@ import type { EventSource } from "./context/sdk"
|
||||
import { DialogVariant } from "./component/dialog-variant"
|
||||
|
||||
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
||||
const mouseEnabled = !Flag.OPENCODE_DISABLE_MOUSE && (_config.mouse ?? true)
|
||||
|
||||
return {
|
||||
externalOutputMode: "passthrough",
|
||||
targetFps: 60,
|
||||
gatherStats: false,
|
||||
exitOnCtrlC: false,
|
||||
useKittyKeyboard: { events: process.platform === "win32" },
|
||||
useKittyKeyboard: {},
|
||||
autoFocus: false,
|
||||
openConsoleOnError: false,
|
||||
useMouse: mouseEnabled,
|
||||
consoleOptions: {
|
||||
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
|
||||
onCopySelection: (text) => {
|
||||
@@ -758,6 +761,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
keybind: "terminal_suspend",
|
||||
category: "System",
|
||||
hidden: true,
|
||||
enabled: tuiConfig.keybinds?.terminal_suspend !== "none",
|
||||
onSelect: () => {
|
||||
process.once("SIGCONT", () => {
|
||||
renderer.resume()
|
||||
|
||||
@@ -129,7 +129,15 @@ export function createDialogProviderOptions() {
|
||||
}
|
||||
}
|
||||
if (method.type === "api") {
|
||||
return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />)
|
||||
let metadata: Record<string, string> | undefined
|
||||
if (method.prompts?.length) {
|
||||
const value = await PromptsMethod({ dialog, prompts: method.prompts })
|
||||
if (!value) return
|
||||
metadata = value
|
||||
}
|
||||
return dialog.replace(() => (
|
||||
<ApiMethod providerID={provider.id} title={method.label} metadata={metadata} />
|
||||
))
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -249,6 +257,7 @@ function CodeMethod(props: CodeMethodProps) {
|
||||
interface ApiMethodProps {
|
||||
providerID: string
|
||||
title: string
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
function ApiMethod(props: ApiMethodProps) {
|
||||
const dialog = useDialog()
|
||||
@@ -293,6 +302,7 @@ function ApiMethod(props: ApiMethodProps) {
|
||||
auth: {
|
||||
type: "api",
|
||||
key: value,
|
||||
...(props.metadata ? { metadata: props.metadata } : {}),
|
||||
},
|
||||
})
|
||||
await sdk.client.instance.dispose()
|
||||
|
||||
@@ -18,7 +18,7 @@ import { usePromptStash } from "./stash"
|
||||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useCommandDialog } from "../dialog-command"
|
||||
import { useKeyboard, useRenderer, type JSX } from "@opentui/solid"
|
||||
import { useRenderer, type JSX } from "@opentui/solid"
|
||||
import { Editor } from "@tui/util/editor"
|
||||
import { useExit } from "../../context/exit"
|
||||
import { Clipboard } from "../../util/clipboard"
|
||||
@@ -400,20 +400,6 @@ export function Prompt(props: PromptProps) {
|
||||
]
|
||||
})
|
||||
|
||||
// Windows Terminal 1.25+ handles Ctrl+V on keydown when kitty events are
|
||||
// enabled, but still reports the kitty key-release event. Probe on release.
|
||||
if (process.platform === "win32") {
|
||||
useKeyboard(
|
||||
(evt) => {
|
||||
if (!input.focused) return
|
||||
if (evt.name === "v" && evt.ctrl && evt.eventType === "release") {
|
||||
command.trigger("prompt.paste")
|
||||
}
|
||||
},
|
||||
{ release: true },
|
||||
)
|
||||
}
|
||||
|
||||
const ref: PromptRef = {
|
||||
get focused() {
|
||||
return input.focused
|
||||
|
||||
@@ -148,5 +148,7 @@ const TIPS = [
|
||||
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
|
||||
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog",
|
||||
"Use {highlight}/rename{/highlight} to rename the current session",
|
||||
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell",
|
||||
...(process.platform === "win32"
|
||||
? ["Press {highlight}Ctrl+Z{/highlight} to undo changes in your prompt"]
|
||||
: ["Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell"]),
|
||||
]
|
||||
|
||||
@@ -125,7 +125,7 @@ export const rpc = {
|
||||
headers,
|
||||
body: input.body,
|
||||
})
|
||||
const response = await Server.Default().fetch(request)
|
||||
const response = await Server.Default().app.fetch(request)
|
||||
const body = await response.text()
|
||||
return {
|
||||
status: response.status,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const WebCommand = cmd({
|
||||
UI.println(UI.Style.TEXT_WARNING_BOLD + "! " + "OPENCODE_SERVER_PASSWORD is not set; server is unsecured.")
|
||||
}
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = Server.listen(opts)
|
||||
const server = await Server.listen(opts)
|
||||
UI.empty()
|
||||
UI.println(UI.logo(" "))
|
||||
UI.empty()
|
||||
|
||||
@@ -22,6 +22,7 @@ export const TuiOptions = z.object({
|
||||
.enum(["auto", "stacked"])
|
||||
.optional()
|
||||
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
|
||||
mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"),
|
||||
})
|
||||
|
||||
export const TuiInfo = z
|
||||
|
||||
@@ -111,7 +111,15 @@ export namespace TuiConfig {
|
||||
}
|
||||
}
|
||||
|
||||
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
|
||||
const keybinds = { ...(acc.result.keybinds ?? {}) }
|
||||
if (process.platform === "win32") {
|
||||
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
||||
keybinds.terminal_suspend = "none"
|
||||
keybinds.input_undo ??= unique(["ctrl+z", ...Config.Keybinds.shape.input_undo.parse(undefined).split(",")]).join(
|
||||
",",
|
||||
)
|
||||
}
|
||||
acc.result.keybinds = Config.Keybinds.parse(keybinds)
|
||||
|
||||
const deps: Promise<void>[] = []
|
||||
if (acc.result.plugin?.length) {
|
||||
|
||||
@@ -32,7 +32,15 @@ export const WorktreeAdaptor: Adaptor = {
|
||||
const config = Config.parse(info)
|
||||
await Worktree.remove({ directory: config.directory })
|
||||
},
|
||||
async fetch(_info, _input: RequestInfo | URL, _init?: RequestInit) {
|
||||
throw new Error("fetch not implemented")
|
||||
async fetch(info, input: RequestInfo | URL, init?: RequestInit) {
|
||||
const { Server } = await import("../../server/server")
|
||||
|
||||
const config = Config.parse(info)
|
||||
const url = input instanceof Request || input instanceof URL ? input : new URL(input, "http://opencode.internal")
|
||||
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
|
||||
headers.set("x-opencode-directory", config.directory)
|
||||
|
||||
const request = new Request(url, { ...init, headers })
|
||||
return Server.Default().app.fetch(request)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export namespace Flag {
|
||||
export const OPENCODE_ENABLE_EXPERIMENTAL_MODELS = truthy("OPENCODE_ENABLE_EXPERIMENTAL_MODELS")
|
||||
export const OPENCODE_DISABLE_AUTOCOMPACT = truthy("OPENCODE_DISABLE_AUTOCOMPACT")
|
||||
export const OPENCODE_DISABLE_MODELS_FETCH = truthy("OPENCODE_DISABLE_MODELS_FETCH")
|
||||
export const OPENCODE_DISABLE_MOUSE = truthy("OPENCODE_DISABLE_MOUSE")
|
||||
export const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
|
||||
export const OPENCODE_DISABLE_CLAUDE_CODE_PROMPT =
|
||||
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
|
||||
|
||||
@@ -35,6 +35,7 @@ import { JsonMigration } from "./storage/json-migration"
|
||||
import { Database } from "./storage/db"
|
||||
import { errorMessage } from "./util/error"
|
||||
import { PluginCommand } from "./cli/cmd/plug"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { Heap } from "./cli/heap"
|
||||
|
||||
process.on("unhandledRejection", (e) => {
|
||||
@@ -119,7 +120,7 @@ const cli = yargs(args)
|
||||
let last = -1
|
||||
if (tty) process.stderr.write("\x1b[?25l")
|
||||
try {
|
||||
await JsonMigration.run(Database.Client().$client, {
|
||||
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
|
||||
progress: (event) => {
|
||||
const percent = Math.floor((event.current / event.total) * 100)
|
||||
if (percent === last && event.current !== event.total) return
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
export { Config } from "./config/config"
|
||||
export { Server } from "./server/server"
|
||||
export { bootstrap } from "./cli/bootstrap"
|
||||
export { Log } from "./util/log"
|
||||
export { Database } from "./storage/db"
|
||||
export { JsonMigration } from "./storage/json-migration"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Arborist } from "@npmcli/arborist"
|
||||
|
||||
export namespace Npm {
|
||||
const log = Log.create({ service: "npm" })
|
||||
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
|
||||
|
||||
export const InstallFailedError = NamedError.create(
|
||||
"NpmInstallFailedError",
|
||||
@@ -19,8 +20,13 @@ export namespace Npm {
|
||||
}),
|
||||
)
|
||||
|
||||
export function sanitize(pkg: string) {
|
||||
if (!illegal) return pkg
|
||||
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
||||
}
|
||||
|
||||
function directory(pkg: string) {
|
||||
return path.join(Global.Path.cache, "packages", pkg)
|
||||
return path.join(Global.Path.cache, "packages", sanitize(pkg))
|
||||
}
|
||||
|
||||
function resolveEntryPoint(name: string, dir: string) {
|
||||
|
||||
67
packages/opencode/src/plugin/cloudflare.ts
Normal file
67
packages/opencode/src/plugin/cloudflare.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
export async function CloudflareWorkersAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||
const prompts = [
|
||||
...(!process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return {
|
||||
auth: {
|
||||
provider: "cloudflare-workers-ai",
|
||||
methods: [
|
||||
{
|
||||
type: "api",
|
||||
label: "API key",
|
||||
prompts,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function CloudflareAIGatewayAuthPlugin(_input: PluginInput): Promise<Hooks> {
|
||||
const prompts = [
|
||||
...(!process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "accountId",
|
||||
message: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!process.env.CLOUDFLARE_GATEWAY_ID
|
||||
? [
|
||||
{
|
||||
type: "text" as const,
|
||||
key: "gatewayId",
|
||||
message: "Enter your Cloudflare AI Gateway ID",
|
||||
placeholder: "e.g. my-gateway",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return {
|
||||
auth: {
|
||||
provider: "cloudflare-ai-gateway",
|
||||
methods: [
|
||||
{
|
||||
type: "api",
|
||||
label: "Gateway API token",
|
||||
prompts,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import { CopilotAuthPlugin } from "./github-copilot/copilot"
|
||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
|
||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
@@ -46,7 +47,14 @@ export namespace Plugin {
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
|
||||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [
|
||||
CodexAuthPlugin,
|
||||
CopilotAuthPlugin,
|
||||
GitlabAuthPlugin,
|
||||
PoeAuthPlugin,
|
||||
CloudflareWorkersAuthPlugin,
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
]
|
||||
|
||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||
return typeof value === "function"
|
||||
@@ -111,7 +119,7 @@ export namespace Plugin {
|
||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||
}
|
||||
: undefined,
|
||||
fetch: async (...args) => Server.Default().fetch(...args),
|
||||
fetch: async (...args) => Server.Default().app.fetch(...args),
|
||||
})
|
||||
const cfg = yield* config.get()
|
||||
const input: PluginInput = {
|
||||
@@ -122,7 +130,8 @@ export namespace Plugin {
|
||||
get serverUrl(): URL {
|
||||
return Server.url ?? new URL("http://localhost:4096")
|
||||
},
|
||||
$: Bun.$,
|
||||
// @ts-expect-error
|
||||
$: typeof Bun === "undefined" ? undefined : Bun.$,
|
||||
}
|
||||
|
||||
for (const plugin of INTERNAL_PLUGINS) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import npa from "npm-package-arg"
|
||||
import semver from "semver"
|
||||
import { Npm } from "@/npm"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -12,11 +13,24 @@ export function isDeprecatedPlugin(spec: string) {
|
||||
return DEPRECATED_PLUGIN_PACKAGES.some((pkg) => spec.includes(pkg))
|
||||
}
|
||||
|
||||
function parse(spec: string) {
|
||||
try {
|
||||
return npa(spec)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function parsePluginSpecifier(spec: string) {
|
||||
const lastAt = spec.lastIndexOf("@")
|
||||
const pkg = lastAt > 0 ? spec.substring(0, lastAt) : spec
|
||||
const version = lastAt > 0 ? spec.substring(lastAt + 1) : "latest"
|
||||
return { pkg, version }
|
||||
const hit = parse(spec)
|
||||
if (hit?.type === "alias" && !hit.name) {
|
||||
const sub = (hit as npa.AliasResult).subSpec
|
||||
if (sub?.name) {
|
||||
const version = !sub.rawSpec || sub.rawSpec === "*" ? "latest" : sub.rawSpec
|
||||
return { pkg: sub.name, version }
|
||||
}
|
||||
}
|
||||
if (!hit?.name) return { pkg: spec, version: "" }
|
||||
if (hit.raw === hit.name) return { pkg: hit.name, version: "latest" }
|
||||
return { pkg: hit.name, version: hit.rawSpec }
|
||||
}
|
||||
|
||||
export type PluginSource = "file" | "npm"
|
||||
@@ -190,9 +204,11 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
|
||||
export async function resolvePluginTarget(spec: string) {
|
||||
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
|
||||
const result = await Npm.add(parsed.pkg + "@" + parsed.version)
|
||||
const hit = parse(spec)
|
||||
const pkg = hit?.name && hit.raw === hit.name ? `${hit.name}@latest` : spec
|
||||
const result = await Npm.add(pkg)
|
||||
return result.directory
|
||||
}
|
||||
|
||||
|
||||
2
packages/opencode/src/provider/models-snapshot.d.ts
vendored
Normal file
2
packages/opencode/src/provider/models-snapshot.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// Auto-generated by build.ts - do not edit
|
||||
export declare const snapshot: Record<string, unknown>
|
||||
3
packages/opencode/src/provider/models-snapshot.js
Normal file
3
packages/opencode/src/provider/models-snapshot.js
Normal file
File diff suppressed because one or more lines are too long
@@ -672,13 +672,26 @@ export namespace Provider {
|
||||
}
|
||||
}),
|
||||
"cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) {
|
||||
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
||||
if (!accountId) return { autoload: false }
|
||||
// When baseURL is already configured (e.g. corporate config routing through a proxy/gateway),
|
||||
// skip the account ID check because the URL is already fully specified.
|
||||
if (input.options?.baseURL) return { autoload: false }
|
||||
|
||||
const auth = yield* dep.auth(input.id)
|
||||
const accountId =
|
||||
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||
if (!accountId)
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel() {
|
||||
throw new Error(
|
||||
"CLOUDFLARE_ACCOUNT_ID is missing. Set it with: export CLOUDFLARE_ACCOUNT_ID=<your-account-id>",
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const apiKey = yield* Effect.gen(function* () {
|
||||
const envToken = Env.get("CLOUDFLARE_API_KEY")
|
||||
if (envToken) return envToken
|
||||
const auth = yield* dep.auth(input.id)
|
||||
if (auth?.type === "api") return auth.key
|
||||
return undefined
|
||||
})
|
||||
@@ -702,16 +715,34 @@ export namespace Provider {
|
||||
}
|
||||
}),
|
||||
"cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) {
|
||||
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
|
||||
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
|
||||
// When baseURL is already configured (e.g. corporate config), skip the ID checks.
|
||||
if (input.options?.baseURL) return { autoload: false }
|
||||
|
||||
if (!accountId || !gateway) return { autoload: false }
|
||||
const auth = yield* dep.auth(input.id)
|
||||
const accountId =
|
||||
Env.get("CLOUDFLARE_ACCOUNT_ID") || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
|
||||
const gateway =
|
||||
Env.get("CLOUDFLARE_GATEWAY_ID") || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
|
||||
|
||||
if (!accountId || !gateway) {
|
||||
const missing = [
|
||||
!accountId ? "CLOUDFLARE_ACCOUNT_ID" : undefined,
|
||||
!gateway ? "CLOUDFLARE_GATEWAY_ID" : undefined,
|
||||
].filter((x): x is string => Boolean(x))
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel() {
|
||||
throw new Error(
|
||||
`${missing.join(" and ")} missing. Set with: ${missing.map((x) => `export ${x}=<value>`).join(" && ")}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Get API token from env or auth - required for authenticated gateways
|
||||
const apiToken = yield* Effect.gen(function* () {
|
||||
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
|
||||
if (envToken) return envToken
|
||||
const auth = yield* dep.auth(input.id)
|
||||
if (auth?.type === "api") return auth.key
|
||||
return undefined
|
||||
})
|
||||
|
||||
@@ -936,6 +936,12 @@ export namespace ProviderTransform {
|
||||
}
|
||||
|
||||
const key = sdkKey(model.api.npm) ?? model.providerID
|
||||
// @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from
|
||||
// providerOptions["openai"], but OpenAIResponsesLanguageModel checks
|
||||
// "azure" first. Pass both so model options work on either code path.
|
||||
if (model.api.npm === "@ai-sdk/azure") {
|
||||
return { openai: options, azure: options }
|
||||
}
|
||||
return { [key]: options }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { type IPty } from "bun-pty"
|
||||
import type { Proc } from "#pty"
|
||||
import z from "zod"
|
||||
import { Log } from "../util/log"
|
||||
import { lazy } from "@opencode-ai/util/lazy"
|
||||
@@ -26,9 +26,11 @@ export namespace Pty {
|
||||
close: (code?: number, reason?: string) => void
|
||||
}
|
||||
|
||||
const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
|
||||
|
||||
type Active = {
|
||||
info: Info
|
||||
process: IPty
|
||||
process: Proc
|
||||
buffer: string
|
||||
bufferCursor: number
|
||||
cursor: number
|
||||
@@ -50,10 +52,7 @@ export namespace Pty {
|
||||
return out
|
||||
}
|
||||
|
||||
const pty = lazy(async () => {
|
||||
const { spawn } = await import("bun-pty")
|
||||
return spawn
|
||||
})
|
||||
const pty = lazy(() => import("#pty"))
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
@@ -124,9 +123,9 @@ export namespace Pty {
|
||||
try {
|
||||
session.process.kill()
|
||||
} catch {}
|
||||
for (const [key, ws] of session.subscribers.entries()) {
|
||||
for (const [sub, ws] of session.subscribers.entries()) {
|
||||
try {
|
||||
if (ws.data === key) ws.close()
|
||||
if (sock(ws) === sub) ws.close()
|
||||
} catch {}
|
||||
}
|
||||
session.subscribers.clear()
|
||||
@@ -198,7 +197,7 @@ export namespace Pty {
|
||||
}
|
||||
log.info("creating session", { id, cmd: command, args, cwd })
|
||||
|
||||
const spawn = yield* Effect.promise(() => pty())
|
||||
const { spawn } = yield* Effect.promise(() => pty())
|
||||
const proc = yield* Effect.sync(() =>
|
||||
spawn(command, args, {
|
||||
name: "xterm-256color",
|
||||
@@ -234,7 +233,7 @@ export namespace Pty {
|
||||
session.subscribers.delete(key)
|
||||
continue
|
||||
}
|
||||
if (ws.data !== key) {
|
||||
if (sock(ws) !== key) {
|
||||
session.subscribers.delete(key)
|
||||
continue
|
||||
}
|
||||
@@ -304,15 +303,12 @@ export namespace Pty {
|
||||
}
|
||||
log.info("client connected to session", { id })
|
||||
|
||||
// Use ws.data as the unique key for this connection lifecycle.
|
||||
// If ws.data is undefined, fallback to ws object.
|
||||
const key = ws.data && typeof ws.data === "object" ? ws.data : ws
|
||||
// Optionally cleanup if the key somehow exists
|
||||
session.subscribers.delete(key)
|
||||
session.subscribers.set(key, ws)
|
||||
const sub = sock(ws)
|
||||
session.subscribers.delete(sub)
|
||||
session.subscribers.set(sub, ws)
|
||||
|
||||
const cleanup = () => {
|
||||
session.subscribers.delete(key)
|
||||
session.subscribers.delete(sub)
|
||||
}
|
||||
|
||||
const start = session.bufferCursor
|
||||
|
||||
26
packages/opencode/src/pty/pty.bun.ts
Normal file
26
packages/opencode/src/pty/pty.bun.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { spawn as create } from "bun-pty"
|
||||
import type { Opts, Proc } from "./pty"
|
||||
|
||||
export type { Disp, Exit, Opts, Proc } from "./pty"
|
||||
|
||||
export function spawn(file: string, args: string[], opts: Opts): Proc {
|
||||
const pty = create(file, args, opts)
|
||||
return {
|
||||
pid: pty.pid,
|
||||
onData(listener) {
|
||||
return pty.onData(listener)
|
||||
},
|
||||
onExit(listener) {
|
||||
return pty.onExit(listener)
|
||||
},
|
||||
write(data) {
|
||||
pty.write(data)
|
||||
},
|
||||
resize(cols, rows) {
|
||||
pty.resize(cols, rows)
|
||||
},
|
||||
kill(signal) {
|
||||
pty.kill(signal)
|
||||
},
|
||||
}
|
||||
}
|
||||
27
packages/opencode/src/pty/pty.node.ts
Normal file
27
packages/opencode/src/pty/pty.node.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** @ts-expect-error */
|
||||
import * as pty from "@lydell/node-pty"
|
||||
import type { Opts, Proc } from "./pty"
|
||||
|
||||
export type { Disp, Exit, Opts, Proc } from "./pty"
|
||||
|
||||
export function spawn(file: string, args: string[], opts: Opts): Proc {
|
||||
const proc = pty.spawn(file, args, opts)
|
||||
return {
|
||||
pid: proc.pid,
|
||||
onData(listener) {
|
||||
return proc.onData(listener)
|
||||
},
|
||||
onExit(listener) {
|
||||
return proc.onExit(listener)
|
||||
},
|
||||
write(data) {
|
||||
proc.write(data)
|
||||
},
|
||||
resize(cols, rows) {
|
||||
proc.resize(cols, rows)
|
||||
},
|
||||
kill(signal) {
|
||||
proc.kill(signal)
|
||||
},
|
||||
}
|
||||
}
|
||||
25
packages/opencode/src/pty/pty.ts
Normal file
25
packages/opencode/src/pty/pty.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type Disp = {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export type Exit = {
|
||||
exitCode: number
|
||||
signal?: number | string
|
||||
}
|
||||
|
||||
export type Opts = {
|
||||
name: string
|
||||
cols?: number
|
||||
rows?: number
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
export type Proc = {
|
||||
pid: number
|
||||
onData(listener: (data: string) => void): Disp
|
||||
onExit(listener: (event: Exit) => void): Disp
|
||||
write(data: string): void
|
||||
resize(cols: number, rows: number): void
|
||||
kill(signal?: string): void
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { proxy } from "hono/proxy"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import z from "zod"
|
||||
import { createHash } from "node:crypto"
|
||||
import { Log } from "../util/log"
|
||||
@@ -41,11 +42,11 @@ const DEFAULT_CSP =
|
||||
const csp = (hash = "") =>
|
||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||
|
||||
export const InstanceRoutes = (app?: Hono) =>
|
||||
(app ?? new Hono())
|
||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()) =>
|
||||
app
|
||||
.onError(errorHandler(log))
|
||||
.route("/project", ProjectRoutes())
|
||||
.route("/pty", PtyRoutes())
|
||||
.route("/pty", PtyRoutes(upgrade))
|
||||
.route("/config", ConfigRoutes())
|
||||
.route("/experimental", ExperimentalRoutes())
|
||||
.route("/session", SessionRoutes())
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { getAdaptor } from "@/control-plane/adaptors"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
import { Workspace } from "@/control-plane/workspace"
|
||||
@@ -24,76 +25,78 @@ function local(method: string, path: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
const routes = lazy(() => InstanceRoutes())
|
||||
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
||||
const routes = lazy(() => InstanceRoutes(upgrade))
|
||||
|
||||
export const WorkspaceRouterMiddleware: MiddlewareHandler = async (c) => {
|
||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||
const directory = Filesystem.resolve(
|
||||
(() => {
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
})(),
|
||||
)
|
||||
return async (c) => {
|
||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||
const directory = Filesystem.resolve(
|
||||
(() => {
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
})(),
|
||||
)
|
||||
|
||||
const url = new URL(c.req.url)
|
||||
const workspaceParam = url.searchParams.get("workspace")
|
||||
const url = new URL(c.req.url)
|
||||
const workspaceParam = url.searchParams.get("workspace")
|
||||
|
||||
// TODO: If session is being routed, force it to lookup the
|
||||
// project/workspace
|
||||
// TODO: If session is being routed, force it to lookup the
|
||||
// project/workspace
|
||||
|
||||
// If no workspace is provided we use the "project" workspace
|
||||
if (!workspaceParam) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
},
|
||||
// If no workspace is provided we use the "project" workspace
|
||||
if (!workspaceParam) {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const workspaceID = WorkspaceID.make(workspaceParam)
|
||||
const workspace = await Workspace.get(workspaceID)
|
||||
if (!workspace) {
|
||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||
status: 500,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handle local workspaces directly so we can pass env to `fetch`,
|
||||
// necessary for websocket upgrades
|
||||
if (workspace.type === "worktree") {
|
||||
return Instance.provide({
|
||||
directory: workspace.directory!,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Remote workspaces
|
||||
|
||||
if (local(c.req.method, url.pathname)) {
|
||||
// No instance provided because we are serving cached data; there
|
||||
// is no instance to work with
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
}
|
||||
|
||||
const adaptor = await getAdaptor(workspace.type)
|
||||
const headers = new Headers(c.req.raw.headers)
|
||||
headers.delete("x-opencode-workspace")
|
||||
|
||||
return adaptor.fetch(workspace, `${url.pathname}${url.search}`, {
|
||||
method: c.req.method,
|
||||
body: c.req.method === "GET" || c.req.method === "HEAD" ? undefined : await c.req.raw.arrayBuffer(),
|
||||
signal: c.req.raw.signal,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
const workspaceID = WorkspaceID.make(workspaceParam)
|
||||
const workspace = await Workspace.get(workspaceID)
|
||||
if (!workspace) {
|
||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||
status: 500,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handle local workspaces directly so we can pass env to `fetch`,
|
||||
// necessary for websocket upgrades
|
||||
if (workspace.type === "worktree") {
|
||||
return Instance.provide({
|
||||
directory: workspace.directory!,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Remote workspaces
|
||||
|
||||
if (local(c.req.method, url.pathname)) {
|
||||
// No instance provided because we are serving cached data; there
|
||||
// is no instance to work with
|
||||
return routes().fetch(c.req.raw, c.env)
|
||||
}
|
||||
|
||||
const adaptor = await getAdaptor(workspace.type)
|
||||
const headers = new Headers(c.req.raw.headers)
|
||||
headers.delete("x-opencode-workspace")
|
||||
|
||||
return adaptor.fetch(workspace, `${url.pathname}${url.search}`, {
|
||||
method: c.req.method,
|
||||
body: c.req.method === "GET" || c.req.method === "HEAD" ? undefined : await c.req.raw.arrayBuffer(),
|
||||
signal: c.req.raw.signal,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { Hono } from "hono"
|
||||
import { Hono, type MiddlewareHandler } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { upgradeWebSocket } from "hono/bun"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import z from "zod"
|
||||
import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { NotFoundError } from "../../storage/db"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
|
||||
export const PtyRoutes = lazy(() =>
|
||||
new Hono()
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
return new Hono()
|
||||
.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
@@ -207,5 +206,5 @@ export const PtyRoutes = lazy(() =>
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -843,19 +843,17 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
validator("json", SessionPrompt.PromptInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
c.status(204)
|
||||
c.header("Content-Type", "application/json")
|
||||
return stream(c, async () => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
|
||||
log.error("prompt_async failed", { sessionID, error: err })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
|
||||
})
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
|
||||
log.error("prompt_async failed", { sessionID, error: err })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
|
||||
})
|
||||
})
|
||||
|
||||
return c.body(null, 204)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
|
||||
@@ -2,14 +2,15 @@ import { Log } from "../util/log"
|
||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { compress } from "hono/compress"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import { cors } from "hono/cors"
|
||||
import { basicAuth } from "hono/basic-auth"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import z from "zod"
|
||||
import { Auth } from "../auth"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { ProviderID } from "../provider/schema"
|
||||
import { WorkspaceRouterMiddleware } from "./router"
|
||||
import { websocket } from "hono/bun"
|
||||
import { errors } from "./error"
|
||||
import { GlobalRoutes } from "./routes/global"
|
||||
import { MDNS } from "./mdns"
|
||||
@@ -17,6 +18,7 @@ import { lazy } from "@/util/lazy"
|
||||
import { errorHandler } from "./middleware"
|
||||
import { InstanceRoutes } from "./instance"
|
||||
import { initProjectors } from "./projectors"
|
||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||
|
||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -24,8 +26,14 @@ globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
initProjectors()
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
export type Listener = {
|
||||
hostname: string
|
||||
port: number
|
||||
url: URL
|
||||
stop: (close?: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
const zipped = compress()
|
||||
|
||||
const skipCompress = (path: string, method: string) => {
|
||||
@@ -34,10 +42,9 @@ export namespace Server {
|
||||
return false
|
||||
}
|
||||
|
||||
export const Default = lazy(() => ControlPlaneRoutes())
|
||||
export const Default = lazy(() => create({}))
|
||||
|
||||
export const ControlPlaneRoutes = (opts?: { cors?: string[] }): Hono => {
|
||||
const app = new Hono()
|
||||
export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono {
|
||||
return app
|
||||
.onError(errorHandler(log))
|
||||
.use((c, next) => {
|
||||
@@ -62,9 +69,7 @@ export namespace Server {
|
||||
path: c.req.path,
|
||||
})
|
||||
await next()
|
||||
if (!skip) {
|
||||
timer.stop()
|
||||
}
|
||||
if (!skip) timer.stop()
|
||||
})
|
||||
.use(
|
||||
cors({
|
||||
@@ -81,15 +86,8 @@ export namespace Server {
|
||||
)
|
||||
return input
|
||||
|
||||
// *.opencode.ai (https only, adjust if needed)
|
||||
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
|
||||
return input
|
||||
}
|
||||
if (opts?.cors?.includes(input)) {
|
||||
return input
|
||||
}
|
||||
|
||||
return
|
||||
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
|
||||
if (opts?.cors?.includes(input)) return input
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -234,11 +232,20 @@ export namespace Server {
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.use(WorkspaceRouterMiddleware)
|
||||
.use(WorkspaceRouterMiddleware(upgrade))
|
||||
}
|
||||
|
||||
function create(opts: { cors?: string[] }) {
|
||||
const app = new Hono()
|
||||
const ws = createNodeWebSocket({ app })
|
||||
return {
|
||||
app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts),
|
||||
ws,
|
||||
}
|
||||
}
|
||||
|
||||
export function createApp(opts: { cors?: string[] }) {
|
||||
return ControlPlaneRoutes(opts)
|
||||
return create(opts).app
|
||||
}
|
||||
|
||||
export async function openapi() {
|
||||
@@ -246,8 +253,8 @@ export namespace Server {
|
||||
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
||||
// handlers when the sub-app has a custom errorHandler, which
|
||||
// strips the metadata symbol).
|
||||
const app = ControlPlaneRoutes()
|
||||
InstanceRoutes(app)
|
||||
const { app, ws } = create({})
|
||||
InstanceRoutes(ws.upgradeWebSocket, app)
|
||||
const result = await generateSpecs(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
@@ -261,52 +268,86 @@ export namespace Server {
|
||||
return result
|
||||
}
|
||||
|
||||
/** @deprecated do not use this dumb shit */
|
||||
export let url: URL
|
||||
|
||||
export function listen(opts: {
|
||||
export async function listen(opts: {
|
||||
port: number
|
||||
hostname: string
|
||||
mdns?: boolean
|
||||
mdnsDomain?: string
|
||||
cors?: string[]
|
||||
}) {
|
||||
url = new URL(`http://${opts.hostname}:${opts.port}`)
|
||||
const app = ControlPlaneRoutes({ cors: opts.cors })
|
||||
const args = {
|
||||
hostname: opts.hostname,
|
||||
idleTimeout: 0,
|
||||
fetch: app.fetch,
|
||||
websocket: websocket,
|
||||
} as const
|
||||
const tryServe = (port: number) => {
|
||||
try {
|
||||
return Bun.serve({ ...args, port })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
|
||||
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
|
||||
}): Promise<Listener> {
|
||||
const built = create(opts)
|
||||
const start = (port: number) =>
|
||||
new Promise<ServerType>((resolve, reject) => {
|
||||
const server = createAdaptorServer({ fetch: built.app.fetch })
|
||||
built.ws.injectWebSocket(server)
|
||||
const fail = (err: Error) => {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
const ready = () => {
|
||||
cleanup()
|
||||
resolve(server)
|
||||
}
|
||||
const cleanup = () => {
|
||||
server.off("error", fail)
|
||||
server.off("listening", ready)
|
||||
}
|
||||
server.once("error", fail)
|
||||
server.once("listening", ready)
|
||||
server.listen(port, opts.hostname)
|
||||
})
|
||||
|
||||
const shouldPublishMDNS =
|
||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||
const addr = server.address()
|
||||
if (!addr || typeof addr === "string") {
|
||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||
}
|
||||
|
||||
const next = new URL("http://localhost")
|
||||
next.hostname = opts.hostname
|
||||
next.port = String(addr.port)
|
||||
url = next
|
||||
|
||||
const mdns =
|
||||
opts.mdns &&
|
||||
server.port &&
|
||||
addr.port &&
|
||||
opts.hostname !== "127.0.0.1" &&
|
||||
opts.hostname !== "localhost" &&
|
||||
opts.hostname !== "::1"
|
||||
if (shouldPublishMDNS) {
|
||||
MDNS.publish(server.port!, opts.mdnsDomain)
|
||||
if (mdns) {
|
||||
MDNS.publish(addr.port, opts.mdnsDomain)
|
||||
} else if (opts.mdns) {
|
||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||
}
|
||||
|
||||
const originalStop = server.stop.bind(server)
|
||||
server.stop = async (closeActiveConnections?: boolean) => {
|
||||
if (shouldPublishMDNS) MDNS.unpublish()
|
||||
return originalStop(closeActiveConnections)
|
||||
let closing: Promise<void> | undefined
|
||||
return {
|
||||
hostname: opts.hostname,
|
||||
port: addr.port,
|
||||
url: next,
|
||||
stop(close?: boolean) {
|
||||
closing ??= new Promise((resolve, reject) => {
|
||||
if (mdns) MDNS.unpublish()
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
if (close) {
|
||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||
server.closeAllConnections()
|
||||
}
|
||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||
server.closeIdleConnections()
|
||||
}
|
||||
}
|
||||
})
|
||||
return closing
|
||||
},
|
||||
}
|
||||
|
||||
return server
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
|
||||
import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
@@ -23,7 +23,7 @@ export namespace JsonMigration {
|
||||
progress?: (event: Progress) => void
|
||||
}
|
||||
|
||||
export async function run(sqlite: Database, options?: Options) {
|
||||
export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<any, any>, options?: Options) {
|
||||
const storageDir = path.join(Global.Path.data, "storage")
|
||||
|
||||
if (!existsSync(storageDir)) {
|
||||
@@ -43,13 +43,13 @@ export namespace JsonMigration {
|
||||
log.info("starting json to sqlite migration", { storageDir })
|
||||
const start = performance.now()
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
// const db = drizzle({ client: sqlite })
|
||||
|
||||
// Optimize SQLite for bulk inserts
|
||||
sqlite.exec("PRAGMA journal_mode = WAL")
|
||||
sqlite.exec("PRAGMA synchronous = OFF")
|
||||
sqlite.exec("PRAGMA cache_size = 10000")
|
||||
sqlite.exec("PRAGMA temp_store = MEMORY")
|
||||
db.run("PRAGMA journal_mode = WAL")
|
||||
db.run("PRAGMA synchronous = OFF")
|
||||
db.run("PRAGMA cache_size = 10000")
|
||||
db.run("PRAGMA temp_store = MEMORY")
|
||||
const stats = {
|
||||
projects: 0,
|
||||
sessions: 0,
|
||||
@@ -146,7 +146,7 @@ export namespace JsonMigration {
|
||||
|
||||
progress?.({ current, total, label: "starting" })
|
||||
|
||||
sqlite.exec("BEGIN TRANSACTION")
|
||||
db.run("BEGIN TRANSACTION")
|
||||
|
||||
// Migrate projects first (no FK deps)
|
||||
// Derive all IDs from file paths, not JSON content
|
||||
@@ -400,7 +400,7 @@ export namespace JsonMigration {
|
||||
log.warn("skipped orphaned session shares", { count: orphans.shares })
|
||||
}
|
||||
|
||||
sqlite.exec("COMMIT")
|
||||
db.run("COMMIT")
|
||||
|
||||
log.info("json migration complete", {
|
||||
projects: stats.projects,
|
||||
|
||||
@@ -185,7 +185,7 @@ export const ReadTool = Tool.defineEffect(
|
||||
)
|
||||
}
|
||||
|
||||
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>"].join("\n")
|
||||
let output = [`<path>${filepath}</path>`, `<type>file</type>`, "<content>" + "\n"].join("\n")
|
||||
output += file.raw.map((line, i) => `${i + file.offset}: ${line}`).join("\n")
|
||||
|
||||
const last = file.offset + file.raw.length - 1
|
||||
|
||||
@@ -33,7 +33,6 @@ import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Env } from "../env"
|
||||
import { Agent as AgentSvc } from "../agent/agent"
|
||||
import { Question } from "../question"
|
||||
import { Todo } from "../session/todo"
|
||||
import { LSP } from "../lsp"
|
||||
@@ -69,7 +68,6 @@ export namespace ToolRegistry {
|
||||
| Plugin.Service
|
||||
| Question.Service
|
||||
| Todo.Service
|
||||
| AgentSvc.Service
|
||||
| LSP.Service
|
||||
| FileTime.Service
|
||||
| Instruction.Service
|
||||
@@ -239,7 +237,6 @@ export namespace ToolRegistry {
|
||||
layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(AgentSvc.defaultLayer),
|
||||
Layer.provide(Question.defaultLayer),
|
||||
Layer.provide(Todo.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./task.txt"
|
||||
import z from "zod"
|
||||
import { Effect } from "effect"
|
||||
import { Session } from "../session"
|
||||
import { SessionID, MessageID } from "../session/schema"
|
||||
import { MessageV2 } from "../session/message-v2"
|
||||
import { Identifier } from "../id/id"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { SessionPrompt } from "../session/prompt"
|
||||
import { iife } from "@/util/iife"
|
||||
import { defer } from "@/util/defer"
|
||||
import { Config } from "../config/config"
|
||||
import { Permission } from "@/permission"
|
||||
|
||||
@@ -23,103 +25,87 @@ const parameters = z.object({
|
||||
command: z.string().describe("The command that triggered this task").optional(),
|
||||
})
|
||||
|
||||
export const TaskTool = Tool.defineEffect(
|
||||
"task",
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const config = yield* Config.Service
|
||||
export const TaskTool = Tool.define("task", async (ctx) => {
|
||||
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
|
||||
|
||||
const list = Effect.fn("TaskTool.list")(function* (caller?: Tool.InitContext["agent"]) {
|
||||
const items = yield* agent.list().pipe(Effect.map((items) => items.filter((item) => item.mode !== "primary")))
|
||||
const filtered = caller
|
||||
? items.filter((item) => Permission.evaluate("task", item.name, caller.permission).action !== "deny")
|
||||
: items
|
||||
return filtered.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
// Filter agents by permissions if agent provided
|
||||
const caller = ctx?.agent
|
||||
const accessibleAgents = caller
|
||||
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
|
||||
: agents
|
||||
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
const desc = Effect.fn("TaskTool.desc")(function* (caller?: Tool.InitContext["agent"]) {
|
||||
const items = yield* list(caller)
|
||||
return DESCRIPTION.replace(
|
||||
"{agents}",
|
||||
items
|
||||
.map(
|
||||
(item) =>
|
||||
`- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
|
||||
)
|
||||
.join("\n"),
|
||||
)
|
||||
})
|
||||
|
||||
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
||||
const cfg = yield* config.get()
|
||||
const description = DESCRIPTION.replace(
|
||||
"{agents}",
|
||||
list
|
||||
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
|
||||
.join("\n"),
|
||||
)
|
||||
return {
|
||||
description,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||
const config = await Config.get()
|
||||
|
||||
// Skip permission check when user explicitly invoked via @ or command subtask
|
||||
if (!ctx.extra?.bypassAgentCheck) {
|
||||
yield* Effect.promise(() =>
|
||||
ctx.ask({
|
||||
permission: "task",
|
||||
patterns: [params.subagent_type],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
description: params.description,
|
||||
subagent_type: params.subagent_type,
|
||||
},
|
||||
}),
|
||||
)
|
||||
await ctx.ask({
|
||||
permission: "task",
|
||||
patterns: [params.subagent_type],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
description: params.description,
|
||||
subagent_type: params.subagent_type,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const next = yield* agent.get(params.subagent_type)
|
||||
if (!next) {
|
||||
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
|
||||
}
|
||||
const agent = await Agent.get(params.subagent_type)
|
||||
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
|
||||
|
||||
const hasTask = next.permission.some((rule) => rule.permission === "task")
|
||||
const hasTodo = next.permission.some((rule) => rule.permission === "todowrite")
|
||||
const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
|
||||
const hasTodoWritePermission = agent.permission.some((rule) => rule.permission === "todowrite")
|
||||
|
||||
const taskID = params.task_id
|
||||
const session = taskID
|
||||
? yield* Effect.promise(() => {
|
||||
const id = SessionID.make(taskID)
|
||||
return Session.get(id).catch(() => undefined)
|
||||
})
|
||||
: undefined
|
||||
const nextSession =
|
||||
session ??
|
||||
(yield* Effect.promise(() =>
|
||||
Session.create({
|
||||
parentID: ctx.sessionID,
|
||||
title: params.description + ` (@${next.name} subagent)`,
|
||||
permission: [
|
||||
...(hasTodo
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: "todowrite" as const,
|
||||
pattern: "*" as const,
|
||||
action: "deny" as const,
|
||||
},
|
||||
]),
|
||||
...(hasTask
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: "task" as const,
|
||||
pattern: "*" as const,
|
||||
action: "deny" as const,
|
||||
},
|
||||
]),
|
||||
...(cfg.experimental?.primary_tools?.map((item) => ({
|
||||
pattern: "*",
|
||||
action: "allow" as const,
|
||||
permission: item,
|
||||
})) ?? []),
|
||||
],
|
||||
}),
|
||||
))
|
||||
const session = await iife(async () => {
|
||||
if (params.task_id) {
|
||||
const found = await Session.get(SessionID.make(params.task_id)).catch(() => {})
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
|
||||
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
|
||||
return await Session.create({
|
||||
parentID: ctx.sessionID,
|
||||
title: params.description + ` (@${agent.name} subagent)`,
|
||||
permission: [
|
||||
...(hasTodoWritePermission
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: "todowrite" as const,
|
||||
pattern: "*" as const,
|
||||
action: "deny" as const,
|
||||
},
|
||||
]),
|
||||
...(hasTaskPermission
|
||||
? []
|
||||
: [
|
||||
{
|
||||
permission: "task" as const,
|
||||
pattern: "*" as const,
|
||||
action: "deny" as const,
|
||||
},
|
||||
]),
|
||||
...(config.experimental?.primary_tools?.map((t) => ({
|
||||
pattern: "*",
|
||||
action: "allow" as const,
|
||||
permission: t,
|
||||
})) ?? []),
|
||||
],
|
||||
})
|
||||
})
|
||||
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
|
||||
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
|
||||
|
||||
const model = next.model ?? {
|
||||
const model = agent.model ?? {
|
||||
modelID: msg.info.modelID,
|
||||
providerID: msg.info.providerID,
|
||||
}
|
||||
@@ -127,7 +113,7 @@ export const TaskTool = Tool.defineEffect(
|
||||
ctx.metadata({
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: nextSession.id,
|
||||
sessionId: session.id,
|
||||
model,
|
||||
},
|
||||
})
|
||||
@@ -135,64 +121,46 @@ export const TaskTool = Tool.defineEffect(
|
||||
const messageID = MessageID.ascending()
|
||||
|
||||
function cancel() {
|
||||
SessionPrompt.cancel(nextSession.id)
|
||||
SessionPrompt.cancel(session.id)
|
||||
}
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
ctx.abort.addEventListener("abort", cancel)
|
||||
}),
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt))
|
||||
const result = yield* Effect.promise(() =>
|
||||
SessionPrompt.prompt({
|
||||
messageID,
|
||||
sessionID: nextSession.id,
|
||||
model: {
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
},
|
||||
agent: next.name,
|
||||
tools: {
|
||||
...(hasTodo ? {} : { todowrite: false }),
|
||||
...(hasTask ? {} : { task: false }),
|
||||
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
|
||||
},
|
||||
parts,
|
||||
}),
|
||||
)
|
||||
return {
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: nextSession.id,
|
||||
model,
|
||||
},
|
||||
output: [
|
||||
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
|
||||
"",
|
||||
"<task_result>",
|
||||
result.parts.findLast((item) => item.type === "text")?.text ?? "",
|
||||
"</task_result>",
|
||||
].join("\n"),
|
||||
}
|
||||
}),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
ctx.abort.removeEventListener("abort", cancel)
|
||||
}),
|
||||
)
|
||||
})
|
||||
ctx.abort.addEventListener("abort", cancel)
|
||||
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))
|
||||
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
|
||||
|
||||
return async (ctx) => {
|
||||
const description = await Effect.runPromise(desc(ctx?.agent))
|
||||
const result = await SessionPrompt.prompt({
|
||||
messageID,
|
||||
sessionID: session.id,
|
||||
model: {
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
},
|
||||
agent: agent.name,
|
||||
tools: {
|
||||
...(hasTodoWritePermission ? {} : { todowrite: false }),
|
||||
...(hasTaskPermission ? {} : { task: false }),
|
||||
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
|
||||
},
|
||||
parts: promptParts,
|
||||
})
|
||||
|
||||
const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""
|
||||
|
||||
const output = [
|
||||
`task_id: ${session.id} (for resuming to continue this task if needed)`,
|
||||
"",
|
||||
"<task_result>",
|
||||
text,
|
||||
"</task_result>",
|
||||
].join("\n")
|
||||
|
||||
return {
|
||||
description,
|
||||
parameters,
|
||||
async execute(params: z.infer<typeof parameters>, ctx) {
|
||||
return Effect.runPromise(run(params, ctx))
|
||||
title: params.description,
|
||||
metadata: {
|
||||
sessionId: session.id,
|
||||
model,
|
||||
},
|
||||
output,
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Global } from "../../src/global"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
const wintest = process.platform === "win32" ? test : test.skip
|
||||
|
||||
beforeEach(async () => {
|
||||
await Config.invalidate(true)
|
||||
@@ -441,6 +442,53 @@ test("merges keybind overrides across precedence layers", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
wintest("defaults Ctrl+Z to input undo on Windows", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
wintest("keeps explicit input undo overrides on Windows", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { input_undo: "ctrl+y" } }))
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||
expect(config.keybinds?.input_undo).toBe("ctrl+y")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
wintest("ignores terminal suspend bindings on Windows", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { terminal_suspend: "alt+z" } }))
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
expect(config.keybinds?.terminal_suspend).toBe("none")
|
||||
expect(config.keybinds?.input_undo).toBe("ctrl+z,ctrl+-,super+z")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
18
packages/opencode/test/npm.test.ts
Normal file
18
packages/opencode/test/npm.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Npm } from "../src/npm"
|
||||
|
||||
const win = process.platform === "win32"
|
||||
|
||||
describe("Npm.sanitize", () => {
|
||||
test("keeps normal scoped package specs unchanged", () => {
|
||||
expect(Npm.sanitize("@opencode/acme")).toBe("@opencode/acme")
|
||||
expect(Npm.sanitize("@opencode/acme@1.0.0")).toBe("@opencode/acme@1.0.0")
|
||||
expect(Npm.sanitize("prettier")).toBe("prettier")
|
||||
})
|
||||
|
||||
test("handles git https specs", () => {
|
||||
const spec = "acme@git+https://github.com/opencode/acme.git"
|
||||
const expected = win ? "acme@git+https_//github.com/opencode/acme.git" : spec
|
||||
expect(Npm.sanitize(spec)).toBe(expected)
|
||||
})
|
||||
})
|
||||
88
packages/opencode/test/plugin/shared.test.ts
Normal file
88
packages/opencode/test/plugin/shared.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { parsePluginSpecifier } from "../../src/plugin/shared"
|
||||
|
||||
describe("parsePluginSpecifier", () => {
|
||||
test("parses standard npm package without version", () => {
|
||||
expect(parsePluginSpecifier("acme")).toEqual({
|
||||
pkg: "acme",
|
||||
version: "latest",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses standard npm package with version", () => {
|
||||
expect(parsePluginSpecifier("acme@1.0.0")).toEqual({
|
||||
pkg: "acme",
|
||||
version: "1.0.0",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses scoped npm package without version", () => {
|
||||
expect(parsePluginSpecifier("@opencode/acme")).toEqual({
|
||||
pkg: "@opencode/acme",
|
||||
version: "latest",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses scoped npm package with version", () => {
|
||||
expect(parsePluginSpecifier("@opencode/acme@1.0.0")).toEqual({
|
||||
pkg: "@opencode/acme",
|
||||
version: "1.0.0",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses package with git+https url", () => {
|
||||
expect(parsePluginSpecifier("acme@git+https://github.com/opencode/acme.git")).toEqual({
|
||||
pkg: "acme",
|
||||
version: "git+https://github.com/opencode/acme.git",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses scoped package with git+https url", () => {
|
||||
expect(parsePluginSpecifier("@opencode/acme@git+https://github.com/opencode/acme.git")).toEqual({
|
||||
pkg: "@opencode/acme",
|
||||
version: "git+https://github.com/opencode/acme.git",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses package with git+ssh url containing another @", () => {
|
||||
expect(parsePluginSpecifier("acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({
|
||||
pkg: "acme",
|
||||
version: "git+ssh://git@github.com/opencode/acme.git",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses scoped package with git+ssh url containing another @", () => {
|
||||
expect(parsePluginSpecifier("@opencode/acme@git+ssh://git@github.com/opencode/acme.git")).toEqual({
|
||||
pkg: "@opencode/acme",
|
||||
version: "git+ssh://git@github.com/opencode/acme.git",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses unaliased git+ssh url", () => {
|
||||
expect(parsePluginSpecifier("git+ssh://git@github.com/opencode/acme.git")).toEqual({
|
||||
pkg: "git+ssh://git@github.com/opencode/acme.git",
|
||||
version: "",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses npm alias using the alias name", () => {
|
||||
expect(parsePluginSpecifier("acme@npm:@opencode/acme@1.0.0")).toEqual({
|
||||
pkg: "acme",
|
||||
version: "npm:@opencode/acme@1.0.0",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses bare npm protocol specifier using the target package", () => {
|
||||
expect(parsePluginSpecifier("npm:@opencode/acme@1.0.0")).toEqual({
|
||||
pkg: "@opencode/acme",
|
||||
version: "1.0.0",
|
||||
})
|
||||
})
|
||||
|
||||
test("parses unversioned npm protocol specifier", () => {
|
||||
expect(parsePluginSpecifier("npm:@opencode/acme")).toEqual({
|
||||
pkg: "@opencode/acme",
|
||||
version: "latest",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@ afterEach(async () => {
|
||||
describe("project.initGit endpoint", () => {
|
||||
test("initializes git and reloads immediately", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const app = Server.Default()
|
||||
const app = Server.Default().app
|
||||
const seen: { directory?: string; payload: { type: string } }[] = []
|
||||
const fn = (evt: { directory?: string; payload: { type: string } }) => {
|
||||
seen.push(evt)
|
||||
@@ -76,7 +76,7 @@ describe("project.initGit endpoint", () => {
|
||||
|
||||
test("does not reload when the project is already git", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const app = Server.Default()
|
||||
const app = Server.Default().app
|
||||
const seen: { directory?: string; payload: { type: string } }[] = []
|
||||
const fn = (evt: { directory?: string; payload: { type: string } }) => {
|
||||
seen.push(evt)
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("session action routes", () => {
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
|
||||
const app = Server.Default()
|
||||
const app = Server.Default().app
|
||||
|
||||
const res = await app.request(`/session/${session.id}/abort`, {
|
||||
method: "POST",
|
||||
@@ -66,7 +66,7 @@ describe("session action routes", () => {
|
||||
const msg = await user(session.id, "hello")
|
||||
const busy = spyOn(SessionPrompt, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id))
|
||||
const remove = spyOn(Session, "removeMessage").mockResolvedValue(msg.id)
|
||||
const app = Server.Default()
|
||||
const app = Server.Default().app
|
||||
|
||||
const res = await app.request(`/session/${session.id}/message/${msg.id}`, {
|
||||
method: "DELETE",
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("session messages endpoint", () => {
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const ids = await fill(session.id, 5)
|
||||
const app = Server.Default()
|
||||
const app = Server.Default().app
|
||||
|
||||
const a = await app.request(`/session/${session.id}/message?limit=2`)
|
||||
expect(a.status).toBe(200)
|
||||
@@ -89,7 +89,7 @@ describe("session messages endpoint", () => {
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const ids = await fill(session.id, 3)
|
||||
const app = Server.Default()
|
||||
const app = Server.Default().app
|
||||
|
||||
const res = await app.request(`/session/${session.id}/message`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -109,7 +109,7 @@ describe("session messages endpoint", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const app = Server.Default()
|
||||
const app = Server.Default().app
|
||||
|
||||
const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`)
|
||||
expect(bad.status).toBe(400)
|
||||
@@ -131,7 +131,7 @@ describe("session messages endpoint", () => {
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
await fill(session.id, 520)
|
||||
const app = Server.Default()
|
||||
const app = Server.Default().app
|
||||
|
||||
const res = await app.request(`/session/${session.id}/message?limit=510`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("tui.selectSession endpoint", () => {
|
||||
const session = await Session.create({})
|
||||
|
||||
// #when
|
||||
const app = Server.Default()
|
||||
const app = Server.Default().app
|
||||
const response = await app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -47,7 +47,7 @@ describe("tui.selectSession endpoint", () => {
|
||||
const nonExistentSessionID = "ses_nonexistent123"
|
||||
|
||||
// #when
|
||||
const app = Server.Default()
|
||||
const app = Server.Default().app
|
||||
const response = await app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -69,7 +69,7 @@ describe("tui.selectSession endpoint", () => {
|
||||
const invalidSessionID = "invalid_session_id"
|
||||
|
||||
// #when
|
||||
const app = Server.Default()
|
||||
const app = Server.Default().app
|
||||
const response = await app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -1080,7 +1080,7 @@ describe("session.getUsage", () => {
|
||||
expect(result.tokens.cache.read).toBe(200)
|
||||
})
|
||||
|
||||
test("handles reasoning tokens", () => {
|
||||
test("separates reasoning tokens from output tokens", () => {
|
||||
const model = createModel({ context: 100_000, output: 32_000 })
|
||||
const result = Session.getUsage({
|
||||
model,
|
||||
@@ -1092,7 +1092,35 @@ describe("session.getUsage", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.tokens.input).toBe(1000)
|
||||
expect(result.tokens.output).toBe(400)
|
||||
expect(result.tokens.reasoning).toBe(100)
|
||||
expect(result.tokens.total).toBe(1500)
|
||||
})
|
||||
|
||||
test("does not double count reasoning tokens in cost", () => {
|
||||
const model = createModel({
|
||||
context: 100_000,
|
||||
output: 32_000,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 15,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
})
|
||||
const result = Session.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 1_000_000,
|
||||
totalTokens: 1_000_000,
|
||||
reasoningTokens: 250_000,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.tokens.output).toBe(750_000)
|
||||
expect(result.tokens.reasoning).toBe(250_000)
|
||||
expect(result.cost).toBe(15)
|
||||
})
|
||||
|
||||
test("handles undefined optional values gracefully", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { expect } from "bun:test"
|
||||
import { expect, spyOn } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
@@ -13,6 +13,7 @@ import { MCP } from "../../src/mcp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||
import type { Provider } from "../../src/provider/provider"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Question } from "../../src/question"
|
||||
import { Todo } from "../../src/session/todo"
|
||||
@@ -28,6 +29,7 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { Shell } from "../../src/shell/shell"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { TaskTool } from "../../src/tool/task"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
import { Truncate } from "../../src/tool/truncate"
|
||||
import { Log } from "../../src/util/log"
|
||||
@@ -625,13 +627,11 @@ it.live(
|
||||
"cancel finalizes subtask tool state",
|
||||
() =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const ready = defer<void>()
|
||||
const aborted = defer<void>()
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const init = registry.named.task.init
|
||||
registry.named.task.init = async () => ({
|
||||
const init = spyOn(TaskTool, "init").mockImplementation(async () => ({
|
||||
description: "task",
|
||||
parameters: z.object({
|
||||
description: z.string(),
|
||||
@@ -641,13 +641,6 @@ it.live(
|
||||
command: z.string().optional(),
|
||||
}),
|
||||
execute: async (_args, ctx) => {
|
||||
ctx.metadata({
|
||||
title: "inspect bug",
|
||||
metadata: {
|
||||
sessionId: SessionID.make("task"),
|
||||
model: ref,
|
||||
},
|
||||
})
|
||||
ready.resolve()
|
||||
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
|
||||
await new Promise<void>(() => {})
|
||||
@@ -660,8 +653,8 @@ it.live(
|
||||
output: "",
|
||||
}
|
||||
},
|
||||
})
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => void (registry.named.task.init = init)))
|
||||
}))
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => init.mockRestore()))
|
||||
|
||||
const { prompt, chat } = yield* boot()
|
||||
const msg = yield* user(chat.id, "hello")
|
||||
@@ -680,19 +673,11 @@ it.live(
|
||||
expect(taskMsg?.info.role).toBe("assistant")
|
||||
if (!taskMsg || taskMsg.info.role !== "assistant") return
|
||||
|
||||
const tool = errorTool(taskMsg.parts)
|
||||
const tool = toolPart(taskMsg.parts)
|
||||
expect(tool?.type).toBe("tool")
|
||||
if (!tool) return
|
||||
|
||||
expect(tool.state.error).toBe("Cancelled")
|
||||
expect(tool.state.input).toEqual({
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "general",
|
||||
})
|
||||
expect(tool.state.metadata).toEqual({
|
||||
sessionId: SessionID.make("task"),
|
||||
model: ref,
|
||||
})
|
||||
expect(tool.state.status).not.toBe("running")
|
||||
expect(taskMsg.info.time.completed).toBeDefined()
|
||||
expect(taskMsg.info.finish).toBeDefined()
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { Database } from "bun:sqlite"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
import { drizzle, SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
@@ -89,18 +89,21 @@ function createTestDb() {
|
||||
name: entry.name,
|
||||
}))
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
migrate(drizzle({ client: sqlite }), migrations)
|
||||
|
||||
return sqlite
|
||||
const db = drizzle({ client: sqlite })
|
||||
migrate(db, migrations)
|
||||
|
||||
return [sqlite, db] as const
|
||||
}
|
||||
|
||||
describe("JSON to SQLite migration", () => {
|
||||
let storageDir: string
|
||||
let sqlite: Database
|
||||
let db: SQLiteBunDatabase
|
||||
|
||||
beforeEach(async () => {
|
||||
storageDir = await setupStorageDir()
|
||||
sqlite = createTestDb()
|
||||
;[sqlite, db] = createTestDb()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -118,11 +121,10 @@ describe("JSON to SQLite migration", () => {
|
||||
sandboxes: ["/test/sandbox"],
|
||||
})
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.projects).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const projects = db.select().from(ProjectTable).all()
|
||||
expect(projects.length).toBe(1)
|
||||
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
|
||||
@@ -143,11 +145,10 @@ describe("JSON to SQLite migration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.projects).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const projects = db.select().from(ProjectTable).all()
|
||||
expect(projects.length).toBe(1)
|
||||
expect(projects[0].id).toBe(ProjectID.make("proj_filename")) // Uses filename, not JSON id
|
||||
@@ -164,11 +165,10 @@ describe("JSON to SQLite migration", () => {
|
||||
commands: { start: "npm run dev" },
|
||||
})
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.projects).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const projects = db.select().from(ProjectTable).all()
|
||||
expect(projects.length).toBe(1)
|
||||
expect(projects[0].id).toBe(ProjectID.make("proj_with_commands"))
|
||||
@@ -185,11 +185,10 @@ describe("JSON to SQLite migration", () => {
|
||||
sandboxes: [],
|
||||
})
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.projects).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const projects = db.select().from(ProjectTable).all()
|
||||
expect(projects.length).toBe(1)
|
||||
expect(projects[0].id).toBe(ProjectID.make("proj_no_commands"))
|
||||
@@ -216,9 +215,8 @@ describe("JSON to SQLite migration", () => {
|
||||
share: { url: "https://example.com/share" },
|
||||
})
|
||||
|
||||
await JsonMigration.run(sqlite)
|
||||
await JsonMigration.run(db)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const sessions = db.select().from(SessionTable).all()
|
||||
expect(sessions.length).toBe(1)
|
||||
expect(sessions[0].id).toBe(SessionID.make("ses_test456def"))
|
||||
@@ -247,12 +245,11 @@ describe("JSON to SQLite migration", () => {
|
||||
JSON.stringify({ ...fixtures.part }),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.messages).toBe(1)
|
||||
expect(stats?.parts).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const messages = db.select().from(MessageTable).all()
|
||||
expect(messages.length).toBe(1)
|
||||
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
|
||||
@@ -287,12 +284,11 @@ describe("JSON to SQLite migration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.messages).toBe(1)
|
||||
expect(stats?.parts).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const messages = db.select().from(MessageTable).all()
|
||||
expect(messages.length).toBe(1)
|
||||
expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
|
||||
@@ -329,11 +325,10 @@ describe("JSON to SQLite migration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.messages).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const messages = db.select().from(MessageTable).all()
|
||||
expect(messages.length).toBe(1)
|
||||
expect(messages[0].id).toBe(MessageID.make("msg_from_filename")) // Uses filename, not JSON id
|
||||
@@ -367,11 +362,10 @@ describe("JSON to SQLite migration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.parts).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const parts = db.select().from(PartTable).all()
|
||||
expect(parts.length).toBe(1)
|
||||
expect(parts[0].id).toBe(PartID.make("prt_from_filename")) // Uses filename, not JSON id
|
||||
@@ -392,7 +386,7 @@ describe("JSON to SQLite migration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.sessions).toBe(0)
|
||||
})
|
||||
@@ -420,11 +414,10 @@ describe("JSON to SQLite migration", () => {
|
||||
time: { created: 1700000000000, updated: 1700000001000 },
|
||||
})
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.sessions).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const sessions = db.select().from(SessionTable).all()
|
||||
expect(sessions.length).toBe(1)
|
||||
expect(sessions[0].id).toBe(SessionID.make("ses_migrated"))
|
||||
@@ -452,11 +445,10 @@ describe("JSON to SQLite migration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.sessions).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const sessions = db.select().from(SessionTable).all()
|
||||
expect(sessions.length).toBe(1)
|
||||
expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id
|
||||
@@ -471,10 +463,9 @@ describe("JSON to SQLite migration", () => {
|
||||
sandboxes: [],
|
||||
})
|
||||
|
||||
await JsonMigration.run(sqlite)
|
||||
await JsonMigration.run(sqlite)
|
||||
await JsonMigration.run(db)
|
||||
await JsonMigration.run(db)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const projects = db.select().from(ProjectTable).all()
|
||||
expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing
|
||||
})
|
||||
@@ -507,11 +498,10 @@ describe("JSON to SQLite migration", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.todos).toBe(2)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
|
||||
expect(todos.length).toBe(2)
|
||||
expect(todos[0].content).toBe("First todo")
|
||||
@@ -540,9 +530,8 @@ describe("JSON to SQLite migration", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
await JsonMigration.run(sqlite)
|
||||
await JsonMigration.run(db)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
|
||||
|
||||
expect(todos.length).toBe(3)
|
||||
@@ -570,11 +559,10 @@ describe("JSON to SQLite migration", () => {
|
||||
]
|
||||
await Bun.write(path.join(storageDir, "permission", "proj_test123abc.json"), JSON.stringify(permissionData))
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.permissions).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const permissions = db.select().from(PermissionTable).all()
|
||||
expect(permissions.length).toBe(1)
|
||||
expect(permissions[0].project_id).toBe("proj_test123abc")
|
||||
@@ -600,11 +588,10 @@ describe("JSON to SQLite migration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats?.shares).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const shares = db.select().from(SessionShareTable).all()
|
||||
expect(shares.length).toBe(1)
|
||||
expect(shares[0].session_id).toBe("ses_test456def")
|
||||
@@ -616,7 +603,7 @@ describe("JSON to SQLite migration", () => {
|
||||
test("returns empty stats when storage directory does not exist", async () => {
|
||||
await fs.rm(storageDir, { recursive: true, force: true })
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats.projects).toBe(0)
|
||||
expect(stats.sessions).toBe(0)
|
||||
@@ -637,12 +624,11 @@ describe("JSON to SQLite migration", () => {
|
||||
})
|
||||
await Bun.write(path.join(storageDir, "project", "broken.json"), "{ invalid json")
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats.projects).toBe(1)
|
||||
expect(stats.errors.some((x) => x.includes("failed to read") && x.includes("broken.json"))).toBe(true)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const projects = db.select().from(ProjectTable).all()
|
||||
expect(projects.length).toBe(1)
|
||||
expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
|
||||
@@ -666,10 +652,9 @@ describe("JSON to SQLite migration", () => {
|
||||
]),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
expect(stats.todos).toBe(2)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
|
||||
expect(todos.length).toBe(2)
|
||||
expect(todos[0].content).toBe("keep-0")
|
||||
@@ -714,13 +699,12 @@ describe("JSON to SQLite migration", () => {
|
||||
JSON.stringify({ id: "share_missing", secret: "secret", url: "https://missing.example.com" }),
|
||||
)
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
expect(stats.todos).toBe(1)
|
||||
expect(stats.permissions).toBe(1)
|
||||
expect(stats.shares).toBe(1)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
expect(db.select().from(TodoTable).all().length).toBe(1)
|
||||
expect(db.select().from(PermissionTable).all().length).toBe(1)
|
||||
expect(db.select().from(SessionShareTable).all().length).toBe(1)
|
||||
@@ -823,7 +807,7 @@ describe("JSON to SQLite migration", () => {
|
||||
)
|
||||
await Bun.write(path.join(storageDir, "session_share", "ses_broken.json"), "{ nope")
|
||||
|
||||
const stats = await JsonMigration.run(sqlite)
|
||||
const stats = await JsonMigration.run(db)
|
||||
|
||||
// Projects: proj_test123abc (valid), proj_missing_id (now derives id from filename)
|
||||
// Sessions: ses_test456def (valid), ses_missing_project (now uses dir path),
|
||||
@@ -837,7 +821,6 @@ describe("JSON to SQLite migration", () => {
|
||||
expect(stats.shares).toBe(1)
|
||||
expect(stats.errors.length).toBeGreaterThanOrEqual(6)
|
||||
|
||||
const db = drizzle({ client: sqlite })
|
||||
expect(db.select().from(ProjectTable).all().length).toBe(2)
|
||||
expect(db.select().from(SessionTable).all().length).toBe(3)
|
||||
expect(db.select().from(MessageTable).all().length).toBe(1)
|
||||
|
||||
@@ -1,267 +1,49 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Config } from "../../src/config/config"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Session } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { TaskTool } from "../../src/tool/task"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const ref = {
|
||||
providerID: ProviderID.make("test"),
|
||||
modelID: ModelID.make("test-model"),
|
||||
}
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer),
|
||||
)
|
||||
|
||||
const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
|
||||
const session = yield* Session.Service
|
||||
const chat = yield* session.create({ title })
|
||||
const user = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
model: ref,
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const assistant: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
parentID: user.id,
|
||||
sessionID: chat.id,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
cost: 0,
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: ref.modelID,
|
||||
providerID: ref.providerID,
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
yield* session.updateMessage(assistant)
|
||||
return { chat, assistant }
|
||||
})
|
||||
|
||||
function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.WithParts {
|
||||
const id = MessageID.ascending()
|
||||
return {
|
||||
info: {
|
||||
id,
|
||||
role: "assistant",
|
||||
parentID: input.messageID ?? MessageID.ascending(),
|
||||
sessionID: input.sessionID,
|
||||
mode: input.agent ?? "general",
|
||||
agent: input.agent ?? "general",
|
||||
cost: 0,
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: input.model?.modelID ?? ref.modelID,
|
||||
providerID: input.model?.providerID ?? ref.providerID,
|
||||
time: { created: Date.now() },
|
||||
finish: "stop",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: PartID.ascending(),
|
||||
messageID: id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
text,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
describe("tool.task", () => {
|
||||
it.live("description sorts subagents by name and is stable across calls", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const agent = yield* Agent.Service
|
||||
const build = yield* agent.get("build")
|
||||
const tool = yield* TaskTool
|
||||
const first = yield* Effect.promise(() => tool.init({ agent: build }))
|
||||
const second = yield* Effect.promise(() => tool.init({ agent: build }))
|
||||
|
||||
expect(first.description).toBe(second.description)
|
||||
|
||||
const alpha = first.description.indexOf("- alpha: Alpha agent")
|
||||
const explore = first.description.indexOf("- explore:")
|
||||
const general = first.description.indexOf("- general:")
|
||||
const zebra = first.description.indexOf("- zebra: Zebra agent")
|
||||
|
||||
expect(alpha).toBeGreaterThan(-1)
|
||||
expect(explore).toBeGreaterThan(alpha)
|
||||
expect(general).toBeGreaterThan(explore)
|
||||
expect(zebra).toBeGreaterThan(general)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
agent: {
|
||||
zebra: {
|
||||
description: "Zebra agent",
|
||||
mode: "subagent",
|
||||
},
|
||||
alpha: {
|
||||
description: "Alpha agent",
|
||||
mode: "subagent",
|
||||
},
|
||||
test("description sorts subagents by name and is stable across calls", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
agent: {
|
||||
zebra: {
|
||||
description: "Zebra agent",
|
||||
mode: "subagent",
|
||||
},
|
||||
alpha: {
|
||||
description: "Alpha agent",
|
||||
mode: "subagent",
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("execute resumes an existing task session from task_id", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* Effect.promise(() => tool.init())
|
||||
const resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const build = await Agent.get("build")
|
||||
const first = await TaskTool.init({ agent: build })
|
||||
const second = await TaskTool.init({ agent: build })
|
||||
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => {
|
||||
seen = input
|
||||
return reply(input, "resumed")
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
expect(first.description).toBe(second.description)
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "general",
|
||||
task_id: child.id,
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
},
|
||||
),
|
||||
)
|
||||
const alpha = first.description.indexOf("- alpha: Alpha agent")
|
||||
const explore = first.description.indexOf("- explore:")
|
||||
const general = first.description.indexOf("- general:")
|
||||
const zebra = first.description.indexOf("- zebra: Zebra agent")
|
||||
|
||||
const kids = yield* sessions.children(chat.id)
|
||||
expect(kids).toHaveLength(1)
|
||||
expect(kids[0]?.id).toBe(child.id)
|
||||
expect(result.metadata.sessionId).toBe(child.id)
|
||||
expect(result.output).toContain(`task_id: ${child.id}`)
|
||||
expect(seen?.sessionID).toBe(child.id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("execute shapes child permissions for task, todowrite, and primary tools", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* Effect.promise(() => tool.init())
|
||||
const resolve = SessionPrompt.resolvePromptParts
|
||||
const prompt = SessionPrompt.prompt
|
||||
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
|
||||
|
||||
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
|
||||
SessionPrompt.prompt = async (input) => {
|
||||
seen = input
|
||||
return reply(input, "done")
|
||||
}
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
SessionPrompt.resolvePromptParts = resolve
|
||||
SessionPrompt.prompt = prompt
|
||||
}),
|
||||
)
|
||||
|
||||
const result = yield* Effect.promise(() =>
|
||||
def.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "reviewer",
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata() {},
|
||||
ask: async () => {},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const child = yield* sessions.get(result.metadata.sessionId)
|
||||
expect(child.parentID).toBe(chat.id)
|
||||
expect(child.permission).toEqual([
|
||||
{
|
||||
permission: "todowrite",
|
||||
pattern: "*",
|
||||
action: "deny",
|
||||
},
|
||||
{
|
||||
permission: "bash",
|
||||
pattern: "*",
|
||||
action: "allow",
|
||||
},
|
||||
{
|
||||
permission: "read",
|
||||
pattern: "*",
|
||||
action: "allow",
|
||||
},
|
||||
])
|
||||
expect(seen?.tools).toEqual({
|
||||
todowrite: false,
|
||||
bash: false,
|
||||
read: false,
|
||||
})
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
agent: {
|
||||
reviewer: {
|
||||
mode: "subagent",
|
||||
permission: {
|
||||
task: "allow",
|
||||
},
|
||||
},
|
||||
},
|
||||
experimental: {
|
||||
primary_tools: ["bash", "read"],
|
||||
},
|
||||
},
|
||||
expect(alpha).toBeGreaterThan(-1)
|
||||
expect(explore).toBeGreaterThan(alpha)
|
||||
expect(general).toBeGreaterThan(explore)
|
||||
expect(zebra).toBeGreaterThan(general)
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1639,6 +1639,9 @@ export type OAuth = {
|
||||
export type ApiAuth = {
|
||||
type: "api"
|
||||
key: string
|
||||
metadata?: {
|
||||
[key: string]: string
|
||||
}
|
||||
}
|
||||
|
||||
export type WellKnownAuth = {
|
||||
|
||||
@@ -11621,6 +11621,15 @@
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["type", "key"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -573,6 +573,7 @@ OpenCode can be configured using environment variables.
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Disable reading `~/.claude/CLAUDE.md` |
|
||||
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disable loading `.claude/skills` |
|
||||
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Disable fetching models from remote sources |
|
||||
| `OPENCODE_DISABLE_MOUSE` | boolean | Disable mouse capture in the TUI |
|
||||
| `OPENCODE_FAKE_VCS` | string | Fake VCS provider for testing purposes |
|
||||
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Disable file time checking for optimization |
|
||||
| `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) |
|
||||
|
||||
@@ -272,7 +272,8 @@ Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings.
|
||||
"scroll_acceleration": {
|
||||
"enabled": true
|
||||
},
|
||||
"diff_style": "auto"
|
||||
"diff_style": "auto",
|
||||
"mouse": true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -280,8 +281,6 @@ Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file.
|
||||
|
||||
Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.
|
||||
|
||||
[Learn more about TUI configuration here](/docs/tui#configure).
|
||||
|
||||
---
|
||||
|
||||
### Server
|
||||
|
||||
@@ -368,7 +368,8 @@ You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
|
||||
"scroll_acceleration": {
|
||||
"enabled": true
|
||||
},
|
||||
"diff_style": "auto"
|
||||
"diff_style": "auto",
|
||||
"mouse": true
|
||||
}
|
||||
```
|
||||
|
||||
@@ -381,6 +382,7 @@ This is separate from `opencode.json`, which configures server/runtime behavior.
|
||||
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
|
||||
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
|
||||
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
|
||||
- `mouse` - Enable or disable mouse capture in the TUI (default: `true`). When disabled, the terminal's native mouse selection/scrolling behavior is preserved.
|
||||
|
||||
Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.
|
||||
|
||||
|
||||
@@ -94,8 +94,6 @@ You can also access our models through the following API endpoints.
|
||||
| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo V2 Pro Free | mimo-v2-pro-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiMo V2 Omni Free | mimo-v2-omni-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3.6 Plus Free | qwen3.6-plus-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
@@ -122,8 +120,6 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
|
||||
| Model | Input | Output | Cached Read | Cached Write |
|
||||
| --------------------------------- | ------ | ------- | ----------- | ------------ |
|
||||
| Big Pickle | Free | Free | Free | - |
|
||||
| MiMo V2 Pro Free | Free | Free | Free | - |
|
||||
| MiMo V2 Omni Free | Free | Free | Free | - |
|
||||
| Qwen3.6 Plus Free | Free | Free | Free | - |
|
||||
| Nemotron 3 Super Free | Free | Free | Free | - |
|
||||
| MiniMax M2.5 Free | Free | Free | Free | - |
|
||||
@@ -169,8 +165,6 @@ Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don
|
||||
The free models:
|
||||
|
||||
- MiniMax M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- MiMo V2 Pro Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- MiMo V2 Omni Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Qwen3.6 Plus Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Nemotron 3 Super Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
|
||||
@@ -218,8 +212,6 @@ All our models are hosted in the US. Our providers follow a zero-retention polic
|
||||
|
||||
- Big Pickle: During its free period, collected data may be used to improve the model.
|
||||
- MiniMax M2.5 Free: During its free period, collected data may be used to improve the model.
|
||||
- MiMo V2 Pro Free: During its free period, collected data may be used to improve the model.
|
||||
- MiMo V2 Omni Free: During its free period, collected data may be used to improve the model.
|
||||
- Qwen3.6 Plus Free: During its free period, collected data may be used to improve the model.
|
||||
- Nemotron 3 Super Free: During its free period, collected data may be used to improve the model.
|
||||
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.3.15",
|
||||
"version": "1.3.17",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user