Merge branch 'dev' into brendan/cli-codesign

This commit is contained in:
Brendan Allan 2026-03-25 13:15:32 +08:00 committed by GitHub
commit 28cb8efa4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 3240 additions and 1490 deletions

24
.github/workflows/close-issues.yml vendored Normal file
View file

@ -0,0 +1,24 @@
name: close-issues
on:
schedule:
- cron: "0 2 * * *" # Daily at 2:00 AM
workflow_dispatch:
jobs:
close:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Close stale issues
env:
GITHUB_TOKEN: ${{ github.token }}
run: bun script/github/close-issues.ts

View file

@ -56,7 +56,7 @@ jobs:
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
# Extract hash from build log with portability # Extract hash from build log with portability
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
if [ -z "$HASH" ]; then if [ -z "$HASH" ]; then
echo "::error::Failed to compute hash for ${SYSTEM}" echo "::error::Failed to compute hash for ${SYSTEM}"

View file

@ -1,33 +0,0 @@
name: stale-issues
on:
schedule:
- cron: "30 1 * * *" # Daily at 1:30 AM
workflow_dispatch:
env:
DAYS_BEFORE_STALE: 90
DAYS_BEFORE_CLOSE: 7
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v10
with:
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
stale-issue-label: "stale"
close-issue-message: |
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
Feel free to reopen if you still need this!
stale-issue-message: |
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
remove-stale-when-updated: true
exempt-issue-labels: "pinned,security,feature-request,on-hold"
start-date: "2025-12-27"

View file

@ -1,5 +1,21 @@
---
model: opencode/kimi-k2.5
---
create UPCOMING_CHANGELOG.md
it should have sections
```
# TUI
# Desktop
# Core
# Misc
```
go through each PR merged since the last tag go through each PR merged since the last tag
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved

View file

@ -137,4 +137,4 @@ OpenCode 内置两种 Agent可用 `Tab` 键快速切换:
--- ---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode) **加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)

View file

@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent您可以使用 `Tab` 鍵快速切換。
--- ---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode) **加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)

View file

@ -26,7 +26,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@ -79,7 +79,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@ -113,7 +113,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@ -140,7 +140,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@ -164,7 +164,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@ -188,7 +188,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@ -221,7 +221,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@ -252,7 +252,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@ -281,7 +281,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@ -297,7 +297,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.3.0", "version": "1.3.2",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@ -358,7 +358,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.3.1", "gitlab-ai-provider": "5.3.2",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@ -370,6 +370,7 @@
"minimatch": "10.0.3", "minimatch": "10.0.3",
"open": "10.1.2", "open": "10.1.2",
"opencode-gitlab-auth": "2.0.0", "opencode-gitlab-auth": "2.0.0",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"remeda": "catalog:", "remeda": "catalog:",
@ -421,7 +422,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@ -445,7 +446,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.3.0", "version": "1.3.2",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.90.10", "@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@ -456,7 +457,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@ -491,7 +492,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@ -537,7 +538,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@ -548,7 +549,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@ -3036,7 +3037,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-QeNP2af/5wyOHYaLvDxn72n4xbMbJNqRiKExZJM8MnynebnqnoaJoojbtue7roCl/XcnjX6Of2+oc7hS44S45Q=="], "gitlab-ai-provider": ["gitlab-ai-provider@5.3.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": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@ -3792,6 +3793,8 @@
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="], "opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
"opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="],
"opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="], "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="],
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
@ -3924,6 +3927,8 @@
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"poe-oauth": ["poe-oauth@0.0.3", "", {}, "sha512-KgxDylcuq/mov8URSplrBGjrIjkQwjN/Ml8BhqaGsAvHzYN3yhuROdv1sDRfwqncg7TT8XzJvMeJAWmv/4NDLw=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
@ -5544,6 +5549,8 @@
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"opencode-poe-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@ -6296,6 +6303,8 @@
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode-poe-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],

6
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1772091128, "lastModified": 1773909469,
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=", "narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3f0336406035444b4a24b942788334af5f906259", "rev": "7149c06513f335be57f26fcbbbe34afda923882b",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-nMERinypUtIZGfLlAS5meYrvH5tTl2SkdG3GUguhOos=", "x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=",
"aarch64-linux": "sha256-aQ42YVcjXSxpweA3e0SfJ8mnMWEqGeIOKg1cIhn8szA=", "aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=",
"aarch64-darwin": "sha256-OGtUfhKWTRqi8bYcqkvfb1RZa3iS0DVy5bbRry47Og4=", "aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=",
"x86_64-darwin": "sha256-kdzsr67cGduvGl+4UVdngiKNCaVw88WeMgx1ckVbG30=" "x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg="
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.3.0", "version": "1.3.2",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {

View file

@ -55,7 +55,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
<Tooltip value={props.tip} placement="top"> <Tooltip value={props.tip} placement="top">
<div <div
classList={{ classList={{
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] bg-white/5 px-0.5 py-1 text-center": true, "flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
"col-span-2": !!props.wide, "col-span-2": !!props.wide,
}} }}
> >
@ -363,11 +363,7 @@ export function DebugBar() {
return ( return (
<aside <aside
aria-label={language.t("debugBar.ariaLabel")} aria-label={language.t("debugBar.ariaLabel")}
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]" class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
style={{
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
"border-color": "color-mix(in srgb, white 14%, transparent)",
}}
> >
<div class="grid grid-cols-5 gap-px font-mono"> <div class="grid grid-cols-5 gap-px font-mono">
<Cell <Cell

View file

@ -1497,7 +1497,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
id={local.model.current()!.provider.id} id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }} style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/> />
@ -1529,7 +1529,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
id={local.model.current()!.provider.id} id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }} style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/> />

View file

@ -15,6 +15,8 @@ import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
export function applyGlobalEvent(input: { export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown } event: { type: string; properties?: unknown }
project: Project[] project: Project[]
@ -211,6 +213,7 @@ export function applyDirectoryEvent(input: {
} }
case "message.part.updated": { case "message.part.updated": {
const part = (event.properties as { part: Part }).part const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) break
const parts = input.store.part[part.messageID] const parts = input.store.part[part.messageID]
if (!parts) { if (!parts) {
input.setStore("part", part.messageID, [part]) input.setStore("part", part.messageID, [part])

View file

@ -14,6 +14,8 @@ import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) { function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
} }
@ -336,7 +338,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
batch(() => { batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" })) input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) { for (const p of next.part) {
input.setStore("part", p.id, p.part) const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
} }
setMeta("limit", key, message.length) setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor) setMeta("cursor", key, next.cursor)

View file

@ -1798,6 +1798,9 @@ export default function Layout(props: ParentProps) {
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
}) })
const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
const panel = createMemo(() => Math.max(side() - 64, 0))
const loadedSessionDirs = new Set<string>() const loadedSessionDirs = new Set<string>()
createEffect( createEffect(
@ -2074,7 +2077,7 @@ export default function Layout(props: ParentProps) {
"max-w-full overflow-hidden": panelProps.mobile, "max-w-full overflow-hidden": panelProps.mobile,
}} }}
style={{ style={{
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`, width: panelProps.mobile ? undefined : `${panel()}px`,
}} }}
> >
<Show <Show
@ -2137,9 +2140,11 @@ export default function Layout(props: ParentProps) {
variant="ghost" variant="ghost"
data-action="project-menu" data-action="project-menu"
data-project={slug()} data-project={slug()}
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active" class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
classList={{ classList={{
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile, "opacity-100": panelProps.mobile || merged(),
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
!panelProps.mobile && !merged(),
}} }}
aria-label={language.t("common.moreOptions")} aria-label={language.t("common.moreOptions")}
/> />
@ -2364,7 +2369,7 @@ export default function Layout(props: ParentProps) {
"absolute inset-y-0 left-0": true, "absolute inset-y-0 left-0": true,
"z-10": true, "z-10": true,
}} }}
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }} style={{ width: `${side()}px` }}
ref={(el) => { ref={(el) => {
setState("nav", el) setState("nav", el)
}} }}
@ -2379,24 +2384,29 @@ export default function Layout(props: ParentProps) {
}} }}
> >
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div> <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
<Show when={layout.sidebar.opened()}>
<div onPointerDown={() => setState("sizing", true)}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
</nav> </nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div <div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base" class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }} style={{ left: "calc(4rem + 12px)" }}
@ -2436,7 +2446,7 @@ export default function Layout(props: ParentProps) {
!state.sizing, !state.sizing,
}} }}
style={{ style={{
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem", "--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}} }}
> >
<main <main
@ -2483,7 +2493,7 @@ export default function Layout(props: ParentProps) {
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(), "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(), "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}} }}
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }} style={{ left: `calc(4rem + ${panel()}px)` }}
> >
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} /> <div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div> </div>

View file

@ -104,7 +104,7 @@ const SessionRow = (props: {
}): JSX.Element => ( }): JSX.Element => (
<A <A
href={`/${props.slug}/session/${props.session.id}`} href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress} onPointerDown={props.warmPress}
onPointerEnter={props.warmHover} onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch} onPointerLeave={props.cancelHoverPrefetch}
@ -115,30 +115,26 @@ const SessionRow = (props: {
props.clearHoverProjectSoon() props.clearHoverProjectSoon()
}} }}
> >
<div class="flex items-center gap-1 w-full"> <div
<div class="shrink-0 size-6 flex items-center justify-center"
class="shrink-0 size-6 flex items-center justify-center" style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} >
> <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}> <Match when={props.isWorking()}>
<Match when={props.isWorking()}> <Spinner class="size-[15px]" />
<Spinner class="size-[15px]" /> </Match>
</Match> <Match when={props.hasPermissions()}>
<Match when={props.hasPermissions()}> <div class="size-1.5 rounded-full bg-surface-warning-strong" />
<div class="size-1.5 rounded-full bg-surface-warning-strong" /> </Match>
</Match> <Match when={props.hasError()}>
<Match when={props.hasError()}> <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
<div class="size-1.5 rounded-full bg-text-diff-delete-base" /> </Match>
</Match> <Match when={props.unseenCount() > 0}>
<Match when={props.unseenCount() > 0}> <div class="size-1.5 rounded-full bg-text-interactive-base" />
<div class="size-1.5 rounded-full bg-text-interactive-base" /> </Match>
</Match> </Switch>
</Switch>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
</div> </div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
</A> </A>
) )
@ -167,7 +163,11 @@ const SessionHoverPreview = (props: {
placement="right-start" placement="right-start"
gutter={16} gutter={16}
shift={-2} shift={-2}
trigger={<div ref={ref}>{props.trigger}</div>} trigger={
<div ref={ref} class="min-w-0 w-full">
{props.trigger}
</div>
}
open={props.hoverSession() === props.session.id} open={props.hoverSession() === props.session.id}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
@ -309,62 +309,71 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
return ( return (
<div <div
data-session-id={props.session.id} data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
> >
<Show <div class="flex min-w-0 items-center gap-1">
when={hoverEnabled()} <div class="min-w-0 flex-1">
fallback={ <Show
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}> when={hoverEnabled()}
{item} fallback={
</Tooltip> <Tooltip
} placement={props.mobile ? "bottom" : "right"}
> value={props.session.title}
<SessionHoverPreview gutter={10}
mobile={props.mobile} class="min-w-0 w-full"
nav={props.nav} >
hoverSession={props.hoverSession} {item}
session={props.session} </Tooltip>
sidebarHovering={props.sidebarHovering} }
hoverReady={hoverReady} >
hoverMessages={hoverMessages} <SessionHoverPreview
language={language} mobile={props.mobile}
isActive={isActive} nav={props.nav}
slug={props.slug} hoverSession={props.hoverSession}
setHoverSession={props.setHoverSession} session={props.session}
messageLabel={messageLabel} sidebarHovering={props.sidebarHovering}
onMessageSelect={(message) => { hoverReady={hoverReady}
if (!isActive()) hoverMessages={hoverMessages}
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) language={language}
isActive={isActive}
slug={props.slug}
setHoverSession={props.setHoverSession}
messageLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive())
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
}}
trigger={item}
/>
</Show>
</div>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
"w-0 opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}} }}
trigger={item} >
/> <Tooltip value={language.t("common.archive")} placement="top">
</Show> <IconButton
icon="archive"
<div variant="ghost"
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`} class="size-6 rounded-md"
classList={{ aria-label={language.t("common.archive")}
"opacity-100 pointer-events-auto": !!props.mobile, onClick={(event) => {
"opacity-0 pointer-events-none": !props.mobile, event.preventDefault()
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true, event.stopPropagation()
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true, void props.archiveSession(props.session)
}} }}
> />
<Tooltip value={language.t("common.archive")} placement="top"> </Tooltip>
<IconButton </div>
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.session)
}}
/>
</Tooltip>
</div> </div>
</div> </div>
) )
@ -386,30 +395,26 @@ export const NewSessionItem = (props: {
<A <A
href={`/${props.slug}/session`} href={`/${props.slug}/session`}
end end
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onClick={() => { onClick={() => {
props.setHoverSession(undefined) props.setHoverSession(undefined)
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
props.clearHoverProjectSoon() props.clearHoverProjectSoon()
}} }}
> >
<div class="flex items-center gap-1 w-full"> <div class="shrink-0 size-6 flex items-center justify-center">
<div class="shrink-0 size-6 flex items-center justify-center"> <Icon name="new-session" size="small" class="text-icon-weak" />
<Icon name="new-session" size="small" class="text-icon-weak" />
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{label}
</span>
</div> </div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
</A> </A>
) )
return ( return (
<div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"> <div class="group/session relative w-full min-w-0 rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
<Show <Show
when={!tooltip()} when={!tooltip()}
fallback={ fallback={
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}> <Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
{item} {item}
</Tooltip> </Tooltip>
} }

View file

@ -41,7 +41,13 @@ import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers" import {
createOpenReviewFile,
createSessionTabs,
createSizing,
focusTerminalById,
shouldFocusTerminalOnKeyDown,
} from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline" import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
@ -240,14 +246,19 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
if (added <= 0) return if (added <= 0) return
if (growth <= 0) return if (growth <= 0) return
if (opts?.prefetch) {
const current = turnStart()
preserveScroll(() => setTurnStart(current + growth))
return
}
if (turnStart() !== start) return if (turnStart() !== start) return
const reveal = !opts?.prefetch
const currentRendered = renderedUserMessages().length const currentRendered = renderedUserMessages().length
const base = Math.max(beforeRendered, currentRendered) const base = Math.max(beforeRendered, currentRendered)
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base const target = Math.min(afterVisible, base + turnBatch)
const nextStart = Math.max(0, afterVisible - target) preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
preserveScroll(() => setTurnStart(nextStart))
} }
const onScrollerScroll = () => { const onScrollerScroll = () => {
@ -850,7 +861,7 @@ export default function Page() {
// Prefer the open terminal over the composer when it can take focus // Prefer the open terminal over the composer when it can take focus
if (view().terminal.opened()) { if (view().terminal.opened()) {
const id = terminal.active() const id = terminal.active()
if (id && focusTerminalById(id)) return if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
} }
// Only treat explicit scroll keys as potential "user scroll" gestures. // Only treat explicit scroll keys as potential "user scroll" gestures.

View file

@ -7,6 +7,7 @@ import {
createSessionTabs, createSessionTabs,
focusTerminalById, focusTerminalById,
getTabReorderIndex, getTabReorderIndex,
shouldFocusTerminalOnKeyDown,
} from "./helpers" } from "./helpers"
describe("createOpenReviewFile", () => { describe("createOpenReviewFile", () => {
@ -86,6 +87,26 @@ describe("focusTerminalById", () => {
}) })
}) })
describe("shouldFocusTerminalOnKeyDown", () => {
test("skips pure modifier keys", () => {
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Meta", metaKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Alt", altKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Shift", shiftKey: true }))).toBe(false)
})
test("skips shortcut key combos", () => {
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", metaKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", ctrlKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }))).toBe(false)
})
test("keeps plain typing focused on terminal", () => {
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "a" }))).toBe(true)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "A", shiftKey: true }))).toBe(true)
})
})
describe("getTabReorderIndex", () => { describe("getTabReorderIndex", () => {
test("returns target index for valid drag reorder", () => { test("returns target index for valid drag reorder", () => {
expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2) expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2)

View file

@ -93,6 +93,13 @@ export const focusTerminalById = (id: string) => {
return true return true
} }
const skip = new Set(["Alt", "Control", "Meta", "Shift"])
export const shouldFocusTerminalOnKeyDown = (event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">) => {
if (skip.has(event.key)) return false
return !(event.ctrlKey || event.metaKey || event.altKey)
}
export const createOpenReviewFile = (input: { export const createOpenReviewFile = (input: {
showAllFiles: () => void showAllFiles: () => void
tabForPath: (path: string) => string tabForPath: (path: string) => string

View file

@ -923,7 +923,15 @@ export function MessageTimeline(props: {
{(messageID) => { {(messageID) => {
const active = createMemo(() => activeMessageID() === messageID) const active = createMemo(() => activeMessageID() === messageID)
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), equals: (a, b) =>
a.length === b.length &&
a.every(
(c, i) =>
c.path === b[i].path &&
c.comment === b[i].comment &&
c.selection?.startLine === b[i].selection?.startLine &&
c.selection?.endLine === b[i].selection?.endLine,
),
}) })
const commentCount = createMemo(() => comments().length) const commentCount = createMemo(() => comments().length)
return ( return (
@ -979,6 +987,7 @@ export function MessageTimeline(props: {
<SessionTurn <SessionTurn
sessionID={sessionID() ?? ""} sessionID={sessionID() ?? ""}
messageID={messageID} messageID={messageID}
messages={sessionMessages()}
actions={props.actions} actions={props.actions}
active={active()} active={active()}
status={active() ? sessionStatus() : undefined} status={active() ? sessionStatus() : undefined}

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.3.0", "version": "1.3.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -2,6 +2,6 @@ import { redirect } from "@solidjs/router"
export async function GET() { export async function GET() {
return redirect( return redirect(
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true", "https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true",
) )
} }

View file

@ -340,6 +340,13 @@ export async function handler(
"error.message": error.message, "error.message": error.message,
"error.cause": error.cause?.toString(), "error.cause": error.cause?.toString(),
}) })
if (error.message.startsWith("Failed query")) {
try {
logger.metric({
"error.cause2": JSON.stringify(error.cause),
})
} catch (e) {}
}
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message. // Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
if ( if (

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.3.0", "version": "1.3.2",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.3.0", "version": "1.3.2",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.3.0", "version": "1.3.2",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",

View file

@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.3.0", "version": "1.3.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",

View file

@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.3.0", "version": "1.3.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.3.0", "version": "1.3.2",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View file

@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.3.0" version = "1.3.2"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.3.0", "version": "1.3.2",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",

View file

@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.3.0", "version": "1.3.2",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@ -121,7 +121,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.3.1", "gitlab-ai-provider": "5.3.2",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@ -135,6 +135,7 @@
"opencode-gitlab-auth": "2.0.0", "opencode-gitlab-auth": "2.0.0",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"opencode-poe-auth": "0.0.1",
"remeda": "catalog:", "remeda": "catalog:",
"semver": "^7.6.3", "semver": "^7.6.3",
"solid-js": "catalog:", "solid-js": "catalog:",

View file

@ -21,10 +21,14 @@ const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text() ? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text()) : await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
await Bun.write( await Bun.write(
path.join(dir, "src/provider/models-snapshot.ts"), path.join(dir, "src/provider/models-snapshot.js"),
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`, `// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
) )
console.log("Generated models-snapshot.ts") 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 // Load migrations from migration directories
const migrationDirs = ( const migrationDirs = (

View file

@ -164,7 +164,7 @@ Still open and likely worth migrating:
- [x] `Plugin` - [x] `Plugin`
- [x] `ToolRegistry` - [x] `ToolRegistry`
- [ ] `Pty` - [ ] `Pty`
- [ ] `Worktree` - [x] `Worktree`
- [ ] `Bus` - [ ] `Bus`
- [x] `Command` - [x] `Command`
- [ ] `Config` - [ ] `Config`
@ -173,6 +173,6 @@ Still open and likely worth migrating:
- [ ] `SessionPrompt` - [ ] `SessionPrompt`
- [ ] `SessionCompaction` - [ ] `SessionCompaction`
- [ ] `Provider` - [ ] `Provider`
- [ ] `Project` - [x] `Project`
- [ ] `LSP` - [ ] `LSP`
- [ ] `MCP` - [ ] `MCP`

View file

@ -3,7 +3,6 @@ import z from "zod"
import { Provider } from "../provider/provider" import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai" import { generateObject, streamObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncate" import { Truncate } from "../tool/truncate"
import { Auth } from "../auth" import { Auth } from "../auth"
@ -20,6 +19,9 @@ import { Global } from "@/global"
import path from "path" import path from "path"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Effect, ServiceMap, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
export namespace Agent { export namespace Agent {
export const Info = z export const Info = z
@ -49,295 +51,364 @@ export namespace Agent {
}) })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
const state = Instance.state(async () => { export interface Interface {
const cfg = await Config.get() readonly get: (agent: string) => Effect.Effect<Agent.Info>
readonly list: () => Effect.Effect<Agent.Info[]>
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<{
identifier: string
whenToUse: string
systemPrompt: string
}>
}
const skillDirs = await Skill.dirs() type State = Omit<Interface, "generate">
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = Permission.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
})
const user = Permission.fromConfig(cfg.permission ?? {})
const result: Record<string, Info> = { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {}
build: {
name: "build", export const layer = Layer.effect(
description: "The default agent. Executes tools based on configured permissions.", Service,
options: {}, Effect.gen(function* () {
permission: Permission.merge( const config = () => Effect.promise(() => Config.get())
defaults, const auth = yield* Auth.Service
Permission.fromConfig({
question: "allow", const state = yield* InstanceState.make<State>(
plan_enter: "allow", Effect.fn("Agent.state")(function* (ctx) {
}), const cfg = yield* config()
user, const skillDirs = yield* Effect.promise(() => Skill.dirs())
), const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
mode: "primary",
native: true, const defaults = Permission.fromConfig({
}, "*": "allow",
plan: { doom_loop: "ask",
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge(
defaults,
Permission.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
user,
),
options: {},
mode: "subagent",
native: true,
},
explore: {
name: "explore",
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: { external_directory: {
"*": "ask", "*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
}, },
}), question: "deny",
user, plan_enter: "deny",
), plan_exit: "deny",
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, // mirrors github.com/github/gitignore Node.gitignore pattern for .env files
prompt: PROMPT_EXPLORE, read: {
options: {}, "*": "allow",
mode: "subagent", "*.env": "ask",
native: true, "*.env.*": "ask",
}, "*.env.example": "allow",
compaction: { },
name: "compaction", })
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) { const user = Permission.fromConfig(cfg.permission ?? {})
if (value.disable) {
delete result[key]
continue
}
let item = result[key]
if (!item)
item = result[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
options: {},
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
// Ensure Truncate.GLOB is allowed unless explicitly configured const agents: Record<string, Info> = {
for (const name in result) { build: {
const agent = result[name] name: "build",
const explicit = agent.permission.some((r) => { description: "The default agent. Executes tools based on configured permissions.",
if (r.permission !== "external_directory") return false options: {},
if (r.action !== "deny") return false permission: Permission.merge(
return r.pattern === Truncate.GLOB defaults,
}) Permission.fromConfig({
if (explicit) continue question: "allow",
plan_enter: "allow",
}),
user,
),
mode: "primary",
native: true,
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]:
"allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge(
defaults,
Permission.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
user,
),
options: {},
mode: "subagent",
native: true,
},
explore: {
name: "explore",
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
mode: "subagent",
native: true,
},
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
},
}
result[name].permission = Permission.merge( for (const [key, value] of Object.entries(cfg.agent ?? {})) {
result[name].permission, if (value.disable) {
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), delete agents[key]
continue
}
let item = agents[key]
if (!item)
item = agents[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
options: {},
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
// Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in agents) {
const agent = agents[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.GLOB
})
if (explicit) continue
agents[name].permission = Permission.merge(
agents[name].permission,
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
}
const get = Effect.fnUntraced(function* (agent: string) {
return agents[agent]
})
const list = Effect.fnUntraced(function* () {
const cfg = yield* config()
return pipe(
agents,
values(),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
})
const defaultAgent = Effect.fnUntraced(function* () {
const c = yield* config()
if (c.default_agent) {
const agent = agents[c.default_agent]
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
return agent.name
}
const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!visible) throw new Error("no primary visible agent found")
return visible.name
})
return {
get,
list,
defaultAgent,
} satisfies State
}),
) )
}
return result return Service.of({
}) get: Effect.fn("Agent.get")(function* (agent: string) {
return yield* InstanceState.useEffect(state, (s) => s.get(agent))
}),
list: Effect.fn("Agent.list")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.list())
}),
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) {
const cfg = yield* config()
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
const system = [PROMPT_GENERATE]
yield* Effect.promise(() =>
Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }),
)
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
metadata: {
userId: cfg.username ?? "unknown",
},
},
temperature: 0.3,
messages: [
...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`,
},
],
model: language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} 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") {
return yield* Effect.promise(async () => {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(resolved, {
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
})
}
return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
const runPromise = makeRunPromise(Service, defaultLayer)
export async function get(agent: string) { export async function get(agent: string) {
return state().then((x) => x[agent]) return runPromise((svc) => svc.get(agent))
} }
export async function list() { export async function list() {
const cfg = await Config.get() return runPromise((svc) => svc.list())
return pipe(
await state(),
values(),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
} }
export async function defaultAgent() { export async function defaultAgent() {
const cfg = await Config.get() return runPromise((svc) => svc.defaultAgent())
const agents = await state()
if (cfg.default_agent) {
const agent = agents[cfg.default_agent]
if (!agent) throw new Error(`default agent "${cfg.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${cfg.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${cfg.default_agent}" is hidden`)
return agent.name
}
const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!primaryVisible) throw new Error("no primary visible agent found")
return primaryVisible.name
} }
export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) { export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) {
const cfg = await Config.get() return runPromise((svc) => svc.generate(input))
const defaultModel = input.model ?? (await Provider.defaultModel())
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
const language = await Provider.getLanguage(model)
const system = [PROMPT_GENERATE]
await Plugin.trigger("experimental.chat.system.transform", { model }, { system })
const existing = await list()
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
metadata: {
userId: cfg.username ?? "unknown",
},
},
temperature: 0.3,
messages: [
...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`,
},
],
model: language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} satisfies Parameters<typeof generateObject>[0]
// TODO: clean this up so provider specific logic doesnt bleed over
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(model, {
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
}
const result = await generateObject(params)
return result.object
} }
} }

View file

@ -110,6 +110,7 @@ export function tui(input: {
url: string url: string
args: Args args: Args
config: TuiConfig.Info config: TuiConfig.Info
onSnapshot?: () => Promise<string[]>
directory?: string directory?: string
fetch?: typeof fetch fetch?: typeof fetch
headers?: RequestInit["headers"] headers?: RequestInit["headers"]
@ -160,7 +161,7 @@ export function tui(input: {
<FrecencyProvider> <FrecencyProvider>
<PromptHistoryProvider> <PromptHistoryProvider>
<PromptRefProvider> <PromptRefProvider>
<App /> <App onSnapshot={input.onSnapshot} />
</PromptRefProvider> </PromptRefProvider>
</PromptHistoryProvider> </PromptHistoryProvider>
</FrecencyProvider> </FrecencyProvider>
@ -201,7 +202,7 @@ export function tui(input: {
}) })
} }
function App() { function App(props: { onSnapshot?: () => Promise<string[]> }) {
const route = useRoute() const route = useRoute()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const renderer = useRenderer() const renderer = useRenderer()
@ -212,7 +213,7 @@ function App() {
const command = useCommandDialog() const command = useCommandDialog()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const { theme, mode, setMode } = useTheme() const { theme, mode, setMode, locked, lock, unlock } = useTheme()
const sync = useSync() const sync = useSync()
const exit = useExit() const exit = useExit()
const promptRef = usePromptRef() const promptRef = usePromptRef()
@ -557,7 +558,7 @@ function App() {
category: "System", category: "System",
}, },
{ {
title: "Toggle appearance", title: "Toggle Theme Mode",
value: "theme.switch_mode", value: "theme.switch_mode",
onSelect: (dialog) => { onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark") setMode(mode() === "dark" ? "light" : "dark")
@ -565,6 +566,16 @@ function App() {
}, },
category: "System", category: "System",
}, },
{
title: locked() ? "Unlock Theme Mode" : "Lock Theme Mode",
value: "theme.mode.lock",
onSelect: (dialog) => {
if (locked()) unlock()
else lock()
dialog.clear()
},
category: "System",
},
{ {
title: "Help", title: "Help",
value: "help.show", value: "help.show",
@ -617,11 +628,11 @@ function App() {
title: "Write heap snapshot", title: "Write heap snapshot",
category: "System", category: "System",
value: "app.heap_snapshot", value: "app.heap_snapshot",
onSelect: (dialog) => { onSelect: async (dialog) => {
const path = writeHeapSnapshot() const files = await props.onSnapshot?.()
toast.show({ toast.show({
variant: "info", variant: "info",
message: `Heap snapshot written to ${path}`, message: `Heap snapshot written to ${files?.join(", ")}`,
duration: 5000, duration: 5000,
}) })
dialog.clear() dialog.clear()

View file

@ -53,6 +53,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
message: store, message: store,
}, },
) )
process.on("SIGHUP", () => exit())
return exit return exit
}, },
}) })

View file

@ -283,9 +283,15 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
const renderer = useRenderer() const renderer = useRenderer()
const config = useTuiConfig() const config = useTuiConfig()
const kv = useKV() const kv = useKV()
const pick = (value: unknown) => {
if (value === "dark" || value === "light") return value
return
}
const lock = pick(kv.get("theme_mode_lock"))
const [store, setStore] = createStore({ const [store, setStore] = createStore({
themes: DEFAULT_THEMES, themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode), mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode,
lock,
active: (config.theme ?? kv.get("theme", "opencode")) as string, active: (config.theme ?? kv.get("theme", "opencode")) as string,
ready: false, ready: false,
}) })
@ -345,16 +351,30 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}) })
} }
function update(mode: "dark" | "light") { function apply(mode: "dark" | "light") {
kv.set("theme_mode", mode)
if (store.mode === mode) return if (store.mode === mode) return
setStore("mode", mode) setStore("mode", mode)
kv.set("theme_mode", mode)
renderer.clearPaletteCache() renderer.clearPaletteCache()
resolveSystemTheme(mode) resolveSystemTheme(mode)
} }
function pin(mode: "dark" | "light" = store.mode) {
setStore("lock", mode)
kv.set("theme_mode_lock", mode)
apply(mode)
}
function free() {
setStore("lock", undefined)
kv.set("theme_mode_lock", undefined)
const mode = renderer.themeMode
if (mode) apply(mode)
}
const handle = (mode: "dark" | "light") => { const handle = (mode: "dark" | "light") => {
update(mode) if (store.lock) return
apply(mode)
} }
renderer.on(CliRenderEvents.THEME_MODE, handle) renderer.on(CliRenderEvents.THEME_MODE, handle)
onCleanup(() => { onCleanup(() => {
@ -390,8 +410,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
mode() { mode() {
return store.mode return store.mode
}, },
locked() {
return store.lock !== undefined
},
lock() {
pin(store.mode)
},
unlock() {
free()
},
setMode(mode: "dark" | "light") { setMode(mode: "dark" | "light") {
update(mode) pin(mode)
}, },
set(theme: string) { set(theme: string) {
setStore("active", theme) setStore("active", theme)

View file

@ -14,6 +14,7 @@ import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui" import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { writeHeapSnapshot } from "v8"
declare global { declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
@ -201,6 +202,11 @@ export const TuiThreadCommand = cmd({
try { try {
await tui({ await tui({
url: transport.url, url: transport.url,
async onSnapshot() {
const tui = writeHeapSnapshot("tui.heapsnapshot")
const server = await client.call("snapshot", undefined)
return [tui, server]
},
config, config,
directory: cwd, directory: cwd,
fetch: transport.fetch, fetch: transport.fetch,

View file

@ -10,6 +10,7 @@ import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { writeHeapSnapshot } from "node:v8"
await Log.init({ await Log.init({
print: process.argv.includes("--print-logs"), print: process.argv.includes("--print-logs"),
@ -117,6 +118,10 @@ export const rpc = {
body, body,
} }
}, },
snapshot() {
const result = writeHeapSnapshot("server.heapsnapshot")
return result
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true) if (server) await server.stop(true)
server = await Server.listen(input) server = await Server.listen(input)

View file

@ -22,12 +22,11 @@ export const WorktreeAdaptor: Adaptor = {
}, },
async create(info) { async create(info) {
const config = Config.parse(info) const config = Config.parse(info)
const bootstrap = await Worktree.createFromInfo({ await Worktree.createFromInfo({
name: config.name, name: config.name,
directory: config.directory, directory: config.directory,
branch: config.branch, branch: config.branch,
}) })
return bootstrap()
}, },
async remove(info) { async remove(info) {
const config = Config.parse(info) const config = Config.parse(info)

View file

@ -0,0 +1,476 @@
import type * as Arr from "effect/Array"
import { NodeSink, NodeStream } from "@effect/platform-node"
import * as Deferred from "effect/Deferred"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as FileSystem from "effect/FileSystem"
import * as Layer from "effect/Layer"
import * as Path from "effect/Path"
import * as PlatformError from "effect/PlatformError"
import * as Predicate from "effect/Predicate"
import type * as Scope from "effect/Scope"
import * as Sink from "effect/Sink"
import * as Stream from "effect/Stream"
import * as ChildProcess from "effect/unstable/process/ChildProcess"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
import {
ChildProcessSpawner,
ExitCode,
make as makeSpawner,
makeHandle,
ProcessId,
} from "effect/unstable/process/ChildProcessSpawner"
import * as NodeChildProcess from "node:child_process"
import { PassThrough } from "node:stream"
import launch from "cross-spawn"
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => {
switch (err.code) {
case "ENOENT":
return "NotFound"
case "EACCES":
return "PermissionDenied"
case "EEXIST":
return "AlreadyExists"
case "EISDIR":
return "BadResource"
case "ENOTDIR":
return "BadResource"
case "EBUSY":
return "Busy"
case "ELOOP":
return "BadResource"
default:
return "Unknown"
}
}
const flatten = (command: ChildProcess.Command) => {
const commands: Array<ChildProcess.StandardCommand> = []
const opts: Array<ChildProcess.PipeOptions> = []
const walk = (cmd: ChildProcess.Command): void => {
switch (cmd._tag) {
case "StandardCommand":
commands.push(cmd)
return
case "PipedCommand":
walk(cmd.left)
opts.push(cmd.options)
walk(cmd.right)
return
}
}
walk(command)
if (commands.length === 0) throw new Error("flatten produced empty commands array")
const [head, ...tail] = commands
return {
commands: [head, ...tail] as Arr.NonEmptyReadonlyArray<ChildProcess.StandardCommand>,
opts,
}
}
const toPlatformError = (
method: string,
err: NodeJS.ErrnoException,
command: ChildProcess.Command,
): PlatformError.PlatformError => {
const cmd = flatten(command)
.commands.map((x) => `${x.command} ${x.args.join(" ")}`)
.join(" | ")
return PlatformError.systemError({
_tag: toTag(err),
module: "ChildProcess",
method,
pathOrDescriptor: cmd,
syscall: err.syscall,
cause: err,
})
}
type ExitSignal = Deferred.Deferred<readonly [code: number | null, signal: NodeJS.Signals | null]>
export const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const cwd = Effect.fnUntraced(function* (opts: ChildProcess.CommandOptions) {
if (Predicate.isUndefined(opts.cwd)) return undefined
yield* fs.access(opts.cwd)
return path.resolve(opts.cwd)
})
const env = (opts: ChildProcess.CommandOptions) =>
opts.extendEnv ? { ...globalThis.process.env, ...opts.env } : opts.env
const input = (x: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined =>
Stream.isStream(x) ? "pipe" : x
const output = (x: ChildProcess.CommandOutput | undefined): NodeChildProcess.IOType | undefined =>
Sink.isSink(x) ? "pipe" : x
const stdin = (opts: ChildProcess.CommandOptions): ChildProcess.StdinConfig => {
const cfg: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true }
if (Predicate.isUndefined(opts.stdin)) return cfg
if (typeof opts.stdin === "string") return { ...cfg, stream: opts.stdin }
if (Stream.isStream(opts.stdin)) return { ...cfg, stream: opts.stdin }
return {
stream: opts.stdin.stream,
encoding: opts.stdin.encoding ?? cfg.encoding,
endOnDone: opts.stdin.endOnDone ?? cfg.endOnDone,
}
}
const stdio = (opts: ChildProcess.CommandOptions, key: "stdout" | "stderr"): ChildProcess.StdoutConfig => {
const cfg = opts[key]
if (Predicate.isUndefined(cfg)) return { stream: "pipe" }
if (typeof cfg === "string") return { stream: cfg }
if (Sink.isSink(cfg)) return { stream: cfg }
return { stream: cfg.stream }
}
const fds = (opts: ChildProcess.CommandOptions) => {
if (Predicate.isUndefined(opts.additionalFds)) return []
return Object.entries(opts.additionalFds)
.flatMap(([name, config]) => {
const fd = ChildProcess.parseFdName(name)
return Predicate.isUndefined(fd) ? [] : [{ fd, config }]
})
.toSorted((a, b) => a.fd - b.fd)
}
const stdios = (
sin: ChildProcess.StdinConfig,
sout: ChildProcess.StdoutConfig,
serr: ChildProcess.StderrConfig,
extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>,
): NodeChildProcess.StdioOptions => {
const pipe = (x: NodeChildProcess.IOType | undefined) =>
process.platform === "win32" && x === "pipe" ? "overlapped" : x
const arr: Array<NodeChildProcess.IOType | undefined> = [
pipe(input(sin.stream)),
pipe(output(sout.stream)),
pipe(output(serr.stream)),
]
if (extra.length === 0) return arr as NodeChildProcess.StdioOptions
const max = extra.reduce((acc, x) => Math.max(acc, x.fd), 2)
for (let i = 3; i <= max; i++) arr[i] = "ignore"
for (const x of extra) arr[x.fd] = pipe("pipe")
return arr as NodeChildProcess.StdioOptions
}
const setupFds = Effect.fnUntraced(function* (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>,
) {
if (extra.length === 0) {
return {
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
}
}
const ins = new Map<number, Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError>>()
const outs = new Map<number, Stream.Stream<Uint8Array, PlatformError.PlatformError>>()
for (const x of extra) {
const node = proc.stdio[x.fd]
switch (x.config.type) {
case "input": {
let sink: Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError> = Sink.drain
if (node && "write" in node) {
sink = NodeSink.fromWritable({
evaluate: () => node,
onError: (err) => toPlatformError(`fromWritable(fd${x.fd})`, toError(err), command),
endOnDone: true,
})
}
if (x.config.stream) yield* Effect.forkScoped(Stream.run(x.config.stream, sink))
ins.set(x.fd, sink)
break
}
case "output": {
let stream: Stream.Stream<Uint8Array, PlatformError.PlatformError> = Stream.empty
if (node && "read" in node) {
const tap = new PassThrough()
node.on("error", (err) => tap.destroy(toError(err)))
node.pipe(tap)
stream = NodeStream.fromReadable({
evaluate: () => tap,
onError: (err) => toPlatformError(`fromReadable(fd${x.fd})`, toError(err), command),
})
}
if (x.config.sink) stream = Stream.transduce(stream, x.config.sink)
outs.set(x.fd, stream)
break
}
}
}
return {
getInputFd: (fd: number) => ins.get(fd) ?? Sink.drain,
getOutputFd: (fd: number) => outs.get(fd) ?? Stream.empty,
}
})
const setupStdin = (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
cfg: ChildProcess.StdinConfig,
) =>
Effect.suspend(() => {
let sink: Sink.Sink<void, unknown, never, PlatformError.PlatformError> = Sink.drain
if (Predicate.isNotNull(proc.stdin)) {
sink = NodeSink.fromWritable({
evaluate: () => proc.stdin!,
onError: (err) => toPlatformError("fromWritable(stdin)", toError(err), command),
endOnDone: cfg.endOnDone,
encoding: cfg.encoding,
})
}
if (Stream.isStream(cfg.stream)) return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink)
return Effect.succeed(sink)
})
const setupOutput = (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
out: ChildProcess.StdoutConfig,
err: ChildProcess.StderrConfig,
) => {
let stdout = proc.stdout
? NodeStream.fromReadable({
evaluate: () => proc.stdout!,
onError: (cause) => toPlatformError("fromReadable(stdout)", toError(cause), command),
})
: Stream.empty
let stderr = proc.stderr
? NodeStream.fromReadable({
evaluate: () => proc.stderr!,
onError: (cause) => toPlatformError("fromReadable(stderr)", toError(cause), command),
})
: Stream.empty
if (Sink.isSink(out.stream)) stdout = Stream.transduce(stdout, out.stream)
if (Sink.isSink(err.stream)) stderr = Stream.transduce(stderr, err.stream)
return { stdout, stderr, all: Stream.merge(stdout, stderr) }
}
const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) =>
Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
const signal = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
const proc = launch(command.command, command.args, opts)
let end = false
let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined
proc.on("error", (err) => {
resume(Effect.fail(toPlatformError("spawn", err, command)))
})
proc.on("exit", (...args) => {
exit = args
})
proc.on("close", (...args) => {
if (end) return
end = true
Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args))
})
proc.on("spawn", () => {
resume(Effect.succeed([proc, signal]))
})
return Effect.sync(() => {
proc.kill("SIGTERM")
})
})
const killGroup = (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
signal: NodeJS.Signals,
) => {
if (globalThis.process.platform === "win32") {
return Effect.callback<void, PlatformError.PlatformError>((resume) => {
NodeChildProcess.exec(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true }, (err) => {
if (err) return resume(Effect.fail(toPlatformError("kill", toError(err), command)))
resume(Effect.void)
})
})
}
return Effect.try({
try: () => {
globalThis.process.kill(-proc.pid!, signal)
},
catch: (err) => toPlatformError("kill", toError(err), command),
})
}
const killOne = (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
signal: NodeJS.Signals,
) =>
Effect.suspend(() => {
if (proc.kill(signal)) return Effect.void
return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command))
})
const timeout =
(
proc: NodeChildProcess.ChildProcess,
command: ChildProcess.StandardCommand,
opts: ChildProcess.KillOptions | undefined,
) =>
<A, E, R>(
f: (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
signal: NodeJS.Signals,
) => Effect.Effect<A, E, R>,
) => {
const signal = opts?.killSignal ?? "SIGTERM"
if (Predicate.isUndefined(opts?.forceKillAfter)) return f(command, proc, signal)
return Effect.timeoutOrElse(f(command, proc, signal), {
duration: opts.forceKillAfter,
onTimeout: () => f(command, proc, "SIGKILL"),
})
}
const source = (handle: ChildProcessHandle, from: ChildProcess.PipeFromOption | undefined) => {
const opt = from ?? "stdout"
switch (opt) {
case "stdout":
return handle.stdout
case "stderr":
return handle.stderr
case "all":
return handle.all
default: {
const fd = ChildProcess.parseFdName(opt)
return Predicate.isNotUndefined(fd) ? handle.getOutputFd(fd) : handle.stdout
}
}
}
const spawnCommand: (
command: ChildProcess.Command,
) => Effect.Effect<ChildProcessHandle, PlatformError.PlatformError, Scope.Scope> = Effect.fnUntraced(
function* (command) {
switch (command._tag) {
case "StandardCommand": {
const sin = stdin(command.options)
const sout = stdio(command.options, "stdout")
const serr = stdio(command.options, "stderr")
const extra = fds(command.options)
const dir = yield* cwd(command.options)
const [proc, signal] = yield* Effect.acquireRelease(
spawn(command, {
cwd: dir,
env: env(command.options),
stdio: stdios(sin, sout, serr, extra),
detached: command.options.detached ?? process.platform !== "win32",
shell: command.options.shell,
windowsHide: process.platform === "win32",
}),
Effect.fnUntraced(function* ([proc, signal]) {
const done = yield* Deferred.isDone(signal)
const kill = timeout(proc, command, command.options)
if (done) {
const [code] = yield* Deferred.await(signal)
if (process.platform === "win32") return yield* Effect.void
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
return yield* Effect.void
}
return yield* kill((command, proc, signal) =>
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore)
}),
)
const fd = yield* setupFds(command, proc, extra)
const out = setupOutput(command, proc, sout, serr)
return makeHandle({
pid: ProcessId(proc.pid!),
stdin: yield* setupStdin(command, proc, sin),
stdout: out.stdout,
stderr: out.stderr,
all: out.all,
getInputFd: fd.getInputFd,
getOutputFd: fd.getOutputFd,
isRunning: Effect.map(Deferred.isDone(signal), (done) => !done),
exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => {
if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code))
return Effect.fail(
toPlatformError(
"exitCode",
new Error(`Process interrupted due to receipt of signal: '${signal}'`),
command,
),
)
}),
kill: (opts?: ChildProcess.KillOptions) =>
timeout(
proc,
command,
opts,
)((command, proc, signal) =>
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
})
}
case "PipedCommand": {
const flat = flatten(command)
const [head, ...tail] = flat.commands
let handle = spawnCommand(head)
for (let i = 0; i < tail.length; i++) {
const next = tail[i]
const opts = flat.opts[i] ?? {}
const sin = stdin(next.options)
const stream = Stream.unwrap(Effect.map(handle, (x) => source(x, opts.from)))
const to = opts.to ?? "stdin"
if (to === "stdin") {
handle = spawnCommand(
ChildProcess.make(next.command, next.args, {
...next.options,
stdin: { ...sin, stream },
}),
)
continue
}
const fd = ChildProcess.parseFdName(to)
if (Predicate.isUndefined(fd)) {
handle = spawnCommand(
ChildProcess.make(next.command, next.args, {
...next.options,
stdin: { ...sin, stream },
}),
)
continue
}
handle = spawnCommand(
ChildProcess.make(next.command, next.args, {
...next.options,
additionalFds: {
...next.options.additionalFds,
[ChildProcess.fdName(fd) as `fd${number}`]: { type: "input", stream },
},
}),
)
}
return yield* handle
}
}
},
)
return makeSpawner(spawnCommand)
})
export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
ChildProcessSpawner,
make,
)

View file

@ -1,6 +1,7 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { makeRunPromise } from "@/effect/run-service" import { makeRunPromise } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
@ -340,7 +341,7 @@ export namespace Installation {
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeChildProcessSpawner.layer), Layer.provide(CrossSpawnSpawner.layer),
Layer.provide(NodeFileSystem.layer), Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer), Layer.provide(NodePath.layer),
) )

View file

@ -11,6 +11,7 @@ import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot" import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRunPromise } from "@/effect/run-service"
@ -44,7 +45,7 @@ export namespace Plugin {
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
// Built-in plugins that are directly imported (not installed from npm) // Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin] const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
// Old npm package names for plugins that are now built-in — skip if users still have them in config // Old npm package names for plugins that are now built-in — skip if users still have them in config
const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"] const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
@ -136,7 +137,11 @@ export namespace Plugin {
// Notify plugins of current config // Notify plugins of current config
for (const hook of hooks) { for (const hook of hooks) {
await (hook as any).config?.(cfg) try {
await (hook as any).config?.(cfg)
} catch (err) {
log.error("plugin config hook failed", { error: err })
}
} }
}) })

View file

@ -1,36 +1,23 @@
import z from "zod" import z from "zod"
import { Filesystem } from "../util/filesystem"
import path from "path"
import { and, Database, eq } from "../storage/db" import { and, Database, eq } from "../storage/db"
import { ProjectTable } from "./project.sql" import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql" import { SessionTable } from "../session/session.sql"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { fn } from "@opencode-ai/util/fn"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
import { git } from "../util/git"
import { Glob } from "../util/glob"
import { which } from "../util/which" import { which } from "../util/which"
import { ProjectID } from "./schema" import { ProjectID } from "./schema"
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRunPromise } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
export namespace Project { export namespace Project {
const log = Log.create({ service: "project" }) const log = Log.create({ service: "project" })
function gitpath(cwd: string, name: string) {
if (!name) return cwd
// git output includes trailing newlines; keep path whitespace intact.
name = name.replace(/[\r\n]+$/, "")
if (!name) return cwd
name = Filesystem.windowsPath(name)
if (path.isAbsolute(name)) return path.normalize(name)
return path.resolve(cwd, name)
}
export const Info = z export const Info = z
.object({ .object({
id: ProjectID.zod, id: ProjectID.zod,
@ -73,7 +60,7 @@ export namespace Project {
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
: undefined : undefined
return { return {
id: ProjectID.make(row.id), id: row.id,
worktree: row.worktree, worktree: row.worktree,
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
name: row.name ?? undefined, name: row.name ?? undefined,
@ -88,245 +75,405 @@ export namespace Project {
} }
} }
function readCachedId(dir: string) { export const UpdateInput = z.object({
return Filesystem.readText(path.join(dir, "opencode")) projectID: ProjectID.zod,
.then((x) => x.trim()) name: z.string().optional(),
.then(ProjectID.make) icon: Info.shape.icon.optional(),
.catch(() => undefined) commands: Info.shape.commands.optional(),
})
export type UpdateInput = z.infer<typeof UpdateInput>
// ---------------------------------------------------------------------------
// Effect service
// ---------------------------------------------------------------------------
export interface Interface {
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
readonly discover: (input: Info) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
readonly update: (input: UpdateInput) => Effect.Effect<Info>
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
} }
export async function fromDirectory(directory: string) { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Project") {}
log.info("fromDirectory", { directory })
const data = await iife(async () => { type GitResult = { code: number; text: string; stderr: string }
const matches = Filesystem.up({ targets: [".git"], start: directory })
const dotgit = await matches.next().then((x) => x.value)
await matches.return()
if (dotgit) {
let sandbox = path.dirname(dotgit)
const gitBinary = which("git") export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
// cached id calculation const git = Effect.fnUntraced(
let id = await readCachedId(dotgit) function* (args: string[], opts?: { cwd?: string }) {
const handle = yield* spawner.spawn(
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
)
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
)
if (!gitBinary) { const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
return { Effect.sync(() => Database.use(fn))
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
const worktree = await git(["rev-parse", "--git-common-dir"], { const emitUpdated = (data: Info) =>
cwd: sandbox, Effect.sync(() =>
}) GlobalBus.emit("event", {
.then(async (result) => { payload: { type: Event.Updated.type, properties: data },
const common = gitpath(sandbox, await result.text()) }),
// Avoid going to parent of sandbox when git-common-dir is empty. )
return common === sandbox ? sandbox : path.dirname(common)
})
.catch(() => undefined)
if (!worktree) { const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
// In the case of a git worktree, it can't cache the id const resolveGitPath = (cwd: string, name: string) => {
// because `.git` is not a folder, but it always needs the if (!name) return cwd
// same project id as the common dir, so we resolve it now name = name.replace(/[\r\n]+$/, "")
if (id == null) { if (!name) return cwd
id = await readCachedId(path.join(worktree, ".git")) name = AppFileSystem.windowsPath(name)
} if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
return pathSvc.resolve(cwd, name)
}
// generate id from root commit const scope = yield* Scope.Scope
if (!id) {
const roots = await git(["rev-list", "--max-parents=0", "HEAD"], {
cwd: sandbox,
})
.then(async (result) =>
(await result.text())
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
.catch(() => undefined)
if (!roots) { const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
return yield* fsys.readFileString(pathSvc.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map(ProjectID.make),
Effect.catch(() => Effect.succeed(undefined)),
)
})
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
log.info("fromDirectory", { directory })
// Phase 1: discover git info
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
const data: DiscoveryResult = yield* Effect.gen(function* () {
const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
const dotgit = dotgitMatches[0]
if (!dotgit) {
return { return {
id: ProjectID.global, id: ProjectID.global,
worktree: sandbox, worktree: "/",
sandbox, sandbox: "/",
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), vcs: fakeVcs,
} }
} }
id = roots[0] ? ProjectID.make(roots[0]) : undefined let sandbox = pathSvc.dirname(dotgit)
if (id) { const gitBinary = yield* Effect.sync(() => which("git"))
// Write to common dir so the cache is shared across worktrees. let id = yield* readCachedProjectId(dotgit)
await Filesystem.write(path.join(worktree, ".git", "opencode"), id).catch(() => undefined)
}
}
if (!id) { if (!gitBinary) {
return { return {
id: ProjectID.global, id: id ?? ProjectID.global,
worktree: sandbox, worktree: sandbox,
sandbox, sandbox,
vcs: "git", vcs: fakeVcs,
}
} }
}
const top = await git(["rev-parse", "--show-toplevel"], { const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
cwd: sandbox, if (commonDir.code !== 0) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
const worktree = (() => {
const common = resolveGitPath(sandbox, commonDir.text.trim())
return common === sandbox ? sandbox : pathSvc.dirname(common)
})()
if (id == null) {
id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
}
if (!id) {
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
const roots = revList.text
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted()
id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) {
yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
}
}
if (!id) {
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
}
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
if (topLevel.code !== 0) {
return {
id,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
return { id, sandbox, worktree, vcs: "git" as const }
}) })
.then(async (result) => gitpath(sandbox, await result.text()))
.catch(() => undefined)
if (!top) { // Phase 2: upsert
return { const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
id, const existing = row
worktree: sandbox, ? fromRow(row)
sandbox, : {
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), id: data.id,
} worktree: data.worktree,
} vcs: data.vcs,
sandboxes: [] as string[],
time: { created: Date.now(), updated: Date.now() },
}
sandbox = top if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY)
yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
return { const result: Info = {
id, ...existing,
sandbox,
worktree,
vcs: "git",
}
}
return {
id: ProjectID.global,
worktree: "/",
sandbox: "/",
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
})
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
const existing = row
? fromRow(row)
: {
id: data.id,
worktree: data.worktree, worktree: data.worktree,
vcs: data.vcs as Info["vcs"], vcs: data.vcs,
sandboxes: [] as string[], time: { ...existing.time, updated: Date.now() },
time: { }
created: Date.now(), if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
updated: Date.now(), result.sandboxes.push(data.sandbox)
}, result.sandboxes = yield* Effect.forEach(
result.sandboxes,
(s) =>
fsys.exists(s).pipe(
Effect.orDie,
Effect.map((exists) => (exists ? s : undefined)),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
yield* db((d) =>
d
.insert(ProjectTable)
.values({
id: result.id,
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_created: result.time.created,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
})
.onConflictDoUpdate({
target: ProjectTable.id,
set: {
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
},
})
.run(),
)
if (data.id !== ProjectID.global) {
yield* db((d) =>
d
.update(SessionTable)
.set({ project_id: data.id })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
.run(),
)
} }
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing) yield* emitUpdated(result)
return { project: result, sandbox: data.sandbox }
})
const result: Info = { const discover = Effect.fn("Project.discover")(function* (input: Info) {
...existing, if (input.vcs !== "git") return
worktree: data.worktree, if (input.icon?.override) return
vcs: data.vcs as Info["vcs"], if (input.icon?.url) return
time: {
...existing.time, const matches = yield* fsys
updated: Date.now(), .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
}, cwd: input.worktree,
} absolute: true,
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) include: "file",
result.sandboxes.push(data.sandbox) })
result.sandboxes = result.sandboxes.filter((x) => existsSync(x)) .pipe(Effect.orDie)
const insert = { const shortest = matches.sort((a, b) => a.length - b.length)[0]
id: result.id, if (!shortest) return
worktree: result.worktree,
vcs: result.vcs ?? null, const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie)
name: result.name, const base64 = Buffer.from(buffer).toString("base64")
icon_url: result.icon?.url, const mime = AppFileSystem.mimeType(shortest)
icon_color: result.icon?.color, const url = `data:${mime};base64,${base64}`
time_created: result.time.created, yield* update({ projectID: input.id, icon: { url } })
time_updated: result.time.updated, })
time_initialized: result.time.initialized,
sandboxes: result.sandboxes, const list = Effect.fn("Project.list")(function* () {
commands: result.commands, return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
} })
const updateSet = {
worktree: result.worktree, const get = Effect.fn("Project.get")(function* (id: ProjectID) {
vcs: result.vcs ?? null, const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
name: result.name, return row ? fromRow(row) : undefined
icon_url: result.icon?.url, })
icon_color: result.icon?.color,
time_updated: result.time.updated, const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
time_initialized: result.time.initialized, const result = yield* db((d) =>
sandboxes: result.sandboxes, d
commands: result.commands, .update(ProjectTable)
} .set({
Database.use((db) => name: input.name,
db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(), icon_url: input.icon?.url,
) icon_color: input.icon?.color,
// Runs after upsert so the target project row exists (FK constraint). commands: input.commands,
// Runs on every startup because sessions created before git init time_updated: Date.now(),
// accumulate under "global" and need migrating whenever they appear. })
if (data.id !== ProjectID.global) { .where(eq(ProjectTable.id, input.projectID))
Database.use((db) => .returning()
db .get(),
.update(SessionTable) )
.set({ project_id: data.id }) if (!result) throw new Error(`Project not found: ${input.projectID}`)
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree))) const data = fromRow(result)
.run(), yield* emitUpdated(data)
) return data
} })
GlobalBus.emit("event", {
payload: { const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
type: Event.Updated.type, if (input.project.vcs === "git") return input.project
properties: result, if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed")
}, const result = yield* git(["init", "--quiet"], { cwd: input.directory })
}) if (result.code !== 0) {
return { project: result, sandbox: data.sandbox } throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository")
}
const { project } = yield* fromDirectory(input.directory)
return project
})
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
yield* db((d) =>
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
})
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
return yield* Effect.forEach(
data.sandboxes,
(dir) =>
fsys.isDir(dir).pipe(
Effect.orDie,
Effect.map((ok) => (ok ? dir : undefined)),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
})
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = [...row.sandboxes]
if (!sboxes.includes(directory)) sboxes.push(directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = row.sandboxes.filter((s) => s !== directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
return Service.of({
fromDirectory,
discover,
list,
get,
update,
initGit,
setInitialized,
sandboxes,
addSandbox,
removeSandbox,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
const runPromise = makeRunPromise(Service, defaultLayer)
// ---------------------------------------------------------------------------
// Promise-based API (delegates to Effect service via runPromise)
// ---------------------------------------------------------------------------
export function fromDirectory(directory: string) {
return runPromise((svc) => svc.fromDirectory(directory))
} }
export async function discover(input: Info) { export function discover(input: Info) {
if (input.vcs !== "git") return return runPromise((svc) => svc.discover(input))
if (input.icon?.override) return
if (input.icon?.url) return
const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
cwd: input.worktree,
absolute: true,
include: "file",
})
const shortest = matches.sort((a, b) => a.length - b.length)[0]
if (!shortest) return
const buffer = await Filesystem.readBytes(shortest)
const base64 = buffer.toString("base64")
const mime = Filesystem.mimeType(shortest) || "image/png"
const url = `data:${mime};base64,${base64}`
await update({
projectID: input.id,
icon: {
url,
},
})
return
}
export function setInitialized(id: ProjectID) {
Database.use((db) =>
db
.update(ProjectTable)
.set({
time_initialized: Date.now(),
})
.where(eq(ProjectTable.id, id))
.run(),
)
} }
export function list() { export function list() {
@ -345,112 +492,29 @@ export namespace Project {
return fromRow(row) return fromRow(row)
} }
export async function initGit(input: { directory: string; project: Info }) { export function setInitialized(id: ProjectID) {
if (input.project.vcs === "git") return input.project Database.use((db) =>
if (!which("git")) throw new Error("Git is not installed") db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
const result = await git(["init", "--quiet"], {
cwd: input.directory,
})
if (result.exitCode !== 0) {
const text = result.stderr.toString().trim() || result.text().trim()
throw new Error(text || "Failed to initialize git repository")
}
return (await fromDirectory(input.directory)).project
}
export const update = fn(
z.object({
projectID: ProjectID.zod,
name: z.string().optional(),
icon: Info.shape.icon.optional(),
commands: Info.shape.commands.optional(),
}),
async (input) => {
const id = ProjectID.make(input.projectID)
const result = Database.use((db) =>
db
.update(ProjectTable)
.set({
name: input.name,
icon_url: input.icon?.url,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${input.projectID}`)
const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: data,
},
})
return data
},
)
export async function sandboxes(id: ProjectID) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
const valid: string[] = []
for (const dir of data.sandboxes) {
const s = Filesystem.stat(dir)
if (s?.isDirectory()) valid.push(dir)
}
return valid
}
export async function addSandbox(id: ProjectID, directory: string) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sandboxes = [...row.sandboxes]
if (!sandboxes.includes(directory)) sandboxes.push(directory)
const result = Database.use((db) =>
db
.update(ProjectTable)
.set({ sandboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
) )
if (!result) throw new Error(`Project not found: ${id}`)
const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: data,
},
})
return data
} }
export async function removeSandbox(id: ProjectID, directory: string) { export function initGit(input: { directory: string; project: Info }) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) return runPromise((svc) => svc.initGit(input))
if (!row) throw new Error(`Project not found: ${id}`) }
const sandboxes = row.sandboxes.filter((s) => s !== directory)
const result = Database.use((db) => export function update(input: UpdateInput) {
db return runPromise((svc) => svc.update(input))
.update(ProjectTable) }
.set({ sandboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id)) export function sandboxes(id: ProjectID) {
.returning() return runPromise((svc) => svc.sandboxes(id))
.get(), }
)
if (!result) throw new Error(`Project not found: ${id}`) export function addSandbox(id: ProjectID, directory: string) {
const data = fromRow(result) return runPromise((svc) => svc.addSandbox(id, directory))
GlobalBus.emit("event", { }
payload: {
type: Event.Updated.type, export function removeSandbox(id: ProjectID, directory: string) {
properties: data, return runPromise((svc) => svc.removeSandbox(id, directory))
},
})
return data
} }
} }

View file

@ -89,7 +89,7 @@ export namespace ModelsDev {
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {}) const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
if (result) return result if (result) return result
// @ts-ignore // @ts-ignore
const snapshot = await import("./models-snapshot") const snapshot = await import("./models-snapshot.js")
.then((m) => m.snapshot as Record<string, unknown>) .then((m) => m.snapshot as Record<string, unknown>)
.catch(() => undefined) .catch(() => undefined)
if (snapshot) return snapshot if (snapshot) return snapshot

View file

@ -194,7 +194,10 @@ export namespace ProviderTransform {
} }
for (const msg of unique([...system, ...final])) { for (const msg of unique([...system, ...final])) {
const useMessageLevelOptions = model.providerID === "anthropic" || model.providerID.includes("bedrock") const useMessageLevelOptions =
model.providerID === "anthropic" ||
model.providerID.includes("bedrock") ||
model.api.npm === "@ai-sdk/amazon-bedrock"
const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
if (shouldUseContentOptions) { if (shouldUseContentOptions) {

View file

@ -108,7 +108,7 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400), ...errors(400),
}, },
}), }),
validator("json", Worktree.create.schema), validator("json", Worktree.CreateInput.optional()),
async (c) => { async (c) => {
const body = c.req.valid("json") const body = c.req.valid("json")
const worktree = await Worktree.create(body) const worktree = await Worktree.create(body)
@ -155,7 +155,7 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400), ...errors(400),
}, },
}), }),
validator("json", Worktree.remove.schema), validator("json", Worktree.RemoveInput),
async (c) => { async (c) => {
const body = c.req.valid("json") const body = c.req.valid("json")
await Worktree.remove(body) await Worktree.remove(body)
@ -181,7 +181,7 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400), ...errors(400),
}, },
}), }),
validator("json", Worktree.reset.schema), validator("json", Worktree.ResetInput),
async (c) => { async (c) => {
const body = c.req.valid("json") const body = c.req.valid("json")
await Worktree.reset(body) await Worktree.reset(body)

View file

@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() =>
}, },
}), }),
validator("param", z.object({ projectID: ProjectID.zod })), validator("param", z.object({ projectID: ProjectID.zod })),
validator("json", Project.update.schema.omit({ projectID: true })), validator("json", Project.UpdateInput.omit({ projectID: true })),
async (c) => { async (c) => {
const projectID = c.req.valid("param").projectID const projectID = c.req.valid("param").projectID
const body = c.req.valid("json") const body = c.req.valid("json")

View file

@ -19,6 +19,8 @@ import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { Bus } from "../../bus"
import { NamedError } from "@opencode-ai/util/error"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@ -846,7 +848,13 @@ export const SessionRoutes = lazy(() =>
return stream(c, async () => { return stream(c, async () => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID }) 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(),
})
})
}) })
}, },
) )

View file

@ -113,17 +113,20 @@ export namespace LLM {
options.instructions = system.join("\n") options.instructions = system.join("\n")
} }
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
const messages = isOpenaiOauth const messages = isOpenaiOauth
? input.messages ? input.messages
: [ : isWorkflow
...system.map( ? input.messages
(x): ModelMessage => ({ : [
role: "system", ...system.map(
content: x, (x): ModelMessage => ({
}), role: "system",
), content: x,
...input.messages, }),
] ),
...input.messages,
]
const params = await Plugin.trigger( const params = await Plugin.trigger(
"chat.params", "chat.params",
@ -190,6 +193,7 @@ export namespace LLM {
// and results sent back over the WebSocket. // and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) { if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language const workflowModel = language
workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
const t = tools[toolName] const t = tools[toolName]
if (!t || !t.execute) { if (!t || !t.execute) {

View file

@ -418,6 +418,16 @@ export namespace SessionPrompt {
) )
let executionError: Error | undefined let executionError: Error | undefined
const taskAgent = await Agent.get(task.agent) const taskAgent = await Agent.get(task.agent)
if (!taskAgent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${task.agent}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID,
error: error.toObject(),
})
throw error
}
const taskCtx: Tool.Context = { const taskCtx: Tool.Context = {
agent: task.agent, agent: task.agent,
messageID: assistantMessage.id, messageID: assistantMessage.id,
@ -560,6 +570,16 @@ export namespace SessionPrompt {
// normal processing // normal processing
const agent = await Agent.get(lastUser.agent) const agent = await Agent.get(lastUser.agent)
if (!agent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${lastUser.agent}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID,
error: error.toObject(),
})
throw error
}
const maxSteps = agent.steps ?? Infinity const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps const isLastStep = step >= maxSteps
msgs = await insertReminders({ msgs = await insertReminders({
@ -964,7 +984,18 @@ export namespace SessionPrompt {
} }
async function createUserMessage(input: PromptInput) { async function createUserMessage(input: PromptInput) {
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) const agentName = input.agent || (await Agent.defaultAgent())
const agent = await Agent.get(agentName)
if (!agent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${agentName}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: error.toObject(),
})
throw error
}
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
const full = const full =
@ -1531,6 +1562,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
await SessionRevert.cleanup(session) await SessionRevert.cleanup(session)
} }
const agent = await Agent.get(input.agent) const agent = await Agent.get(input.agent)
if (!agent) {
const available = await Agent.list().then((agents) => agents.filter((a) => !a.hidden).map((a) => a.name))
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Agent not found: "${input.agent}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: error.toObject(),
})
throw error
}
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
const userMsg: MessageV2.User = { const userMsg: MessageV2.User = {
id: MessageID.ascending(), id: MessageID.ascending(),
@ -1783,7 +1824,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
log.info("command", input) log.info("command", input)
const command = await Command.get(input.command) const command = await Command.get(input.command)
if (!command) { if (!command) {
throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` }) const available = await Command.list().then((cmds) => cmds.map((c) => c.name))
const hint = available.length ? ` Available commands: ${available.join(", ")}` : ""
const error = new NamedError.Unknown({ message: `Command not found: "${input.command}".${hint}` })
Bus.publish(Session.Event.Error, {
sessionID: input.sessionID,
error: error.toObject(),
})
throw error
} }
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent()) const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())

View file

@ -1,8 +1,9 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect" import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path" import path from "path"
import z from "zod" import z from "zod"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRunPromise } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
@ -33,6 +34,7 @@ export namespace Snapshot {
const log = Log.create({ service: "snapshot" }) const log = Log.create({ service: "snapshot" })
const prune = "7.days" const prune = "7.days"
const limit = 2 * 1024 * 1024
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const cfg = ["-c", "core.autocrlf=false", ...core] const cfg = ["-c", "core.autocrlf=false", ...core]
const quote = [...cfg, "-c", "core.quotepath=false"] const quote = [...cfg, "-c", "core.quotepath=false"]
@ -122,20 +124,69 @@ export namespace Snapshot {
return file return file
}) })
const sync = Effect.fnUntraced(function* () { const sync = Effect.fnUntraced(function* (list: string[] = []) {
const file = yield* excludes() const file = yield* excludes()
const target = path.join(state.gitdir, "info", "exclude") const target = path.join(state.gitdir, "info", "exclude")
const text = [
file ? (yield* read(file)).trimEnd() : "",
...list.map((item) => `/${item.replaceAll("\\", "/")}`),
]
.filter(Boolean)
.join("\n")
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie) yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
if (!file) { yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
return
}
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
}) })
const add = Effect.fnUntraced(function* () { const add = Effect.fnUntraced(function* () {
yield* sync() yield* sync()
yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory }) const [diff, other] = yield* Effect.all(
[
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
cwd: state.directory,
}),
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
cwd: state.directory,
}),
],
{ concurrency: 2 },
)
if (diff.code !== 0 || other.code !== 0) {
log.warn("failed to list snapshot files", {
diffCode: diff.code,
diffStderr: diff.stderr,
otherCode: other.code,
otherStderr: other.stderr,
})
return
}
const tracked = diff.text.split("\0").filter(Boolean)
const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)]))
if (!all.length) return
const large = (yield* Effect.all(
all.map((item) =>
fs
.stat(path.join(state.directory, item))
.pipe(Effect.catch(() => Effect.void))
.pipe(
Effect.map((stat) => {
if (!stat || stat.type !== "File") return
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
return size > limit ? item : undefined
}),
),
),
{ concurrency: 8 },
)).filter((item): item is string => Boolean(item))
yield* sync(large)
const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
if (result.code !== 0) {
log.warn("failed to add snapshot files", {
exitCode: result.code,
stderr: result.stderr,
})
}
}) })
const cleanup = Effect.fnUntraced(function* () { const cleanup = Effect.fnUntraced(function* () {
@ -176,7 +227,7 @@ export namespace Snapshot {
const patch = Effect.fnUntraced(function* (hash: string) { const patch = Effect.fnUntraced(function* (hash: string) {
yield* add() yield* add()
const result = yield* git( const result = yield* git(
[...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
{ {
cwd: state.directory, cwd: state.directory,
}, },
@ -244,7 +295,7 @@ export namespace Snapshot {
const diff = Effect.fnUntraced(function* (hash: string) { const diff = Effect.fnUntraced(function* (hash: string) {
yield* add() yield* add()
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], { const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
cwd: state.worktree, cwd: state.worktree,
}) })
if (result.code !== 0) { if (result.code !== 0) {
@ -354,9 +405,9 @@ export namespace Snapshot {
) )
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(NodeChildProcessSpawner.layer), Layer.provide(CrossSpawnSpawner.layer),
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner Layer.provide(NodeFileSystem.layer), // needed by CrossSpawnSpawner
Layer.provide(NodePath.layer), Layer.provide(NodePath.layer),
) )

View file

@ -1,5 +1,3 @@
import fs from "fs/promises"
import path from "path"
import z from "zod" import z from "zod"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { Global } from "../global" import { Global } from "../global"
@ -9,12 +7,15 @@ import { Project } from "../project/project"
import { Database, eq } from "../storage/db" import { Database, eq } from "../storage/db"
import { ProjectTable } from "../project/project.sql" import { ProjectTable } from "../project/project.sql"
import type { ProjectID } from "../project/schema" import type { ProjectID } from "../project/schema"
import { fn } from "../util/fn"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Process } from "../util/process" import { Slug } from "@opencode-ai/util/slug"
import { git } from "../util/git"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRunPromise } from "@/effect/run-service"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
export namespace Worktree { export namespace Worktree {
const log = Log.create({ service: "worktree" }) const log = Log.create({ service: "worktree" })
@ -123,77 +124,7 @@ export namespace Worktree {
}), }),
) )
const ADJECTIVES = [ function slugify(input: string) {
"brave",
"calm",
"clever",
"cosmic",
"crisp",
"curious",
"eager",
"gentle",
"glowing",
"happy",
"hidden",
"jolly",
"kind",
"lucky",
"mighty",
"misty",
"neon",
"nimble",
"playful",
"proud",
"quick",
"quiet",
"shiny",
"silent",
"stellar",
"sunny",
"swift",
"tidy",
"witty",
] as const
const NOUNS = [
"cabin",
"cactus",
"canyon",
"circuit",
"comet",
"eagle",
"engine",
"falcon",
"forest",
"garden",
"harbor",
"island",
"knight",
"lagoon",
"meadow",
"moon",
"mountain",
"nebula",
"orchid",
"otter",
"panda",
"pixel",
"planet",
"river",
"rocket",
"sailor",
"squid",
"star",
"tiger",
"wizard",
"wolf",
] as const
function pick<const T extends readonly string[]>(list: T) {
return list[Math.floor(Math.random() * list.length)]
}
function slug(input: string) {
return input return input
.trim() .trim()
.toLowerCase() .toLowerCase()
@ -202,28 +133,8 @@ export namespace Worktree {
.replace(/-+$/, "") .replace(/-+$/, "")
} }
function randomName() { function failedRemoves(...chunks: string[]) {
return `${pick(ADJECTIVES)}-${pick(NOUNS)}` return chunks.filter(Boolean).flatMap((chunk) =>
}
async function exists(target: string) {
return fs
.stat(target)
.then(() => true)
.catch(() => false)
}
function outputText(input: Uint8Array | undefined) {
if (!input?.length) return ""
return new TextDecoder().decode(input).trim()
}
function errorText(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
}
function failed(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).flatMap((chunk) =>
chunk chunk
.split("\n") .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
@ -237,436 +148,484 @@ export namespace Worktree {
) )
} }
async function prune(root: string, entries: string[]) { // ---------------------------------------------------------------------------
const base = await canonical(root) // Effect service
await Promise.all( // ---------------------------------------------------------------------------
entries.map(async (entry) => {
const target = await canonical(path.resolve(root, entry)) export interface Interface {
if (target === base) return readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
if (!target.startsWith(`${base}${path.sep}`)) return readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
await fs.rm(target, { recursive: true, force: true }).catch(() => undefined) readonly create: (input?: CreateInput) => Effect.Effect<Info>
}), readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
) readonly reset: (input: ResetInput) => Effect.Effect<boolean>
} }
async function sweep(root: string) { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Worktree") {}
const first = await git(["clean", "-ffdx"], { cwd: root })
if (first.exitCode === 0) return first
const entries = failed(first) type GitResult = { code: number; text: string; stderr: string }
if (!entries.length) return first
await prune(root, entries) export const layer: Layer.Layer<
return git(["clean", "-ffdx"], { cwd: root }) Service,
} never,
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const scope = yield* Scope.Scope
const fsys = yield* FileSystem.FileSystem
const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
async function canonical(input: string) { const git = Effect.fnUntraced(
const abs = path.resolve(input) function* (args: string[], opts?: { cwd?: string }) {
const real = await fs.realpath(abs).catch(() => abs) const handle = yield* spawner.spawn(
const normalized = path.normalize(real) ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
return process.platform === "win32" ? normalized.toLowerCase() : normalized )
} const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch((e) =>
Effect.succeed({ code: 1, text: "", stderr: e instanceof Error ? e.message : String(e) } satisfies GitResult),
),
)
async function candidate(root: string, base?: string) { const MAX_NAME_ATTEMPTS = 26
for (const attempt of Array.from({ length: 26 }, (_, i) => i)) { const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName() for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
const branch = `opencode/${name}` const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create()
const directory = path.join(root, name) const branch = `opencode/${name}`
const directory = pathSvc.join(root, name)
if (await exists(directory)) continue if (yield* fsys.exists(directory).pipe(Effect.orDie)) continue
const ref = `refs/heads/${branch}` const ref = `refs/heads/${branch}`
const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], { const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree })
cwd: Instance.worktree, if (branchCheck.code === 0) continue
return Info.parse({ name, branch, directory })
}
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
}) })
if (branchCheck.exitCode === 0) continue
return Info.parse({ name, branch, directory }) const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
} if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" }) const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
} yield* fsys.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
async function runStartCommand(directory: string, cmd: string) { const base = name ? slugify(name) : ""
if (process.platform === "win32") { return yield* candidate(root, base || undefined)
return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
}
return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
}
type StartKind = "project" | "worktree"
async function runStartScript(directory: string, cmd: string, kind: StartKind) {
const text = cmd.trim()
if (!text) return true
const ran = await runStartCommand(directory, text)
if (ran.code === 0) return true
log.error("worktree start command failed", {
kind,
directory,
message: errorText(ran),
})
return false
}
async function runStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get())
const project = row ? Project.fromRow(row) : undefined
const startup = project?.commands?.start?.trim() ?? ""
const ok = await runStartScript(directory, startup, "project")
if (!ok) return false
const extra = input.extra ?? ""
await runStartScript(directory, extra, "worktree")
return true
}
function queueStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) {
setTimeout(() => {
const start = async () => {
await runStartScripts(directory, input)
}
void start().catch((error) => {
log.error("worktree start task failed", { directory, error })
}) })
}, 0)
}
export async function makeWorktreeInfo(name?: string): Promise<Info> { const setup = Effect.fnUntraced(function* (info: Info) {
if (Instance.project.vcs !== "git") { const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
throw new NotGitError({ message: "Worktrees are only supported for git projects" }) cwd: Instance.worktree,
} })
if (created.code !== 0) {
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
}
const root = path.join(Global.Path.data, "worktree", Instance.project.id) yield* Effect.promise(() => Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined))
await fs.mkdir(root, { recursive: true }) })
const base = name ? slug(name) : "" const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
return candidate(root, base || undefined) const projectID = Instance.project.id
} const extra = startCommand?.trim()
export async function createFromInfo(info: Info, startCommand?: string) { const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], { if (populated.code !== 0) {
cwd: Instance.worktree, const message = populated.stderr || populated.text || "Failed to populate worktree"
})
if (created.exitCode !== 0) {
throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
}
await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined)
const projectID = Instance.project.id
const extra = startCommand?.trim()
return () => {
const start = async () => {
const populated = await git(["reset", "--hard"], { cwd: info.directory })
if (populated.exitCode !== 0) {
const message = errorText(populated) || "Failed to populate worktree"
log.error("worktree checkout failed", { directory: info.directory, message }) log.error("worktree checkout failed", { directory: info.directory, message })
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: info.directory, directory: info.directory,
payload: { payload: { type: Event.Failed.type, properties: { message } },
type: Event.Failed.type,
properties: {
message,
},
},
}) })
return return
} }
const booted = await Instance.provide({ const booted = yield* Effect.promise(() =>
directory: info.directory, Instance.provide({
init: InstanceBootstrap, directory: info.directory,
fn: () => undefined, init: InstanceBootstrap,
}) fn: () => undefined,
.then(() => true)
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
log.error("worktree bootstrap failed", { directory: info.directory, message })
GlobalBus.emit("event", {
directory: info.directory,
payload: {
type: Event.Failed.type,
properties: {
message,
},
},
})
return false
}) })
.then(() => true)
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
log.error("worktree bootstrap failed", { directory: info.directory, message })
GlobalBus.emit("event", {
directory: info.directory,
payload: { type: Event.Failed.type, properties: { message } },
})
return false
}),
)
if (!booted) return if (!booted) return
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: info.directory, directory: info.directory,
payload: { payload: {
type: Event.Ready.type, type: Event.Ready.type,
properties: { properties: { name: info.name, branch: info.branch },
name: info.name,
branch: info.branch,
},
}, },
}) })
await runStartScripts(info.directory, { projectID, extra }) yield* runStartScripts(info.directory, { projectID, extra })
})
const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
yield* setup(info)
yield* boot(info, startCommand)
})
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
const info = yield* makeWorktreeInfo(input?.name)
yield* setup(info)
yield* boot(info, input?.startCommand).pipe(
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
Effect.forkIn(scope),
)
return info
})
const canonical = Effect.fnUntraced(function* (input: string) {
const abs = pathSvc.resolve(input)
const real = yield* fsys.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
const normalized = pathSvc.normalize(real)
return process.platform === "win32" ? normalized.toLowerCase() : normalized
})
function parseWorktreeList(text: string) {
return text
.split("\n")
.map((line) => line.trim())
.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
if (!line) return acc
if (line.startsWith("worktree ")) {
acc.push({ path: line.slice("worktree ".length).trim() })
return acc
}
const current = acc[acc.length - 1]
if (!current) return acc
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).trim()
}
return acc
}, [])
} }
return start().catch((error) => { const locateWorktree = Effect.fnUntraced(function* (
log.error("worktree start task failed", { directory: info.directory, error }) entries: { path?: string; branch?: string }[],
}) directory: string,
} ) {
}
export const create = fn(CreateInput.optional(), async (input) => {
const info = await makeWorktreeInfo(input?.name)
const bootstrap = await createFromInfo(info, input?.startCommand)
// This is needed due to how worktrees currently work in the
// desktop app
setTimeout(() => {
bootstrap()
}, 0)
return info
})
export const remove = fn(RemoveInput, async (input) => {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = await canonical(input.directory)
const locate = async (stdout: Uint8Array | undefined) => {
const lines = outputText(stdout)
.split("\n")
.map((line) => line.trim())
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
if (!line) return acc
if (line.startsWith("worktree ")) {
acc.push({ path: line.slice("worktree ".length).trim() })
return acc
}
const current = acc[acc.length - 1]
if (!current) return acc
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).trim()
}
return acc
}, [])
return (async () => {
for (const item of entries) { for (const item of entries) {
if (!item.path) continue if (!item.path) continue
const key = await canonical(item.path) const key = yield* canonical(item.path)
if (key === directory) return item if (key === directory) return item
} }
})() return undefined
} })
const clean = (target: string) => function stopFsmonitor(target: string) {
fs return fsys.exists(target).pipe(
.rm(target, { Effect.orDie,
recursive: true, Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
force: true, )
maxRetries: 5,
retryDelay: 100,
})
.catch((error) => {
const message = error instanceof Error ? error.message : String(error)
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
})
const stop = async (target: string) => {
if (!(await exists(target))) return
await git(["fsmonitor--daemon", "stop"], { cwd: target })
}
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
const entry = await locate(list.stdout)
if (!entry?.path) {
const directoryExists = await exists(directory)
if (directoryExists) {
await stop(directory)
await clean(directory)
}
return true
}
await stop(entry.path)
const removed = await git(["worktree", "remove", "--force", entry.path], {
cwd: Instance.worktree,
})
if (removed.exitCode !== 0) {
const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (next.exitCode !== 0) {
throw new RemoveFailedError({
message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
})
} }
const stale = await locate(next.stdout) function cleanDirectory(target: string) {
if (stale?.path) { return Effect.promise(() =>
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" }) import("fs/promises").then((fsp) =>
fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
),
)
} }
}
await clean(entry.path) const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const branch = entry.branch?.replace(/^refs\/heads\//, "") const directory = yield* canonical(input.directory)
if (branch) {
const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree })
if (deleted.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
}
}
return true const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
}) if (list.code !== 0) {
throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
}
export const reset = fn(ResetInput, async (input) => { const entries = parseWorktreeList(list.text)
if (Instance.project.vcs !== "git") { const entry = yield* locateWorktree(entries, directory)
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = await canonical(input.directory) if (!entry?.path) {
const primary = await canonical(Instance.worktree) const directoryExists = yield* fsys.exists(directory).pipe(Effect.orDie)
if (directory === primary) { if (directoryExists) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" }) yield* stopFsmonitor(directory)
} yield* cleanDirectory(directory)
}
return true
}
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) yield* stopFsmonitor(entry.path)
if (list.exitCode !== 0) { const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree })
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" }) if (removed.code !== 0) {
} const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (next.code !== 0) {
throw new RemoveFailedError({
message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
})
}
const lines = outputText(list.stdout) const stale = yield* locateWorktree(parseWorktreeList(next.text), directory)
.split("\n") if (stale?.path) {
.map((line) => line.trim()) throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => { }
if (!line) return acc }
if (line.startsWith("worktree ")) {
acc.push({ path: line.slice("worktree ".length).trim() })
return acc
}
const current = acc[acc.length - 1]
if (!current) return acc
if (line.startsWith("branch ")) {
current.branch = line.slice("branch ".length).trim()
}
return acc
}, [])
const entry = await (async () => { yield* cleanDirectory(entry.path)
for (const item of entries) {
if (!item.path) continue
const key = await canonical(item.path)
if (key === directory) return item
}
})()
if (!entry?.path) {
throw new ResetFailedError({ message: "Worktree not found" })
}
const remoteList = await git(["remote"], { cwd: Instance.worktree }) const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (remoteList.exitCode !== 0) { if (branch) {
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" }) const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree })
} if (deleted.code !== 0) {
throw new RemoveFailedError({
message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
})
}
}
const remotes = outputText(remoteList.stdout) return true
.split("\n") })
.map((line) => line.trim())
.filter(Boolean)
const remote = remotes.includes("origin") const gitExpect = Effect.fnUntraced(function* (
? "origin" args: string[],
: remotes.length === 1 opts: { cwd: string },
? remotes[0] error: (r: GitResult) => Error,
: remotes.includes("upstream") ) {
? "upstream" const result = yield* git(args, opts)
: "" if (result.code !== 0) throw error(result)
return result
})
const remoteHead = remote const runStartCommand = Effect.fnUntraced(
? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree }) function* (directory: string, cmd: string) {
: { exitCode: 1, stdout: undefined, stderr: undefined } const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]]
const handle = yield* spawner.spawn(
ChildProcess.make(shell, args, { cwd: directory, extendEnv: true, stdin: "ignore" }),
)
// Drain stdout, capture stderr for error reporting
const [, stderr] = yield* Effect.all(
[Stream.runDrain(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
).pipe(Effect.orDie)
const code = yield* handle.exitCode
return { code, stderr }
},
Effect.scoped,
Effect.catch(() => Effect.succeed({ code: 1, stderr: "" })),
)
const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : "" const runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) {
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : "" const text = cmd.trim()
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : "" if (!text) return true
const result = yield* runStartCommand(directory, text)
if (result.code === 0) return true
log.error("worktree start command failed", { kind, directory, message: result.stderr })
return false
})
const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { const runStartScripts = Effect.fnUntraced(function* (
cwd: Instance.worktree, directory: string,
}) input: { projectID: ProjectID; extra?: string },
const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { ) {
cwd: Instance.worktree, const row = yield* Effect.sync(() =>
}) Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()),
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : "" )
const project = row ? Project.fromRow(row) : undefined
const startup = project?.commands?.start?.trim() ?? ""
const ok = yield* runStartScript(directory, startup, "project")
if (!ok) return false
yield* runStartScript(directory, input.extra ?? "", "worktree")
return true
})
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch const prune = Effect.fnUntraced(function* (root: string, entries: string[]) {
if (!target) { const base = yield* canonical(root)
throw new ResetFailedError({ message: "Default branch not found" }) yield* Effect.forEach(
} entries,
(entry) =>
Effect.gen(function* () {
const target = yield* canonical(pathSvc.resolve(root, entry))
if (target === base) return
if (!target.startsWith(`${base}${pathSvc.sep}`)) return
yield* fsys.remove(target, { recursive: true }).pipe(Effect.ignore)
}),
{ concurrency: "unbounded" },
)
})
if (remoteBranch) { const sweep = Effect.fnUntraced(function* (root: string) {
const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree }) const first = yield* git(["clean", "-ffdx"], { cwd: root })
if (fetch.exitCode !== 0) { if (first.code === 0) return first
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
}
}
if (!entry.path) { const entries = failedRemoves(first.stderr, first.text)
throw new ResetFailedError({ message: "Worktree path not found" }) if (!entries.length) return first
}
const worktreePath = entry.path yield* prune(root, entries)
return yield* git(["clean", "-ffdx"], { cwd: root })
})
const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath }) const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
if (resetToTarget.exitCode !== 0) { if (Instance.project.vcs !== "git") {
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" }) throw new NotGitError({ message: "Worktrees are only supported for git projects" })
} }
const clean = await sweep(worktreePath) const directory = yield* canonical(input.directory)
if (clean.exitCode !== 0) { const primary = yield* canonical(Instance.worktree)
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" }) if (directory === primary) {
} throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath }) const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (update.exitCode !== 0) { if (list.code !== 0) {
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" }) throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
} }
const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], { const entry = yield* locateWorktree(parseWorktreeList(list.text), directory)
cwd: worktreePath, if (!entry?.path) {
}) throw new ResetFailedError({ message: "Worktree not found" })
if (subReset.exitCode !== 0) { }
throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
}
const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], { const worktreePath = entry.path
cwd: worktreePath,
})
if (subClean.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
}
const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath }) const remoteList = yield* git(["remote"], { cwd: Instance.worktree })
if (status.exitCode !== 0) { if (remoteList.code !== 0) {
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" }) throw new ResetFailedError({ message: remoteList.stderr || remoteList.text || "Failed to list git remotes" })
} }
const dirty = outputText(status.stdout) const remotes = remoteList.text
if (dirty) { .split("\n")
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` }) .map((l) => l.trim())
} .filter(Boolean)
const remote = remotes.includes("origin")
? "origin"
: remotes.length === 1
? remotes[0]
: remotes.includes("upstream")
? "upstream"
: ""
const projectID = Instance.project.id const remoteHead = remote
queueStartScripts(worktreePath, { projectID }) ? yield* git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
: { code: 1, text: "", stderr: "" }
return true const remoteRef = remoteHead.code === 0 ? remoteHead.text.trim() : ""
}) const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
const remoteBranch =
remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
const [mainCheck, masterCheck] = yield* Effect.all(
[
git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree }),
git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree }),
],
{ concurrency: 2 },
)
const localBranch = mainCheck.code === 0 ? "main" : masterCheck.code === 0 ? "master" : ""
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
if (!target) {
throw new ResetFailedError({ message: "Default branch not found" })
}
if (remoteBranch) {
yield* gitExpect(
["fetch", remote, remoteBranch],
{ cwd: Instance.worktree },
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${target}` }),
)
}
yield* gitExpect(
["reset", "--hard", target],
{ cwd: worktreePath },
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
)
const cleanResult = yield* sweep(worktreePath)
if (cleanResult.code !== 0) {
throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" })
}
yield* gitExpect(
["submodule", "update", "--init", "--recursive", "--force"],
{ cwd: worktreePath },
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to update submodules" }),
)
yield* gitExpect(
["submodule", "foreach", "--recursive", "git", "reset", "--hard"],
{ cwd: worktreePath },
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset submodules" }),
)
yield* gitExpect(
["submodule", "foreach", "--recursive", "git", "clean", "-fdx"],
{ cwd: worktreePath },
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to clean submodules" }),
)
const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
if (status.code !== 0) {
throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" })
}
if (status.text.trim()) {
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
}
yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe(
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))),
Effect.forkIn(scope),
)
return true
})
return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset })
}),
)
const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.layer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
const runPromise = makeRunPromise(Service, defaultLayer)
export async function makeWorktreeInfo(name?: string) {
return runPromise((svc) => svc.makeWorktreeInfo(name))
}
export async function createFromInfo(info: Info, startCommand?: string) {
return runPromise((svc) => svc.createFromInfo(info, startCommand))
}
export async function create(input?: CreateInput) {
return runPromise((svc) => svc.create(input))
}
export async function remove(input: RemoveInput) {
return runPromise((svc) => svc.remove(input))
}
export async function reset(input: ResetInput) {
return runPromise((svc) => svc.reset(input))
}
} }

View file

@ -0,0 +1,402 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { Effect, Exit, Layer, Stream } from "effect"
import type * as PlatformError from "effect/PlatformError"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { tmpdir } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const live = CrossSpawnSpawner.layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
const fx = testEffect(live)
function js(code: string, opts?: ChildProcess.CommandOptions) {
return ChildProcess.make("node", ["-e", code], opts)
}
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
return Stream.runCollect(stream).pipe(
Effect.map((chunks) => {
const total = chunks.reduce((acc, x) => acc + x.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const chunk of chunks) {
out.set(chunk, off)
off += chunk.length
}
return new TextDecoder("utf-8").decode(out).trim()
}),
)
}
function alive(pid: number) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function gone(pid: number, timeout = 5_000) {
const end = Date.now() + timeout
while (Date.now() < end) {
if (!alive(pid)) return true
await Bun.sleep(50)
}
return !alive(pid)
}
describe("cross-spawn spawner", () => {
describe("basic spawning", () => {
fx.effect(
"captures stdout",
Effect.gen(function* () {
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])),
)
expect(out).toBe("ok")
}),
)
fx.effect(
"captures multiple lines",
Effect.gen(function* () {
const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")')
const out = yield* decodeByteStream(handle.stdout)
expect(out).toBe("line1\nline2\nline3")
}),
)
fx.effect(
"returns exit code",
Effect.gen(function* () {
const handle = yield* js("process.exit(0)")
const code = yield* handle.exitCode
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
fx.effect(
"returns non-zero exit code",
Effect.gen(function* () {
const handle = yield* js("process.exit(42)")
const code = yield* handle.exitCode
expect(code).toBe(ChildProcessSpawner.ExitCode(42))
}),
)
})
describe("cwd option", () => {
fx.effect(
"uses cwd when spawning commands",
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.string(
ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }),
),
)
expect(out).toBe(tmp.path)
}),
)
fx.effect(
"fails for invalid cwd",
Effect.gen(function* () {
const exit = yield* Effect.exit(
ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(),
)
expect(Exit.isFailure(exit)).toBe(true)
}),
)
})
describe("env option", () => {
fx.effect(
"passes environment variables with extendEnv",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', {
env: { TEST_VAR: "test_value" },
extendEnv: true,
})
const out = yield* decodeByteStream(handle.stdout)
expect(out).toBe("test_value")
}),
)
fx.effect(
"passes multiple environment variables",
Effect.gen(function* () {
const handle = yield* js(
"process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)",
{
env: { VAR1: "one", VAR2: "two", VAR3: "three" },
extendEnv: true,
},
)
const out = yield* decodeByteStream(handle.stdout)
expect(out).toBe("one-two-three")
}),
)
})
describe("stderr", () => {
fx.effect(
"captures stderr output",
Effect.gen(function* () {
const handle = yield* js('process.stderr.write("error message")')
const err = yield* decodeByteStream(handle.stderr)
expect(err).toBe("error message")
}),
)
fx.effect(
"captures both stdout and stderr",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")')
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
expect(stdout).toBe("stdout")
expect(stderr).toBe("stderr")
}),
)
})
describe("combined output (all)", () => {
fx.effect(
"captures stdout via .all when no stderr",
Effect.gen(function* () {
const handle = yield* ChildProcess.make("echo", ["hello from stdout"])
const all = yield* decodeByteStream(handle.all)
expect(all).toBe("hello from stdout")
}),
)
fx.effect(
"captures stderr via .all when no stdout",
Effect.gen(function* () {
const handle = yield* js('process.stderr.write("hello from stderr")')
const all = yield* decodeByteStream(handle.all)
expect(all).toBe("hello from stderr")
}),
)
})
describe("stdin", () => {
fx.effect(
"allows providing standard input to a command",
Effect.gen(function* () {
const input = "a b c"
const stdin = Stream.make(Buffer.from(input, "utf-8"))
const handle = yield* js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
{ stdin },
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toBe("a b c")
}),
)
})
describe("process control", () => {
fx.effect(
"kills a running process",
Effect.gen(function* () {
const exit = yield* Effect.exit(
Effect.gen(function* () {
const handle = yield* js("setTimeout(() => {}, 10_000)")
yield* handle.kill()
return yield* handle.exitCode
}),
)
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
}),
)
fx.effect(
"kills a child when scope exits",
Effect.gen(function* () {
const pid = yield* Effect.scoped(
Effect.gen(function* () {
const handle = yield* js("setInterval(() => {}, 10_000)")
return Number(handle.pid)
}),
)
const done = yield* Effect.promise(() => gone(pid))
expect(done).toBe(true)
}),
)
fx.effect(
"forceKillAfter escalates for stubborn processes",
Effect.gen(function* () {
if (process.platform === "win32") return
const started = Date.now()
const exit = yield* Effect.exit(
Effect.gen(function* () {
const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)')
yield* handle.kill({ forceKillAfter: 100 })
return yield* handle.exitCode
}),
)
expect(Date.now() - started).toBeLessThan(1_000)
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
}),
)
fx.effect(
"isRunning reflects process state",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("done")')
yield* handle.exitCode
const running = yield* handle.isRunning
expect(running).toBe(false)
}),
)
})
describe("error handling", () => {
fx.effect(
"fails for invalid command",
Effect.gen(function* () {
const exit = yield* Effect.exit(
Effect.gen(function* () {
const handle = yield* ChildProcess.make("nonexistent-command-12345")
return yield* handle.exitCode
}),
)
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
}),
)
})
describe("pipeline", () => {
fx.effect(
"pipes stdout of one command to stdin of another",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("hello world")').pipe(
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
),
),
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toBe("HELLO WORLD")
}),
)
fx.effect(
"three-stage pipeline",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("hello world")').pipe(
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
),
),
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))',
),
),
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toBe("HELLO-WORLD")
}),
)
fx.effect(
"pipes stderr with { from: 'stderr' }",
Effect.gen(function* () {
const handle = yield* js('process.stderr.write("error")').pipe(
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
),
{ from: "stderr" },
),
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toBe("error")
}),
)
fx.effect(
"pipes combined output with { from: 'all' }",
Effect.gen(function* () {
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe(
ChildProcess.pipeTo(
js(
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
),
{ from: "all" },
),
)
const out = yield* decodeByteStream(handle.stdout)
yield* handle.exitCode
expect(out).toContain("stdout")
expect(out).toContain("stderr")
}),
)
})
describe("Windows-specific", () => {
fx.effect(
"uses shell routing on Windows",
Effect.gen(function* () {
if (process.platform !== "win32") return
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.string(
ChildProcess.make("set", ["OPENCODE_TEST_SHELL"], {
shell: true,
extendEnv: true,
env: { OPENCODE_TEST_SHELL: "ok" },
}),
),
)
expect(out).toContain("OPENCODE_TEST_SHELL=ok")
}),
)
fx.effect(
"runs cmd scripts with spaces on Windows without shell",
Effect.gen(function* () {
if (process.platform !== "win32") return
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const dir = path.join(tmp.path, "with space")
const file = path.join(dir, "echo cmd.cmd")
yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
yield* Effect.promise(() => Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n"))
const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.exitCode(
ChildProcess.make(file, ["--stdio"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
}),
),
)
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
}),
)
})
})

View file

@ -54,3 +54,19 @@ describe("plugin.auth-override", () => {
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth") expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
}, 30000) // Increased timeout for plugin installation }, 30000) // Increased timeout for plugin installation
}) })
const file = path.join(import.meta.dir, "../../src/plugin/index.ts")
describe("plugin.config-hook-error-isolation", () => {
test("config hooks are individually error-isolated in the layer factory", async () => {
const src = await Bun.file(file).text()
// The config hook try/catch lives in the InstanceState factory (layer definition),
// not in init() which now just delegates to the Effect service.
expect(src).toContain("plugin config hook failed")
const pattern =
/for\s*\(const hook of hooks\)\s*\{[\s\S]*?try\s*\{[\s\S]*?\.config\?\.\([\s\S]*?\}\s*catch\s*\(err\)\s*\{[\s\S]*?plugin config hook failed[\s\S]*?\}/
expect(pattern.test(src)).toBe(true)
})
})

View file

@ -1,78 +1,69 @@
import { describe, expect, mock, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Project } from "../../src/project/project" import { Project } from "../../src/project/project"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
import { $ } from "bun" import { $ } from "bun"
import path from "path" import path from "path"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { Filesystem } from "../../src/util/filesystem"
import { GlobalBus } from "../../src/bus/global" import { GlobalBus } from "../../src/bus/global"
import { ProjectID } from "../../src/project/schema" import { ProjectID } from "../../src/project/schema"
import { Effect, Layer, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { AppFileSystem } from "../../src/filesystem"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
Log.init({ print: false }) Log.init({ print: false })
const gitModule = await import("../../src/util/git") const encoder = new TextEncoder()
const originalGit = gitModule.git
type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail" /**
let mode: Mode = "none" * Creates a mock ChildProcessSpawner layer that intercepts git subcommands
* matching `failArg` and returns exit code 128, while delegating everything
mock.module("../../src/util/git", () => ({ * else to the real CrossSpawnSpawner.
git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => { */
const cmd = ["git", ...args].join(" ") function mockGitFailure(failArg: string) {
if ( return Layer.effect(
mode === "rev-list-fail" && ChildProcessSpawner.ChildProcessSpawner,
cmd.includes("git rev-list") && Effect.gen(function* () {
cmd.includes("--max-parents=0") && const real = yield* ChildProcessSpawner.ChildProcessSpawner
cmd.includes("HEAD") return ChildProcessSpawner.make(
) { Effect.fnUntraced(function* (command) {
return Promise.resolve({ const std = ChildProcess.isStandardCommand(command) ? command : undefined
exitCode: 128, if (std?.command === "git" && std.args.some((a) => a === failArg)) {
text: () => Promise.resolve(""), return ChildProcessSpawner.makeHandle({
stdout: Buffer.from(""), pid: ChildProcessSpawner.ProcessId(0),
stderr: Buffer.from("fatal"), exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
}) isRunning: Effect.succeed(false),
} kill: () => Effect.void,
if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) { stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
return Promise.resolve({ stdout: Stream.empty,
exitCode: 128, stderr: Stream.make(encoder.encode("fatal: simulated failure\n")),
text: () => Promise.resolve(""), all: Stream.empty,
stdout: Buffer.from(""), getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
stderr: Buffer.from("fatal"), getOutputFd: () => Stream.empty,
}) })
} }
if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) { return yield* real.spawn(command)
return Promise.resolve({ }),
exitCode: 128, )
text: () => Promise.resolve(""), }),
stdout: Buffer.from(""), ).pipe(Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
stderr: Buffer.from("fatal"),
})
}
return originalGit(args, opts)
},
}))
async function withMode(next: Mode, run: () => Promise<void>) {
const prev = mode
mode = next
try {
await run()
} finally {
mode = prev
}
} }
async function loadProject() { function projectLayerWithFailure(failArg: string) {
return (await import("../../src/project/project")).Project return Project.layer.pipe(
Layer.provide(mockGitFailure(failArg)),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodePath.layer),
)
} }
describe("Project.fromDirectory", () => { describe("Project.fromDirectory", () => {
test("should handle git repository with no commits", async () => { test("should handle git repository with no commits", async () => {
const p = await loadProject()
await using tmp = await tmpdir() await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet() await $`git init`.cwd(tmp.path).quiet()
const { project } = await p.fromDirectory(tmp.path) const { project } = await Project.fromDirectory(tmp.path)
expect(project).toBeDefined() expect(project).toBeDefined()
expect(project.id).toBe(ProjectID.global) expect(project.id).toBe(ProjectID.global)
@ -80,15 +71,13 @@ describe("Project.fromDirectory", () => {
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
const opencodeFile = path.join(tmp.path, ".git", "opencode") const opencodeFile = path.join(tmp.path, ".git", "opencode")
const fileExists = await Filesystem.exists(opencodeFile) expect(await Bun.file(opencodeFile).exists()).toBe(false)
expect(fileExists).toBe(false)
}) })
test("should handle git repository with commits", async () => { test("should handle git repository with commits", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await p.fromDirectory(tmp.path) const { project } = await Project.fromDirectory(tmp.path)
expect(project).toBeDefined() expect(project).toBeDefined()
expect(project.id).not.toBe(ProjectID.global) expect(project.id).not.toBe(ProjectID.global)
@ -96,54 +85,63 @@ describe("Project.fromDirectory", () => {
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
const opencodeFile = path.join(tmp.path, ".git", "opencode") const opencodeFile = path.join(tmp.path, ".git", "opencode")
const fileExists = await Filesystem.exists(opencodeFile) expect(await Bun.file(opencodeFile).exists()).toBe(true)
expect(fileExists).toBe(true)
}) })
test("keeps git vcs when rev-list exits non-zero with empty output", async () => { test("returns global for non-git directory", async () => {
const p = await loadProject() await using tmp = await tmpdir()
const { project } = await Project.fromDirectory(tmp.path)
expect(project.id).toBe(ProjectID.global)
})
test("derives stable project ID from root commit", async () => {
await using tmp = await tmpdir({ git: true })
const { project: a } = await Project.fromDirectory(tmp.path)
const { project: b } = await Project.fromDirectory(tmp.path)
expect(b.id).toBe(a.id)
})
})
describe("Project.fromDirectory git failure paths", () => {
test("keeps vcs when rev-list exits non-zero (no commits)", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await $`git init`.cwd(tmp.path).quiet() await $`git init`.cwd(tmp.path).quiet()
await withMode("rev-list-fail", async () => { // rev-list fails because HEAD doesn't exist yet — this is the natural scenario
const { project } = await p.fromDirectory(tmp.path) const { project } = await Project.fromDirectory(tmp.path)
expect(project.vcs).toBe("git") expect(project.vcs).toBe("git")
expect(project.id).toBe(ProjectID.global) expect(project.id).toBe(ProjectID.global)
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
})
}) })
test("keeps git vcs when show-toplevel exits non-zero with empty output", async () => { test("handles show-toplevel failure gracefully", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--show-toplevel")
await withMode("top-fail", async () => { const { project, sandbox } = await Effect.runPromise(
const { project, sandbox } = await p.fromDirectory(tmp.path) Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
expect(project.vcs).toBe("git") )
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path) expect(sandbox).toBe(tmp.path)
})
}) })
test("keeps git vcs when git-common-dir exits non-zero with empty output", async () => { test("handles git-common-dir failure gracefully", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const layer = projectLayerWithFailure("--git-common-dir")
await withMode("common-dir-fail", async () => { const { project, sandbox } = await Effect.runPromise(
const { project, sandbox } = await p.fromDirectory(tmp.path) Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
expect(project.vcs).toBe("git") )
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path) expect(sandbox).toBe(tmp.path)
})
}) })
}) })
describe("Project.fromDirectory with worktrees", () => { describe("Project.fromDirectory with worktrees", () => {
test("should set worktree to root when called from root", async () => { test("should set worktree to root when called from root", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project, sandbox } = await p.fromDirectory(tmp.path) const { project, sandbox } = await Project.fromDirectory(tmp.path)
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(tmp.path) expect(sandbox).toBe(tmp.path)
@ -151,14 +149,13 @@ describe("Project.fromDirectory with worktrees", () => {
}) })
test("should set worktree to root when called from a worktree", async () => { test("should set worktree to root when called from a worktree", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree") const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree")
try { try {
await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet() await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
const { project, sandbox } = await p.fromDirectory(worktreePath) const { project, sandbox } = await Project.fromDirectory(worktreePath)
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(sandbox).toBe(worktreePath) expect(sandbox).toBe(worktreePath)
@ -173,22 +170,21 @@ describe("Project.fromDirectory with worktrees", () => {
}) })
test("worktree should share project ID with main repo", async () => { test("worktree should share project ID with main repo", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project: main } = await p.fromDirectory(tmp.path) const { project: main } = await Project.fromDirectory(tmp.path)
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared") const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
try { try {
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet() await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
const { project: wt } = await p.fromDirectory(worktreePath) const { project: wt } = await Project.fromDirectory(worktreePath)
expect(wt.id).toBe(main.id) expect(wt.id).toBe(main.id)
// Cache should live in the common .git dir, not the worktree's .git file // Cache should live in the common .git dir, not the worktree's .git file
const cache = path.join(tmp.path, ".git", "opencode") const cache = path.join(tmp.path, ".git", "opencode")
const exists = await Filesystem.exists(cache) const exists = await Bun.file(cache).exists()
expect(exists).toBe(true) expect(exists).toBe(true)
} finally { } finally {
await $`git worktree remove ${worktreePath}` await $`git worktree remove ${worktreePath}`
@ -199,7 +195,6 @@ describe("Project.fromDirectory with worktrees", () => {
}) })
test("separate clones of the same repo should share project ID", async () => { test("separate clones of the same repo should share project ID", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
// Create a bare remote, push, then clone into a second directory // Create a bare remote, push, then clone into a second directory
@ -209,8 +204,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git clone --bare ${tmp.path} ${bare}`.quiet() await $`git clone --bare ${tmp.path} ${bare}`.quiet()
await $`git clone ${bare} ${clone}`.quiet() await $`git clone ${bare} ${clone}`.quiet()
const { project: a } = await p.fromDirectory(tmp.path) const { project: a } = await Project.fromDirectory(tmp.path)
const { project: b } = await p.fromDirectory(clone) const { project: b } = await Project.fromDirectory(clone)
expect(b.id).toBe(a.id) expect(b.id).toBe(a.id)
} finally { } finally {
@ -219,7 +214,6 @@ describe("Project.fromDirectory with worktrees", () => {
}) })
test("should accumulate multiple worktrees in sandboxes", async () => { test("should accumulate multiple worktrees in sandboxes", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1") const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1")
@ -228,8 +222,8 @@ describe("Project.fromDirectory with worktrees", () => {
await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet() await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet() await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
await p.fromDirectory(worktree1) await Project.fromDirectory(worktree1)
const { project } = await p.fromDirectory(worktree2) const { project } = await Project.fromDirectory(worktree2)
expect(project.worktree).toBe(tmp.path) expect(project.worktree).toBe(tmp.path)
expect(project.sandboxes).toContain(worktree1) expect(project.sandboxes).toContain(worktree1)
@ -250,14 +244,13 @@ describe("Project.fromDirectory with worktrees", () => {
describe("Project.discover", () => { describe("Project.discover", () => {
test("should discover favicon.png in root", async () => { test("should discover favicon.png in root", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await p.fromDirectory(tmp.path) const { project } = await Project.fromDirectory(tmp.path)
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
await Bun.write(path.join(tmp.path, "favicon.png"), pngData) await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
await p.discover(project) await Project.discover(project)
const updated = Project.get(project.id) const updated = Project.get(project.id)
expect(updated).toBeDefined() expect(updated).toBeDefined()
@ -268,13 +261,12 @@ describe("Project.discover", () => {
}) })
test("should not discover non-image files", async () => { test("should not discover non-image files", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await p.fromDirectory(tmp.path) const { project } = await Project.fromDirectory(tmp.path)
await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image") await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")
await p.discover(project) await Project.discover(project)
const updated = Project.get(project.id) const updated = Project.get(project.id)
expect(updated).toBeDefined() expect(updated).toBeDefined()
@ -344,8 +336,6 @@ describe("Project.update", () => {
}) })
test("should throw error when project not found", async () => { test("should throw error when project not found", async () => {
await using tmp = await tmpdir({ git: true })
await expect( await expect(
Project.update({ Project.update({
projectID: ProjectID.make("nonexistent-project-id"), projectID: ProjectID.make("nonexistent-project-id"),
@ -358,22 +348,24 @@ describe("Project.update", () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path) const { project } = await Project.fromDirectory(tmp.path)
let eventFired = false
let eventPayload: any = null let eventPayload: any = null
const on = (data: any) => {
GlobalBus.on("event", (data) => {
eventFired = true
eventPayload = data eventPayload = data
}) }
GlobalBus.on("event", on)
await Project.update({ try {
projectID: project.id, await Project.update({
name: "Updated Name", projectID: project.id,
}) name: "Updated Name",
})
expect(eventFired).toBe(true) expect(eventPayload).not.toBeNull()
expect(eventPayload.payload.type).toBe("project.updated") expect(eventPayload.payload.type).toBe("project.updated")
expect(eventPayload.payload.properties.name).toBe("Updated Name") expect(eventPayload.payload.properties.name).toBe("Updated Name")
} finally {
GlobalBus.off("event", on)
}
}) })
test("should update multiple fields at once", async () => { test("should update multiple fields at once", async () => {
@ -393,3 +385,75 @@ describe("Project.update", () => {
expect(updated.commands?.start).toBe("make start") expect(updated.commands?.start).toBe("make start")
}) })
}) })
describe("Project.list and Project.get", () => {
test("list returns all projects", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const all = Project.list()
expect(all.length).toBeGreaterThan(0)
expect(all.find((p) => p.id === project.id)).toBeDefined()
})
test("get returns project by id", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const found = Project.get(project.id)
expect(found).toBeDefined()
expect(found!.id).toBe(project.id)
})
test("get returns undefined for unknown id", () => {
const found = Project.get(ProjectID.make("nonexistent"))
expect(found).toBeUndefined()
})
})
describe("Project.setInitialized", () => {
test("sets time_initialized on project", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
expect(project.time.initialized).toBeUndefined()
Project.setInitialized(project.id)
const updated = Project.get(project.id)
expect(updated?.time.initialized).toBeDefined()
})
})
describe("Project.addSandbox and Project.removeSandbox", () => {
test("addSandbox adds directory and removeSandbox removes it", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const sandboxDir = path.join(tmp.path, "sandbox-test")
await Project.addSandbox(project.id, sandboxDir)
let found = Project.get(project.id)
expect(found?.sandboxes).toContain(sandboxDir)
await Project.removeSandbox(project.id, sandboxDir)
found = Project.get(project.id)
expect(found?.sandboxes).not.toContain(sandboxDir)
})
test("addSandbox emits GlobalBus event", async () => {
await using tmp = await tmpdir({ git: true })
const { project } = await Project.fromDirectory(tmp.path)
const sandboxDir = path.join(tmp.path, "sandbox-event")
const events: any[] = []
const on = (evt: any) => events.push(evt)
GlobalBus.on("event", on)
await Project.addSandbox(project.id, sandboxDir)
GlobalBus.off("event", on)
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
})
})

View file

@ -0,0 +1,173 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
const wintest = process.platform !== "win32" ? test : test.skip
import fs from "fs/promises"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Worktree } from "../../src/worktree"
import { tmpdir } from "../fixture/fixture"
function withInstance(directory: string, fn: () => Promise<any>) {
return Instance.provide({ directory, fn })
}
function normalize(input: string) {
return input.replace(/\\/g, "/").toLowerCase()
}
async function waitReady() {
const { GlobalBus } = await import("../../src/bus/global")
return await new Promise<{ name: string; branch: string }>((resolve, reject) => {
const timer = setTimeout(() => {
GlobalBus.off("event", on)
reject(new Error("timed out waiting for worktree.ready"))
}, 10_000)
function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch: string } } }) {
if (evt.payload.type !== Worktree.Event.Ready.type) return
clearTimeout(timer)
GlobalBus.off("event", on)
resolve(evt.payload.properties)
}
GlobalBus.on("event", on)
})
}
describe("Worktree", () => {
afterEach(() => Instance.disposeAll())
describe("makeWorktreeInfo", () => {
test("returns info with name, branch, and directory", async () => {
await using tmp = await tmpdir({ git: true })
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo())
expect(info.name).toBeDefined()
expect(typeof info.name).toBe("string")
expect(info.branch).toBe(`opencode/${info.name}`)
expect(info.directory).toContain(info.name)
})
test("uses provided name as base", async () => {
await using tmp = await tmpdir({ git: true })
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("my-feature"))
expect(info.name).toBe("my-feature")
expect(info.branch).toBe("opencode/my-feature")
})
test("slugifies the provided name", async () => {
await using tmp = await tmpdir({ git: true })
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("My Feature Branch!"))
expect(info.name).toBe("my-feature-branch")
})
test("throws NotGitError for non-git directories", async () => {
await using tmp = await tmpdir()
await expect(withInstance(tmp.path, () => Worktree.makeWorktreeInfo())).rejects.toThrow("WorktreeNotGitError")
})
})
describe("create + remove lifecycle", () => {
test("create returns worktree info and remove cleans up", async () => {
await using tmp = await tmpdir({ git: true })
const info = await withInstance(tmp.path, () => Worktree.create())
expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/")
expect(info.directory).toBeDefined()
// Wait for bootstrap to complete
await Bun.sleep(1000)
const ok = await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
expect(ok).toBe(true)
})
test("create returns after setup and fires Event.Ready after bootstrap", async () => {
await using tmp = await tmpdir({ git: true })
const ready = waitReady()
const info = await withInstance(tmp.path, () => Worktree.create())
// create returns before bootstrap completes, but the worktree already exists
expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/")
const text = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
const dir = await fs.realpath(info.directory).catch(() => info.directory)
expect(normalize(text)).toContain(normalize(dir))
// Event.Ready fires after bootstrap finishes in the background
const props = await ready
expect(props.name).toBe(info.name)
expect(props.branch).toBe(info.branch)
// Cleanup
await withInstance(info.directory, () => Instance.dispose())
await Bun.sleep(100)
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
test("create with custom name", async () => {
await using tmp = await tmpdir({ git: true })
const ready = waitReady()
const info = await withInstance(tmp.path, () => Worktree.create({ name: "test-workspace" }))
expect(info.name).toBe("test-workspace")
expect(info.branch).toBe("opencode/test-workspace")
// Cleanup
await ready
await withInstance(info.directory, () => Instance.dispose())
await Bun.sleep(100)
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
})
describe("createFromInfo", () => {
wintest("creates and bootstraps git worktree", async () => {
await using tmp = await tmpdir({ git: true })
const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("from-info-test"))
await withInstance(tmp.path, () => Worktree.createFromInfo(info))
// Worktree should exist in git (normalize slashes for Windows)
const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
const normalizedList = list.replace(/\\/g, "/")
const normalizedDir = info.directory.replace(/\\/g, "/")
expect(normalizedList).toContain(normalizedDir)
// Cleanup
await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
})
describe("remove edge cases", () => {
test("remove non-existent directory succeeds silently", async () => {
await using tmp = await tmpdir({ git: true })
const ok = await withInstance(tmp.path, () =>
Worktree.remove({ directory: path.join(tmp.path, "does-not-exist") }),
)
expect(ok).toBe(true)
})
test("throws NotGitError for non-git directories", async () => {
await using tmp = await tmpdir()
await expect(withInstance(tmp.path, () => Worktree.remove({ directory: "/tmp/fake" }))).rejects.toThrow(
"WorktreeNotGitError",
)
})
})
})

View file

@ -1629,6 +1629,43 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile"
}) })
}) })
describe("ProviderTransform.message - bedrock caching with non-bedrock providerID", () => {
test("applies cache options at message level when npm package is amazon-bedrock", () => {
const model = {
id: "aws/us.anthropic.claude-opus-4-6-v1",
providerID: "aws",
api: {
id: "us.anthropic.claude-opus-4-6-v1",
url: "https://bedrock-runtime.us-east-1.amazonaws.com",
npm: "@ai-sdk/amazon-bedrock",
},
name: "Claude Opus 4.6",
capabilities: {},
options: {},
headers: {},
} as any
const msgs = [
{
role: "system",
content: [{ type: "text", text: "You are a helpful assistant" }],
},
{
role: "user",
content: [{ type: "text", text: "Hello" }],
},
] as any[]
const result = ProviderTransform.message(msgs, model, {}) as any[]
// Cache should be at the message level and not the content-part level
expect(result[0].providerOptions?.bedrock).toEqual({
cachePoint: { type: "default" },
})
expect(result[0].content[0].providerOptions?.bedrock).toBeUndefined()
})
})
describe("ProviderTransform.message - cache control on gateway", () => { describe("ProviderTransform.message - cache control on gateway", () => {
const createModel = (overrides: Partial<any> = {}) => const createModel = (overrides: Partial<any> = {}) =>
({ ({

View file

@ -117,3 +117,16 @@ describe("session messages endpoint", () => {
}) })
}) })
}) })
describe("session.prompt_async error handling", () => {
test("prompt_async route has error handler for detached prompt call", async () => {
const src = await Bun.file(path.join(import.meta.dir, "../../src/server/routes/session.ts")).text()
const start = src.indexOf('"/:sessionID/prompt_async"')
const end = src.indexOf('"/:sessionID/command"', start)
expect(start).toBeGreaterThan(-1)
expect(end).toBeGreaterThan(start)
const route = src.slice(start, end)
expect(route).toContain(".catch(")
expect(route).toContain("Bus.publish(Session.Event.Error")
})
})

View file

@ -1,5 +1,6 @@
import path from "path" import path from "path"
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { NamedError } from "@opencode-ai/util/error"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
@ -210,3 +211,78 @@ describe("session.prompt agent variant", () => {
} }
}) })
}) })
describe("session.agent-resolution", () => {
test("unknown agent throws typed error", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}).then(
() => undefined,
(e) => e,
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"')
}
},
})
}, 30000)
test("unknown agent error includes available agent names", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.prompt({
sessionID: session.id,
agent: "nonexistent-agent-xyz",
noReply: true,
parts: [{ type: "text", text: "hello" }],
}).then(
() => undefined,
(e) => e,
)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain("build")
}
},
})
}, 30000)
test("unknown command throws typed error with available names", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const err = await SessionPrompt.command({
sessionID: session.id,
command: "nonexistent-command-xyz",
arguments: "",
}).then(
() => undefined,
(e) => e,
)
expect(err).toBeDefined()
expect(err).not.toBeInstanceOf(TypeError)
expect(NamedError.Unknown.isInstance(err)).toBe(true)
if (NamedError.Unknown.isInstance(err)) {
expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"')
expect(err.data.message).toContain("init")
}
},
})
}, 30000)
})

View file

@ -181,7 +181,7 @@ test("symlink handling", async () => {
}) })
}) })
test("large file handling", async () => { test("file under size limit handling", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide({ await Instance.provide({
directory: tmp.path, directory: tmp.path,
@ -196,6 +196,23 @@ test("large file handling", async () => {
}) })
}) })
test("large added files are skipped", async () => {
await using tmp = await bootstrap()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Filesystem.write(`${tmp.path}/huge.txt`, new Uint8Array(2 * 1024 * 1024 + 1))
expect((await Snapshot.patch(before!)).files).toEqual([])
expect(await Snapshot.diff(before!)).toBe("")
expect(await Snapshot.track()).toBe(before)
},
})
})
test("nested directory revert", async () => { test("nested directory revert", async () => {
await using tmp = await bootstrap() await using tmp = await bootstrap()
await Instance.provide({ await Instance.provide({

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.3.0", "version": "1.3.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.3.0", "version": "1.3.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.3.0", "version": "1.3.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.3.0", "version": "1.3.2",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {

View file

@ -142,6 +142,7 @@ export function SessionTurn(
props: ParentProps<{ props: ParentProps<{
sessionID: string sessionID: string
messageID: string messageID: string
messages?: MessageType[]
actions?: UserActions actions?: UserActions
showReasoningSummaries?: boolean showReasoningSummaries?: boolean
shellToolDefaultOpen?: boolean shellToolDefaultOpen?: boolean
@ -166,7 +167,7 @@ export function SessionTurn(
const emptyDiffs: FileDiff[] = [] const emptyDiffs: FileDiff[] = []
const idle = { type: "idle" as const } const idle = { type: "idle" as const }
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages)) const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages))
const messageIndex = createMemo(() => { const messageIndex = createMemo(() => {
const messages = allMessages() ?? emptyMessages const messages = allMessages() ?? emptyMessages
@ -340,30 +341,28 @@ export function SessionTurn(
if (end < start) return undefined if (end < start) return undefined
return end - start return end - start
}) })
const assistantVisible = createMemo(() => const assistantDerived = createMemo(() => {
assistantMessages().reduce((count, message) => { let visible = 0
const parts = list(data.store.part?.[message.id], emptyParts) let tail: "text" | "other" | undefined
return count + parts.filter((part) => partState(part, showReasoningSummaries()) === "visible").length let reason: string | undefined
}, 0), const show = showReasoningSummaries()
) for (const message of assistantMessages()) {
const assistantTailVisible = createMemo(() => for (const part of list(data.store.part?.[message.id], emptyParts)) {
assistantMessages() if (partState(part, show) === "visible") {
.flatMap((message) => list(data.store.part?.[message.id], emptyParts)) visible++
.flatMap((part) => { tail = part.type === "text" ? "text" : "other"
if (partState(part, showReasoningSummaries()) !== "visible") return [] }
if (part.type === "text") return ["text" as const] if (part.type === "reasoning" && part.text) {
return ["other" as const] const h = heading(part.text)
}) if (h) reason = h
.at(-1), }
) }
const reasoningHeading = createMemo(() => }
assistantMessages() return { visible, tail, reason }
.flatMap((message) => list(data.store.part?.[message.id], emptyParts)) })
.filter((part): part is PartType & { type: "reasoning"; text: string } => part.type === "reasoning") const assistantVisible = createMemo(() => assistantDerived().visible)
.map((part) => heading(part.text)) const assistantTailVisible = createMemo(() => assistantDerived().tail)
.filter((text): text is string => !!text) const reasoningHeading = createMemo(() => assistantDerived().reason)
.at(-1),
)
const showThinking = createMemo(() => { const showThinking = createMemo(() => {
if (!working() || !!error()) return false if (!working() || !!error()) return false
if (status().type === "retry") return false if (status().type === "retry") return false

View file

@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.3.0", "version": "1.3.2",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",

View file

@ -2,7 +2,7 @@
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "1.3.0", "version": "1.3.2",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View file

@ -50,11 +50,32 @@ async function cleanup() {
} catch {} } catch {}
} }
async function fix(pr: PR, files: string[]) { async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: number) {
console.log(` Trying to auto-resolve ${files.length} conflict(s) with opencode...`) console.log(` Trying to auto-resolve ${files.length} conflict(s) with opencode...`)
const done =
prs
.filter((x) => applied.includes(x.number))
.map((x) => `- #${x.number}: ${x.title}`)
.join("\n") || "(none yet)"
const next =
prs
.slice(idx + 1)
.map((x) => `- #${x.number}: ${x.title}`)
.join("\n") || "(none)"
const prompt = [ const prompt = [
`Resolve the current git merge conflicts while merging PR #${pr.number} into the beta branch.`, `Resolve the current git merge conflicts while merging PR #${pr.number} into the beta branch.`,
`PR #${pr.number}: ${pr.title}`,
`Only touch these files: ${files.join(", ")}.`, `Only touch these files: ${files.join(", ")}.`,
`Merged PRs on HEAD:\n${done}`,
`Pending PRs after this one (context only):\n${next}`,
"IMPORTANT: The conflict resolution must be consistent with already-merged PRs.",
"Pending PRs are context only; do not introduce their changes unless they are already present on HEAD.",
"Prefer already-merged PRs over the base branch when resolving stacked conflicts.",
"If a PR already deleted a file/directory, do not re-add it, instead apply changes in the new semantic location.",
"If a PR already changed an import, keep that change.",
"Keep the merge in progress, do not abort the merge, and do not create a commit.", "Keep the merge in progress, do not abort the merge, and do not create a commit.",
"When done, leave the working tree with no unmerged files.", "When done, leave the working tree with no unmerged files.",
].join("\n") ].join("\n")
@ -99,7 +120,7 @@ async function main() {
const applied: number[] = [] const applied: number[] = []
const failed: FailedPR[] = [] const failed: FailedPR[] = []
for (const pr of prs) { for (const [idx, pr] of prs.entries()) {
console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) console.log(`\nProcessing PR #${pr.number}: ${pr.title}`)
console.log(" Fetching PR head...") console.log(" Fetching PR head...")
@ -119,7 +140,7 @@ async function main() {
const files = await conflicts() const files = await conflicts()
if (files.length > 0) { if (files.length > 0) {
console.log(" Failed to merge (conflicts)") console.log(" Failed to merge (conflicts)")
if (!(await fix(pr, files))) { if (!(await fix(pr, files, prs, applied, idx))) {
await cleanup() await cleanup()
failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" })
await commentOnPR(pr.number, "Merge conflicts with dev branch") await commentOnPR(pr.number, "Merge conflicts with dev branch")

97
script/github/close-issues.ts Executable file
View file

@ -0,0 +1,97 @@
#!/usr/bin/env bun
const repo = "anomalyco/opencode"
const days = 60
const msg =
"To stay organized issues are automatically closed after 90 days of no activity. If the issue is still relevant please open a new one."
const token = process.env.GITHUB_TOKEN
if (!token) {
console.error("GITHUB_TOKEN environment variable is required")
process.exit(1)
}
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
type Issue = {
number: number
updated_at: string
}
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async function close(num: number) {
const base = `https://api.github.com/repos/${repo}/issues/${num}`
const comment = await fetch(`${base}/comments`, {
method: "POST",
headers,
body: JSON.stringify({ body: msg }),
})
if (!comment.ok) throw new Error(`Failed to comment #${num}: ${comment.status} ${comment.statusText}`)
const patch = await fetch(base, {
method: "PATCH",
headers,
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
})
if (!patch.ok) throw new Error(`Failed to close #${num}: ${patch.status} ${patch.statusText}`)
console.log(`Closed https://github.com/${repo}/issues/${num}`)
}
async function main() {
let page = 1
let closed = 0
while (true) {
const res = await fetch(
`https://api.github.com/repos/${repo}/issues?state=open&sort=updated&direction=asc&per_page=100&page=${page}`,
{ headers },
)
if (!res.ok) throw new Error(res.statusText)
const all = (await res.json()) as Issue[]
if (all.length === 0) break
console.log(`Fetched page ${page} ${all.length} issues`)
const stale: number[] = []
for (const i of all) {
const updated = new Date(i.updated_at)
if (updated < cutoff) {
stale.push(i.number)
} else {
console.log(`\nFound fresh issue #${i.number}, stopping`)
if (stale.length > 0) {
for (const num of stale) {
await close(num)
closed++
}
}
console.log(`Closed ${closed} issues total`)
return
}
}
if (stale.length > 0) {
for (const num of stale) {
await close(num)
closed++
}
}
page++
}
console.log(`Closed ${closed} issues total`)
}
main().catch((err) => {
console.error("Error:", err)
process.exit(1)
})

View file

@ -2,18 +2,19 @@
import { Script } from "@opencode-ai/script" import { Script } from "@opencode-ai/script"
import { $ } from "bun" import { $ } from "bun"
import { buildNotes, getLatestRelease } from "./changelog"
const output = [`version=${Script.version}`] const output = [`version=${Script.version}`]
if (!Script.preview) { if (!Script.preview) {
const previous = await getLatestRelease() await $`opencode run --command changelog`.cwd(process.cwd())
const notes = await buildNotes(previous, "HEAD") const file = `${process.cwd()}/UPCOMING_CHANGELOG.md`
const body = notes.join("\n") || "No notable changes" const body = await Bun.file(file)
.text()
.catch(() => "No notable changes")
const dir = process.env.RUNNER_TEMP ?? "/tmp" const dir = process.env.RUNNER_TEMP ?? "/tmp"
const file = `${dir}/opencode-release-notes.txt` const notesFile = `${dir}/opencode-release-notes.txt`
await Bun.write(file, body) await Bun.write(notesFile, body)
await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes-file ${file}` await $`gh release create v${Script.version} -d --title "v${Script.version}" --notes-file ${notesFile}`
const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json() const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json()
output.push(`release=${release.databaseId}`) output.push(`release=${release.databaseId}`)
output.push(`tag=${release.tagName}`) output.push(`tag=${release.tagName}`)

View file

@ -2,7 +2,7 @@
"name": "opencode", "name": "opencode",
"displayName": "opencode", "displayName": "opencode",
"description": "opencode for VS Code", "description": "opencode for VS Code",
"version": "1.3.0", "version": "1.3.2",
"publisher": "sst-dev", "publisher": "sst-dev",
"repository": { "repository": {
"type": "git", "type": "git",