Merge branch 'dev' into brendan/cli-codesign
This commit is contained in:
commit
28cb8efa4b
73 changed files with 3240 additions and 1490 deletions
24
.github/workflows/close-issues.yml
vendored
Normal file
24
.github/workflows/close-issues.yml
vendored
Normal 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
|
||||||
2
.github/workflows/nix-hashes.yml
vendored
2
.github/workflows/nix-hashes.yml
vendored
|
|
@ -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}"
|
||||||
|
|
|
||||||
33
.github/workflows/stale-issues.yml
vendored
33
.github/workflows/stale-issues.yml
vendored
|
|
@ -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"
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
45
bun.lock
45
bun.lock
|
|
@ -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
6
flake.lock
generated
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)" }}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:",
|
||||||
|
|
|
||||||
|
|
@ -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 = (
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||||
message: store,
|
message: store,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
process.on("SIGHUP", () => exit())
|
||||||
return exit
|
return exit
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
476
packages/opencode/src/effect/cross-spawn-spawner.ts
Normal file
476
packages/opencode/src/effect/cross-spawn-spawner.ts
Normal 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,
|
||||||
|
)
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
402
packages/opencode/test/effect/cross-spawn-spawner.test.ts
Normal file
402
packages/opencode/test/effect/cross-spawn-spawner.test.ts
Normal 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))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
173
packages/opencode/test/project/worktree.test.ts
Normal file
173
packages/opencode/test/project/worktree.test.ts
Normal 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",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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> = {}) =>
|
||||||
({
|
({
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
97
script/github/close-issues.ts
Executable 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)
|
||||||
|
})
|
||||||
|
|
@ -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}`)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue