mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-10 07:54:51 +00:00
Compare commits
57 Commits
production
...
kit/tool-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
835bc1cc52 | ||
|
|
eca11ca71a | ||
|
|
17bd16667c | ||
|
|
16c60c9ee7 | ||
|
|
0970b102e1 | ||
|
|
04074d3f4a | ||
|
|
b16ee08fd5 | ||
|
|
98874a09f7 | ||
|
|
877be7e8e0 | ||
|
|
eac50f9151 | ||
|
|
1a902b291c | ||
|
|
bbe4a04f9f | ||
|
|
b2f621b897 | ||
|
|
7202b3a325 | ||
|
|
35b44df94a | ||
|
|
10441efad1 | ||
|
|
3199383eef | ||
|
|
9f54115c5d | ||
|
|
2ecc6ae65f | ||
|
|
02b32e1ba7 | ||
|
|
34b9792654 | ||
|
|
537160dbc0 | ||
|
|
b0600664ab | ||
|
|
581a7692ff | ||
|
|
f73e4d5d31 | ||
|
|
a7743e6467 | ||
|
|
5d3dba666c | ||
|
|
bd53b651a3 | ||
|
|
46da801f30 | ||
|
|
58a99916bb | ||
|
|
c29392d085 | ||
|
|
46f243fea7 | ||
|
|
847fc9d268 | ||
|
|
489f57974d | ||
|
|
3fc3974cbc | ||
|
|
ca57248246 | ||
|
|
ee23043d64 | ||
|
|
9c1c061b84 | ||
|
|
d82b163e56 | ||
|
|
cd8e8a9928 | ||
|
|
8bdcc22541 | ||
|
|
2bdd279467 | ||
|
|
51535d8ef3 | ||
|
|
38f8714c09 | ||
|
|
4961d72c0f | ||
|
|
00cb8839ae | ||
|
|
689b1a4b3a | ||
|
|
d98be39344 | ||
|
|
039c60170d | ||
|
|
cd87d4f9d3 | ||
|
|
988c9894f2 | ||
|
|
ae614d919f | ||
|
|
65cde7f494 | ||
|
|
98325dcdc6 | ||
|
|
0788a535e2 | ||
|
|
b7fab49b64 | ||
|
|
463318486f |
9
.github/workflows/publish.yml
vendored
9
.github/workflows/publish.yml
vendored
@@ -114,7 +114,7 @@ jobs:
|
||||
- build-cli
|
||||
- version
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -213,6 +213,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.ref_name != 'beta'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -389,6 +390,7 @@ jobs:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
@@ -421,7 +423,6 @@ jobs:
|
||||
target: aarch64-unknown-linux-gnu
|
||||
platform_flag: --linux
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
# if: github.ref_name == 'beta'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
@@ -547,6 +548,7 @@ jobs:
|
||||
- sign-cli-windows
|
||||
- build-tauri
|
||||
- build-electron
|
||||
if: always() && !failure() && !cancelled()
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -589,12 +591,13 @@ jobs:
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: github.ref_name != 'beta'
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: needs.version.outputs.release
|
||||
if: needs.version.outputs.release && github.ref_name != 'beta'
|
||||
with:
|
||||
pattern: latest-yml-*
|
||||
path: /tmp/latest-yml
|
||||
|
||||
13
.github/workflows/test.yml
vendored
13
.github/workflows/test.yml
vendored
@@ -17,6 +17,9 @@ permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: unit (${{ matrix.settings.name }})
|
||||
@@ -38,6 +41,11 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
@@ -102,6 +110,11 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
|
||||
80
bun.lock
80
bun.lock
@@ -9,6 +9,7 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"heap-snapshot-toolkit": "1.1.3",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -26,7 +27,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -80,7 +81,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -114,7 +115,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -141,7 +142,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -165,7 +166,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -189,7 +190,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -222,14 +223,8 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"effect": "catalog:",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-log": "^5",
|
||||
@@ -237,24 +232,41 @@
|
||||
"electron-updater": "^6",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"marked": "^15",
|
||||
"solid-js": "catalog:",
|
||||
"tree-kill": "^1.2.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "4.0.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@valibot/to-json-schema": "1.6.0",
|
||||
"electron": "40.4.1",
|
||||
"electron-builder": "^26",
|
||||
"electron-vite": "^5",
|
||||
"solid-js": "catalog:",
|
||||
"sury": "11.0.0-alpha.4",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "catalog:",
|
||||
"zod-openapi": "5.4.6",
|
||||
},
|
||||
"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.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -283,7 +295,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -299,7 +311,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -335,7 +347,7 @@
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@lydell/node-pty": "1.2.0-beta.10",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
@@ -366,7 +378,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.0.0",
|
||||
"gitlab-ai-provider": "6.4.2",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -435,7 +447,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -469,7 +481,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -484,7 +496,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -519,7 +531,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -532,6 +544,7 @@
|
||||
"@solid-primitives/resize-observer": "2.1.3",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "catalog:",
|
||||
"diff": "catalog:",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "catalog:",
|
||||
"katex": "0.16.27",
|
||||
@@ -567,7 +580,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -578,7 +591,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -631,6 +644,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",
|
||||
@@ -2311,6 +2325,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=="],
|
||||
@@ -3163,7 +3179,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.0.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": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-683GcJdrer/GhnljkbVcGsndCEhvGB8f9fUdCxQBlkuyt8rzf0G9DpSh+iMBYp9HpcSvYmYG0Qv5ks9dLrNxwQ=="],
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "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": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
@@ -3257,6 +3273,8 @@
|
||||
|
||||
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||
|
||||
"heap-snapshot-toolkit": ["heap-snapshot-toolkit@1.1.3", "", {}, "sha512-joThu2rEsDu8/l4arupRDI1qP4CZXNG+J6Wr348vnbLGSiBkwRdqZ6aOHl5BzEiC+Dc8OTbMlmWjD0lbXD5K2Q=="],
|
||||
|
||||
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
|
||||
|
||||
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],
|
||||
@@ -4573,6 +4591,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=="],
|
||||
@@ -4651,8 +4671,6 @@
|
||||
|
||||
"traverse": ["traverse@0.3.9", "", {}, "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ=="],
|
||||
|
||||
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
|
||||
|
||||
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
|
||||
|
||||
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
|
||||
@@ -4807,6 +4825,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=="],
|
||||
@@ -4963,6 +4983,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=="],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-r1+AehuOGIOaaxfXkQGracT/6OdFRn5Ub8s7H+MeKFY=",
|
||||
"aarch64-linux": "sha256-WkMSRF/ZJLyzxNBjpiMR459C9G0NVOEw31tm8roPneA=",
|
||||
"aarch64-darwin": "sha256-Z127cxFpTl8Ml7PB3CG9TcCU08oYCPuk0FECK2MQ2CI=",
|
||||
"x86_64-darwin": "sha256-pkRoFtnVjyl+5fm+rrFyRnEwvptxylnFxPAcEv4ZOCg="
|
||||
"x86_64-linux": "sha256-285KZ7rZLRoc6XqCZRHc25NE+mmpGh/BVeMpv8aPQtQ=",
|
||||
"aarch64-linux": "sha256-qIwmY4TP4CI7R7G6A5OMYRrorVNXjkg25tTtVpIHm2o=",
|
||||
"aarch64-darwin": "sha256-RwvnZQhdYZ0u7h7evyfxuPLHHX9eO/jXTAxIFc8B+IE=",
|
||||
"x86_64-darwin": "sha256-vVj40al+TEeMpbe5XG2GmJEpN+eQAvtr9W0T98l5PBE="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,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": {
|
||||
@@ -91,6 +92,7 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"heap-snapshot-toolkit": "1.1.3",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -44,8 +44,12 @@ async function waitForHealth(url: string, probe = "/global/health") {
|
||||
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
|
||||
}
|
||||
|
||||
function done(proc: ReturnType<typeof spawn>) {
|
||||
return proc.exitCode !== null || proc.signalCode !== null
|
||||
}
|
||||
|
||||
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
|
||||
if (proc.exitCode !== null) return
|
||||
if (done(proc)) return
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
|
||||
@@ -123,11 +127,11 @@ export async function startBackend(label: string, input?: { llmUrl?: string }):
|
||||
return {
|
||||
url,
|
||||
async stop() {
|
||||
if (proc.exitCode === null) {
|
||||
if (!done(proc)) {
|
||||
proc.kill("SIGTERM")
|
||||
await waitExit(proc)
|
||||
}
|
||||
if (proc.exitCode === null) {
|
||||
if (!done(proc)) {
|
||||
proc.kill("SIGKILL")
|
||||
await waitExit(proc)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -182,7 +182,6 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||
if (checkMode() === "background" || type === "http") return false
|
||||
}
|
||||
}).pipe(
|
||||
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
||||
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
|
||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||
Effect.runPromise,
|
||||
|
||||
@@ -174,6 +174,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const auth = server.current?.http
|
||||
const username = auth?.username ?? "opencode"
|
||||
const password = auth?.password ?? ""
|
||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||
const id = local.pty.id
|
||||
@@ -519,8 +520,12 @@ export const Terminal = (props: TerminalProps) => {
|
||||
next.searchParams.set("directory", directory)
|
||||
next.searchParams.set("cursor", String(seek))
|
||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||
next.username = username
|
||||
next.password = password
|
||||
if (!sameOrigin && password) {
|
||||
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
|
||||
// For same-origin requests, let the browser reuse the page's existing auth.
|
||||
next.username = username
|
||||
next.password = password
|
||||
}
|
||||
|
||||
const socket = new WebSocket(next)
|
||||
socket.binaryType = "arraybuffer"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type {
|
||||
FileDiff,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
@@ -9,11 +8,13 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
@@ -161,8 +162,8 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: FileDiff[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
|
||||
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||
break
|
||||
}
|
||||
case "todo.updated": {
|
||||
@@ -177,7 +178,7 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "message.updated": {
|
||||
const info = (event.properties as { info: Message }).info
|
||||
const info = clean((event.properties as { info: Message }).info)
|
||||
const messages = input.store.message[info.sessionID]
|
||||
if (!messages) {
|
||||
input.setStore("message", info.sessionID, [info])
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
FileDiff,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
@@ -33,7 +33,7 @@ describe("app session cache", () => {
|
||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiff[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
@@ -64,7 +64,7 @@ describe("app session cache", () => {
|
||||
const m = msg("msg_1", "ses_1")
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiff[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
FileDiff,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
@@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
|
||||
|
||||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiff[] | undefined>
|
||||
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
Agent,
|
||||
Command,
|
||||
Config,
|
||||
FileDiff,
|
||||
LspStatus,
|
||||
McpStatus,
|
||||
Message,
|
||||
@@ -14,6 +13,7 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
@@ -48,7 +48,7 @@ export type State = {
|
||||
[sessionID: string]: SessionStatus
|
||||
}
|
||||
session_diff: {
|
||||
[sessionID: string]: FileDiff[]
|
||||
[sessionID: string]: SnapshotFileDiff[]
|
||||
}
|
||||
todo: {
|
||||
[sessionID: string]: Todo[]
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useGlobalSync } from "./global-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
|
||||
@@ -300,7 +301,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
|
||||
)
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
|
||||
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
|
||||
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
||||
return {
|
||||
@@ -509,7 +510,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return runInflight(inflightDiff, key, () =>
|
||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import {
|
||||
@@ -58,6 +58,7 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { same } from "@/utils/same"
|
||||
@@ -68,7 +69,7 @@ type FollowupItem = FollowupDraft & { id: string }
|
||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||
const emptyFollowups: FollowupItem[] = []
|
||||
|
||||
type ChangeMode = "git" | "branch" | "session" | "turn"
|
||||
type ChangeMode = "git" | "branch" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
@@ -430,7 +431,7 @@ export default function Page() {
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
|
||||
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||
const canReview = createMemo(() => !!sync.project)
|
||||
@@ -463,13 +464,6 @@ export default function Page() {
|
||||
if (!id) return false
|
||||
return sync.session.history.loading(id)
|
||||
})
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasSessionReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
emptyUserMessages,
|
||||
@@ -527,10 +521,19 @@ export default function Page() {
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
const [vcs, setVcs] = createStore({
|
||||
const [vcs, setVcs] = createStore<{
|
||||
diff: {
|
||||
git: [] as FileDiff[],
|
||||
branch: [] as FileDiff[],
|
||||
git: VcsFileDiff[]
|
||||
branch: VcsFileDiff[]
|
||||
}
|
||||
ready: {
|
||||
git: boolean
|
||||
branch: boolean
|
||||
}
|
||||
}>({
|
||||
diff: {
|
||||
git: [] as VcsFileDiff[],
|
||||
branch: [] as VcsFileDiff[],
|
||||
},
|
||||
ready: {
|
||||
git: false,
|
||||
@@ -609,7 +612,7 @@ export default function Page() {
|
||||
.diff({ mode })
|
||||
.then((result) => {
|
||||
if (vcsRun.get(mode) !== run) return
|
||||
setVcs("diff", mode, result.data ?? [])
|
||||
setVcs("diff", mode, list(result.data))
|
||||
setVcs("ready", mode, true)
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -647,7 +650,8 @@ export default function Page() {
|
||||
return open
|
||||
}, desktopReviewOpen())
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
|
||||
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
|
||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
if (sync.project?.vcs === "git") list.push("git")
|
||||
@@ -659,29 +663,22 @@ export default function Page() {
|
||||
) {
|
||||
list.push("branch")
|
||||
}
|
||||
list.push("session", "turn")
|
||||
list.push("turn")
|
||||
return list
|
||||
})
|
||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||
})
|
||||
const reviewDiffs = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.diff.git
|
||||
if (store.changes === "branch") return vcs.diff.branch
|
||||
if (store.changes === "session") return diffs()
|
||||
if (store.changes === "git") return list(vcs.diff.git)
|
||||
if (store.changes === "branch") return list(vcs.diff.branch)
|
||||
return turnDiffs()
|
||||
})
|
||||
const reviewCount = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.diff.git.length
|
||||
if (store.changes === "branch") return vcs.diff.branch.length
|
||||
if (store.changes === "session") return sessionCount()
|
||||
return turnDiffs().length
|
||||
})
|
||||
const reviewCount = createMemo(() => reviewDiffs().length)
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const reviewReady = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.ready.git
|
||||
if (store.changes === "branch") return vcs.ready.branch
|
||||
if (store.changes === "session") return !hasSessionReview() || diffsReady()
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -749,13 +746,6 @@ export default function Page() {
|
||||
scrollToMessage(msgs[targetIndex], "auto")
|
||||
}
|
||||
|
||||
const sessionEmptyKey = createMemo(() => {
|
||||
const project = sync.project
|
||||
if (project && !project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
return "session.review.empty"
|
||||
})
|
||||
|
||||
function upsert(next: Project) {
|
||||
const list = globalSync.data.project
|
||||
sync.set("project", next.id)
|
||||
@@ -1156,7 +1146,6 @@ export default function Page() {
|
||||
const label = (option: ChangeMode) => {
|
||||
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||
if (option === "session") return language.t("ui.sessionReview.title")
|
||||
return language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
|
||||
@@ -1179,11 +1168,26 @@ export default function Page() {
|
||||
</div>
|
||||
)
|
||||
|
||||
const createGit = (input: { emptyClass: string }) => (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
|
||||
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||
{language.t("session.review.noVcs.createGit.description")}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
||||
{gitMutation.isPending
|
||||
? language.t("session.review.noVcs.createGit.actionLoading")
|
||||
: language.t("session.review.noVcs.createGit.action")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
const reviewEmptyText = createMemo(() => {
|
||||
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
|
||||
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
|
||||
if (store.changes === "turn") return language.t("session.review.noChanges")
|
||||
return language.t(sessionEmptyKey())
|
||||
return language.t("session.review.noChanges")
|
||||
})
|
||||
|
||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||
@@ -1193,31 +1197,10 @@ export default function Page() {
|
||||
}
|
||||
|
||||
if (store.changes === "turn") {
|
||||
if (nogit()) return createGit(input)
|
||||
return empty(reviewEmptyText())
|
||||
}
|
||||
|
||||
if (hasSessionReview() && !diffsReady()) {
|
||||
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
}
|
||||
|
||||
if (sessionEmptyKey() === "session.review.noVcs") {
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
|
||||
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||
{language.t("session.review.noVcs.createGit.description")}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
||||
{gitMutation.isPending
|
||||
? language.t("session.review.noVcs.createGit.actionLoading")
|
||||
: language.t("session.review.noVcs.createGit.action")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||
import type {
|
||||
SessionReviewCommentActions,
|
||||
@@ -14,10 +14,12 @@ import type { LineComment } from "@/context/comments"
|
||||
|
||||
export type DiffStyle = "unified" | "split"
|
||||
|
||||
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
export interface SessionReviewTabProps {
|
||||
title?: JSX.Element
|
||||
empty?: JSX.Element
|
||||
diffs: () => FileDiff[]
|
||||
diffs: () => ReviewDiff[]
|
||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||
diffStyle: DiffStyle
|
||||
onDiffStyleChange?: (style: DiffStyle) => void
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => FileDiff[]
|
||||
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
|
||||
74
packages/app/src/utils/diffs.test.ts
Normal file
74
packages/app/src/utils/diffs.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { diffs, message } from "./diffs"
|
||||
|
||||
const item = {
|
||||
file: "src/app.ts",
|
||||
patch: "@@ -1 +1 @@\n-old\n+new\n",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
} satisfies SnapshotFileDiff
|
||||
|
||||
describe("diffs", () => {
|
||||
test("keeps valid arrays", () => {
|
||||
expect(diffs([item])).toEqual([item])
|
||||
})
|
||||
|
||||
test("wraps a single diff object", () => {
|
||||
expect(diffs(item)).toEqual([item])
|
||||
})
|
||||
|
||||
test("reads keyed diff objects", () => {
|
||||
expect(diffs({ a: item })).toEqual([item])
|
||||
})
|
||||
|
||||
test("drops invalid entries", () => {
|
||||
expect(
|
||||
diffs([
|
||||
item,
|
||||
{ file: "src/bad.ts", additions: 1, deletions: 1 },
|
||||
{ patch: item.patch, additions: 1, deletions: 1 },
|
||||
]),
|
||||
).toEqual([item])
|
||||
})
|
||||
})
|
||||
|
||||
describe("message", () => {
|
||||
test("normalizes user summaries with object diffs", () => {
|
||||
const input = {
|
||||
id: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
summary: {
|
||||
title: "Edit",
|
||||
diffs: { a: item },
|
||||
},
|
||||
} as unknown as Message
|
||||
|
||||
expect(message(input)).toMatchObject({
|
||||
summary: {
|
||||
title: "Edit",
|
||||
diffs: [item],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("drops invalid user summaries", () => {
|
||||
const input = {
|
||||
id: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
summary: true,
|
||||
} as unknown as Message
|
||||
|
||||
expect(message(input)).toMatchObject({ summary: undefined })
|
||||
})
|
||||
})
|
||||
49
packages/app/src/utils/diffs.ts
Normal file
49
packages/app/src/utils/diffs.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
type Diff = SnapshotFileDiff | VcsFileDiff
|
||||
|
||||
function diff(value: unknown): value is Diff {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
||||
if (!("file" in value) || typeof value.file !== "string") return false
|
||||
if (!("patch" in value) || typeof value.patch !== "string") return false
|
||||
if (!("additions" in value) || typeof value.additions !== "number") return false
|
||||
if (!("deletions" in value) || typeof value.deletions !== "number") return false
|
||||
if (!("status" in value) || value.status === undefined) return true
|
||||
return value.status === "added" || value.status === "deleted" || value.status === "modified"
|
||||
}
|
||||
|
||||
function object(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function diffs(value: unknown): Diff[] {
|
||||
if (Array.isArray(value) && value.every(diff)) return value
|
||||
if (Array.isArray(value)) return value.filter(diff)
|
||||
if (diff(value)) return [value]
|
||||
if (!object(value)) return []
|
||||
return Object.values(value).filter(diff)
|
||||
}
|
||||
|
||||
export function message(value: Message): Message {
|
||||
if (value.role !== "user") return value
|
||||
|
||||
const raw = value.summary as unknown
|
||||
if (raw === undefined) return value
|
||||
if (!object(raw)) return { ...value, summary: undefined }
|
||||
|
||||
const title = typeof raw.title === "string" ? raw.title : undefined
|
||||
const body = typeof raw.body === "string" ? raw.body : undefined
|
||||
const next = diffs(raw.diffs)
|
||||
|
||||
if (title === raw.title && body === raw.body && next === raw.diffs) return value
|
||||
|
||||
return {
|
||||
...value,
|
||||
summary: {
|
||||
...(title === undefined ? {} : { title }),
|
||||
...(body === undefined ? {} : { body }),
|
||||
diffs: next,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"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,33 @@ 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",
|
||||
async writeBundle() {
|
||||
for (const l of await fs.readdir(OPENCODE_SERVER_DIST)) {
|
||||
if (!l.endsWith(".wasm")) continue
|
||||
await fs.writeFile(`./out/main/chunks/${l}`, await fs.readFile(`${OPENCODE_SERVER_DIST}/${l}`))
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
preload: {
|
||||
build: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"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",
|
||||
@@ -24,31 +24,42 @@
|
||||
},
|
||||
"main": "./out/main/index.js",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"effect": "catalog:",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-log": "^5",
|
||||
"electron-store": "^10",
|
||||
"electron-updater": "^6",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"marked": "^15",
|
||||
"solid-js": "catalog:",
|
||||
"tree-kill": "^1.2.2"
|
||||
"marked": "^15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/artifact": "4.0.0",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@solid-primitives/i18n": "2.2.1",
|
||||
"@solid-primitives/storage": "catalog:",
|
||||
"@solidjs/meta": "catalog:",
|
||||
"@solidjs/router": "0.15.4",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@valibot/to-json-schema": "1.6.0",
|
||||
"electron": "40.4.1",
|
||||
"electron-builder": "^26",
|
||||
"electron-vite": "^5",
|
||||
"solid-js": "catalog:",
|
||||
"sury": "11.0.0-alpha.4",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "catalog:"
|
||||
"vite": "catalog:",
|
||||
"zod-openapi": "5.4.6"
|
||||
},
|
||||
"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 type { 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,5 +1,6 @@
|
||||
import { serve, type CommandChild } from "./cli"
|
||||
import { app } from "electron"
|
||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||
import { store } from "./store"
|
||||
|
||||
export type WslConfig = { enabled: boolean }
|
||||
@@ -29,8 +30,16 @@ 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) {
|
||||
prepareServerEnv(password)
|
||||
const { Log, Server } = await import("virtual:opencode-server")
|
||||
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 +51,26 @@ 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 } }
|
||||
}
|
||||
|
||||
function prepareServerEnv(password: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
|
||||
const env = {
|
||||
...process.env,
|
||||
...shellEnv,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
OPENCODE_SERVER_USERNAME: "opencode",
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
XDG_STATE_HOME: app.getPath("userData"),
|
||||
}
|
||||
Object.assign(process.env, env)
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
@@ -82,5 +98,3 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export type { CommandChild }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { basename } from "node:path"
|
||||
|
||||
const SHELL_ENV_TIMEOUT = 5_000
|
||||
const TIMEOUT = 5_000
|
||||
|
||||
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
|
||||
|
||||
@@ -20,28 +20,28 @@ export function parseShellEnv(out: Buffer) {
|
||||
return env
|
||||
}
|
||||
|
||||
function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe {
|
||||
function probe(shell: string, mode: "-il" | "-l"): Probe {
|
||||
const out = spawnSync(shell, [mode, "-c", "env -0"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: SHELL_ENV_TIMEOUT,
|
||||
timeout: TIMEOUT,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
const err = out.error as NodeJS.ErrnoException | undefined
|
||||
if (err) {
|
||||
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
|
||||
console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
|
||||
console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
if (out.status !== 0) {
|
||||
console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`)
|
||||
console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
const env = parseShellEnv(out.stdout)
|
||||
if (Object.keys(env).length === 0) {
|
||||
console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`)
|
||||
console.log(`[server] Shell env probe returned empty env for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
@@ -56,27 +56,27 @@ export function isNushell(shell: string) {
|
||||
|
||||
export function loadShellEnv(shell: string) {
|
||||
if (isNushell(shell)) {
|
||||
console.log(`[cli] Skipping shell env probe for nushell: ${shell}`)
|
||||
console.log(`[server] Skipping shell env probe for nushell: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const interactive = probeShellEnv(shell, "-il")
|
||||
const interactive = probe(shell, "-il")
|
||||
if (interactive.type === "Loaded") {
|
||||
console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||
console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||
return interactive.value
|
||||
}
|
||||
if (interactive.type === "Timeout") {
|
||||
console.warn(`[cli] Interactive shell env probe timed out: ${shell}`)
|
||||
console.warn(`[server] Interactive shell env probe timed out: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const login = probeShellEnv(shell, "-l")
|
||||
const login = probe(shell, "-l")
|
||||
if (login.type === "Loaded") {
|
||||
console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||
console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||
return login.value
|
||||
}
|
||||
|
||||
console.warn(`[cli] Falling back to app environment: ${shell}`)
|
||||
console.warn(`[server] Falling back to app environment: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -21,7 +21,7 @@ const releaseId = process.env.OPENCODE_RELEASE
|
||||
if (!releaseId) throw new Error("OPENCODE_RELEASE is required")
|
||||
|
||||
const version = process.env.OPENCODE_VERSION
|
||||
if (!releaseId) throw new Error("OPENCODE_VERSION is required")
|
||||
if (!version) throw new Error("OPENCODE_VERSION is required")
|
||||
|
||||
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
|
||||
if (!token) throw new Error("GH_TOKEN or GITHUB_TOKEN is required")
|
||||
@@ -54,7 +54,10 @@ const assets = release.assets ?? []
|
||||
const assetByName = new Map(assets.map((asset) => [asset.name, asset]))
|
||||
|
||||
const latestAsset = assetByName.get("latest.json")
|
||||
if (!latestAsset) throw new Error("latest.json asset not found")
|
||||
if (!latestAsset) {
|
||||
console.log("latest.json not found, skipping tauri finalization")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const latestRes = await fetch(latestAsset.url, {
|
||||
headers: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
|
||||
import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { fn } from "@opencode-ai/util/fn"
|
||||
import { iife } from "@opencode-ai/util/iife"
|
||||
import z from "zod"
|
||||
@@ -27,7 +27,7 @@ export namespace Share {
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("session_diff"),
|
||||
data: z.custom<FileDiff[]>(),
|
||||
data: z.custom<SnapshotFileDiff[]>(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("model"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Message, Model, Part, Session, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||
import { DataProvider } from "@opencode-ai/ui/context"
|
||||
@@ -51,7 +51,7 @@ const getData = query(async (shareID) => {
|
||||
shareID: string
|
||||
session: Session[]
|
||||
session_diff: {
|
||||
[sessionID: string]: FileDiff[]
|
||||
[sessionID: string]: SnapshotFileDiff[]
|
||||
}
|
||||
session_status: {
|
||||
[sessionID: string]: SessionStatus
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.3.17"
|
||||
version = "1.4.3"
|
||||
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.17/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/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.17/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/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.17/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/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.17/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.3/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.17",
|
||||
"version": "1.4.3",
|
||||
"$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.17",
|
||||
"version": "1.4.3",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -106,7 +106,7 @@
|
||||
"@hono/node-ws": "1.3.0",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@lydell/node-pty": "1.2.0-beta.10",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
@@ -137,7 +137,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.0.0",
|
||||
"gitlab-ai-provider": "6.4.2",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
@@ -9,18 +8,11 @@ import { fileURLToPath } from "url"
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
const root = path.resolve(dir, "../..")
|
||||
|
||||
function linker(): "hoisted" | "isolated" {
|
||||
// jsonc-parser is only declared in packages/opencode, so its install location
|
||||
// tells us whether Bun used a hoisted or isolated workspace layout.
|
||||
if (fs.existsSync(path.join(dir, "node_modules", "jsonc-parser"))) return "isolated"
|
||||
if (fs.existsSync(path.join(root, "node_modules", "jsonc-parser"))) return "hoisted"
|
||||
throw new Error("Could not detect Bun linker from jsonc-parser")
|
||||
}
|
||||
|
||||
process.chdir(dir)
|
||||
|
||||
await import("./generate.ts")
|
||||
|
||||
// Load migrations from migration directories
|
||||
const migrationDirs = (
|
||||
await fs.promises.readdir(path.join(dir, "migration"), {
|
||||
@@ -51,21 +43,20 @@ const migrations = await Promise.all(
|
||||
)
|
||||
console.log(`Loaded ${migrations.length} migrations`)
|
||||
|
||||
const link = linker()
|
||||
|
||||
await $`bun install --linker=${link} --os="*" --cpu="*" @lydell/node-pty@1.2.0-beta.10`
|
||||
|
||||
await Bun.build({
|
||||
target: "node",
|
||||
entrypoints: ["./src/node.ts"],
|
||||
outdir: "./dist",
|
||||
outdir: "./dist/node",
|
||||
format: "esm",
|
||||
sourcemap: "linked",
|
||||
external: ["jsonc-parser"],
|
||||
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")
|
||||
|
||||
@@ -12,24 +12,11 @@ const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
|
||||
await import("./generate.ts")
|
||||
|
||||
import { Script } from "@opencode-ai/script"
|
||||
import pkg from "../package.json"
|
||||
|
||||
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
// Fetch and generate models.dev snapshot
|
||||
const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.js"),
|
||||
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.d.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
|
||||
)
|
||||
console.log("Generated models-snapshot.js")
|
||||
|
||||
// Load migrations from migration directories
|
||||
const migrationDirs = (
|
||||
await fs.promises.readdir(path.join(dir, "migration"), {
|
||||
|
||||
23
packages/opencode/script/generate.ts
Normal file
23
packages/opencode/script/generate.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const dir = path.resolve(__dirname, "..")
|
||||
|
||||
process.chdir(dir)
|
||||
|
||||
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
|
||||
// Fetch and generate models.dev snapshot
|
||||
const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.js"),
|
||||
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.d.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
|
||||
)
|
||||
console.log("Generated models-snapshot.js")
|
||||
@@ -1,8 +1,4 @@
|
||||
# 2.0
|
||||
|
||||
What we would change if we could
|
||||
|
||||
## Keybindings vs. Keymappings
|
||||
# Keybindings vs. Keymappings
|
||||
|
||||
Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Commands don't define their binding, but have an id that a key can be mapped to like
|
||||
|
||||
136
packages/opencode/specs/v2/message-shape.md
Normal file
136
packages/opencode/specs/v2/message-shape.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Message Shape
|
||||
|
||||
Problem:
|
||||
|
||||
- stored messages need enough data to replay and resume a session later
|
||||
- prompt hooks often just want to append a synthetic user/assistant message
|
||||
- today that means faking ids, timestamps, and request metadata
|
||||
|
||||
## Option 1: Two Message Shapes
|
||||
|
||||
Keep `User` / `Assistant` for stored history, but clean them up.
|
||||
|
||||
```ts
|
||||
type User = {
|
||||
role: "user"
|
||||
time: { created: number }
|
||||
request: {
|
||||
agent: string
|
||||
model: ModelRef
|
||||
variant?: string
|
||||
format?: OutputFormat
|
||||
system?: string
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
type Assistant = {
|
||||
role: "assistant"
|
||||
run: { agent: string; model: ModelRef; path: { cwd: string; root: string } }
|
||||
usage: { cost: number; tokens: Tokens }
|
||||
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
|
||||
}
|
||||
```
|
||||
|
||||
Add a separate transient `PromptMessage` for prompt surgery.
|
||||
|
||||
```ts
|
||||
type PromptMessage = {
|
||||
role: "user" | "assistant"
|
||||
parts: PromptPart[]
|
||||
}
|
||||
```
|
||||
|
||||
Plugin hook example:
|
||||
|
||||
```ts
|
||||
prompt.push({
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
|
||||
})
|
||||
```
|
||||
|
||||
Tradeoff: prompt hooks get easy lightweight messages, but there are now two message shapes.
|
||||
|
||||
## Option 2: Prompt Mutators
|
||||
|
||||
Keep `User` / `Assistant` as the stored history model.
|
||||
|
||||
Prompt hooks do not build messages directly. The runtime gives them prompt mutators.
|
||||
|
||||
```ts
|
||||
type PromptEditor = {
|
||||
append(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
|
||||
prepend(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
|
||||
appendTo(target: "last-user" | "last-assistant", parts: PromptPart[]): void
|
||||
insertAfter(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
|
||||
insertBefore(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
|
||||
}
|
||||
```
|
||||
|
||||
Plugin hook examples:
|
||||
|
||||
```ts
|
||||
prompt.append({
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
|
||||
})
|
||||
```
|
||||
|
||||
```ts
|
||||
prompt.appendTo("last-user", [{ type: "text", text: BUILD_SWITCH }])
|
||||
```
|
||||
|
||||
Tradeoff: avoids a second full message type and avoids fake ids/timestamps, but moves more magic into the hook API.
|
||||
|
||||
## Option 3: Separate Turn State
|
||||
|
||||
Move execution settings out of `User` and into a separate turn/request object.
|
||||
|
||||
```ts
|
||||
type Turn = {
|
||||
id: string
|
||||
request: {
|
||||
agent: string
|
||||
model: ModelRef
|
||||
variant?: string
|
||||
format?: OutputFormat
|
||||
system?: string
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
type User = {
|
||||
role: "user"
|
||||
turnID: string
|
||||
time: { created: number }
|
||||
}
|
||||
|
||||
type Assistant = {
|
||||
role: "assistant"
|
||||
turnID: string
|
||||
usage: { cost: number; tokens: Tokens }
|
||||
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
|
||||
}
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```ts
|
||||
const turn = {
|
||||
request: {
|
||||
agent: "build",
|
||||
model: { providerID: "openai", modelID: "gpt-5" },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
const msg = {
|
||||
role: "user",
|
||||
turnID: turn.id,
|
||||
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
|
||||
}
|
||||
```
|
||||
|
||||
Tradeoff: stored messages get much smaller and cleaner, but replay now has to join messages with turn state and prompt hooks still need a way to pick which turn they belong to.
|
||||
@@ -461,28 +461,11 @@ export namespace Account {
|
||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
||||
}
|
||||
|
||||
export async function list(): Promise<Info[]> {
|
||||
return runPromise((service) => service.list())
|
||||
}
|
||||
|
||||
export async function activeOrg(): Promise<ActiveOrg | undefined> {
|
||||
return Option.getOrUndefined(await runPromise((service) => service.activeOrg()))
|
||||
}
|
||||
|
||||
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
|
||||
return runPromise((service) => service.orgsByAccount())
|
||||
}
|
||||
|
||||
export async function orgs(accountID: AccountID): Promise<readonly Org[]> {
|
||||
return runPromise((service) => service.orgs(accountID))
|
||||
}
|
||||
|
||||
export async function switchOrg(accountID: AccountID, orgID: OrgID) {
|
||||
return runPromise((service) => service.use(accountID, Option.some(orgID)))
|
||||
}
|
||||
|
||||
export async function token(accountID: AccountID): Promise<AccessToken | undefined> {
|
||||
const t = await runPromise((service) => service.token(accountID))
|
||||
return Option.getOrUndefined(t)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +341,10 @@ export namespace Agent {
|
||||
)
|
||||
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
|
||||
|
||||
// TODO: clean this up so provider specific logic doesnt bleed over
|
||||
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
|
||||
const isOpenaiOauth = model.providerID === "openai" && authInfo?.type === "oauth"
|
||||
|
||||
const params = {
|
||||
experimental_telemetry: {
|
||||
isEnabled: cfg.experimental?.openTelemetry,
|
||||
@@ -350,12 +354,14 @@ export namespace Agent {
|
||||
},
|
||||
temperature: 0.3,
|
||||
messages: [
|
||||
...system.map(
|
||||
(item): ModelMessage => ({
|
||||
role: "system",
|
||||
content: item,
|
||||
}),
|
||||
),
|
||||
...(isOpenaiOauth
|
||||
? []
|
||||
: system.map(
|
||||
(item): ModelMessage => ({
|
||||
role: "system",
|
||||
content: item,
|
||||
}),
|
||||
)),
|
||||
{
|
||||
role: "user",
|
||||
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
|
||||
@@ -369,13 +375,12 @@ export namespace Agent {
|
||||
}),
|
||||
} satisfies Parameters<typeof generateObject>[0]
|
||||
|
||||
// TODO: clean this up so provider specific logic doesnt bleed over
|
||||
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
|
||||
if (model.providerID === "openai" && authInfo?.type === "oauth") {
|
||||
if (isOpenaiOauth) {
|
||||
return yield* Effect.promise(async () => {
|
||||
const result = streamObject({
|
||||
...params,
|
||||
providerOptions: ProviderTransform.providerOptions(resolved, {
|
||||
instructions: system.join("\n"),
|
||||
store: false,
|
||||
}),
|
||||
onError: () => {},
|
||||
@@ -393,11 +398,13 @@ export namespace Agent {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -71,7 +71,10 @@ export const AgentCommand = cmd({
|
||||
|
||||
async function getAvailableTools(agent: Agent.Info) {
|
||||
const model = agent.model ?? (await Provider.defaultModel())
|
||||
return ToolRegistry.tools(model, agent)
|
||||
return ToolRegistry.tools({
|
||||
...model,
|
||||
agent,
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { cmd } from "./cmd"
|
||||
import { ModelsDev } from "../../provider/models"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { Session } from "../../session"
|
||||
import type { SessionID } from "../../session/schema"
|
||||
import { MessageID, PartID } from "../../session/schema"
|
||||
@@ -559,7 +560,7 @@ export const GithubRunCommand = cmd({
|
||||
shareId = await (async () => {
|
||||
if (share === false) return
|
||||
if (!share && repoData.data.private) return
|
||||
await Session.share(session.id)
|
||||
await SessionShare.share(session.id)
|
||||
return session.id.slice(-8)
|
||||
})()
|
||||
console.log("opencode session", session.id)
|
||||
|
||||
@@ -688,6 +688,7 @@ export const McpDebugCommand = cmd({
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async () => {},
|
||||
|
||||
@@ -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"
|
||||
@@ -680,7 +680,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)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { RGBA, TextAttributes } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import open from "open"
|
||||
import { createSignal } from "solid-js"
|
||||
import { selectedForeground, useTheme } from "@tui/context/theme"
|
||||
import { useDialog, type DialogContext } from "@tui/ui/dialog"
|
||||
import { Link } from "@tui/ui/link"
|
||||
|
||||
const GO_URL = "https://opencode.ai/go"
|
||||
|
||||
export type DialogGoUpsellProps = {
|
||||
onClose?: (dontShowAgain?: boolean) => void
|
||||
}
|
||||
|
||||
function subscribe(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
|
||||
open(GO_URL).catch(() => {})
|
||||
props.onClose?.()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
function dismiss(props: DialogGoUpsellProps, dialog: ReturnType<typeof useDialog>) {
|
||||
props.onClose?.(true)
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
export function DialogGoUpsell(props: DialogGoUpsellProps) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const fg = selectedForeground(theme)
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
|
||||
useKeyboard((evt) => {
|
||||
if (evt.name === "left" || evt.name === "right" || evt.name === "tab") {
|
||||
setSelected((s) => (s === 0 ? 1 : 0))
|
||||
return
|
||||
}
|
||||
if (evt.name !== "return") return
|
||||
if (selected() === 0) subscribe(props, dialog)
|
||||
else dismiss(props, dialog)
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Free limit reached
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<box gap={1} paddingBottom={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
Subscribe to OpenCode Go to keep going with reliable access to the best open-source models, starting at
|
||||
$5/month.
|
||||
</text>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<Link href={GO_URL} fg={theme.primary} />
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end" gap={1} paddingBottom={1}>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={selected() === 0 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected(0)}
|
||||
onMouseUp={() => subscribe(props, dialog)}
|
||||
>
|
||||
<text fg={selected() === 0 ? fg : theme.text} attributes={selected() === 0 ? TextAttributes.BOLD : undefined}>
|
||||
subscribe
|
||||
</text>
|
||||
</box>
|
||||
<box
|
||||
paddingLeft={3}
|
||||
paddingRight={3}
|
||||
backgroundColor={selected() === 1 ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
onMouseOver={() => setSelected(1)}
|
||||
onMouseUp={() => dismiss(props, dialog)}
|
||||
>
|
||||
<text
|
||||
fg={selected() === 1 ? fg : theme.textMuted}
|
||||
attributes={selected() === 1 ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
don't show again
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogGoUpsell.show = (dialog: DialogContext) => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
dialog.replace(
|
||||
() => <DialogGoUpsell onClose={(dontShow) => resolve(dontShow ?? false)} />,
|
||||
() => resolve(false),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
||||
import { DialogVariant } from "./dialog-variant"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import * as fuzzysort from "fuzzysort"
|
||||
import { consoleManagedProviderLabel } from "@tui/util/provider-origin"
|
||||
|
||||
export function useConnected() {
|
||||
const sync = useSync()
|
||||
@@ -47,11 +46,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
key: item,
|
||||
value: { providerID: provider.id, modelID: model.id },
|
||||
title: model.name ?? item.modelID,
|
||||
description: consoleManagedProviderLabel(
|
||||
sync.data.console_state.consoleManagedProviders,
|
||||
provider.id,
|
||||
provider.name,
|
||||
),
|
||||
description: provider.name,
|
||||
category,
|
||||
disabled: provider.id === "opencode" && model.id.includes("-nano"),
|
||||
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
@@ -89,9 +84,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
|
||||
? "(Favorite)"
|
||||
: undefined,
|
||||
category: connected()
|
||||
? consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, provider.id, provider.name)
|
||||
: undefined,
|
||||
category: connected() ? provider.name : undefined,
|
||||
disabled: provider.id === "opencode" && model.includes("-nano"),
|
||||
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
|
||||
onSelect() {
|
||||
@@ -142,7 +135,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
const title = createMemo(() => {
|
||||
const value = provider()
|
||||
if (!value) return "Select model"
|
||||
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, value.id, value.name)
|
||||
return value.name
|
||||
})
|
||||
|
||||
function onSelect(providerID: string, modelID: string) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { DialogModel } from "./dialog-model"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { Clipboard } from "@tui/util/clipboard"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { CONSOLE_MANAGED_ICON, isConsoleManagedProvider } from "@tui/util/provider-origin"
|
||||
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
|
||||
|
||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
opencode: 0,
|
||||
@@ -49,11 +49,7 @@ export function createDialogProviderOptions() {
|
||||
}[provider.id],
|
||||
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
|
||||
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
|
||||
gutter: consoleManaged ? (
|
||||
<text fg={theme.textMuted}>{CONSOLE_MANAGED_ICON}</text>
|
||||
) : connected ? (
|
||||
<text fg={theme.success}>✓</text>
|
||||
) : undefined,
|
||||
gutter: connected ? <text fg={theme.success}>✓</text> : undefined,
|
||||
async onSelect() {
|
||||
if (consoleManaged) return
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ import { useToast } from "../../ui/toast"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { useTextareaKeybindings } from "../textarea-keybindings"
|
||||
import { DialogSkill } from "../dialog-skill"
|
||||
import { CONSOLE_MANAGED_ICON, consoleManagedProviderLabel } from "@tui/util/provider-origin"
|
||||
|
||||
export type PromptProps = {
|
||||
sessionID?: string
|
||||
@@ -96,15 +95,8 @@ export function Prompt(props: PromptProps) {
|
||||
const list = createMemo(() => props.placeholders?.normal ?? [])
|
||||
const shell = createMemo(() => props.placeholders?.shell ?? [])
|
||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||
const activeOrgName = createMemo(() => sync.data.console_state.activeOrgName)
|
||||
const canSwitchOrgs = createMemo(() => sync.data.console_state.switchableOrgCount > 1)
|
||||
const currentProviderLabel = createMemo(() => {
|
||||
const current = local.model.current()
|
||||
const provider = local.model.parsed().provider
|
||||
if (!current) return provider
|
||||
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, current.providerID, provider)
|
||||
})
|
||||
const hasRightContent = createMemo(() => Boolean(props.right || activeOrgName()))
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const hasRightContent = createMemo(() => Boolean(props.right))
|
||||
|
||||
function promptModelWarning() {
|
||||
toast.show({
|
||||
@@ -1120,17 +1112,6 @@ export function Prompt(props: PromptProps) {
|
||||
<Show when={hasRightContent()}>
|
||||
<box flexDirection="row" gap={1} alignItems="center">
|
||||
{props.right}
|
||||
<Show when={activeOrgName()}>
|
||||
<text
|
||||
fg={theme.textMuted}
|
||||
onMouseUp={() => {
|
||||
if (!canSwitchOrgs()) return
|
||||
command.trigger("console.org.switch")
|
||||
}}
|
||||
>
|
||||
{`${CONSOLE_MANAGED_ICON} ${activeOrgName()}`}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
@@ -1162,7 +1143,7 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between">
|
||||
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
|
||||
<box
|
||||
flexDirection="row"
|
||||
|
||||
@@ -83,9 +83,15 @@ import { UI } from "@/cli/ui.ts"
|
||||
import { useTuiConfig } from "../../context/tui-config"
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { TuiPluginRuntime } from "../../plugin"
|
||||
import { DialogGoUpsell } from "../../component/dialog-go-upsell"
|
||||
import { SessionRetry } from "@/session/retry"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
const GO_UPSELL_LAST_SEEN_AT = "go_upsell_last_seen_at"
|
||||
const GO_UPSELL_DONT_SHOW = "go_upsell_dont_show"
|
||||
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
|
||||
|
||||
const context = createContext<{
|
||||
width: number
|
||||
sessionID: string
|
||||
@@ -149,7 +155,7 @@ export function Session() {
|
||||
const [timestamps, setTimestamps] = kv.signal<"hide" | "show">("timestamps", "hide")
|
||||
const [showDetails, setShowDetails] = kv.signal("tool_details_visibility", true)
|
||||
const [showAssistantMetadata, setShowAssistantMetadata] = kv.signal("assistant_metadata_visibility", true)
|
||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", true)
|
||||
const [showScrollbar, setShowScrollbar] = kv.signal("scrollbar_visible", false)
|
||||
const [diffWrapMode] = kv.signal<"word" | "none">("diff_wrap_mode", "word")
|
||||
const [animationsEnabled, setAnimationsEnabled] = kv.signal("animations_enabled", true)
|
||||
const [showGenericToolOutput, setShowGenericToolOutput] = kv.signal("generic_tool_output_visibility", false)
|
||||
@@ -218,6 +224,23 @@ export function Session() {
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
|
||||
sdk.event.on("session.status", (evt) => {
|
||||
if (evt.properties.sessionID !== route.sessionID) return
|
||||
if (evt.properties.status.type !== "retry") return
|
||||
if (evt.properties.status.message !== SessionRetry.GO_UPSELL_MESSAGE) return
|
||||
if (dialog.stack.length > 0) return
|
||||
|
||||
const seen = kv.get(GO_UPSELL_LAST_SEEN_AT)
|
||||
if (typeof seen === "number" && Date.now() - seen < GO_UPSELL_WINDOW) return
|
||||
|
||||
if (kv.get(GO_UPSELL_DONT_SHOW)) return
|
||||
|
||||
DialogGoUpsell.show(dialog).then((dontShowAgain) => {
|
||||
if (dontShowAgain) kv.set(GO_UPSELL_DONT_SHOW, true)
|
||||
kv.set(GO_UPSELL_LAST_SEEN_AT, Date.now())
|
||||
})
|
||||
})
|
||||
|
||||
// Allow exit when in child session (prompt is hidden)
|
||||
const exit = useExit()
|
||||
|
||||
@@ -2124,7 +2147,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<Diff diff={file.diff} filePath={file.filePath} />
|
||||
<Diff diff={file.patch} filePath={file.filePath} />
|
||||
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={file.movePath ?? file.filePath} />
|
||||
</Show>
|
||||
</BlockTool>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const CONSOLE_MANAGED_ICON = "⌂"
|
||||
|
||||
const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
|
||||
Array.isArray(consoleManagedProviders)
|
||||
? consoleManagedProviders.includes(providerID)
|
||||
@@ -7,14 +5,3 @@ const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, provi
|
||||
|
||||
export const isConsoleManagedProvider = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
|
||||
contains(consoleManagedProviders, providerID)
|
||||
|
||||
export const consoleManagedProviderSuffix = (
|
||||
consoleManagedProviders: string[] | ReadonlySet<string>,
|
||||
providerID: string,
|
||||
) => (contains(consoleManagedProviders, providerID) ? ` ${CONSOLE_MANAGED_ICON}` : "")
|
||||
|
||||
export const consoleManagedProviderLabel = (
|
||||
consoleManagedProviders: string[] | ReadonlySet<string>,
|
||||
providerID: string,
|
||||
providerName: string,
|
||||
) => `${providerName}${consoleManagedProviderSuffix(consoleManagedProviders, providerID)}`
|
||||
|
||||
@@ -138,7 +138,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,
|
||||
|
||||
@@ -399,6 +399,10 @@ export namespace Config {
|
||||
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
|
||||
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
|
||||
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
|
||||
redirectUri: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
@@ -786,28 +790,81 @@ export namespace Config {
|
||||
})
|
||||
export type Layout = z.infer<typeof Layout>
|
||||
|
||||
export const Provider = ModelsDev.Provider.partial()
|
||||
.extend({
|
||||
whitelist: z.array(z.string()).optional(),
|
||||
blacklist: z.array(z.string()).optional(),
|
||||
models: z
|
||||
export const Model = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
family: z.string().optional(),
|
||||
release_date: z.string(),
|
||||
attachment: z.boolean(),
|
||||
reasoning: z.boolean(),
|
||||
temperature: z.boolean(),
|
||||
tool_call: z.boolean(),
|
||||
interleaved: z
|
||||
.union([
|
||||
z.literal(true),
|
||||
z
|
||||
.object({
|
||||
field: z.enum(["reasoning_content", "reasoning_details"]),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
.optional(),
|
||||
cost: z
|
||||
.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
context_over_200k: z
|
||||
.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
limit: z.object({
|
||||
context: z.number(),
|
||||
input: z.number().optional(),
|
||||
output: z.number(),
|
||||
}),
|
||||
modalities: z
|
||||
.object({
|
||||
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
||||
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
||||
})
|
||||
.optional(),
|
||||
experimental: z.boolean().optional(),
|
||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
|
||||
options: z.record(z.string(), z.any()),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
variants: z
|
||||
.record(
|
||||
z.string(),
|
||||
ModelsDev.Model.partial().extend({
|
||||
variants: z
|
||||
.record(
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
disabled: z.boolean().optional().describe("Disable this variant for the model"),
|
||||
})
|
||||
.catchall(z.any()),
|
||||
)
|
||||
.optional()
|
||||
.describe("Variant-specific configuration"),
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
disabled: z.boolean().optional().describe("Disable this variant for the model"),
|
||||
})
|
||||
.catchall(z.any()),
|
||||
)
|
||||
.optional(),
|
||||
.optional()
|
||||
.describe("Variant-specific configuration"),
|
||||
})
|
||||
.partial()
|
||||
|
||||
export const Provider = z
|
||||
.object({
|
||||
api: z.string().optional(),
|
||||
name: z.string(),
|
||||
env: z.array(z.string()),
|
||||
id: z.string(),
|
||||
npm: z.string().optional(),
|
||||
whitelist: z.array(z.string()).optional(),
|
||||
blacklist: z.array(z.string()).optional(),
|
||||
options: z
|
||||
.object({
|
||||
apiKey: z.string().optional(),
|
||||
@@ -840,11 +897,14 @@ export namespace Config {
|
||||
})
|
||||
.catchall(z.any())
|
||||
.optional(),
|
||||
models: z.record(z.string(), Model).optional(),
|
||||
})
|
||||
.partial()
|
||||
.strict()
|
||||
.meta({
|
||||
ref: "ProviderConfig",
|
||||
})
|
||||
|
||||
export type Provider = z.infer<typeof Provider>
|
||||
|
||||
export const Info = z
|
||||
|
||||
@@ -499,4 +499,3 @@ const rt = lazy(async () => {
|
||||
|
||||
type RT = Awaited<ReturnType<typeof rt>>
|
||||
export const runPromiseExit: RT["runPromiseExit"] = async (...args) => (await rt()).runPromiseExit(...(args as [any]))
|
||||
export const runPromise: RT["runPromise"] = async (...args) => (await rt()).runPromise(...(args as [any]))
|
||||
|
||||
@@ -73,10 +73,4 @@ export namespace InstanceState {
|
||||
Effect.gen(function* () {
|
||||
return yield* ScopedCache.invalidate(self.cache, yield* directory)
|
||||
})
|
||||
|
||||
/**
|
||||
* Effect finalizers run on the fiber scheduler after the original async
|
||||
* boundary, so ALS reads like Instance.directory can be gone by then.
|
||||
*/
|
||||
export const withALS = <T>(fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema, Scope, SynchronizedRef } from "effect"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Schema, Scope, SynchronizedRef } from "effect"
|
||||
|
||||
export interface Runner<A, E = never> {
|
||||
readonly state: Runner.State<A, E>
|
||||
readonly busy: boolean
|
||||
readonly ensureRunning: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
|
||||
readonly startShell: (work: (signal: AbortSignal) => Effect.Effect<A, E>) => Effect.Effect<A, E>
|
||||
readonly startShell: (work: Effect.Effect<A, E>) => Effect.Effect<A, E>
|
||||
readonly cancel: Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export namespace Runner {
|
||||
interface ShellHandle<A, E> {
|
||||
id: number
|
||||
fiber: Fiber.Fiber<A, E>
|
||||
abort: AbortController
|
||||
}
|
||||
|
||||
interface PendingHandle<A, E> {
|
||||
@@ -100,13 +99,7 @@ export namespace Runner {
|
||||
}),
|
||||
).pipe(Effect.flatten)
|
||||
|
||||
const stopShell = (shell: ShellHandle<A, E>) =>
|
||||
Effect.gen(function* () {
|
||||
shell.abort.abort()
|
||||
const exit = yield* Fiber.await(shell.fiber).pipe(Effect.timeoutOption("100 millis"))
|
||||
if (Option.isNone(exit)) yield* Fiber.interrupt(shell.fiber)
|
||||
yield* Fiber.await(shell.fiber).pipe(Effect.exit, Effect.asVoid)
|
||||
})
|
||||
const stopShell = (shell: ShellHandle<A, E>) => Fiber.interrupt(shell.fiber)
|
||||
|
||||
const ensureRunning = (work: Effect.Effect<A, E>) =>
|
||||
SynchronizedRef.modifyEffect(
|
||||
@@ -138,7 +131,7 @@ export namespace Runner {
|
||||
),
|
||||
)
|
||||
|
||||
const startShell = (work: (signal: AbortSignal) => Effect.Effect<A, E>) =>
|
||||
const startShell = (work: Effect.Effect<A, E>) =>
|
||||
SynchronizedRef.modifyEffect(
|
||||
ref,
|
||||
Effect.fnUntraced(function* (st) {
|
||||
@@ -153,9 +146,8 @@ export namespace Runner {
|
||||
}
|
||||
yield* busy
|
||||
const id = next()
|
||||
const abort = new AbortController()
|
||||
const fiber = yield* work(abort.signal).pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
|
||||
const shell = { id, fiber, abort } satisfies ShellHandle<A, E>
|
||||
const fiber = yield* work.pipe(Effect.ensuring(finishShell(id)), Effect.forkChild)
|
||||
const shell = { id, fiber } satisfies ShellHandle<A, E>
|
||||
return [
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
|
||||
@@ -46,7 +46,7 @@ export namespace FileTime {
|
||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||
|
||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.void))
|
||||
return {
|
||||
read: yield* DateTime.nowAsDate,
|
||||
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
|
||||
|
||||
@@ -265,39 +265,7 @@ export namespace Git {
|
||||
return runPromise((git) => git.run(args, opts))
|
||||
}
|
||||
|
||||
export async function branch(cwd: string) {
|
||||
return runPromise((git) => git.branch(cwd))
|
||||
}
|
||||
|
||||
export async function prefix(cwd: string) {
|
||||
return runPromise((git) => git.prefix(cwd))
|
||||
}
|
||||
|
||||
export async function defaultBranch(cwd: string) {
|
||||
return runPromise((git) => git.defaultBranch(cwd))
|
||||
}
|
||||
|
||||
export async function hasHead(cwd: string) {
|
||||
return runPromise((git) => git.hasHead(cwd))
|
||||
}
|
||||
|
||||
export async function mergeBase(cwd: string, base: string, head?: string) {
|
||||
return runPromise((git) => git.mergeBase(cwd, base, head))
|
||||
}
|
||||
|
||||
export async function show(cwd: string, ref: string, file: string, prefix?: string) {
|
||||
return runPromise((git) => git.show(cwd, ref, file, prefix))
|
||||
}
|
||||
|
||||
export async function status(cwd: string) {
|
||||
return runPromise((git) => git.status(cwd))
|
||||
}
|
||||
|
||||
export async function diff(cwd: string, ref: string) {
|
||||
return runPromise((git) => git.diff(cwd, ref))
|
||||
}
|
||||
|
||||
export async function stats(cwd: string, ref: string) {
|
||||
return runPromise((git) => git.stats(cwd, ref))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { Database } from "./storage/db"
|
||||
import { errorMessage } from "./util/error"
|
||||
import { PluginCommand } from "./cli/cmd/plug"
|
||||
import { Heap } from "./cli/heap"
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
||||
|
||||
process.on("unhandledRejection", (e) => {
|
||||
Log.Default.error("rejection", {
|
||||
@@ -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
|
||||
|
||||
@@ -245,7 +245,7 @@ export namespace LSP {
|
||||
})
|
||||
|
||||
if (!handle) return undefined
|
||||
log.info("spawned lsp server", { serverID: server.id })
|
||||
log.info("spawned lsp server", { serverID: server.id, root })
|
||||
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
|
||||
@@ -867,7 +867,7 @@ export namespace LSPServer {
|
||||
|
||||
export const Clangd: Info = {
|
||||
id: "clangd",
|
||||
root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd", "CMakeLists.txt", "Makefile"]),
|
||||
root: NearestRoot(["compile_commands.json", "compile_flags.txt", ".clangd"]),
|
||||
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
|
||||
async spawn(root) {
|
||||
const args = ["--background-index", "--clang-tidy"]
|
||||
|
||||
@@ -286,6 +286,7 @@ export namespace MCP {
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async (url) => {
|
||||
@@ -501,7 +502,7 @@ export namespace MCP {
|
||||
return
|
||||
}
|
||||
|
||||
const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void))
|
||||
if (!result) return
|
||||
|
||||
s.status[key] = result.status
|
||||
@@ -716,13 +717,16 @@ export namespace MCP {
|
||||
if (mcpConfig.type !== "remote") throw new Error(`MCP server ${mcpName} is not a remote server`)
|
||||
if (mcpConfig.oauth === false) throw new Error(`MCP server ${mcpName} has OAuth explicitly disabled`)
|
||||
|
||||
yield* Effect.promise(() => McpOAuthCallback.ensureRunning())
|
||||
// OAuth config is optional - if not provided, we'll use auto-discovery
|
||||
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
||||
|
||||
// Start the callback server with custom redirectUri if configured
|
||||
yield* Effect.promise(() => McpOAuthCallback.ensureRunning(oauthConfig?.redirectUri))
|
||||
|
||||
const oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
yield* auth.updateOAuthState(mcpName, oauthState)
|
||||
const oauthConfig = typeof mcpConfig.oauth === "object" ? mcpConfig.oauth : undefined
|
||||
let capturedUrl: URL | undefined
|
||||
const authProvider = new McpOAuthProvider(
|
||||
mcpName,
|
||||
@@ -731,6 +735,7 @@ export namespace MCP {
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
redirectUri: oauthConfig?.redirectUri,
|
||||
},
|
||||
{
|
||||
onRedirect: async (url) => {
|
||||
@@ -901,9 +906,6 @@ export namespace MCP {
|
||||
|
||||
export const disconnect = async (name: string) => runPromise((svc) => svc.disconnect(name))
|
||||
|
||||
export const getPrompt = async (clientName: string, name: string, args?: Record<string, string>) =>
|
||||
runPromise((svc) => svc.getPrompt(clientName, name, args))
|
||||
|
||||
export const startAuth = async (mcpName: string) => runPromise((svc) => svc.startAuth(mcpName))
|
||||
|
||||
export const authenticate = async (mcpName: string) => runPromise((svc) => svc.authenticate(mcpName))
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { createConnection } from "net"
|
||||
import { createServer } from "http"
|
||||
import { Log } from "../util/log"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider"
|
||||
|
||||
const log = Log.create({ service: "mcp.oauth-callback" })
|
||||
|
||||
// Current callback server configuration (may differ from defaults if custom redirectUri is used)
|
||||
let currentPort = OAUTH_CALLBACK_PORT
|
||||
let currentPath = OAUTH_CALLBACK_PATH
|
||||
|
||||
const HTML_SUCCESS = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -71,9 +75,9 @@ export namespace McpOAuthCallback {
|
||||
}
|
||||
|
||||
function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_CALLBACK_PORT}`)
|
||||
const url = new URL(req.url || "/", `http://localhost:${currentPort}`)
|
||||
|
||||
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
||||
if (url.pathname !== currentPath) {
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
return
|
||||
@@ -135,19 +139,31 @@ export namespace McpOAuthCallback {
|
||||
res.end(HTML_SUCCESS)
|
||||
}
|
||||
|
||||
export async function ensureRunning(): Promise<void> {
|
||||
export async function ensureRunning(redirectUri?: string): Promise<void> {
|
||||
// Parse the redirect URI to get port and path (uses defaults if not provided)
|
||||
const { port, path } = parseRedirectUri(redirectUri)
|
||||
|
||||
// If server is running on a different port/path, stop it first
|
||||
if (server && (currentPort !== port || currentPath !== path)) {
|
||||
log.info("stopping oauth callback server to reconfigure", { oldPort: currentPort, newPort: port })
|
||||
await stop()
|
||||
}
|
||||
|
||||
if (server) return
|
||||
|
||||
const running = await isPortInUse()
|
||||
const running = await isPortInUse(port)
|
||||
if (running) {
|
||||
log.info("oauth callback server already running on another instance", { port: OAUTH_CALLBACK_PORT })
|
||||
log.info("oauth callback server already running on another instance", { port })
|
||||
return
|
||||
}
|
||||
|
||||
currentPort = port
|
||||
currentPath = path
|
||||
|
||||
server = createServer(handleRequest)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server!.listen(OAUTH_CALLBACK_PORT, () => {
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
server!.listen(currentPort, () => {
|
||||
log.info("oauth callback server started", { port: currentPort, path: currentPath })
|
||||
resolve()
|
||||
})
|
||||
server!.on("error", reject)
|
||||
@@ -182,9 +198,9 @@ export namespace McpOAuthCallback {
|
||||
}
|
||||
}
|
||||
|
||||
export async function isPortInUse(): Promise<boolean> {
|
||||
export async function isPortInUse(port: number = OAUTH_CALLBACK_PORT): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1")
|
||||
const socket = createConnection(port, "127.0.0.1")
|
||||
socket.on("connect", () => {
|
||||
socket.destroy()
|
||||
resolve(true)
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface McpOAuthConfig {
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
scope?: string
|
||||
redirectUri?: string
|
||||
}
|
||||
|
||||
export interface McpOAuthCallbacks {
|
||||
@@ -32,6 +33,9 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
) {}
|
||||
|
||||
get redirectUrl(): string {
|
||||
if (this.config.redirectUri) {
|
||||
return this.config.redirectUri
|
||||
}
|
||||
return `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`
|
||||
}
|
||||
|
||||
@@ -183,3 +187,22 @@ export class McpOAuthProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
export { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH }
|
||||
|
||||
/**
|
||||
* Parse a redirect URI to extract port and path for the callback server.
|
||||
* Returns defaults if the URI can't be parsed.
|
||||
*/
|
||||
export function parseRedirectUri(redirectUri?: string): { port: number; path: string } {
|
||||
if (!redirectUri) {
|
||||
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(redirectUri)
|
||||
const port = url.port ? parseInt(url.port, 10) : url.protocol === "https:" ? 443 : 80
|
||||
const path = url.pathname || OAUTH_CALLBACK_PATH
|
||||
return { port, path }
|
||||
} catch {
|
||||
return { port: OAUTH_CALLBACK_PORT, path: OAUTH_CALLBACK_PATH }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -376,9 +376,9 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
"gpt-5.4",
|
||||
"gpt-5.4-mini",
|
||||
])
|
||||
for (const modelId of Object.keys(provider.models)) {
|
||||
for (const [modelId, model] of Object.entries(provider.models)) {
|
||||
if (modelId.includes("codex")) continue
|
||||
if (allowedModels.has(modelId)) continue
|
||||
if (allowedModels.has(model.api.id)) continue
|
||||
delete provider.models[modelId]
|
||||
}
|
||||
|
||||
|
||||
@@ -119,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 = {
|
||||
|
||||
@@ -158,7 +158,7 @@ export namespace Project {
|
||||
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
|
||||
Effect.map((x) => x.trim()),
|
||||
Effect.map(ProjectID.make),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
Effect.catch(() => Effect.void),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import path from "path"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
@@ -7,7 +8,6 @@ import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Git } from "@/git"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Log } from "@/util/log"
|
||||
import { Instance } from "./instance"
|
||||
import z from "zod"
|
||||
@@ -49,6 +49,8 @@ export namespace Vcs {
|
||||
map: Map<string, { additions: number; deletions: number }>,
|
||||
) {
|
||||
const base = ref ? yield* git.prefix(cwd) : ""
|
||||
const patch = (file: string, before: string, after: string) =>
|
||||
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
|
||||
const next = yield* Effect.forEach(
|
||||
list,
|
||||
(item) =>
|
||||
@@ -58,12 +60,11 @@ export namespace Vcs {
|
||||
const stat = map.get(item.file)
|
||||
return {
|
||||
file: item.file,
|
||||
before,
|
||||
after,
|
||||
patch: patch(item.file, before, after),
|
||||
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
|
||||
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
|
||||
status: item.status,
|
||||
} satisfies Snapshot.FileDiff
|
||||
} satisfies FileDiff
|
||||
}),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
@@ -125,11 +126,24 @@ export namespace Vcs {
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const FileDiff = z
|
||||
.object({
|
||||
file: z.string(),
|
||||
patch: z.string(),
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
status: z.enum(["added", "deleted", "modified"]).optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "VcsFileDiff",
|
||||
})
|
||||
export type FileDiff = z.infer<typeof FileDiff>
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly branch: () => Effect.Effect<string | undefined>
|
||||
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
||||
readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
|
||||
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -147,39 +161,37 @@ export namespace Vcs {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Vcs.state")((ctx) =>
|
||||
Effect.gen(function* () {
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return { current: undefined, root: undefined }
|
||||
}
|
||||
Effect.fn("Vcs.state")(function* (ctx) {
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return { current: undefined, root: undefined }
|
||||
}
|
||||
|
||||
const get = Effect.fnUntraced(function* () {
|
||||
return yield* git.branch(ctx.directory)
|
||||
})
|
||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
const value = { current, root }
|
||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||
const get = Effect.fnUntraced(function* () {
|
||||
return yield* git.branch(ctx.directory)
|
||||
})
|
||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
const value = { current, root }
|
||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||
|
||||
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||
Stream.runForEach((_evt) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* get()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||
Stream.runForEach((_evt) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* get()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return value
|
||||
}),
|
||||
),
|
||||
return value
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
|
||||
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>
|
||||
61474
packages/opencode/src/provider/models-snapshot.js
Normal file
61474
packages/opencode/src/provider/models-snapshot.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,27 @@ export namespace ModelsDev {
|
||||
)
|
||||
const ttl = 5 * 60 * 1000
|
||||
|
||||
type JsonValue = string | number | boolean | null | { [key: string]: JsonValue } | JsonValue[]
|
||||
|
||||
const JsonValue: z.ZodType<JsonValue> = z.lazy(() =>
|
||||
z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(JsonValue), z.record(z.string(), JsonValue)]),
|
||||
)
|
||||
|
||||
const Cost = z.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
context_over_200k: z
|
||||
.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export const Model = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
@@ -41,22 +62,7 @@ export namespace ModelsDev {
|
||||
.strict(),
|
||||
])
|
||||
.optional(),
|
||||
cost: z
|
||||
.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
context_over_200k: z
|
||||
.object({
|
||||
input: z.number(),
|
||||
output: z.number(),
|
||||
cache_read: z.number().optional(),
|
||||
cache_write: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
cost: Cost.optional(),
|
||||
limit: z.object({
|
||||
context: z.number(),
|
||||
input: z.number().optional(),
|
||||
@@ -68,12 +74,26 @@ export namespace ModelsDev {
|
||||
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
||||
})
|
||||
.optional(),
|
||||
experimental: z.boolean().optional(),
|
||||
experimental: z
|
||||
.object({
|
||||
modes: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
cost: Cost.optional(),
|
||||
provider: z
|
||||
.object({
|
||||
body: z.record(z.string(), JsonValue).optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||
options: z.record(z.string(), z.any()),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
|
||||
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
||||
})
|
||||
export type Model = z.infer<typeof Model>
|
||||
|
||||
|
||||
@@ -574,6 +574,7 @@ export namespace Provider {
|
||||
const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
|
||||
const model = sdk.workflowChat(sdkModelID, {
|
||||
featureFlags,
|
||||
workflowDefinition: options?.workflowDefinition as string | undefined,
|
||||
})
|
||||
if (workflowRef) {
|
||||
model.selectedModelRef = workflowRef
|
||||
@@ -925,6 +926,28 @@ export namespace Provider {
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Provider") {}
|
||||
|
||||
function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
|
||||
const result: Model["cost"] = {
|
||||
input: c?.input ?? 0,
|
||||
output: c?.output ?? 0,
|
||||
cache: {
|
||||
read: c?.cache_read ?? 0,
|
||||
write: c?.cache_write ?? 0,
|
||||
},
|
||||
}
|
||||
if (c?.context_over_200k) {
|
||||
result.experimentalOver200K = {
|
||||
cache: {
|
||||
read: c.context_over_200k.cache_read ?? 0,
|
||||
write: c.context_over_200k.cache_write ?? 0,
|
||||
},
|
||||
input: c.context_over_200k.input,
|
||||
output: c.context_over_200k.output,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
|
||||
const m: Model = {
|
||||
id: ModelID.make(model.id),
|
||||
@@ -937,26 +960,9 @@ export namespace Provider {
|
||||
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
|
||||
},
|
||||
status: model.status ?? "active",
|
||||
headers: model.headers ?? {},
|
||||
options: model.options ?? {},
|
||||
cost: {
|
||||
input: model.cost?.input ?? 0,
|
||||
output: model.cost?.output ?? 0,
|
||||
cache: {
|
||||
read: model.cost?.cache_read ?? 0,
|
||||
write: model.cost?.cache_write ?? 0,
|
||||
},
|
||||
experimentalOver200K: model.cost?.context_over_200k
|
||||
? {
|
||||
cache: {
|
||||
read: model.cost.context_over_200k.cache_read ?? 0,
|
||||
write: model.cost.context_over_200k.cache_write ?? 0,
|
||||
},
|
||||
input: model.cost.context_over_200k.input,
|
||||
output: model.cost.context_over_200k.output,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
headers: {},
|
||||
options: {},
|
||||
cost: cost(model.cost),
|
||||
limit: {
|
||||
context: model.limit.context,
|
||||
input: model.limit.input,
|
||||
@@ -993,13 +999,31 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
|
||||
const models: Record<string, Model> = {}
|
||||
for (const [key, model] of Object.entries(provider.models)) {
|
||||
models[key] = fromModelsDevModel(provider, model)
|
||||
for (const [mode, opts] of Object.entries(model.experimental?.modes ?? {})) {
|
||||
const id = `${model.id}-${mode}`
|
||||
const m = fromModelsDevModel(provider, model)
|
||||
m.id = ModelID.make(id)
|
||||
m.name = `${model.name} ${mode[0].toUpperCase()}${mode.slice(1)}`
|
||||
if (opts.cost) m.cost = mergeDeep(m.cost, cost(opts.cost))
|
||||
// convert body params to camelCase for ai sdk compatibility
|
||||
if (opts.provider?.body)
|
||||
m.options = Object.fromEntries(
|
||||
Object.entries(opts.provider.body).map(([k, v]) => [k.replace(/_([a-z])/g, (_, c) => c.toUpperCase()), v]),
|
||||
)
|
||||
if (opts.provider?.headers) m.headers = opts.provider.headers
|
||||
models[id] = m
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: ProviderID.make(provider.id),
|
||||
source: "custom",
|
||||
name: provider.name,
|
||||
env: provider.env ?? [],
|
||||
options: {},
|
||||
models: mapValues(provider.models, (model) => fromModelsDevModel(provider, model)),
|
||||
models,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -376,7 +376,8 @@ export namespace ProviderTransform {
|
||||
id.includes("mistral") ||
|
||||
id.includes("kimi") ||
|
||||
id.includes("k2p5") ||
|
||||
id.includes("qwen")
|
||||
id.includes("qwen") ||
|
||||
id.includes("big-pickle")
|
||||
)
|
||||
return {}
|
||||
|
||||
|
||||
@@ -371,10 +371,6 @@ export namespace Pty {
|
||||
return runPromise((svc) => svc.get(id))
|
||||
}
|
||||
|
||||
export async function resize(id: PtyID, cols: number, rows: number) {
|
||||
return runPromise((svc) => svc.resize(id, cols, rows))
|
||||
}
|
||||
|
||||
export async function write(id: PtyID, data: string) {
|
||||
return runPromise((svc) => svc.write(id, data))
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { proxy } from "hono/proxy"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import z from "zod"
|
||||
import { createHash } from "node:crypto"
|
||||
import * as fs from "node:fs/promises"
|
||||
import { Log } from "../util/log"
|
||||
import { Format } from "../format"
|
||||
import { TuiRoutes } from "./routes/tui"
|
||||
@@ -28,6 +29,7 @@ import { ExperimentalRoutes } from "./routes/experimental"
|
||||
import { ProviderRoutes } from "./routes/provider"
|
||||
import { EventRoutes } from "./routes/event"
|
||||
import { errorHandler } from "./middleware"
|
||||
import { getMimeType } from "hono/utils/mime"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -154,7 +156,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
|
||||
description: "VCS diff",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Snapshot.FileDiff.array()),
|
||||
schema: resolver(Vcs.FileDiff.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -285,13 +287,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
|
||||
if (embeddedWebUI) {
|
||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
if (!match) return c.json({ error: "Not Found" }, 404)
|
||||
const file = Bun.file(match)
|
||||
if (await file.exists()) {
|
||||
c.header("Content-Type", file.type)
|
||||
if (file.type.startsWith("text/html")) {
|
||||
|
||||
if (await fs.exists(match)) {
|
||||
const mime = getMimeType(match) ?? "text/plain"
|
||||
c.header("Content-Type", mime)
|
||||
if (mime.startsWith("text/html")) {
|
||||
c.header("Content-Security-Policy", DEFAULT_CSP)
|
||||
}
|
||||
return c.body(await file.arrayBuffer())
|
||||
return c.body(new Uint8Array(await fs.readFile(match)))
|
||||
} else {
|
||||
return c.json({ error: "Not Found" }, 404)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Target } from "@/control-plane/types"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Hono } from "hono"
|
||||
import { upgradeWebSocket } from "hono/bun"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
|
||||
const hop = new Set([
|
||||
"connection",
|
||||
@@ -53,10 +52,10 @@ function send(ws: { send(data: string | ArrayBuffer | Uint8Array): void }, data:
|
||||
return ws.send(data)
|
||||
}
|
||||
|
||||
const app = lazy(() =>
|
||||
const app = (upgrade: UpgradeWebSocket) =>
|
||||
new Hono().get(
|
||||
"/__workspace_ws",
|
||||
upgradeWebSocket((c) => {
|
||||
upgrade((c) => {
|
||||
const url = c.req.header("x-opencode-proxy-url")
|
||||
const queue: Msg[] = []
|
||||
let remote: WebSocket | undefined
|
||||
@@ -96,8 +95,7 @@ const app = lazy(() =>
|
||||
},
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
export namespace ServerProxy {
|
||||
export function http(target: Extract<Target, { type: "remote" }>, req: Request) {
|
||||
@@ -112,13 +110,18 @@ export namespace ServerProxy {
|
||||
)
|
||||
}
|
||||
|
||||
export function websocket(target: Extract<Target, { type: "remote" }>, req: Request, env: unknown) {
|
||||
export function websocket(
|
||||
upgrade: UpgradeWebSocket,
|
||||
target: Extract<Target, { type: "remote" }>,
|
||||
req: Request,
|
||||
env: unknown,
|
||||
) {
|
||||
const url = new URL(req.url)
|
||||
url.pathname = "/__workspace_ws"
|
||||
url.search = ""
|
||||
const next = new Headers(req.headers)
|
||||
next.set("x-opencode-proxy-url", socket(target.url))
|
||||
return app().fetch(
|
||||
return app(upgrade).fetch(
|
||||
new Request(url, {
|
||||
method: req.method,
|
||||
headers: next,
|
||||
|
||||
@@ -89,7 +89,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
||||
}
|
||||
|
||||
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
|
||||
return ServerProxy.websocket(target, c.req.raw, c.env)
|
||||
return ServerProxy.websocket(upgrade, target, c.req.raw, c.env)
|
||||
}
|
||||
|
||||
const headers = new Headers(c.req.raw.headers)
|
||||
|
||||
@@ -15,6 +15,7 @@ import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { WorkspaceRoutes } from "./workspace"
|
||||
import { Agent } from "@/agent/agent"
|
||||
|
||||
const ConsoleOrgOption = z.object({
|
||||
accountID: z.string(),
|
||||
@@ -181,7 +182,11 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const { provider, model } = c.req.valid("query")
|
||||
const tools = await ToolRegistry.tools({ providerID: ProviderID.make(provider), modelID: ModelID.make(model) })
|
||||
const tools = await ToolRegistry.tools({
|
||||
providerID: ProviderID.make(provider),
|
||||
modelID: ModelID.make(model),
|
||||
agent: await Agent.get(await Agent.defaultAgent()),
|
||||
})
|
||||
return c.json(
|
||||
tools.map((t) => ({
|
||||
id: t.id,
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ProviderRoutes = lazy(() =>
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
all: ModelsDev.Provider.array(),
|
||||
all: Provider.Info.array(),
|
||||
default: z.record(z.string(), z.string()),
|
||||
connected: z.array(z.string()),
|
||||
}),
|
||||
|
||||
@@ -6,13 +6,16 @@ import z from "zod"
|
||||
import { Session } from "../../session"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "../../session/prompt"
|
||||
import { SessionRunState } from "@/session/run-state"
|
||||
import { SessionCompaction } from "../../session/compaction"
|
||||
import { SessionRevert } from "../../session/revert"
|
||||
import { SessionShare } from "@/share/session"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "../../session/todo"
|
||||
import { Agent } from "../../agent/agent"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Command } from "../../command"
|
||||
import { Log } from "../../util/log"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
@@ -121,7 +124,6 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
log.info("SEARCH", { url: c.req.url })
|
||||
const session = await Session.get(sessionID)
|
||||
return c.json(session)
|
||||
},
|
||||
@@ -205,10 +207,10 @@ export const SessionRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator("json", Session.create.schema.optional()),
|
||||
validator("json", Session.create.schema),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json") ?? {}
|
||||
const session = await Session.create(body)
|
||||
const session = await SessionShare.create(body)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
@@ -292,6 +294,7 @@ export const SessionRoutes = lazy(() =>
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
// TODO(v2): remove this dedicated route and rely on the normal `/init` command flow.
|
||||
.post(
|
||||
"/:sessionID/init",
|
||||
describeRoute({
|
||||
@@ -317,11 +320,24 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
validator("json", Session.initialize.schema.omit({ sessionID: true })),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
modelID: ModelID.zod,
|
||||
providerID: ProviderID.zod,
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
await Session.initialize({ ...body, sessionID })
|
||||
await SessionPrompt.command({
|
||||
sessionID,
|
||||
messageID: body.messageID,
|
||||
model: body.providerID + "/" + body.modelID,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
@@ -411,7 +427,7 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
await Session.share(sessionID)
|
||||
await SessionShare.share(sessionID)
|
||||
const session = await Session.get(sessionID)
|
||||
return c.json(session)
|
||||
},
|
||||
@@ -476,12 +492,12 @@ export const SessionRoutes = lazy(() =>
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
sessionID: Session.unshare.schema,
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
await Session.unshare(sessionID)
|
||||
await SessionShare.unshare(sessionID)
|
||||
const session = await Session.get(sessionID)
|
||||
return c.json(session)
|
||||
},
|
||||
@@ -699,7 +715,7 @@ export const SessionRoutes = lazy(() =>
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await SessionPrompt.assertNotBusy(params.sessionID)
|
||||
await SessionRunState.assertNotBusy(params.sessionID)
|
||||
await Session.removeMessage({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
@@ -843,19 +859,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(
|
||||
@@ -906,7 +920,7 @@ export const SessionRoutes = lazy(() =>
|
||||
description: "Created message",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(MessageV2.Assistant),
|
||||
schema: resolver(MessageV2.WithParts),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
@@ -9,8 +10,6 @@ import z from "zod"
|
||||
import { Auth } from "../auth"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { ProviderID } from "../provider/schema"
|
||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||
import { createNodeWebSocket } from "@hono/node-ws"
|
||||
import { WorkspaceRouterMiddleware } from "./router"
|
||||
import { errors } from "./error"
|
||||
import { GlobalRoutes } from "./routes/global"
|
||||
@@ -19,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
|
||||
@@ -42,7 +42,7 @@ export namespace Server {
|
||||
return false
|
||||
}
|
||||
|
||||
export const Default = lazy(() => create({}).app)
|
||||
export const Default = lazy(() => create({}))
|
||||
|
||||
export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono {
|
||||
return app
|
||||
@@ -54,6 +54,9 @@ export namespace Server {
|
||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||
if (!password) return next()
|
||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||
|
||||
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
||||
|
||||
return basicAuth({ username, password })(c, next)
|
||||
})
|
||||
.use(async (c, next) => {
|
||||
|
||||
@@ -253,23 +253,21 @@ When constructing the summary, try to stick to this template:
|
||||
sessionID: input.sessionID,
|
||||
model,
|
||||
})
|
||||
const result = yield* processor
|
||||
.process({
|
||||
user: userMessage,
|
||||
agent,
|
||||
sessionID: input.sessionID,
|
||||
tools: {},
|
||||
system: [],
|
||||
messages: [
|
||||
...modelMessages,
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: prompt }],
|
||||
},
|
||||
],
|
||||
model,
|
||||
})
|
||||
.pipe(Effect.onInterrupt(() => processor.abort()))
|
||||
const result = yield* processor.process({
|
||||
user: userMessage,
|
||||
agent,
|
||||
sessionID: input.sessionID,
|
||||
tools: {},
|
||||
system: [],
|
||||
messages: [
|
||||
...modelMessages,
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: prompt }],
|
||||
},
|
||||
],
|
||||
model,
|
||||
})
|
||||
|
||||
if (result === "compact") {
|
||||
processor.message.error = new MessageV2.ContextOverflowError({
|
||||
@@ -379,17 +377,15 @@ When constructing the summary, try to stick to this template:
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.sync(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionProcessor.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
),
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionProcessor.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -403,17 +399,6 @@ When constructing the summary, try to stick to this template:
|
||||
return runPromise((svc) => svc.prune(input))
|
||||
}
|
||||
|
||||
export const process = fn(
|
||||
z.object({
|
||||
parentID: MessageID.zod,
|
||||
messages: z.custom<MessageV2.WithParts[]>(),
|
||||
sessionID: SessionID.zod,
|
||||
auto: z.boolean(),
|
||||
overflow: z.boolean().optional(),
|
||||
}),
|
||||
(input) => runPromise((svc) => svc.process(input)),
|
||||
)
|
||||
|
||||
export const create = fn(
|
||||
z.object({
|
||||
sessionID: SessionID.zod,
|
||||
|
||||
@@ -5,14 +5,13 @@ import { Bus } from "@/bus"
|
||||
import { Decimal } from "decimal.js"
|
||||
import z from "zod"
|
||||
import { type ProviderMetadata } from "ai"
|
||||
import { Config } from "../config/config"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Installation } from "../installation"
|
||||
|
||||
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
|
||||
import { SyncEvent } from "../sync"
|
||||
import type { SQL } from "../storage/db"
|
||||
import { SessionTable } from "./session.sql"
|
||||
import { PartTable, SessionTable } from "./session.sql"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
@@ -20,20 +19,17 @@ import { updateSchema } from "../util/update-schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { SessionPrompt } from "./prompt"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Command } from "../command"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { ProjectID } from "../project/schema"
|
||||
import { WorkspaceID } from "../control-plane/schema"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Permission } from "@/permission"
|
||||
import { Global } from "@/global"
|
||||
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
|
||||
import { Effect, Layer, Scope, ServiceMap } from "effect"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace Session {
|
||||
@@ -322,8 +318,6 @@ export namespace Session {
|
||||
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
|
||||
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
|
||||
readonly get: (id: SessionID) => Effect.Effect<Info>
|
||||
readonly share: (id: SessionID) => Effect.Effect<{ url: string }>
|
||||
readonly unshare: (id: SessionID) => Effect.Effect<void>
|
||||
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
|
||||
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
|
||||
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
|
||||
@@ -345,6 +339,11 @@ export namespace Session {
|
||||
messageID: MessageID
|
||||
partID: PartID
|
||||
}) => Effect.Effect<PartID>
|
||||
readonly getPart: (input: {
|
||||
sessionID: SessionID
|
||||
messageID: MessageID
|
||||
partID: PartID
|
||||
}) => Effect.Effect<MessageV2.Part | undefined>
|
||||
readonly updatePart: <T extends MessageV2.Part>(part: T) => Effect.Effect<T>
|
||||
readonly updatePartDelta: (input: {
|
||||
sessionID: SessionID
|
||||
@@ -353,12 +352,6 @@ export namespace Session {
|
||||
field: string
|
||||
delta: string
|
||||
}) => Effect.Effect<void>
|
||||
readonly initialize: (input: {
|
||||
sessionID: SessionID
|
||||
modelID: ModelID
|
||||
providerID: ProviderID
|
||||
messageID: MessageID
|
||||
}) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Session") {}
|
||||
@@ -368,12 +361,10 @@ export namespace Session {
|
||||
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
||||
Effect.sync(() => Database.use(fn))
|
||||
|
||||
export const layer: Layer.Layer<Service, never, Bus.Service | Config.Service> = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, Bus.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Service
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const createNext = Effect.fn("Session.createNext")(function* (input: {
|
||||
id?: SessionID
|
||||
@@ -403,11 +394,6 @@ export namespace Session {
|
||||
|
||||
yield* Effect.sync(() => SyncEvent.run(Event.Created, { sessionID: result.id, info: result }))
|
||||
|
||||
const cfg = yield* config.get()
|
||||
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) {
|
||||
yield* share(result.id).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
// This only exist for backwards compatibility. We should not be
|
||||
// manually publishing this event; it is a sync event now
|
||||
@@ -426,25 +412,6 @@ export namespace Session {
|
||||
return fromRow(row)
|
||||
})
|
||||
|
||||
const share = Effect.fn("Session.share")(function* (id: SessionID) {
|
||||
const cfg = yield* config.get()
|
||||
if (cfg.share === "disabled") throw new Error("Sharing is disabled in configuration")
|
||||
const result = yield* Effect.promise(async () => {
|
||||
const { ShareNext } = await import("@/share/share-next")
|
||||
return ShareNext.create(id)
|
||||
})
|
||||
yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: result.url } } }))
|
||||
return result
|
||||
})
|
||||
|
||||
const unshare = Effect.fn("Session.unshare")(function* (id: SessionID) {
|
||||
yield* Effect.promise(async () => {
|
||||
const { ShareNext } = await import("@/share/share-next")
|
||||
await ShareNext.remove(id)
|
||||
})
|
||||
yield* Effect.sync(() => SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } }))
|
||||
})
|
||||
|
||||
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const rows = yield* db((d) =>
|
||||
@@ -464,7 +431,6 @@ export namespace Session {
|
||||
for (const child of kids) {
|
||||
yield* remove(child.id)
|
||||
}
|
||||
yield* unshare(sessionID).pipe(Effect.ignore)
|
||||
yield* Effect.sync(() => {
|
||||
SyncEvent.run(Event.Deleted, { sessionID, info: session })
|
||||
SyncEvent.remove(sessionID)
|
||||
@@ -492,6 +458,29 @@ export namespace Session {
|
||||
return part
|
||||
}).pipe(Effect.withSpan("Session.updatePart"))
|
||||
|
||||
const getPart: Interface["getPart"] = Effect.fn("Session.getPart")(function* (input) {
|
||||
const row = Database.use((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(PartTable)
|
||||
.where(
|
||||
and(
|
||||
eq(PartTable.session_id, input.sessionID),
|
||||
eq(PartTable.message_id, input.messageID),
|
||||
eq(PartTable.id, input.partID),
|
||||
),
|
||||
)
|
||||
.get(),
|
||||
)
|
||||
if (!row) return
|
||||
return {
|
||||
...row.data,
|
||||
id: row.id,
|
||||
sessionID: row.session_id,
|
||||
messageID: row.message_id,
|
||||
} as MessageV2.Part
|
||||
})
|
||||
|
||||
const create = Effect.fn("Session.create")(function* (input?: {
|
||||
parentID?: SessionID
|
||||
title?: string
|
||||
@@ -588,7 +577,7 @@ export namespace Session {
|
||||
|
||||
const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) {
|
||||
return yield* Effect.tryPromise(() => Storage.read<Snapshot.FileDiff[]>(["session_diff", sessionID])).pipe(
|
||||
Effect.orElseSucceed(() => [] as Snapshot.FileDiff[]),
|
||||
Effect.orElseSucceed((): Snapshot.FileDiff[] => []),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -637,30 +626,11 @@ export namespace Session {
|
||||
yield* bus.publish(MessageV2.Event.PartDelta, input)
|
||||
})
|
||||
|
||||
const initialize = Effect.fn("Session.initialize")(function* (input: {
|
||||
sessionID: SessionID
|
||||
modelID: ModelID
|
||||
providerID: ProviderID
|
||||
messageID: MessageID
|
||||
}) {
|
||||
yield* Effect.promise(() =>
|
||||
SessionPrompt.command({
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
model: input.providerID + "/" + input.modelID,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
create,
|
||||
fork,
|
||||
touch,
|
||||
get,
|
||||
share,
|
||||
unshare,
|
||||
setTitle,
|
||||
setArchived,
|
||||
setPermission,
|
||||
@@ -675,13 +645,13 @@ export namespace Session {
|
||||
removeMessage,
|
||||
removePart,
|
||||
updatePart,
|
||||
getPart,
|
||||
updatePartDelta,
|
||||
initialize,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -701,10 +671,7 @@ export namespace Session {
|
||||
runPromise((svc) => svc.fork(input)),
|
||||
)
|
||||
|
||||
export const touch = fn(SessionID.zod, (id) => runPromise((svc) => svc.touch(id)))
|
||||
export const get = fn(SessionID.zod, (id) => runPromise((svc) => svc.get(id)))
|
||||
export const share = fn(SessionID.zod, (id) => runPromise((svc) => svc.share(id)))
|
||||
export const unshare = fn(SessionID.zod, (id) => runPromise((svc) => svc.unshare(id)))
|
||||
|
||||
export const setTitle = fn(z.object({ sessionID: SessionID.zod, title: z.string() }), (input) =>
|
||||
runPromise((svc) => svc.setTitle(input)),
|
||||
@@ -714,24 +681,12 @@ export namespace Session {
|
||||
runPromise((svc) => svc.setArchived(input)),
|
||||
)
|
||||
|
||||
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
|
||||
runPromise((svc) => svc.setPermission(input)),
|
||||
)
|
||||
|
||||
export const setRevert = fn(
|
||||
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
|
||||
(input) =>
|
||||
runPromise((svc) => svc.setRevert({ sessionID: input.sessionID, revert: input.revert, summary: input.summary })),
|
||||
)
|
||||
|
||||
export const clearRevert = fn(SessionID.zod, (id) => runPromise((svc) => svc.clearRevert(id)))
|
||||
|
||||
export const setSummary = fn(z.object({ sessionID: SessionID.zod, summary: Info.shape.summary }), (input) =>
|
||||
runPromise((svc) => svc.setSummary({ sessionID: input.sessionID, summary: input.summary })),
|
||||
)
|
||||
|
||||
export const diff = fn(SessionID.zod, (id) => runPromise((svc) => svc.diff(id)))
|
||||
|
||||
export const messages = fn(z.object({ sessionID: SessionID.zod, limit: z.number().optional() }), (input) =>
|
||||
runPromise((svc) => svc.messages(input)),
|
||||
)
|
||||
@@ -879,9 +834,4 @@ export namespace Session {
|
||||
}),
|
||||
(input) => runPromise((svc) => svc.updatePartDelta(input)),
|
||||
)
|
||||
|
||||
export const initialize = fn(
|
||||
z.object({ sessionID: SessionID.zod, modelID: ModelID.zod, providerID: ProviderID.zod, messageID: MessageID.zod }),
|
||||
(input) => runPromise((svc) => svc.initialize(input)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@ import { Plugin } from "@/plugin"
|
||||
import { SystemPrompt } from "./system"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { Bus } from "@/bus"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { Auth } from "@/auth"
|
||||
import { Installation } from "@/installation"
|
||||
|
||||
@@ -230,7 +234,12 @@ export namespace LLM {
|
||||
// from the workflow service are executed via opencode's tool system
|
||||
// and results sent back over the WebSocket.
|
||||
if (language instanceof GitLabWorkflowLanguageModel) {
|
||||
const workflowModel = language
|
||||
const workflowModel = language as GitLabWorkflowLanguageModel & {
|
||||
sessionID?: string
|
||||
sessionPreapprovedTools?: string[]
|
||||
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
|
||||
}
|
||||
workflowModel.sessionID = input.sessionID
|
||||
workflowModel.systemPrompt = system.join("\n")
|
||||
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
||||
const t = tools[toolName]
|
||||
@@ -253,6 +262,57 @@ export namespace LLM {
|
||||
return { result: "", error: e.message ?? String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
|
||||
workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => {
|
||||
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
|
||||
return !match || match.action !== "ask"
|
||||
})
|
||||
|
||||
const approvedToolsForSession = new Set<string>()
|
||||
workflowModel.approvalHandler = Instance.bind(async (approvalTools) => {
|
||||
const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[]
|
||||
// Auto-approve tools that were already approved in this session
|
||||
// (prevents infinite approval loops for server-side MCP tools)
|
||||
if (uniqueNames.every((name) => approvedToolsForSession.has(name))) {
|
||||
return { approved: true }
|
||||
}
|
||||
|
||||
const id = PermissionID.ascending()
|
||||
let reply: Permission.Reply | undefined
|
||||
let unsub: (() => void) | undefined
|
||||
try {
|
||||
unsub = Bus.subscribe(Permission.Event.Replied, (evt) => {
|
||||
if (evt.properties.requestID === id) reply = evt.properties.reply
|
||||
})
|
||||
const toolPatterns = approvalTools.map((t: { name: string; args: string }) => {
|
||||
try {
|
||||
const parsed = JSON.parse(t.args) as Record<string, unknown>
|
||||
const title = (parsed?.title ?? parsed?.name ?? "") as string
|
||||
return title ? `${t.name}: ${title}` : t.name
|
||||
} catch {
|
||||
return t.name
|
||||
}
|
||||
})
|
||||
const uniquePatterns = [...new Set(toolPatterns)] as string[]
|
||||
await Permission.ask({
|
||||
id,
|
||||
sessionID: SessionID.make(input.sessionID),
|
||||
permission: "workflow_tool_approval",
|
||||
patterns: uniquePatterns,
|
||||
metadata: { tools: approvalTools },
|
||||
always: uniquePatterns,
|
||||
ruleset: [],
|
||||
})
|
||||
for (const name of uniqueNames) approvedToolsForSession.add(name)
|
||||
workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames]
|
||||
return { approved: true }
|
||||
} catch {
|
||||
return { approved: false }
|
||||
} finally {
|
||||
unsub?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return streamText({
|
||||
|
||||
@@ -573,6 +573,12 @@ export namespace MessageV2 {
|
||||
}))
|
||||
}
|
||||
|
||||
function providerMeta(metadata: Record<string, any> | undefined) {
|
||||
if (!metadata) return undefined
|
||||
const { providerExecuted: _, ...rest } = metadata
|
||||
return Object.keys(rest).length > 0 ? rest : undefined
|
||||
}
|
||||
|
||||
export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||
input: WithParts[],
|
||||
model: Provider.Model,
|
||||
@@ -741,18 +747,34 @@ export namespace MessageV2 {
|
||||
toolCallId: part.callID,
|
||||
input: part.state.input,
|
||||
output,
|
||||
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
|
||||
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
|
||||
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
|
||||
})
|
||||
}
|
||||
if (part.state.status === "error")
|
||||
assistantMessage.parts.push({
|
||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||
state: "output-error",
|
||||
toolCallId: part.callID,
|
||||
input: part.state.input,
|
||||
errorText: part.state.error,
|
||||
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
|
||||
})
|
||||
if (part.state.status === "error") {
|
||||
const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined
|
||||
if (typeof output === "string") {
|
||||
assistantMessage.parts.push({
|
||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||
state: "output-available",
|
||||
toolCallId: part.callID,
|
||||
input: part.state.input,
|
||||
output,
|
||||
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
|
||||
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
|
||||
})
|
||||
} else {
|
||||
assistantMessage.parts.push({
|
||||
type: ("tool-" + part.tool) as `tool-${string}`,
|
||||
state: "output-error",
|
||||
toolCallId: part.callID,
|
||||
input: part.state.input,
|
||||
errorText: part.state.error,
|
||||
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
|
||||
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
|
||||
})
|
||||
}
|
||||
}
|
||||
// Handle pending/running tool calls to prevent dangling tool_use blocks
|
||||
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
|
||||
if (part.state.status === "pending" || part.state.status === "running")
|
||||
@@ -762,7 +784,8 @@ export namespace MessageV2 {
|
||||
toolCallId: part.callID,
|
||||
input: part.state.input,
|
||||
errorText: "[Tool execution was interrupted]",
|
||||
...(differentModel ? {} : { callProviderMetadata: part.metadata }),
|
||||
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
|
||||
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
|
||||
})
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Effect, Layer, ServiceMap } from "effect"
|
||||
import { Cause, Deferred, Effect, Layer, ServiceMap } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
@@ -18,6 +18,8 @@ import { SessionStatus } from "./status"
|
||||
import { SessionSummary } from "./summary"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Question } from "@/question"
|
||||
import { errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
export namespace SessionProcessor {
|
||||
const DOOM_LOOP_THRESHOLD = 3
|
||||
@@ -29,8 +31,19 @@ export namespace SessionProcessor {
|
||||
|
||||
export interface Handle {
|
||||
readonly message: MessageV2.Assistant
|
||||
readonly partFromToolCall: (toolCallID: string) => MessageV2.ToolPart | undefined
|
||||
readonly abort: () => Effect.Effect<void>
|
||||
readonly updateToolCall: (
|
||||
toolCallID: string,
|
||||
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
|
||||
) => Effect.Effect<MessageV2.ToolPart | undefined>
|
||||
readonly completeToolCall: (
|
||||
toolCallID: string,
|
||||
output: {
|
||||
title: string
|
||||
metadata: Record<string, any>
|
||||
output: string
|
||||
attachments?: MessageV2.FilePart[]
|
||||
},
|
||||
) => Effect.Effect<void>
|
||||
readonly process: (streamInput: LLM.StreamInput) => Effect.Effect<Result>
|
||||
}
|
||||
|
||||
@@ -44,8 +57,15 @@ export namespace SessionProcessor {
|
||||
readonly create: (input: Input) => Effect.Effect<Handle>
|
||||
}
|
||||
|
||||
type ToolCall = {
|
||||
partID: MessageV2.ToolPart["id"]
|
||||
messageID: MessageV2.ToolPart["messageID"]
|
||||
sessionID: MessageV2.ToolPart["sessionID"]
|
||||
done: Deferred.Deferred<void>
|
||||
}
|
||||
|
||||
interface ProcessorContext extends Input {
|
||||
toolcalls: Record<string, MessageV2.ToolPart>
|
||||
toolcalls: Record<string, ToolCall>
|
||||
shouldBreak: boolean
|
||||
snapshot: string | undefined
|
||||
blocked: boolean
|
||||
@@ -108,6 +128,88 @@ export namespace SessionProcessor {
|
||||
aborted,
|
||||
})
|
||||
|
||||
const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) {
|
||||
const done = ctx.toolcalls[toolCallID]?.done
|
||||
delete ctx.toolcalls[toolCallID]
|
||||
if (done) yield* Deferred.succeed(done, undefined).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
|
||||
const call = ctx.toolcalls[toolCallID]
|
||||
if (!call) return
|
||||
const part = yield* session.getPart({
|
||||
partID: call.partID,
|
||||
messageID: call.messageID,
|
||||
sessionID: call.sessionID,
|
||||
})
|
||||
if (!part || part.type !== "tool") {
|
||||
delete ctx.toolcalls[toolCallID]
|
||||
return
|
||||
}
|
||||
return { call, part }
|
||||
})
|
||||
|
||||
const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* (
|
||||
toolCallID: string,
|
||||
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
|
||||
) {
|
||||
const match = yield* readToolCall(toolCallID)
|
||||
if (!match) return
|
||||
const part = yield* session.updatePart(update(match.part))
|
||||
ctx.toolcalls[toolCallID] = {
|
||||
...match.call,
|
||||
partID: part.id,
|
||||
messageID: part.messageID,
|
||||
sessionID: part.sessionID,
|
||||
}
|
||||
return part
|
||||
})
|
||||
|
||||
const completeToolCall = Effect.fn("SessionProcessor.completeToolCall")(function* (
|
||||
toolCallID: string,
|
||||
output: {
|
||||
title: string
|
||||
metadata: Record<string, any>
|
||||
output: string
|
||||
attachments?: MessageV2.FilePart[]
|
||||
},
|
||||
) {
|
||||
const match = yield* readToolCall(toolCallID)
|
||||
if (!match || match.part.state.status !== "running") return
|
||||
yield* session.updatePart({
|
||||
...match.part,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: match.part.state.input,
|
||||
output: output.output,
|
||||
metadata: output.metadata,
|
||||
title: output.title,
|
||||
time: { start: match.part.state.time.start, end: Date.now() },
|
||||
attachments: output.attachments,
|
||||
},
|
||||
})
|
||||
yield* settleToolCall(toolCallID)
|
||||
})
|
||||
|
||||
const failToolCall = Effect.fn("SessionProcessor.failToolCall")(function* (toolCallID: string, error: unknown) {
|
||||
const match = yield* readToolCall(toolCallID)
|
||||
if (!match || match.part.state.status !== "running") return false
|
||||
yield* session.updatePart({
|
||||
...match.part,
|
||||
state: {
|
||||
status: "error",
|
||||
input: match.part.state.input,
|
||||
error: errorMessage(error),
|
||||
time: { start: match.part.state.time.start, end: Date.now() },
|
||||
},
|
||||
})
|
||||
if (error instanceof Permission.RejectedError || error instanceof Question.RejectedError) {
|
||||
ctx.blocked = ctx.shouldBreak
|
||||
}
|
||||
yield* settleToolCall(toolCallID)
|
||||
return true
|
||||
})
|
||||
|
||||
const handleEvent = Effect.fn("SessionProcessor.handleEvent")(function* (value: StreamEvent) {
|
||||
switch (value.type) {
|
||||
case "start":
|
||||
@@ -154,15 +256,22 @@ export namespace SessionProcessor {
|
||||
if (ctx.assistantMessage.summary) {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
}
|
||||
ctx.toolcalls[value.id] = yield* session.updatePart({
|
||||
id: ctx.toolcalls[value.id]?.id ?? PartID.ascending(),
|
||||
const part = yield* session.updatePart({
|
||||
id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
|
||||
messageID: ctx.assistantMessage.id,
|
||||
sessionID: ctx.assistantMessage.sessionID,
|
||||
type: "tool",
|
||||
tool: value.toolName,
|
||||
callID: value.id,
|
||||
state: { status: "pending", input: {}, raw: "" },
|
||||
metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
|
||||
} satisfies MessageV2.ToolPart)
|
||||
ctx.toolcalls[value.id] = {
|
||||
done: yield* Deferred.make<void>(),
|
||||
partID: part.id,
|
||||
messageID: part.messageID,
|
||||
sessionID: part.sessionID,
|
||||
}
|
||||
return
|
||||
|
||||
case "tool-input-delta":
|
||||
@@ -175,14 +284,19 @@ export namespace SessionProcessor {
|
||||
if (ctx.assistantMessage.summary) {
|
||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
||||
}
|
||||
const match = ctx.toolcalls[value.toolCallId]
|
||||
if (!match) return
|
||||
ctx.toolcalls[value.toolCallId] = yield* session.updatePart({
|
||||
yield* updateToolCall(value.toolCallId, (match) => ({
|
||||
...match,
|
||||
tool: value.toolName,
|
||||
state: { status: "running", input: value.input, time: { start: Date.now() } },
|
||||
metadata: value.providerMetadata,
|
||||
} satisfies MessageV2.ToolPart)
|
||||
state: {
|
||||
...match.state,
|
||||
status: "running",
|
||||
input: value.input,
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
metadata: match.metadata?.providerExecuted
|
||||
? { ...value.providerMetadata, providerExecuted: true }
|
||||
: value.providerMetadata,
|
||||
}))
|
||||
|
||||
const parts = MessageV2.parts(ctx.assistantMessage.id)
|
||||
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
|
||||
@@ -213,40 +327,12 @@ export namespace SessionProcessor {
|
||||
}
|
||||
|
||||
case "tool-result": {
|
||||
const match = ctx.toolcalls[value.toolCallId]
|
||||
if (!match || match.state.status !== "running") return
|
||||
yield* session.updatePart({
|
||||
...match,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: value.input ?? match.state.input,
|
||||
output: value.output.output,
|
||||
metadata: value.output.metadata,
|
||||
title: value.output.title,
|
||||
time: { start: match.state.time.start, end: Date.now() },
|
||||
attachments: value.output.attachments,
|
||||
},
|
||||
})
|
||||
delete ctx.toolcalls[value.toolCallId]
|
||||
yield* completeToolCall(value.toolCallId, value.output)
|
||||
return
|
||||
}
|
||||
|
||||
case "tool-error": {
|
||||
const match = ctx.toolcalls[value.toolCallId]
|
||||
if (!match || match.state.status !== "running") return
|
||||
yield* session.updatePart({
|
||||
...match,
|
||||
state: {
|
||||
status: "error",
|
||||
input: value.input ?? match.state.input,
|
||||
error: value.error instanceof Error ? value.error.message : String(value.error),
|
||||
time: { start: match.state.time.start, end: Date.now() },
|
||||
},
|
||||
})
|
||||
if (value.error instanceof Permission.RejectedError || value.error instanceof Question.RejectedError) {
|
||||
ctx.blocked = ctx.shouldBreak
|
||||
}
|
||||
delete ctx.toolcalls[value.toolCallId]
|
||||
yield* failToolCall(value.toolCallId, value.error)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -349,7 +435,10 @@ export namespace SessionProcessor {
|
||||
},
|
||||
{ text: ctx.currentText.text },
|
||||
)).text
|
||||
ctx.currentText.time = { start: Date.now(), end: Date.now() }
|
||||
{
|
||||
const end = Date.now()
|
||||
ctx.currentText.time = { start: ctx.currentText.time?.start ?? end, end }
|
||||
}
|
||||
if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata
|
||||
yield* session.updatePart(ctx.currentText)
|
||||
ctx.currentText = undefined
|
||||
@@ -396,19 +485,30 @@ export namespace SessionProcessor {
|
||||
}
|
||||
ctx.reasoningMap = {}
|
||||
|
||||
const parts = MessageV2.parts(ctx.assistantMessage.id)
|
||||
for (const part of parts) {
|
||||
if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue
|
||||
yield* Effect.forEach(
|
||||
Object.values(ctx.toolcalls),
|
||||
(call) => Deferred.await(call.done).pipe(Effect.timeout("250 millis"), Effect.ignore),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
for (const toolCallID of Object.keys(ctx.toolcalls)) {
|
||||
const match = yield* readToolCall(toolCallID)
|
||||
if (!match) continue
|
||||
const part = match.part
|
||||
const end = Date.now()
|
||||
const metadata = "metadata" in part.state && isRecord(part.state.metadata) ? part.state.metadata : {}
|
||||
yield* session.updatePart({
|
||||
...part,
|
||||
state: {
|
||||
...part.state,
|
||||
status: "error",
|
||||
error: "Tool execution aborted",
|
||||
time: { start: Date.now(), end: Date.now() },
|
||||
metadata: { ...metadata, interrupted: true },
|
||||
time: { start: "time" in part.state ? part.state.time.start : end, end },
|
||||
},
|
||||
})
|
||||
}
|
||||
ctx.toolcalls = {}
|
||||
ctx.assistantMessage.time.completed = Date.now()
|
||||
yield* session.updateMessage(ctx.assistantMessage)
|
||||
})
|
||||
@@ -429,19 +529,6 @@ export namespace SessionProcessor {
|
||||
yield* status.set(ctx.sessionID, { type: "idle" })
|
||||
})
|
||||
|
||||
const abort = Effect.fn("SessionProcessor.abort")(() =>
|
||||
Effect.gen(function* () {
|
||||
if (!ctx.assistantMessage.error) {
|
||||
yield* halt(new DOMException("Aborted", "AbortError"))
|
||||
}
|
||||
if (!ctx.assistantMessage.time.completed) {
|
||||
yield* cleanup()
|
||||
return
|
||||
}
|
||||
yield* session.updateMessage(ctx.assistantMessage)
|
||||
}),
|
||||
)
|
||||
|
||||
const process = Effect.fn("SessionProcessor.process")(function* (streamInput: LLM.StreamInput) {
|
||||
log.info("process")
|
||||
ctx.needsCompaction = false
|
||||
@@ -459,7 +546,14 @@ export namespace SessionProcessor {
|
||||
Stream.runDrain,
|
||||
)
|
||||
}).pipe(
|
||||
Effect.onInterrupt(() => Effect.sync(() => void (aborted = true))),
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.gen(function* () {
|
||||
aborted = true
|
||||
if (!ctx.assistantMessage.error) {
|
||||
yield* halt(new DOMException("Aborted", "AbortError"))
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterruptsOnly(cause),
|
||||
(cause) => Effect.fail(Cause.squash(cause)),
|
||||
@@ -480,23 +574,18 @@ export namespace SessionProcessor {
|
||||
Effect.ensuring(cleanup()),
|
||||
)
|
||||
|
||||
if (aborted && !ctx.assistantMessage.error) {
|
||||
yield* abort()
|
||||
}
|
||||
if (ctx.needsCompaction) return "compact"
|
||||
if (ctx.blocked || ctx.assistantMessage.error || aborted) return "stop"
|
||||
if (ctx.blocked || ctx.assistantMessage.error) return "stop"
|
||||
return "continue"
|
||||
}).pipe(Effect.onInterrupt(() => abort().pipe(Effect.asVoid)))
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
get message() {
|
||||
return ctx.assistantMessage
|
||||
},
|
||||
partFromToolCall(toolCallID: string) {
|
||||
return ctx.toolcalls[toolCallID]
|
||||
},
|
||||
abort,
|
||||
updateToolCall,
|
||||
completeToolCall,
|
||||
process,
|
||||
} satisfies Handle
|
||||
})
|
||||
@@ -505,19 +594,17 @@ export namespace SessionProcessor {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.sync(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(LLM.defaultLayer),
|
||||
Layer.provide(Permission.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(SessionStatus.layer.pipe(Layer.provide(Bus.layer))),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
),
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(LLM.defaultLayer),
|
||||
Layer.provide(Permission.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(SessionStatus.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Provider } from "../provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
|
||||
import { SessionCompaction } from "./compaction"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Bus } from "../bus"
|
||||
import { ProviderTransform } from "../provider/transform"
|
||||
import { SystemPrompt } from "./system"
|
||||
@@ -21,10 +20,8 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
||||
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||
import { ToolRegistry } from "../tool/registry"
|
||||
import { Runner } from "@/effect/runner"
|
||||
import { MCP } from "../mcp"
|
||||
import { LSP } from "../lsp"
|
||||
import { ReadTool } from "../tool/read"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
@@ -37,7 +34,6 @@ import { ConfigMarkdown } from "../config/markdown"
|
||||
import { SessionSummary } from "./summary"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { SessionProcessor } from "./processor"
|
||||
import { TaskTool } from "@/tool/task"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { Permission } from "@/permission"
|
||||
import { SessionStatus } from "./status"
|
||||
@@ -50,6 +46,8 @@ import { Process } from "@/util/process"
|
||||
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { TaskTool } from "@/tool/task"
|
||||
import { SessionRunState } from "./run-state"
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -68,7 +66,6 @@ export namespace SessionPrompt {
|
||||
const log = Log.create({ service: "session.prompt" })
|
||||
|
||||
export interface Interface {
|
||||
readonly assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError>
|
||||
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts>
|
||||
readonly loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts>
|
||||
@@ -101,55 +98,12 @@ export namespace SessionPrompt {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const scope = yield* Scope.Scope
|
||||
const instruction = yield* Instruction.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("SessionPrompt.state")(function* () {
|
||||
const runners = new Map<string, Runner<MessageV2.WithParts>>()
|
||||
yield* Effect.addFinalizer(
|
||||
Effect.fnUntraced(function* () {
|
||||
yield* Effect.forEach(runners.values(), (r) => r.cancel, { concurrency: "unbounded", discard: true })
|
||||
runners.clear()
|
||||
}),
|
||||
)
|
||||
return { runners }
|
||||
}),
|
||||
)
|
||||
|
||||
const getRunner = (runners: Map<string, Runner<MessageV2.WithParts>>, sessionID: SessionID) => {
|
||||
const existing = runners.get(sessionID)
|
||||
if (existing) return existing
|
||||
const runner = Runner.make<MessageV2.WithParts>(scope, {
|
||||
onIdle: Effect.gen(function* () {
|
||||
runners.delete(sessionID)
|
||||
yield* status.set(sessionID, { type: "idle" })
|
||||
}),
|
||||
onBusy: status.set(sessionID, { type: "busy" }),
|
||||
onInterrupt: lastAssistant(sessionID),
|
||||
busy: () => {
|
||||
throw new Session.BusyError(sessionID)
|
||||
},
|
||||
})
|
||||
runners.set(sessionID, runner)
|
||||
return runner
|
||||
}
|
||||
|
||||
const assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError> = Effect.fn(
|
||||
"SessionPrompt.assertNotBusy",
|
||||
)(function* (sessionID: SessionID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const runner = s.runners.get(sessionID)
|
||||
if (runner?.busy) throw new Session.BusyError(sessionID)
|
||||
})
|
||||
const state = yield* SessionRunState.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
|
||||
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
|
||||
log.info("cancel", { sessionID })
|
||||
const s = yield* InstanceState.get(state)
|
||||
const runner = s.runners.get(sessionID)
|
||||
if (!runner || !runner.busy) {
|
||||
yield* status.set(sessionID, { type: "idle" })
|
||||
return
|
||||
}
|
||||
yield* runner.cancel
|
||||
yield* state.cancel(sessionID)
|
||||
})
|
||||
|
||||
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
|
||||
@@ -390,7 +344,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
model: Provider.Model
|
||||
session: Session.Info
|
||||
tools?: Record<string, boolean>
|
||||
processor: Pick<SessionProcessor.Handle, "message" | "partFromToolCall">
|
||||
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
|
||||
bypassAgentCheck: boolean
|
||||
messages: MessageV2.WithParts[]
|
||||
}) {
|
||||
@@ -407,10 +361,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
messages: input.messages,
|
||||
metadata: (val) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
||||
if (!match || !["running", "pending"].includes(match.state.status)) return
|
||||
yield* sessions.updatePart({
|
||||
input.processor.updateToolCall(options.toolCallId, (match) => {
|
||||
if (!["running", "pending"].includes(match.state.status)) return match
|
||||
return {
|
||||
...match,
|
||||
state: {
|
||||
title: val.title,
|
||||
@@ -419,7 +372,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
input: args,
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
})
|
||||
}
|
||||
}),
|
||||
),
|
||||
ask: (req) =>
|
||||
@@ -433,10 +386,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
),
|
||||
})
|
||||
|
||||
for (const item of yield* registry.tools(
|
||||
{ modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID },
|
||||
input.agent,
|
||||
)) {
|
||||
for (const item of yield* registry.tools({
|
||||
modelID: ModelID.make(input.model.api.id),
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
|
||||
tools[item.id] = tool({
|
||||
id: item.id as any,
|
||||
@@ -466,6 +420,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
|
||||
output,
|
||||
)
|
||||
if (options.abortSignal?.aborted) {
|
||||
yield* input.processor.completeToolCall(options.toolCallId, output)
|
||||
}
|
||||
return output
|
||||
}),
|
||||
)
|
||||
@@ -530,7 +487,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
}
|
||||
|
||||
return {
|
||||
const output = {
|
||||
title: "",
|
||||
metadata,
|
||||
output: truncated.content,
|
||||
@@ -542,6 +499,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
})),
|
||||
content: result.content,
|
||||
}
|
||||
if (opts.abortSignal?.aborted) {
|
||||
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||
}
|
||||
return output
|
||||
}),
|
||||
)
|
||||
tools[key] = item
|
||||
@@ -560,7 +521,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}) {
|
||||
const { task, model, lastUser, sessionID, session, msgs } = input
|
||||
const ctx = yield* InstanceState.context
|
||||
const taskTool = yield* Effect.promise(() => registry.named.task.init())
|
||||
const { task: taskTool } = yield* registry.named()
|
||||
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
||||
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
@@ -583,7 +544,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
sessionID: assistantMessage.sessionID,
|
||||
type: "tool",
|
||||
callID: ulid(),
|
||||
tool: registry.named.task.id,
|
||||
tool: TaskTool.id,
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
@@ -601,7 +562,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
subagent_type: task.agent,
|
||||
command: task.command,
|
||||
}
|
||||
yield* plugin.trigger("tool.execute.before", { tool: "task", sessionID, callID: part.id }, { args: taskArgs })
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.before",
|
||||
{ tool: TaskTool.id, sessionID, callID: part.id },
|
||||
{ args: taskArgs },
|
||||
)
|
||||
|
||||
const taskAgent = yield* agents.get(task.agent)
|
||||
if (!taskAgent) {
|
||||
@@ -680,7 +645,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
yield* plugin.trigger(
|
||||
"tool.execute.after",
|
||||
{ tool: "task", sessionID, callID: part.id, args: taskArgs },
|
||||
{ tool: TaskTool.id, sessionID, callID: part.id, args: taskArgs },
|
||||
result,
|
||||
)
|
||||
|
||||
@@ -740,11 +705,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
} satisfies MessageV2.TextPart)
|
||||
})
|
||||
|
||||
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) {
|
||||
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const session = yield* sessions.get(input.sessionID)
|
||||
if (session.revert) {
|
||||
yield* Effect.promise(() => SessionRevert.cleanup(session))
|
||||
yield* revert.cleanup(session)
|
||||
}
|
||||
const agent = yield* agents.get(input.agent)
|
||||
if (!agent) {
|
||||
@@ -961,9 +926,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
|
||||
const full =
|
||||
!input.variant && ag.variant && same
|
||||
? yield* provider
|
||||
.getModel(model.providerID, model.modelID)
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
? yield* provider.getModel(model.providerID, model.modelID).pipe(Effect.catchDefect(() => Effect.void))
|
||||
: undefined
|
||||
const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
|
||||
|
||||
@@ -983,9 +946,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
format: input.format,
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
InstanceState.withALS(() => instruction.clear(info.id)).pipe(Effect.flatMap((x) => x)),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => instruction.clear(info.id))
|
||||
|
||||
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
|
||||
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
|
||||
@@ -1077,6 +1038,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const filepath = fileURLToPath(part.url)
|
||||
if (yield* fsys.isDir(filepath)) part.mime = "application/x-directory"
|
||||
|
||||
const { read } = yield* registry.named()
|
||||
const execRead = (args: Parameters<typeof read.execute>[0], extra?: Tool.Context["extra"]) =>
|
||||
Effect.promise((signal: AbortSignal) =>
|
||||
read.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: signal,
|
||||
agent: input.agent!,
|
||||
messageID: info.id,
|
||||
extra: { bypassCwdCheck: true, ...extra },
|
||||
messages: [],
|
||||
metadata: async () => {},
|
||||
ask: async () => {},
|
||||
}),
|
||||
)
|
||||
|
||||
if (part.mime === "text/plain") {
|
||||
let offset: number | undefined
|
||||
let limit: number | undefined
|
||||
@@ -1113,29 +1089,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
|
||||
},
|
||||
]
|
||||
const read = yield* Effect.promise(() => registry.named.read.init()).pipe(
|
||||
Effect.flatMap((t) =>
|
||||
provider.getModel(info.model.providerID, info.model.modelID).pipe(
|
||||
Effect.flatMap((mdl) =>
|
||||
Effect.promise(() =>
|
||||
t.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: new AbortController().signal,
|
||||
agent: input.agent!,
|
||||
messageID: info.id,
|
||||
extra: { bypassCwdCheck: true, model: mdl },
|
||||
messages: [],
|
||||
metadata: async () => {},
|
||||
ask: async () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const exit = yield* provider.getModel(info.model.providerID, info.model.modelID).pipe(
|
||||
Effect.flatMap((mdl) => execRead(args, { model: mdl })),
|
||||
Effect.exit,
|
||||
)
|
||||
if (Exit.isSuccess(read)) {
|
||||
const result = read.value
|
||||
if (Exit.isSuccess(exit)) {
|
||||
const result = exit.value
|
||||
pieces.push({
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
@@ -1157,7 +1116,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
pieces.push({ ...part, messageID: info.id, sessionID: input.sessionID })
|
||||
}
|
||||
} else {
|
||||
const error = Cause.squash(read.cause)
|
||||
const error = Cause.squash(exit.cause)
|
||||
log.error("failed to read file", { error })
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
@@ -1177,22 +1136,25 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
if (part.mime === "application/x-directory") {
|
||||
const args = { filePath: filepath }
|
||||
const result = yield* Effect.promise(() => registry.named.read.init()).pipe(
|
||||
Effect.flatMap((t) =>
|
||||
Effect.promise(() =>
|
||||
t.execute(args, {
|
||||
sessionID: input.sessionID,
|
||||
abort: new AbortController().signal,
|
||||
agent: input.agent!,
|
||||
messageID: info.id,
|
||||
extra: { bypassCwdCheck: true },
|
||||
messages: [],
|
||||
metadata: async () => {},
|
||||
ask: async () => {},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const exit = yield* execRead(args).pipe(Effect.exit)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = Cause.squash(exit.cause)
|
||||
log.error("failed to read directory", { error })
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
sessionID: input.sessionID,
|
||||
error: new NamedError.Unknown({ message }).toObject(),
|
||||
})
|
||||
return [
|
||||
{
|
||||
messageID: info.id,
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: `Read tool failed to read ${filepath} with the following error: ${message}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
messageID: info.id,
|
||||
@@ -1206,7 +1168,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
sessionID: input.sessionID,
|
||||
type: "text",
|
||||
synthetic: true,
|
||||
text: result.output,
|
||||
text: exit.value.output,
|
||||
},
|
||||
{ ...part, messageID: info.id, sessionID: input.sessionID },
|
||||
]
|
||||
@@ -1308,7 +1270,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
|
||||
function* (input: PromptInput) {
|
||||
const session = yield* sessions.get(input.sessionID)
|
||||
yield* Effect.promise(() => SessionRevert.cleanup(session))
|
||||
yield* revert.cleanup(session)
|
||||
const message = yield* createUserMessage(input)
|
||||
yield* sessions.touch(input.sessionID)
|
||||
|
||||
@@ -1371,7 +1333,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
)
|
||||
// Some providers return "stop" even when the assistant message contains tool calls.
|
||||
// Keep the loop running so tool results can be sent back to the model.
|
||||
const hasToolCalls = lastAssistantMsg?.parts.some((part) => part.type === "tool") ?? false
|
||||
// Skip provider-executed tool parts — those were fully handled within the
|
||||
// provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.
|
||||
const hasToolCalls =
|
||||
lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false
|
||||
|
||||
if (
|
||||
lastAssistant?.finish &&
|
||||
@@ -1455,110 +1420,104 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
model,
|
||||
})
|
||||
|
||||
const outcome: "break" | "continue" = yield* Effect.onExit(
|
||||
Effect.gen(function* () {
|
||||
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
||||
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
||||
const outcome: "break" | "continue" = yield* Effect.gen(function* () {
|
||||
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
||||
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
||||
|
||||
const tools = yield* resolveTools({
|
||||
agent,
|
||||
session,
|
||||
model,
|
||||
tools: lastUser.tools,
|
||||
processor: handle,
|
||||
bypassAgentCheck,
|
||||
messages: msgs,
|
||||
const tools = yield* resolveTools({
|
||||
agent,
|
||||
session,
|
||||
model,
|
||||
tools: lastUser.tools,
|
||||
processor: handle,
|
||||
bypassAgentCheck,
|
||||
messages: msgs,
|
||||
})
|
||||
|
||||
if (lastUser.format?.type === "json_schema") {
|
||||
tools["StructuredOutput"] = createStructuredOutputTool({
|
||||
schema: lastUser.format.schema,
|
||||
onSuccess(output) {
|
||||
structured = output
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (lastUser.format?.type === "json_schema") {
|
||||
tools["StructuredOutput"] = createStructuredOutputTool({
|
||||
schema: lastUser.format.schema,
|
||||
onSuccess(output) {
|
||||
structured = output
|
||||
},
|
||||
})
|
||||
}
|
||||
if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser.id })
|
||||
|
||||
if (step === 1) SessionSummary.summarize({ sessionID, messageID: lastUser.id })
|
||||
|
||||
if (step > 1 && lastFinished) {
|
||||
for (const m of msgs) {
|
||||
if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue
|
||||
for (const p of m.parts) {
|
||||
if (p.type !== "text" || p.ignored || p.synthetic) continue
|
||||
if (!p.text.trim()) continue
|
||||
p.text = [
|
||||
"<system-reminder>",
|
||||
"The user sent the following message:",
|
||||
p.text,
|
||||
"",
|
||||
"Please address this message and continue with your tasks.",
|
||||
"</system-reminder>",
|
||||
].join("\n")
|
||||
}
|
||||
if (step > 1 && lastFinished) {
|
||||
for (const m of msgs) {
|
||||
if (m.info.role !== "user" || m.info.id <= lastFinished.id) continue
|
||||
for (const p of m.parts) {
|
||||
if (p.type !== "text" || p.ignored || p.synthetic) continue
|
||||
if (!p.text.trim()) continue
|
||||
p.text = [
|
||||
"<system-reminder>",
|
||||
"The user sent the following message:",
|
||||
p.text,
|
||||
"",
|
||||
"Please address this message and continue with your tasks.",
|
||||
"</system-reminder>",
|
||||
].join("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
|
||||
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
|
||||
Effect.promise(() => SystemPrompt.skills(agent)),
|
||||
Effect.promise(() => SystemPrompt.environment(model)),
|
||||
instruction.system().pipe(Effect.orDie),
|
||||
Effect.promise(() => MessageV2.toModelMessages(msgs, model)),
|
||||
])
|
||||
const system = [...env, ...(skills ? [skills] : []), ...instructions]
|
||||
const format = lastUser.format ?? { type: "text" as const }
|
||||
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
|
||||
const result = yield* handle.process({
|
||||
user: lastUser,
|
||||
agent,
|
||||
permission: session.permission,
|
||||
sessionID,
|
||||
parentSessionID: session.parentID,
|
||||
system,
|
||||
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],
|
||||
tools,
|
||||
model,
|
||||
toolChoice: format.type === "json_schema" ? "required" : undefined,
|
||||
})
|
||||
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
|
||||
Effect.promise(() => SystemPrompt.skills(agent)),
|
||||
Effect.promise(() => SystemPrompt.environment(model)),
|
||||
instruction.system().pipe(Effect.orDie),
|
||||
MessageV2.toModelMessagesEffect(msgs, model),
|
||||
])
|
||||
const system = [...env, ...(skills ? [skills] : []), ...instructions]
|
||||
const format = lastUser.format ?? { type: "text" as const }
|
||||
if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
|
||||
const result = yield* handle.process({
|
||||
user: lastUser,
|
||||
agent,
|
||||
permission: session.permission,
|
||||
sessionID,
|
||||
parentSessionID: session.parentID,
|
||||
system,
|
||||
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])],
|
||||
tools,
|
||||
model,
|
||||
toolChoice: format.type === "json_schema" ? "required" : undefined,
|
||||
})
|
||||
|
||||
if (structured !== undefined) {
|
||||
handle.message.structured = structured
|
||||
handle.message.finish = handle.message.finish ?? "stop"
|
||||
if (structured !== undefined) {
|
||||
handle.message.structured = structured
|
||||
handle.message.finish = handle.message.finish ?? "stop"
|
||||
yield* sessions.updateMessage(handle.message)
|
||||
return "break" as const
|
||||
}
|
||||
|
||||
const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)
|
||||
if (finished && !handle.message.error) {
|
||||
if (format.type === "json_schema") {
|
||||
handle.message.error = new MessageV2.StructuredOutputError({
|
||||
message: "Model did not produce structured output",
|
||||
retries: 0,
|
||||
}).toObject()
|
||||
yield* sessions.updateMessage(handle.message)
|
||||
return "break" as const
|
||||
}
|
||||
}
|
||||
|
||||
const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)
|
||||
if (finished && !handle.message.error) {
|
||||
if (format.type === "json_schema") {
|
||||
handle.message.error = new MessageV2.StructuredOutputError({
|
||||
message: "Model did not produce structured output",
|
||||
retries: 0,
|
||||
}).toObject()
|
||||
yield* sessions.updateMessage(handle.message)
|
||||
return "break" as const
|
||||
}
|
||||
}
|
||||
|
||||
if (result === "stop") return "break" as const
|
||||
if (result === "compact") {
|
||||
yield* compaction.create({
|
||||
sessionID,
|
||||
agent: lastUser.agent,
|
||||
model: lastUser.model,
|
||||
auto: true,
|
||||
overflow: !handle.message.finish,
|
||||
})
|
||||
}
|
||||
return "continue" as const
|
||||
}),
|
||||
Effect.fnUntraced(function* (exit) {
|
||||
if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort()
|
||||
yield* InstanceState.withALS(() => instruction.clear(handle.message.id)).pipe(Effect.flatMap((x) => x))
|
||||
}),
|
||||
)
|
||||
if (result === "stop") return "break" as const
|
||||
if (result === "compact") {
|
||||
yield* compaction.create({
|
||||
sessionID,
|
||||
agent: lastUser.agent,
|
||||
model: lastUser.model,
|
||||
auto: true,
|
||||
overflow: !handle.message.finish,
|
||||
})
|
||||
}
|
||||
return "continue" as const
|
||||
}).pipe(Effect.ensuring(instruction.clear(handle.message.id)))
|
||||
if (outcome === "break") break
|
||||
continue
|
||||
}
|
||||
@@ -1571,16 +1530,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
|
||||
"SessionPrompt.loop",
|
||||
)(function* (input: z.infer<typeof LoopInput>) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const runner = getRunner(s.runners, input.sessionID)
|
||||
return yield* runner.ensureRunning(runLoop(input.sessionID))
|
||||
return yield* state.ensureRunning(input.sessionID, lastAssistant(input.sessionID), runLoop(input.sessionID))
|
||||
})
|
||||
|
||||
const shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.shell")(
|
||||
function* (input: ShellInput) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const runner = getRunner(s.runners, input.sessionID)
|
||||
return yield* runner.startShell((signal) => shellImpl(input, signal))
|
||||
return yield* state.startShell(input.sessionID, lastAssistant(input.sessionID), shellImpl(input))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1701,7 +1656,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
assertNotBusy,
|
||||
cancel,
|
||||
prompt,
|
||||
loop,
|
||||
@@ -1712,36 +1666,32 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = Layer.unwrap(
|
||||
Effect.sync(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(SessionStatus.layer),
|
||||
Layer.provide(SessionCompaction.defaultLayer),
|
||||
Layer.provide(SessionProcessor.defaultLayer),
|
||||
Layer.provide(Command.defaultLayer),
|
||||
Layer.provide(Permission.defaultLayer),
|
||||
Layer.provide(MCP.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(FileTime.defaultLayer),
|
||||
Layer.provide(ToolRegistry.defaultLayer),
|
||||
Layer.provide(Truncate.layer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
),
|
||||
const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(SessionRunState.defaultLayer),
|
||||
Layer.provide(SessionStatus.defaultLayer),
|
||||
Layer.provide(SessionCompaction.defaultLayer),
|
||||
Layer.provide(SessionProcessor.defaultLayer),
|
||||
Layer.provide(Command.defaultLayer),
|
||||
Layer.provide(Permission.defaultLayer),
|
||||
Layer.provide(MCP.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(FileTime.defaultLayer),
|
||||
Layer.provide(ToolRegistry.defaultLayer),
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionRevert.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
),
|
||||
)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function assertNotBusy(sessionID: SessionID) {
|
||||
return runPromise((svc) => svc.assertNotBusy(SessionID.zod.parse(sessionID)))
|
||||
}
|
||||
|
||||
export const PromptInput = z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod.optional(),
|
||||
|
||||
@@ -6,6 +6,10 @@ import { iife } from "@/util/iife"
|
||||
export namespace SessionRetry {
|
||||
export type Err = ReturnType<NamedError["toObject"]>
|
||||
|
||||
// This exported message is shared with the TUI upsell detector. Matching on a
|
||||
// literal error string kind of sucks, but it is the simplest for now.
|
||||
export const GO_UPSELL_MESSAGE = "Free usage exceeded, subscribe to Go https://opencode.ai/go"
|
||||
|
||||
export const RETRY_INITIAL_DELAY = 2000
|
||||
export const RETRY_BACKOFF_FACTOR = 2
|
||||
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
|
||||
@@ -53,8 +57,7 @@ export namespace SessionRetry {
|
||||
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
|
||||
if (MessageV2.APIError.isInstance(error)) {
|
||||
if (!error.data.isRetryable) return undefined
|
||||
if (error.data.responseBody?.includes("FreeUsageLimitError"))
|
||||
return `Free usage exceeded, subscribe to Go https://opencode.ai/go`
|
||||
if (error.data.responseBody?.includes("FreeUsageLimitError")) return GO_UPSELL_MESSAGE
|
||||
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user