mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ccbf8983d |
@@ -1,24 +0,0 @@
|
||||
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
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||
|
||||
# Extract hash from build log with portability
|
||||
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||
HASH="$(grep -oE 'got:\s*sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||
|
||||
if [ -z "$HASH" ]; then
|
||||
echo "::error::Failed to compute hash for ${SYSTEM}"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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,21 +1,5 @@
|
||||
---
|
||||
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
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved
|
||||
|
||||
+1
-1
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||
|
||||
---
|
||||
|
||||
**加入我们的社区** [飞书](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)
|
||||
**加入我们的社区** [飞书](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)
|
||||
|
||||
+1
-1
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
|
||||
---
|
||||
|
||||
**加入我們的社群** [飞书](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)
|
||||
**加入我們的社群** [飞书](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)
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -79,7 +79,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -140,7 +140,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -164,7 +164,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -188,7 +188,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -221,7 +221,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -281,7 +281,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -358,7 +358,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "5.3.2",
|
||||
"gitlab-ai-provider": "5.3.1",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -370,7 +370,6 @@
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.0",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
@@ -422,7 +421,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -446,7 +445,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -457,7 +456,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -492,7 +491,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -538,7 +537,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -549,7 +548,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -3037,7 +3036,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"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=="],
|
||||
"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=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
@@ -3793,8 +3792,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
@@ -3927,8 +3924,6 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
@@ -5549,8 +5544,6 @@
|
||||
|
||||
"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/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
|
||||
@@ -6303,8 +6296,6 @@
|
||||
|
||||
"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-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=="],
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1773909469,
|
||||
"narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
|
||||
"lastModified": 1772091128,
|
||||
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7149c06513f335be57f26fcbbbe34afda923882b",
|
||||
"rev": "3f0336406035444b4a24b942788334af5f906259",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=",
|
||||
"aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=",
|
||||
"aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=",
|
||||
"x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg="
|
||||
"x86_64-linux": "got:sha256-nMERinypUtIZGfLlAS5meYrvH5tTl2SkdG3GUguhOos=",
|
||||
"aarch64-linux": "got:sha256-aQ42YVcjXSxpweA3e0SfJ8mnMWEqGeIOKg1cIhn8szA=",
|
||||
"aarch64-darwin": "got:sha256-OGtUfhKWTRqi8bYcqkvfb1RZa3iS0DVy5bbRry47Og4=",
|
||||
"x86_64-darwin": "got:sha256-kdzsr67cGduvGl+4UVdngiKNCaVw88WeMgx1ckVbG30="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -55,7 +55,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string;
|
||||
<Tooltip value={props.tip} placement="top">
|
||||
<div
|
||||
classList={{
|
||||
"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,
|
||||
"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,
|
||||
"col-span-2": !!props.wide,
|
||||
}}
|
||||
>
|
||||
@@ -363,7 +363,11 @@ export function DebugBar() {
|
||||
return (
|
||||
<aside
|
||||
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 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]"
|
||||
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]"
|
||||
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">
|
||||
<Cell
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
type Kind = "pendulum" | "compress" | "sort"
|
||||
|
||||
export type BrailleKind = Kind
|
||||
|
||||
const bits = [
|
||||
[0x01, 0x08],
|
||||
[0x02, 0x10],
|
||||
[0x04, 0x20],
|
||||
[0x40, 0x80],
|
||||
]
|
||||
|
||||
const seeded = (seed: number) => {
|
||||
let s = seed
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) & 0xffffffff
|
||||
return (s >>> 0) / 0xffffffff
|
||||
}
|
||||
}
|
||||
|
||||
const pendulum = (cols: number, max = 1) => {
|
||||
const total = 120
|
||||
const span = cols * 2
|
||||
const frames = [] as string[]
|
||||
|
||||
for (let t = 0; t < total; t++) {
|
||||
const codes = Array.from({ length: cols }, () => 0x2800)
|
||||
const p = t / total
|
||||
const spread = Math.sin(Math.PI * p) * max
|
||||
const phase = p * Math.PI * 8
|
||||
|
||||
for (let pc = 0; pc < span; pc++) {
|
||||
const swing = Math.sin(phase + pc * spread)
|
||||
const center = (1 - swing) * 1.5
|
||||
|
||||
for (let row = 0; row < 4; row++) {
|
||||
if (Math.abs(row - center) >= 0.7) continue
|
||||
codes[Math.floor(pc / 2)] |= bits[row][pc % 2]
|
||||
}
|
||||
}
|
||||
|
||||
frames.push(codes.map((code) => String.fromCharCode(code)).join(""))
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
const compress = (cols: number) => {
|
||||
const total = 100
|
||||
const span = cols * 2
|
||||
const dots = span * 4
|
||||
const frames = [] as string[]
|
||||
const rand = seeded(42)
|
||||
const weight = Array.from({ length: dots }, () => rand())
|
||||
|
||||
for (let t = 0; t < total; t++) {
|
||||
const codes = Array.from({ length: cols }, () => 0x2800)
|
||||
const p = t / total
|
||||
const sieve = Math.max(0.1, 1 - p * 1.2)
|
||||
const squeeze = Math.min(1, p / 0.85)
|
||||
const active = Math.max(1, span * (1 - squeeze * 0.95))
|
||||
|
||||
for (let pc = 0; pc < span; pc++) {
|
||||
const map = (pc / span) * active
|
||||
if (map >= active) continue
|
||||
|
||||
const next = Math.round(map)
|
||||
if (next >= span) continue
|
||||
|
||||
const char = Math.floor(next / 2)
|
||||
const dot = next % 2
|
||||
|
||||
for (let row = 0; row < 4; row++) {
|
||||
if (weight[pc * 4 + row] >= sieve) continue
|
||||
codes[char] |= bits[row][dot]
|
||||
}
|
||||
}
|
||||
|
||||
frames.push(codes.map((code) => String.fromCharCode(code)).join(""))
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
const sort = (cols: number) => {
|
||||
const span = cols * 2
|
||||
const total = 100
|
||||
const frames = [] as string[]
|
||||
const rand = seeded(19)
|
||||
const start = Array.from({ length: span }, () => rand() * 3)
|
||||
const end = Array.from({ length: span }, (_, i) => (i / Math.max(1, span - 1)) * 3)
|
||||
|
||||
for (let t = 0; t < total; t++) {
|
||||
const codes = Array.from({ length: cols }, () => 0x2800)
|
||||
const p = t / total
|
||||
const cursor = p * span * 1.2
|
||||
|
||||
for (let pc = 0; pc < span; pc++) {
|
||||
const char = Math.floor(pc / 2)
|
||||
const dot = pc % 2
|
||||
const delta = pc - cursor
|
||||
let center
|
||||
|
||||
if (delta < -3) {
|
||||
center = end[pc]
|
||||
} else if (delta < 2) {
|
||||
const blend = 1 - (delta + 3) / 5
|
||||
const ease = blend * blend * (3 - 2 * blend)
|
||||
center = start[pc] + (end[pc] - start[pc]) * ease
|
||||
if (Math.abs(delta) < 0.8) {
|
||||
for (let row = 0; row < 4; row++) codes[char] |= bits[row][dot]
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
center = start[pc] + Math.sin(p * Math.PI * 16 + pc * 2.7) * 0.6 + Math.sin(p * Math.PI * 9 + pc * 1.3) * 0.4
|
||||
}
|
||||
|
||||
center = Math.max(0, Math.min(3, center))
|
||||
for (let row = 0; row < 4; row++) {
|
||||
if (Math.abs(row - center) >= 0.7) continue
|
||||
codes[char] |= bits[row][dot]
|
||||
}
|
||||
}
|
||||
|
||||
frames.push(codes.map((code) => String.fromCharCode(code)).join(""))
|
||||
}
|
||||
|
||||
return frames
|
||||
}
|
||||
|
||||
const build = (kind: Kind, cols: number) => {
|
||||
if (kind === "compress") return compress(cols)
|
||||
if (kind === "sort") return sort(cols)
|
||||
return pendulum(cols)
|
||||
}
|
||||
|
||||
const pace = (kind: Kind) => {
|
||||
if (kind === "pendulum") return 16
|
||||
return 40
|
||||
}
|
||||
|
||||
const cache = new Map<string, string[]>()
|
||||
|
||||
const get = (kind: Kind, cols: number) => {
|
||||
const key = `${kind}:${cols}`
|
||||
const saved = cache.get(key)
|
||||
if (saved) return saved
|
||||
const made = build(kind, cols)
|
||||
cache.set(key, made)
|
||||
return made
|
||||
}
|
||||
|
||||
export const getBrailleFrames = (kind: Kind, cols: number) => get(kind, cols)
|
||||
|
||||
export function Braille(props: {
|
||||
kind?: Kind
|
||||
cols?: number
|
||||
rate?: number
|
||||
class?: string
|
||||
classList?: ComponentProps<"span">["classList"]
|
||||
style?: ComponentProps<"span">["style"]
|
||||
label?: string
|
||||
}) {
|
||||
const kind = () => props.kind ?? "pendulum"
|
||||
const cols = () => props.cols ?? 2
|
||||
const rate = () => props.rate ?? 1
|
||||
const [idx, setIdx] = createSignal(0)
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const frames = get(kind(), cols())
|
||||
setIdx(0)
|
||||
const id = window.setInterval(
|
||||
() => {
|
||||
setIdx((idx) => (idx + 1) % frames.length)
|
||||
},
|
||||
Math.max(10, Math.round(pace(kind()) / rate())),
|
||||
)
|
||||
onCleanup(() => window.clearInterval(id))
|
||||
})
|
||||
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-label={props.label ?? "Loading"}
|
||||
class={props.class}
|
||||
classList={props.classList}
|
||||
style={props.style}
|
||||
>
|
||||
<span aria-hidden="true">{get(kind(), cols())[idx()]}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Pendulum(props: Omit<Parameters<typeof Braille>[0], "kind">) {
|
||||
return <Braille {...props} kind="pendulum" />
|
||||
}
|
||||
@@ -572,7 +572,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const open = recent()
|
||||
const seen = new Set(open)
|
||||
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
||||
if (!query.trim()) return [...agents, ...pinned]
|
||||
const paths = await files.searchFilesAndDirectories(query)
|
||||
const fileOptions: AtOption[] = paths
|
||||
.filter((path) => !seen.has(path))
|
||||
|
||||
@@ -32,25 +32,6 @@ describe("command keybind helpers", () => {
|
||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("matchKeybind supports bracket keys", () => {
|
||||
const keybinds = parseKeybind("mod+alt+[, mod+alt+]")
|
||||
const prev = keybinds[0]
|
||||
const next = keybinds[1]
|
||||
|
||||
expect(
|
||||
matchKeybind(
|
||||
keybinds,
|
||||
new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
matchKeybind(
|
||||
keybinds,
|
||||
new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("formatKeybind returns human readable output", () => {
|
||||
const display = formatKeybind("ctrl+alt+arrowup")
|
||||
|
||||
|
||||
@@ -31,47 +31,6 @@ type GlobalStore = {
|
||||
reload: undefined | "pending" | "complete"
|
||||
}
|
||||
|
||||
function waitForPaint() {
|
||||
return new Promise<void>((resolve) => {
|
||||
let done = false
|
||||
const finish = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(finish, 50)
|
||||
if (typeof requestAnimationFrame !== "function") return
|
||||
requestAnimationFrame(() => {
|
||||
clearTimeout(timer)
|
||||
finish()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function errors(list: PromiseSettledResult<unknown>[]) {
|
||||
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||
}
|
||||
|
||||
function runAll(list: Array<() => Promise<unknown>>) {
|
||||
return Promise.allSettled(list.map((item) => item()))
|
||||
}
|
||||
|
||||
function showErrors(input: {
|
||||
errors: unknown[]
|
||||
title: string
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
formatMoreCount: (count: number) => string
|
||||
}) {
|
||||
if (input.errors.length === 0) return
|
||||
const message = formatServerError(input.errors[0], input.translate)
|
||||
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.title,
|
||||
description: message + more,
|
||||
})
|
||||
}
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
globalSDK: OpencodeClient
|
||||
requestFailedTitle: string
|
||||
@@ -79,54 +38,45 @@ export async function bootstrapGlobal(input: {
|
||||
formatMoreCount: (count: number) => string
|
||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||
}) {
|
||||
const fast = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.path.get().then((x) => {
|
||||
input.setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.global.config.get().then((x) => {
|
||||
input.setGlobalStore("config", x.data!)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.provider.list().then((x) => {
|
||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
const tasks = [
|
||||
retry(() =>
|
||||
input.globalSDK.path.get().then((x) => {
|
||||
input.setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.global.config.get().then((x) => {
|
||||
input.setGlobalStore("config", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.project.list().then((x) => {
|
||||
const projects = (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
input.setGlobalStore("project", projects)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.globalSDK.provider.list().then((x) => {
|
||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const slow = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.globalSDK.project.list().then((x) => {
|
||||
const projects = (x.data ?? [])
|
||||
.filter((p) => !!p?.id)
|
||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||
.slice()
|
||||
.sort((a, b) => cmp(a.id, b.id))
|
||||
input.setGlobalStore("project", projects)
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
showErrors({
|
||||
errors: errors(await runAll(fast)),
|
||||
title: input.requestFailedTitle,
|
||||
translate: input.translate,
|
||||
formatMoreCount: input.formatMoreCount,
|
||||
})
|
||||
await waitForPaint()
|
||||
showErrors({
|
||||
errors: errors(await runAll(slow)),
|
||||
title: input.requestFailedTitle,
|
||||
translate: input.translate,
|
||||
formatMoreCount: input.formatMoreCount,
|
||||
})
|
||||
const results = await Promise.allSettled(tasks)
|
||||
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
|
||||
if (errors.length) {
|
||||
const message = formatServerError(errors[0], input.translate)
|
||||
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.requestFailedTitle,
|
||||
description: message + more,
|
||||
})
|
||||
}
|
||||
input.setGlobalStore("ready", true)
|
||||
}
|
||||
|
||||
@@ -169,113 +119,95 @@ export async function bootstrapDirectory(input: {
|
||||
}
|
||||
if (loading) input.setStore("status", "partial")
|
||||
|
||||
const fast = [
|
||||
() =>
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.path.get().then((x) => {
|
||||
input.setStore("path", x.data!)
|
||||
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.question.list().then((x) => {
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
]
|
||||
const results = await Promise.allSettled([
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||
retry(() =>
|
||||
input.sdk.provider.list().then((x) => {
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
|
||||
retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
retry(() =>
|
||||
input.sdk.path.get().then((x) => {
|
||||
input.setStore("path", x.data!)
|
||||
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
}),
|
||||
),
|
||||
retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||
retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
input.loadSessions(input.directory),
|
||||
retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
|
||||
retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
|
||||
retry(() =>
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
input.sdk.question.list().then((x) => {
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
),
|
||||
])
|
||||
|
||||
const slow = [
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.provider.list().then((x) => {
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
|
||||
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
|
||||
]
|
||||
|
||||
const errs = errors(await runAll(fast))
|
||||
if (errs.length > 0) {
|
||||
console.error("Failed to bootstrap instance", errs[0])
|
||||
const errors = results
|
||||
.filter((item): item is PromiseRejectedResult => item.status === "rejected")
|
||||
.map((item) => item.reason)
|
||||
if (errors.length > 0) {
|
||||
console.error("Failed to bootstrap instance", errors[0])
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(errs[0], input.translate),
|
||||
description: formatServerError(errors[0], input.translate),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await waitForPaint()
|
||||
const slowErrs = errors(await runAll(slow))
|
||||
if (slowErrs.length > 0) {
|
||||
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(slowErrs[0], input.translate),
|
||||
})
|
||||
}
|
||||
|
||||
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
||||
if (loading) input.setStore("status", "complete")
|
||||
}
|
||||
|
||||
@@ -118,11 +118,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const id = store.appearance?.font ?? defaultSettings.appearance.font
|
||||
if (id !== defaultSettings.appearance.font) {
|
||||
void loadFont().then((x) => x.ensureMonoFont(id))
|
||||
}
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
|
||||
void loadFont().then((x) => x.ensureMonoFont(store.appearance?.font))
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -180,8 +180,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return globalSync.child(directory)
|
||||
}
|
||||
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
|
||||
const initialMessagePageSize = 80
|
||||
const historyMessagePageSize = 200
|
||||
const messagePageSize = 200
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
@@ -464,7 +463,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
|
||||
if (cached && hasSession && !opts?.force) return
|
||||
|
||||
const limit = meta.limit[key] ?? initialMessagePageSize
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
const sessionReq =
|
||||
hasSession && !opts?.force
|
||||
? Promise.resolve()
|
||||
@@ -561,7 +560,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? historyMessagePageSize
|
||||
const step = count ?? messagePageSize
|
||||
if (meta.loading[key]) return
|
||||
if (meta.complete[key]) return
|
||||
const before = meta.cursor[key]
|
||||
|
||||
@@ -97,10 +97,15 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
|
||||
throw new Error(getRootNotFoundError())
|
||||
}
|
||||
|
||||
const localUrl = () =>
|
||||
`http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
|
||||
const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname)
|
||||
|
||||
const getCurrentUrl = () => {
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
if (location.hostname.includes("opencode.ai")) return localUrl()
|
||||
if (import.meta.env.DEV) return localUrl()
|
||||
if (isLocalHost()) return localUrl()
|
||||
return location.origin
|
||||
}
|
||||
|
||||
|
||||
@@ -113,14 +113,6 @@ export default function Home() {
|
||||
</ul>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={!sync.ready}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
|
||||
<Button class="px-3" onClick={chooseProject}>
|
||||
{language.t("command.project.open")}
|
||||
</Button>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
|
||||
<Icon name="folder-add-left" size="large" />
|
||||
|
||||
@@ -965,8 +965,6 @@ export default function Layout(props: ParentProps) {
|
||||
: projects[(index + offset + projects.length) % projects.length]
|
||||
if (!target) return
|
||||
|
||||
// warm up child store to prevent flicker
|
||||
globalSync.child(target.worktree)
|
||||
openProject(target.worktree)
|
||||
}
|
||||
|
||||
@@ -1820,9 +1818,6 @@ export default function Layout(props: ParentProps) {
|
||||
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>()
|
||||
|
||||
createEffect(
|
||||
@@ -2099,7 +2094,7 @@ export default function Layout(props: ParentProps) {
|
||||
"max-w-full overflow-hidden": panelProps.mobile,
|
||||
}}
|
||||
style={{
|
||||
width: panelProps.mobile ? undefined : `${panel()}px`,
|
||||
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`,
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
@@ -2162,11 +2157,9 @@ export default function Layout(props: ParentProps) {
|
||||
variant="ghost"
|
||||
data-action="project-menu"
|
||||
data-project={slug()}
|
||||
class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
|
||||
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
classList={{
|
||||
"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(),
|
||||
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile,
|
||||
}}
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
@@ -2391,7 +2384,7 @@ export default function Layout(props: ParentProps) {
|
||||
"absolute inset-y-0 left-0": true,
|
||||
"z-10": true,
|
||||
}}
|
||||
style={{ width: `${side()}px` }}
|
||||
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }}
|
||||
ref={(el) => {
|
||||
setState("nav", el)
|
||||
}}
|
||||
@@ -2406,29 +2399,24 @@ export default function Layout(props: ParentProps) {
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<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
|
||||
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)" }}
|
||||
@@ -2468,7 +2456,7 @@ export default function Layout(props: ParentProps) {
|
||||
!state.sizing,
|
||||
}}
|
||||
style={{
|
||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem",
|
||||
}}
|
||||
>
|
||||
<main
|
||||
@@ -2515,7 +2503,7 @@ export default function Layout(props: ParentProps) {
|
||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||
}}
|
||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }}
|
||||
>
|
||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HoverCard } from "@opencode-ai/ui/hover-card"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { MessageNav } from "@opencode-ai/ui/message-nav"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
@@ -14,7 +15,6 @@ import { useLanguage } from "@/context/language"
|
||||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { SpinnerLabHeader } from "@/pages/session/spinner-lab"
|
||||
import { messageAgentColor } from "@/utils/agent"
|
||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||
import { hasProjectPermissions } from "./helpers"
|
||||
@@ -104,7 +104,7 @@ const SessionRow = (props: {
|
||||
}): JSX.Element => (
|
||||
<A
|
||||
href={`/${props.slug}/session/${props.session.id}`}
|
||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
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"}`}
|
||||
onPointerDown={props.warmPress}
|
||||
onPointerEnter={props.warmHover}
|
||||
onPointerLeave={props.cancelHoverPrefetch}
|
||||
@@ -115,36 +115,30 @@ const SessionRow = (props: {
|
||||
props.clearHoverProjectSoon()
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={props.isWorking()}
|
||||
fallback={
|
||||
<>
|
||||
<div
|
||||
class="shrink-0 size-6 flex items-center justify-center"
|
||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
||||
<Match when={props.hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={props.hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={props.unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SpinnerLabHeader
|
||||
title={props.session.title}
|
||||
tint={props.tint() ?? "var(--icon-interactive-base)"}
|
||||
class="min-w-0 flex-1"
|
||||
/>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<div
|
||||
class="shrink-0 size-6 flex items-center justify-center"
|
||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||
>
|
||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
||||
<Match when={props.isWorking()}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Match>
|
||||
<Match when={props.hasPermissions()}>
|
||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||
</Match>
|
||||
<Match when={props.hasError()}>
|
||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||
</Match>
|
||||
<Match when={props.unseenCount() > 0}>
|
||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||
</Match>
|
||||
</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>
|
||||
</A>
|
||||
)
|
||||
|
||||
@@ -173,11 +167,7 @@ const SessionHoverPreview = (props: {
|
||||
placement="right-start"
|
||||
gutter={16}
|
||||
shift={-2}
|
||||
trigger={
|
||||
<div ref={ref} class="min-w-0 w-full">
|
||||
{props.trigger}
|
||||
</div>
|
||||
}
|
||||
trigger={<div ref={ref}>{props.trigger}</div>}
|
||||
open={props.hoverSession() === props.session.id}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
@@ -319,71 +309,62 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
return (
|
||||
<div
|
||||
data-session-id={props.session.id}
|
||||
class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
|
||||
class="group/session relative w-full 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"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1">
|
||||
<div class="min-w-0 flex-1">
|
||||
<Show
|
||||
when={hoverEnabled()}
|
||||
fallback={
|
||||
<Tooltip
|
||||
placement={props.mobile ? "bottom" : "right"}
|
||||
value={props.session.title}
|
||||
gutter={10}
|
||||
class="min-w-0 w-full"
|
||||
>
|
||||
{item}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<SessionHoverPreview
|
||||
mobile={props.mobile}
|
||||
nav={props.nav}
|
||||
hoverSession={props.hoverSession}
|
||||
session={props.session}
|
||||
sidebarHovering={props.sidebarHovering}
|
||||
hoverReady={hoverReady}
|
||||
hoverMessages={hoverMessages}
|
||||
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}`)
|
||||
}}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<Tooltip value={language.t("common.archive")} placement="top">
|
||||
<IconButton
|
||||
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)
|
||||
}}
|
||||
/>
|
||||
<Show
|
||||
when={hoverEnabled()}
|
||||
fallback={
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
|
||||
{item}
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SessionHoverPreview
|
||||
mobile={props.mobile}
|
||||
nav={props.nav}
|
||||
hoverSession={props.hoverSession}
|
||||
session={props.session}
|
||||
sidebarHovering={props.sidebarHovering}
|
||||
hoverReady={hoverReady}
|
||||
hoverMessages={hoverMessages}
|
||||
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}`)
|
||||
}}
|
||||
trigger={item}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
|
||||
classList={{
|
||||
"opacity-100 pointer-events-auto": !!props.mobile,
|
||||
"opacity-0 pointer-events-none": !props.mobile,
|
||||
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
||||
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
||||
}}
|
||||
>
|
||||
<Tooltip value={language.t("common.archive")} placement="top">
|
||||
<IconButton
|
||||
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>
|
||||
)
|
||||
@@ -405,26 +386,30 @@ export const NewSessionItem = (props: {
|
||||
<A
|
||||
href={`/${props.slug}/session`}
|
||||
end
|
||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||
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"}`}
|
||||
onClick={() => {
|
||||
props.setHoverSession(undefined)
|
||||
if (layout.sidebar.opened()) return
|
||||
props.clearHoverProjectSoon()
|
||||
}}
|
||||
>
|
||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<Icon name="new-session" size="small" class="text-icon-weak" />
|
||||
<div class="flex items-center gap-1 w-full">
|
||||
<div class="shrink-0 size-6 flex items-center justify-center">
|
||||
<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>
|
||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
|
||||
</A>
|
||||
)
|
||||
|
||||
return (
|
||||
<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">
|
||||
<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">
|
||||
<Show
|
||||
when={!tooltip()}
|
||||
fallback={
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
|
||||
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}>
|
||||
{item}
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
@@ -246,19 +246,14 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
|
||||
if (added <= 0) return
|
||||
if (growth <= 0) return
|
||||
|
||||
if (opts?.prefetch) {
|
||||
const current = turnStart()
|
||||
preserveScroll(() => setTurnStart(current + growth))
|
||||
return
|
||||
}
|
||||
|
||||
if (turnStart() !== start) return
|
||||
|
||||
const reveal = !opts?.prefetch
|
||||
const currentRendered = renderedUserMessages().length
|
||||
const base = Math.max(beforeRendered, currentRendered)
|
||||
const target = Math.min(afterVisible, base + turnBatch)
|
||||
preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
|
||||
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
|
||||
const nextStart = Math.max(0, afterVisible - target)
|
||||
preserveScroll(() => setTurnStart(nextStart))
|
||||
}
|
||||
|
||||
const onScrollerScroll = () => {
|
||||
@@ -1184,6 +1179,8 @@ export default function Page() {
|
||||
on(
|
||||
() => sdk.directory,
|
||||
() => {
|
||||
void file.tree.list("")
|
||||
|
||||
const tab = activeFileTab()
|
||||
if (!tab) return
|
||||
const path = file.pathFromTab(tab)
|
||||
@@ -1638,9 +1635,6 @@ export default function Page() {
|
||||
sessionID: () => params.id,
|
||||
messagesReady,
|
||||
visibleUserMessages,
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
|
||||
turnStart: historyWindow.turnStart,
|
||||
currentMessageId: () => store.messageId,
|
||||
pendingMessage: () => ui.pendingMessage,
|
||||
@@ -1712,7 +1706,7 @@ export default function Page() {
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id}>
|
||||
<Show when={messagesReady()}>
|
||||
<Show when={lastUserMessage()}>
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
|
||||
@@ -15,7 +15,6 @@ type TabsInput = {
|
||||
normalizeTab: (tab: string) => string
|
||||
review?: Accessor<boolean>
|
||||
hasReview?: Accessor<boolean>
|
||||
fixed?: Accessor<string[]>
|
||||
}
|
||||
|
||||
export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
|
||||
@@ -23,7 +22,6 @@ export const getSessionKey = (dir: string | undefined, id: string | undefined) =
|
||||
export const createSessionTabs = (input: TabsInput) => {
|
||||
const review = input.review ?? (() => false)
|
||||
const hasReview = input.hasReview ?? (() => false)
|
||||
const fixed = input.fixed ?? (() => emptyTabs)
|
||||
const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
|
||||
const openedTabs = createMemo(
|
||||
() => {
|
||||
@@ -32,7 +30,7 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
.tabs()
|
||||
.all()
|
||||
.flatMap((tab) => {
|
||||
if (tab === "context" || tab === "review" || fixed().includes(tab)) return []
|
||||
if (tab === "context" || tab === "review") return []
|
||||
const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
|
||||
if (seen.has(value)) return []
|
||||
seen.add(value)
|
||||
@@ -46,7 +44,6 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
const active = input.tabs().active()
|
||||
if (active === "context") return active
|
||||
if (active === "review" && review()) return active
|
||||
if (active && fixed().includes(active)) return active
|
||||
if (active && input.pathFromTab(active)) return input.normalizeTab(active)
|
||||
|
||||
const first = openedTabs()[0]
|
||||
@@ -63,7 +60,6 @@ export const createSessionTabs = (input: TabsInput) => {
|
||||
const closableTab = createMemo(() => {
|
||||
const active = activeTab()
|
||||
if (active === "context") return active
|
||||
if (fixed().includes(active)) return
|
||||
if (!openedTabs().includes(active)) return
|
||||
return active
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { SessionTurn } from "@opencode-ai/ui/session-turn"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
@@ -18,7 +19,6 @@ import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SpinnerLabHeader } from "@/pages/session/spinner-lab"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
@@ -657,31 +657,33 @@ export function MessageTimeline(props: {
|
||||
/>
|
||||
</Show>
|
||||
<div class="flex items-center min-w-0 grow-1">
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{
|
||||
width: working() ? "16px" : "0px",
|
||||
"margin-right": working() ? "8px" : "0px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Show when={workingStatus() !== "hidden"}>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-out"
|
||||
classList={{ "opacity-0": workingStatus() === "hiding" }}
|
||||
>
|
||||
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<Show
|
||||
when={workingStatus() !== "hidden"}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
<div
|
||||
class="min-w-0 grow-1 transition-opacity duration-200 ease-out"
|
||||
classList={{ "opacity-0": workingStatus() === "hiding" }}
|
||||
>
|
||||
<SpinnerLabHeader
|
||||
title={titleValue() ?? ""}
|
||||
tint={tint() ?? "var(--icon-interactive-base)"}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,484 +0,0 @@
|
||||
import { For, Show, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Braille, getBrailleFrames, type BrailleKind } from "@/components/pendulum"
|
||||
|
||||
export const spinnerLabIds = [
|
||||
"current",
|
||||
"pendulum-sweep",
|
||||
"pendulum",
|
||||
"pendulum-glow",
|
||||
"compress-sweep",
|
||||
"compress",
|
||||
"compress-flash",
|
||||
"sort-sweep",
|
||||
"sort",
|
||||
"sort-spark",
|
||||
"pendulum-replace",
|
||||
"compress-replace",
|
||||
"sort-replace",
|
||||
"pendulum-sweep-replace",
|
||||
"compress-flash-replace",
|
||||
"sort-spark-replace",
|
||||
"pendulum-glow-replace",
|
||||
"compress-sweep-replace",
|
||||
"sort-sweep-replace",
|
||||
"pendulum-overlay",
|
||||
"compress-overlay",
|
||||
"sort-overlay",
|
||||
"pendulum-glow-overlay",
|
||||
"sort-spark-overlay",
|
||||
"pendulum-frame",
|
||||
"compress-frame",
|
||||
"compress-tail",
|
||||
"sort-frame",
|
||||
"square-wave",
|
||||
] as const
|
||||
|
||||
export type SpinnerLabId = (typeof spinnerLabIds)[number]
|
||||
|
||||
const ids = new Set<string>(spinnerLabIds)
|
||||
const trailFrames = (cols: number) => {
|
||||
let s = 17
|
||||
const rnd = () => {
|
||||
s = (s * 1664525 + 1013904223) & 0xffffffff
|
||||
return (s >>> 0) / 0xffffffff
|
||||
}
|
||||
return Array.from({ length: 120 }, () =>
|
||||
Array.from({ length: cols }, () => {
|
||||
let mask = 0
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
if (rnd() > 0.45) mask |= 1 << bit
|
||||
}
|
||||
if (!mask) mask = 1 << Math.floor(rnd() * 8)
|
||||
return String.fromCharCode(0x2800 + mask)
|
||||
}).join(""),
|
||||
)
|
||||
}
|
||||
|
||||
const parse = (id: SpinnerLabId) => {
|
||||
const kind: BrailleKind | undefined = id.startsWith("pendulum")
|
||||
? "pendulum"
|
||||
: id.startsWith("compress")
|
||||
? "compress"
|
||||
: id.startsWith("sort")
|
||||
? "sort"
|
||||
: undefined
|
||||
const mode =
|
||||
id === "current"
|
||||
? "current"
|
||||
: id === "square-wave"
|
||||
? "square"
|
||||
: id.endsWith("-tail")
|
||||
? "trail"
|
||||
: id.endsWith("-replace")
|
||||
? "replace"
|
||||
: id.endsWith("-overlay")
|
||||
? "overlay"
|
||||
: id.endsWith("-frame")
|
||||
? "frame"
|
||||
: id === "pendulum" || id === "compress" || id === "sort"
|
||||
? "spin"
|
||||
: "shimmer"
|
||||
const anim = id.includes("glow")
|
||||
? 1.4
|
||||
: id.includes("flash") || id.includes("spark")
|
||||
? 2.4
|
||||
: id.includes("sweep")
|
||||
? 1.9
|
||||
: 1.8
|
||||
const move = mode === "spin" || mode === "current" ? 1 : anim
|
||||
return {
|
||||
id,
|
||||
mode,
|
||||
kind,
|
||||
cols: mode === "spin" ? 3 : 6,
|
||||
anim,
|
||||
move,
|
||||
color: "#FFE865",
|
||||
size: 2,
|
||||
gap: 1,
|
||||
low: 0.08,
|
||||
high: 0.72,
|
||||
}
|
||||
}
|
||||
|
||||
type SpinnerLabTune = ReturnType<typeof parse>
|
||||
|
||||
const defaults = Object.fromEntries(spinnerLabIds.map((id) => [id, parse(id)])) as Record<SpinnerLabId, SpinnerLabTune>
|
||||
const [lab, setLab] = createStore({ active: "pendulum" as SpinnerLabId, tune: defaults })
|
||||
|
||||
const mask = (title: string, fill: string, pos: number) =>
|
||||
Array.from(title)
|
||||
.map((char, idx) => {
|
||||
const off = idx - pos
|
||||
if (off < 0 || off >= fill.length) return char
|
||||
return fill[off] ?? char
|
||||
})
|
||||
.join("")
|
||||
|
||||
const Shimmer = (props: {
|
||||
title: string
|
||||
kind: BrailleKind
|
||||
cols: number
|
||||
anim: number
|
||||
move: number
|
||||
color: string
|
||||
}) => {
|
||||
const [x, setX] = createSignal(-18)
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
setX(-18)
|
||||
const id = window.setInterval(() => setX((x) => (x > 112 ? -18 : x + Math.max(0.5, props.move))), 32)
|
||||
onCleanup(() => window.clearInterval(id))
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="relative min-w-0 flex-1 overflow-hidden py-0.5">
|
||||
<div class="truncate text-14-medium text-text-strong">{props.title}</div>
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute top-1/2 -translate-y-1/2" style={{ left: `calc(${x()}% - 6ch)` }}>
|
||||
<Braille
|
||||
kind={props.kind}
|
||||
cols={props.cols}
|
||||
rate={props.anim}
|
||||
class="inline-flex items-center justify-center overflow-hidden font-mono text-[12px] leading-none font-semibold opacity-80 select-none"
|
||||
style={{ color: props.color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Replace = (props: {
|
||||
title: string
|
||||
kind: BrailleKind
|
||||
cols: number
|
||||
anim: number
|
||||
move: number
|
||||
color: string
|
||||
}) => {
|
||||
const chars = createMemo(() => Array.from(props.title))
|
||||
const frames = createMemo(() => getBrailleFrames(props.kind, props.cols))
|
||||
const [state, setState] = createStore({ pos: 0, idx: 0 })
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
setState({ pos: 0, idx: 0 })
|
||||
const anim = window.setInterval(
|
||||
() => setState("idx", (idx) => (idx + 1) % frames().length),
|
||||
Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
|
||||
)
|
||||
const move = window.setInterval(
|
||||
() => setState("pos", (pos) => (pos >= chars().length - 1 ? 0 : pos + 1)),
|
||||
Math.max(90, Math.round(260 / Math.max(0.4, props.move))),
|
||||
)
|
||||
onCleanup(() => {
|
||||
window.clearInterval(anim)
|
||||
window.clearInterval(move)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="min-w-0 truncate whitespace-nowrap font-mono text-[13px] font-semibold text-text-strong">
|
||||
{mask(props.title, frames()[state.idx] ?? "", state.pos)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Overlay = (props: {
|
||||
title: string
|
||||
kind: BrailleKind
|
||||
cols: number
|
||||
anim: number
|
||||
move: number
|
||||
color: string
|
||||
}) => {
|
||||
let root: HTMLDivElement | undefined
|
||||
let fx: HTMLDivElement | undefined
|
||||
const [state, setState] = createStore({ pos: 0, max: 0, dark: false })
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
setState({ pos: 0 })
|
||||
const id = window.setInterval(
|
||||
() => setState("pos", (pos) => (pos >= state.max ? 0 : Math.min(state.max, pos + 8))),
|
||||
Math.max(90, Math.round(260 / Math.max(0.4, props.move))),
|
||||
)
|
||||
onCleanup(() => window.clearInterval(id))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
if (!root || !fx) return
|
||||
const sync = () => setState("max", Math.max(0, root!.clientWidth - fx!.clientWidth))
|
||||
sync()
|
||||
const observer = new ResizeObserver(sync)
|
||||
observer.observe(root)
|
||||
observer.observe(fx)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
const query = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const sync = () => setState("dark", query.matches)
|
||||
sync()
|
||||
query.addEventListener("change", sync)
|
||||
onCleanup(() => query.removeEventListener("change", sync))
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={root} class="relative min-w-0 flex-1 overflow-hidden py-0.5">
|
||||
<div class="truncate text-14-medium text-text-strong">{props.title}</div>
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
<div ref={fx} class="absolute top-1/2 -translate-y-1/2" style={{ left: `${state.pos}px` }}>
|
||||
<Braille
|
||||
kind={props.kind}
|
||||
cols={props.cols}
|
||||
rate={props.anim}
|
||||
class="inline-flex items-center justify-center overflow-hidden rounded-sm px-0.5 py-2 font-mono text-[12px] leading-none font-semibold select-none"
|
||||
style={{ color: props.color, "background-color": state.dark ? "#151515" : "#FCFCFC" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Frame = (props: { title: string; kind: BrailleKind; cols: number; anim: number; color: string }) => {
|
||||
const head = createMemo(() => getBrailleFrames(props.kind, props.cols))
|
||||
const tail = createMemo(() => getBrailleFrames(props.kind, 64))
|
||||
const [state, setState] = createStore({ idx: 0 })
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
setState({ idx: 0 })
|
||||
const id = window.setInterval(
|
||||
() => setState("idx", (idx) => (idx + 1) % head().length),
|
||||
Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
|
||||
)
|
||||
onCleanup(() => window.clearInterval(id))
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-0.5">
|
||||
<div class="shrink-0 font-mono text-[12px] font-semibold leading-none" style={{ color: props.color }}>
|
||||
{head()[state.idx] ?? ""}
|
||||
</div>
|
||||
<div class="shrink-0 truncate text-14-medium text-text-strong">{props.title}</div>
|
||||
<div
|
||||
class="min-w-0 flex-1 overflow-hidden whitespace-nowrap font-mono text-[12px] font-semibold leading-none"
|
||||
style={{ color: props.color }}
|
||||
>
|
||||
{tail()[state.idx] ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Trail = (props: { title: string; kind: BrailleKind; cols: number; anim: number; color: string }) => {
|
||||
const tail = createMemo(() => trailFrames(Math.max(24, props.cols * 12)))
|
||||
const [state, setState] = createStore({ idx: 0 })
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
setState({ idx: 0 })
|
||||
const id = window.setInterval(
|
||||
() => setState("idx", (idx) => (idx + 1) % tail().length),
|
||||
Math.max(16, Math.round(42 / Math.max(0.4, props.anim))),
|
||||
)
|
||||
onCleanup(() => window.clearInterval(id))
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex w-full min-w-0 flex-1 items-center gap-2 overflow-hidden py-0.5">
|
||||
<div class="min-w-0 max-w-[55%] flex-[0_1_auto] truncate text-14-medium text-text-strong">{props.title}</div>
|
||||
<div
|
||||
class="min-w-[10ch] basis-0 flex-[1_1_0%] overflow-hidden whitespace-nowrap font-mono text-[12px] font-semibold leading-none"
|
||||
style={{ color: props.color }}
|
||||
>
|
||||
{tail()[state.idx] ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Square = (props: {
|
||||
title: string
|
||||
anim: number
|
||||
move: number
|
||||
color: string
|
||||
size: number
|
||||
gap: number
|
||||
low: number
|
||||
high: number
|
||||
}) => {
|
||||
const cols = createMemo(() => Math.max(96, Math.ceil(Array.from(props.title).length * 4.5)))
|
||||
const cells = createMemo(() =>
|
||||
Array.from({ length: cols() * 4 }, (_, idx) => ({ row: Math.floor(idx / cols()), col: idx % cols() })),
|
||||
)
|
||||
const [state, setState] = createStore({ pos: 0, phase: 0 })
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
setState({ pos: 0, phase: 0 })
|
||||
const anim = window.setInterval(
|
||||
() => setState("phase", (phase) => phase + 0.45),
|
||||
Math.max(16, Math.round(44 / Math.max(0.4, props.anim))),
|
||||
)
|
||||
const move = window.setInterval(
|
||||
() => setState("pos", (pos) => (pos >= cols() + 10 ? 0 : pos + 1)),
|
||||
Math.max(40, Math.round(160 / Math.max(0.4, props.move))),
|
||||
)
|
||||
onCleanup(() => {
|
||||
window.clearInterval(anim)
|
||||
window.clearInterval(move)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="relative min-w-0 flex-1 overflow-hidden py-2">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 grid content-center overflow-hidden"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
"grid-template-columns": `repeat(${cols()}, ${props.size}px)`,
|
||||
"grid-auto-rows": `${props.size}px`,
|
||||
gap: `${props.gap}px`,
|
||||
}}
|
||||
>
|
||||
<For each={cells()}>
|
||||
{(cell) => {
|
||||
const opacity = () => {
|
||||
const wave = (Math.cos((cell.col - state.pos) * 0.32 - state.phase + cell.row * 0.55) + 1) / 2
|
||||
return props.low + (props.high - props.low) * wave * wave
|
||||
}
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
"background-color": props.color,
|
||||
opacity: `${opacity()}`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
<div class="relative z-10 truncate px-2 text-14-medium text-text-strong">
|
||||
<span class="bg-background-stronger">{props.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const selectSpinnerLab = (id: string) => {
|
||||
if (!ids.has(id)) return
|
||||
setLab("active", id as SpinnerLabId)
|
||||
}
|
||||
|
||||
export const useSpinnerLab = () => ({
|
||||
active: () => lab.active,
|
||||
isActive: (id: string) => lab.active === id,
|
||||
tune: lab.tune,
|
||||
config: (id: SpinnerLabId) => lab.tune[id],
|
||||
current: () => lab.tune[lab.active],
|
||||
setTune: <K extends keyof SpinnerLabTune>(id: SpinnerLabId, key: K, value: SpinnerLabTune[K]) =>
|
||||
setLab("tune", id, key, value),
|
||||
})
|
||||
|
||||
export function SpinnerLabHeader(props: { title: string; tint?: string; class?: string }) {
|
||||
const cfg = createMemo(() => lab.tune[lab.active])
|
||||
const body = createMemo<JSX.Element>(() => {
|
||||
const cur = cfg()
|
||||
|
||||
if (cur.mode === "current") {
|
||||
return (
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Spinner class="size-4" style={{ color: props.tint ?? cur.color }} />
|
||||
<div class="min-w-0 truncate text-14-medium text-text-strong">{props.title}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (cur.mode === "spin" && cur.kind) {
|
||||
return (
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Braille
|
||||
kind={cur.kind}
|
||||
cols={cur.cols}
|
||||
rate={cur.anim}
|
||||
class="inline-flex w-4 items-center justify-center overflow-hidden font-mono text-[9px] leading-none select-none"
|
||||
style={{ color: cur.color }}
|
||||
/>
|
||||
<div class="min-w-0 truncate text-14-medium text-text-strong">{props.title}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (cur.mode === "shimmer" && cur.kind) {
|
||||
return (
|
||||
<Shimmer
|
||||
title={props.title}
|
||||
kind={cur.kind}
|
||||
cols={cur.cols}
|
||||
anim={cur.anim}
|
||||
move={cur.move}
|
||||
color={cur.color}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (cur.mode === "replace" && cur.kind) {
|
||||
return (
|
||||
<Replace
|
||||
title={props.title}
|
||||
kind={cur.kind}
|
||||
cols={cur.cols}
|
||||
anim={cur.anim}
|
||||
move={cur.move}
|
||||
color={cur.color}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (cur.mode === "overlay" && cur.kind) {
|
||||
return (
|
||||
<Overlay
|
||||
title={props.title}
|
||||
kind={cur.kind}
|
||||
cols={cur.cols}
|
||||
anim={cur.anim}
|
||||
move={cur.move}
|
||||
color={cur.color}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (cur.mode === "trail" && cur.kind) {
|
||||
return <Trail title={props.title} kind={cur.kind} cols={cur.cols} anim={cur.anim} color={cur.color} />
|
||||
}
|
||||
|
||||
if (cur.mode === "frame" && cur.kind) {
|
||||
return <Frame title={props.title} kind={cur.kind} cols={cur.cols} anim={cur.anim} color={cur.color} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Square
|
||||
title={props.title}
|
||||
anim={cur.anim}
|
||||
move={cur.move}
|
||||
color={cur.color}
|
||||
size={cur.size}
|
||||
gap={cur.gap}
|
||||
low={cur.low}
|
||||
high={cur.high}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return <div class={props.class ?? "min-w-0 grow-1 w-full"}>{body()}</div>
|
||||
}
|
||||
@@ -333,7 +333,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
id: "message.previous",
|
||||
title: language.t("command.message.previous"),
|
||||
description: language.t("command.message.previous.description"),
|
||||
keybind: "mod+alt+[",
|
||||
keybind: "mod+arrowup",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(-1),
|
||||
}),
|
||||
@@ -341,7 +341,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
id: "message.next",
|
||||
title: language.t("command.message.next"),
|
||||
description: language.t("command.message.next.description"),
|
||||
keybind: "mod+alt+]",
|
||||
keybind: "mod+arrowdown",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(1),
|
||||
}),
|
||||
|
||||
@@ -8,9 +8,6 @@ export const useSessionHashScroll = (input: {
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
visibleUserMessages: () => UserMessage[]
|
||||
historyMore: () => boolean
|
||||
historyLoading: () => boolean
|
||||
loadMore: (sessionID: string) => Promise<void>
|
||||
turnStart: () => number
|
||||
currentMessageId: () => string | undefined
|
||||
pendingMessage: () => string | undefined
|
||||
@@ -184,21 +181,6 @@ export const useSessionHashScroll = (input: {
|
||||
queue(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = input.sessionID()
|
||||
if (!sessionID || !input.messagesReady()) return
|
||||
|
||||
visibleUserMessages()
|
||||
|
||||
let targetId = input.pendingMessage()
|
||||
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
|
||||
if (!targetId) return
|
||||
if (messageById().has(targetId)) return
|
||||
if (!input.historyMore() || input.historyLoading()) return
|
||||
|
||||
void input.loadMore(sessionID)
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
|
||||
window.history.scrollRestoration = "manual"
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import solidPlugin from "vite-plugin-solid"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
|
||||
|
||||
/**
|
||||
* @type {import("vite").PluginOption}
|
||||
*/
|
||||
@@ -24,15 +21,6 @@ export default [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "opencode-desktop:theme-preload",
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
|
||||
`<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
|
||||
)
|
||||
},
|
||||
},
|
||||
tailwindcss(),
|
||||
solidPlugin(),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,6 +2,6 @@ import { redirect } from "@solidjs/router"
|
||||
|
||||
export async function GET() {
|
||||
return redirect(
|
||||
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true",
|
||||
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -88,7 +88,7 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
"open-directory-picker",
|
||||
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
|
||||
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
||||
title: opts?.title ?? "Choose a folder",
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ const cache = new Map<string, Store>()
|
||||
export function getStore(name = SETTINGS_STORE) {
|
||||
const cached = cache.get(name)
|
||||
if (cached) return cached
|
||||
const next = new Store({ name, fileExtension: "" })
|
||||
const next = new Store({ name })
|
||||
cache.set(name, next)
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.3.2"
|
||||
version = "1.3.0"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.0/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -121,7 +121,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "5.3.2",
|
||||
"gitlab-ai-provider": "5.3.1",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -133,7 +133,6 @@
|
||||
"minimatch": "10.0.3",
|
||||
"open": "10.1.2",
|
||||
"opencode-gitlab-auth": "2.0.0",
|
||||
"opencode-poe-auth": "0.0.1",
|
||||
"opentui-spinner": "0.0.6",
|
||||
"partial-json": "0.1.7",
|
||||
"remeda": "catalog:",
|
||||
|
||||
@@ -21,14 +21,10 @@ const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.js"),
|
||||
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
|
||||
path.join(dir, "src/provider/models-snapshot.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.d.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
|
||||
)
|
||||
console.log("Generated models-snapshot.js")
|
||||
console.log("Generated models-snapshot.ts")
|
||||
|
||||
// Load migrations from migration directories
|
||||
const migrationDirs = (
|
||||
|
||||
@@ -164,7 +164,7 @@ Still open and likely worth migrating:
|
||||
- [x] `Plugin`
|
||||
- [x] `ToolRegistry`
|
||||
- [ ] `Pty`
|
||||
- [x] `Worktree`
|
||||
- [ ] `Worktree`
|
||||
- [ ] `Bus`
|
||||
- [x] `Command`
|
||||
- [ ] `Config`
|
||||
@@ -173,6 +173,6 @@ Still open and likely worth migrating:
|
||||
- [ ] `SessionPrompt`
|
||||
- [ ] `SessionCompaction`
|
||||
- [ ] `Provider`
|
||||
- [x] `Project`
|
||||
- [ ] `Project`
|
||||
- [ ] `LSP`
|
||||
- [ ] `MCP`
|
||||
|
||||
@@ -3,6 +3,7 @@ import z from "zod"
|
||||
import { Provider } from "../provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
import { SystemPrompt } from "../session/system"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Truncate } from "../tool/truncate"
|
||||
import { Auth } from "../auth"
|
||||
@@ -19,9 +20,6 @@ import { Global } from "@/global"
|
||||
import path from "path"
|
||||
import { Plugin } from "@/plugin"
|
||||
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 const Info = z
|
||||
@@ -51,364 +49,295 @@ export namespace Agent {
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export interface Interface {
|
||||
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 state = Instance.state(async () => {
|
||||
const cfg = await Config.get()
|
||||
|
||||
type State = Omit<Interface, "generate">
|
||||
const skillDirs = await Skill.dirs()
|
||||
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 ?? {})
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = () => Effect.promise(() => Config.get())
|
||||
const auth = yield* Auth.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
const cfg = yield* config()
|
||||
const skillDirs = yield* Effect.promise(() => Skill.dirs())
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
|
||||
const defaults = Permission.fromConfig({
|
||||
"*": "allow",
|
||||
doom_loop: "ask",
|
||||
const result: Record<string, Info> = {
|
||||
build: {
|
||||
name: "build",
|
||||
description: "The default agent. Executes tools based on configured permissions.",
|
||||
options: {},
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
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"])),
|
||||
},
|
||||
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",
|
||||
},
|
||||
})
|
||||
}),
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
const user = Permission.fromConfig(cfg.permission ?? {})
|
||||
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
|
||||
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 ?? {}))
|
||||
}
|
||||
|
||||
const agents: Record<string, Info> = {
|
||||
build: {
|
||||
name: "build",
|
||||
description: "The default agent. Executes tools based on configured permissions.",
|
||||
options: {},
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
|
||||
if (value.disable) {
|
||||
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 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))
|
||||
}),
|
||||
// Ensure Truncate.GLOB is allowed unless explicitly configured
|
||||
for (const name in result) {
|
||||
const agent = result[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
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||
result[name].permission = Permission.merge(
|
||||
result[name].permission,
|
||||
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
|
||||
)
|
||||
}
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
return result
|
||||
})
|
||||
|
||||
export async function get(agent: string) {
|
||||
return runPromise((svc) => svc.get(agent))
|
||||
return state().then((x) => x[agent])
|
||||
}
|
||||
|
||||
export async function list() {
|
||||
return runPromise((svc) => svc.list())
|
||||
const cfg = await Config.get()
|
||||
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() {
|
||||
return runPromise((svc) => svc.defaultAgent())
|
||||
const cfg = await Config.get()
|
||||
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 } }) {
|
||||
return runPromise((svc) => svc.generate(input))
|
||||
const cfg = await Config.get()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { writeHeapSnapshot } from "v8"
|
||||
import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { TuiConfigProvider } from "./context/tui-config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { Mock } from "./mock"
|
||||
|
||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||
// can't set raw mode if not a TTY
|
||||
@@ -110,7 +111,6 @@ export function tui(input: {
|
||||
url: string
|
||||
args: Args
|
||||
config: TuiConfig.Info
|
||||
onSnapshot?: () => Promise<string[]>
|
||||
directory?: string
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
@@ -161,7 +161,7 @@ export function tui(input: {
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<App onSnapshot={input.onSnapshot} />
|
||||
<App />
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
@@ -202,7 +202,7 @@ export function tui(input: {
|
||||
})
|
||||
}
|
||||
|
||||
function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
function App() {
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
@@ -628,11 +628,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
title: "Write heap snapshot",
|
||||
category: "System",
|
||||
value: "app.heap_snapshot",
|
||||
onSelect: async (dialog) => {
|
||||
const files = await props.onSnapshot?.()
|
||||
onSelect: (dialog) => {
|
||||
const path = writeHeapSnapshot()
|
||||
toast.show({
|
||||
variant: "info",
|
||||
message: `Heap snapshot written to ${files?.join(", ")}`,
|
||||
message: `Heap snapshot written to ${path}`,
|
||||
duration: 5000,
|
||||
})
|
||||
dialog.clear()
|
||||
@@ -804,6 +804,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
}}
|
||||
onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)}
|
||||
>
|
||||
<Mock />
|
||||
<Switch>
|
||||
<Match when={route.data.type === "home"}>
|
||||
<Home />
|
||||
|
||||
@@ -53,7 +53,6 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
message: store,
|
||||
},
|
||||
)
|
||||
process.on("SIGHUP", () => exit())
|
||||
return exit
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { onMount } from "solid-js"
|
||||
import { useLocal } from "./context/local"
|
||||
import { usePromptRef } from "./context/prompt"
|
||||
import { useRoute } from "./context/route"
|
||||
import { useSync } from "./context/sync"
|
||||
import { useToast } from "./ui/toast"
|
||||
|
||||
const model = {
|
||||
providerID: "mock",
|
||||
modelID: "mock-model",
|
||||
}
|
||||
|
||||
function parse(raw: string) {
|
||||
try {
|
||||
const json = JSON.parse(raw)
|
||||
return Array.isArray(json) ? json : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (Flag.OPENCODE_TUI_RUNNER_FILE) {
|
||||
return parse(await Filesystem.readText(Flag.OPENCODE_TUI_RUNNER_FILE).catch(() => "[]"))
|
||||
}
|
||||
|
||||
return parse(Flag.OPENCODE_TUI_RUNNER_STEPS ?? "[]")
|
||||
}
|
||||
|
||||
function text(step: unknown) {
|
||||
return typeof step === "string" ? step : JSON.stringify(step)
|
||||
}
|
||||
|
||||
async function wait<T>(test: () => T | false | undefined, timeout = 30_000) {
|
||||
const start = Date.now()
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
const value = test()
|
||||
if (value) return value
|
||||
await Bun.sleep(100)
|
||||
}
|
||||
|
||||
throw new Error("Mock runner timed out")
|
||||
}
|
||||
|
||||
export function Mock() {
|
||||
const local = useLocal()
|
||||
const prompt = usePromptRef()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
|
||||
onMount(() => {
|
||||
if (Flag.OPENCODE_TUI_RUNNER !== "mock") return
|
||||
|
||||
void (async () => {
|
||||
const steps = await load()
|
||||
if (!steps.length) {
|
||||
toast.show({
|
||||
message: "Mock runner has no steps",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await wait(() => sync.ready && local.model.ready)
|
||||
await wait(() => !!sync.data.provider.find((item) => item.id === model.providerID)?.models[model.modelID])
|
||||
local.model.set(model, { recent: true })
|
||||
|
||||
for (const step of steps) {
|
||||
await wait(() => !!prompt.current)
|
||||
local.model.set(model)
|
||||
|
||||
const prev = route.data.type === "session" ? route.data.sessionID : undefined
|
||||
const count = prev ? sync.data.message[prev]?.length ?? 0 : 0
|
||||
|
||||
prompt.current!.set({
|
||||
input: text(step),
|
||||
parts: [],
|
||||
})
|
||||
prompt.current!.submit()
|
||||
|
||||
const sid = await wait(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
await wait(() => {
|
||||
const list = sync.data.message[sid] ?? []
|
||||
if (list.length <= (sid === prev ? count : 0)) return false
|
||||
return sync.session.status(sid) === "idle" && list.at(-1)?.role !== "user"
|
||||
}, 120000)
|
||||
}
|
||||
|
||||
toast.show({
|
||||
message: `Mock runner sent ${steps.length} prompt(s)`,
|
||||
variant: "success",
|
||||
duration: 3000,
|
||||
})
|
||||
})().catch((err) => {
|
||||
toast.show({
|
||||
message: err instanceof Error ? err.message : "Mock runner failed",
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return <></>
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import type { EventSource } from "./context/sdk"
|
||||
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { writeHeapSnapshot } from "v8"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_WORKER_PATH: string
|
||||
@@ -202,11 +201,6 @@ export const TuiThreadCommand = cmd({
|
||||
try {
|
||||
await tui({
|
||||
url: transport.url,
|
||||
async onSnapshot() {
|
||||
const tui = writeHeapSnapshot("tui.heapsnapshot")
|
||||
const server = await client.call("snapshot", undefined)
|
||||
return [tui, server]
|
||||
},
|
||||
config,
|
||||
directory: cwd,
|
||||
fetch: transport.fetch,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { GlobalBus } from "@/bus/global"
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
@@ -118,10 +117,6 @@ export const rpc = {
|
||||
body,
|
||||
}
|
||||
},
|
||||
snapshot() {
|
||||
const result = writeHeapSnapshot("server.heapsnapshot")
|
||||
return result
|
||||
},
|
||||
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
|
||||
if (server) await server.stop(true)
|
||||
server = await Server.listen(input)
|
||||
|
||||
@@ -22,11 +22,12 @@ export const WorktreeAdaptor: Adaptor = {
|
||||
},
|
||||
async create(info) {
|
||||
const config = Config.parse(info)
|
||||
await Worktree.createFromInfo({
|
||||
const bootstrap = await Worktree.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
branch: config.branch,
|
||||
})
|
||||
return bootstrap()
|
||||
},
|
||||
async remove(info) {
|
||||
const config = Config.parse(info)
|
||||
|
||||
@@ -16,6 +16,9 @@ export namespace Flag {
|
||||
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
|
||||
export declare const OPENCODE_TUI_CONFIG: string | undefined
|
||||
export declare const OPENCODE_CONFIG_DIR: string | undefined
|
||||
export const OPENCODE_TUI_RUNNER = process.env["OPENCODE_TUI_RUNNER"]
|
||||
export const OPENCODE_TUI_RUNNER_STEPS = process.env["OPENCODE_TUI_RUNNER_STEPS"]
|
||||
export const OPENCODE_TUI_RUNNER_FILE = process.env["OPENCODE_TUI_RUNNER_FILE"]
|
||||
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
|
||||
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
|
||||
export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
|
||||
|
||||
@@ -26,13 +26,15 @@ export namespace Global {
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fs.mkdir(Global.Path.data, { recursive: true }),
|
||||
fs.mkdir(Global.Path.config, { recursive: true }),
|
||||
fs.mkdir(Global.Path.state, { recursive: true }),
|
||||
fs.mkdir(Global.Path.log, { recursive: true }),
|
||||
fs.mkdir(Global.Path.bin, { recursive: true }),
|
||||
])
|
||||
try {
|
||||
await Promise.all([
|
||||
fs.mkdir(Global.Path.data, { recursive: true }),
|
||||
fs.mkdir(Global.Path.config, { recursive: true }),
|
||||
fs.mkdir(Global.Path.state, { recursive: true }),
|
||||
fs.mkdir(Global.Path.log, { recursive: true }),
|
||||
fs.mkdir(Global.Path.bin, { recursive: true }),
|
||||
])
|
||||
} catch (err) {}
|
||||
|
||||
const CACHE_VERSION = "21"
|
||||
|
||||
|
||||
@@ -177,12 +177,6 @@ export namespace LSP {
|
||||
|
||||
async function getClients(file: string) {
|
||||
const s = await state()
|
||||
|
||||
// Only spawn LSP clients for files within the instance directory
|
||||
if (!Instance.containsPath(file)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const extension = path.parse(file).ext || file
|
||||
const result: LSPClient.Info[] = []
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Session } from "../session"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { CopilotAuthPlugin } from "./copilot"
|
||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
@@ -45,7 +44,7 @@ export namespace Plugin {
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
|
||||
// Built-in plugins that are directly imported (not installed from npm)
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
|
||||
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
|
||||
|
||||
// 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"]
|
||||
@@ -137,11 +136,7 @@ export namespace Plugin {
|
||||
|
||||
// Notify plugins of current config
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
await (hook as any).config?.(cfg)
|
||||
} catch (err) {
|
||||
log.error("plugin config hook failed", { error: err })
|
||||
}
|
||||
await (hook as any).config?.(cfg)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
import z from "zod"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import path from "path"
|
||||
import { and, Database, eq } from "../storage/db"
|
||||
import { ProjectTable } from "./project.sql"
|
||||
import { SessionTable } from "../session/session.sql"
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { fn } from "@opencode-ai/util/fn"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { iife } from "@/util/iife"
|
||||
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 { 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 {
|
||||
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
|
||||
.object({
|
||||
id: ProjectID.zod,
|
||||
@@ -60,7 +73,7 @@ export namespace Project {
|
||||
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
|
||||
: undefined
|
||||
return {
|
||||
id: row.id,
|
||||
id: ProjectID.make(row.id),
|
||||
worktree: row.worktree,
|
||||
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
|
||||
name: row.name ?? undefined,
|
||||
@@ -75,405 +88,245 @@ export namespace Project {
|
||||
}
|
||||
}
|
||||
|
||||
export const UpdateInput = z.object({
|
||||
projectID: ProjectID.zod,
|
||||
name: z.string().optional(),
|
||||
icon: Info.shape.icon.optional(),
|
||||
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>
|
||||
function readCachedId(dir: string) {
|
||||
return Filesystem.readText(path.join(dir, "opencode"))
|
||||
.then((x) => x.trim())
|
||||
.then(ProjectID.make)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Project") {}
|
||||
export async function fromDirectory(directory: string) {
|
||||
log.info("fromDirectory", { directory })
|
||||
|
||||
type GitResult = { code: number; text: string; stderr: string }
|
||||
const data = await iife(async () => {
|
||||
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)
|
||||
|
||||
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
|
||||
const gitBinary = which("git")
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
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)),
|
||||
)
|
||||
// cached id calculation
|
||||
let id = await readCachedId(dotgit)
|
||||
|
||||
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
||||
Effect.sync(() => Database.use(fn))
|
||||
if (!gitBinary) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
}
|
||||
}
|
||||
|
||||
const emitUpdated = (data: Info) =>
|
||||
Effect.sync(() =>
|
||||
GlobalBus.emit("event", {
|
||||
payload: { type: Event.Updated.type, properties: data },
|
||||
}),
|
||||
)
|
||||
const worktree = await git(["rev-parse", "--git-common-dir"], {
|
||||
cwd: sandbox,
|
||||
})
|
||||
.then(async (result) => {
|
||||
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)
|
||||
|
||||
const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
|
||||
if (!worktree) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
}
|
||||
}
|
||||
|
||||
const resolveGitPath = (cwd: string, name: string) => {
|
||||
if (!name) return cwd
|
||||
name = name.replace(/[\r\n]+$/, "")
|
||||
if (!name) return cwd
|
||||
name = AppFileSystem.windowsPath(name)
|
||||
if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
|
||||
return pathSvc.resolve(cwd, name)
|
||||
}
|
||||
// In the case of a git worktree, it can't cache the id
|
||||
// because `.git` is not a folder, but it always needs the
|
||||
// same project id as the common dir, so we resolve it now
|
||||
if (id == null) {
|
||||
id = await readCachedId(path.join(worktree, ".git"))
|
||||
}
|
||||
|
||||
const scope = yield* Scope.Scope
|
||||
// generate id from root commit
|
||||
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)
|
||||
|
||||
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) {
|
||||
if (!roots) {
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
worktree: "/",
|
||||
sandbox: "/",
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
}
|
||||
|
||||
let sandbox = pathSvc.dirname(dotgit)
|
||||
const gitBinary = yield* Effect.sync(() => which("git"))
|
||||
let id = yield* readCachedProjectId(dotgit)
|
||||
|
||||
if (!gitBinary) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
}
|
||||
}
|
||||
|
||||
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
|
||||
if (commonDir.code !== 0) {
|
||||
return {
|
||||
id: id ?? ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: fakeVcs,
|
||||
}
|
||||
id = roots[0] ? ProjectID.make(roots[0]) : undefined
|
||||
if (id) {
|
||||
// Write to common dir so the cache is shared across worktrees.
|
||||
await Filesystem.write(path.join(worktree, ".git", "opencode"), id).catch(() => undefined)
|
||||
}
|
||||
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) {
|
||||
return {
|
||||
id: ProjectID.global,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: "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 }
|
||||
const top = await git(["rev-parse", "--show-toplevel"], {
|
||||
cwd: sandbox,
|
||||
})
|
||||
.then(async (result) => gitpath(sandbox, await result.text()))
|
||||
.catch(() => undefined)
|
||||
|
||||
// Phase 2: upsert
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
|
||||
const existing = row
|
||||
? fromRow(row)
|
||||
: {
|
||||
id: data.id,
|
||||
worktree: data.worktree,
|
||||
vcs: data.vcs,
|
||||
sandboxes: [] as string[],
|
||||
time: { created: Date.now(), updated: Date.now() },
|
||||
}
|
||||
if (!top) {
|
||||
return {
|
||||
id,
|
||||
worktree: sandbox,
|
||||
sandbox,
|
||||
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
|
||||
}
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY)
|
||||
yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||
sandbox = top
|
||||
|
||||
const result: Info = {
|
||||
...existing,
|
||||
return {
|
||||
id,
|
||||
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,
|
||||
vcs: data.vcs,
|
||||
time: { ...existing.time, updated: Date.now() },
|
||||
}
|
||||
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
|
||||
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(),
|
||||
)
|
||||
vcs: data.vcs as Info["vcs"],
|
||||
sandboxes: [] as string[],
|
||||
time: {
|
||||
created: Date.now(),
|
||||
updated: Date.now(),
|
||||
},
|
||||
}
|
||||
|
||||
yield* emitUpdated(result)
|
||||
return { project: result, sandbox: data.sandbox }
|
||||
})
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
|
||||
|
||||
const discover = Effect.fn("Project.discover")(function* (input: Info) {
|
||||
if (input.vcs !== "git") return
|
||||
if (input.icon?.override) return
|
||||
if (input.icon?.url) return
|
||||
|
||||
const matches = yield* fsys
|
||||
.glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
|
||||
cwd: input.worktree,
|
||||
absolute: true,
|
||||
include: "file",
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
const shortest = matches.sort((a, b) => a.length - b.length)[0]
|
||||
if (!shortest) return
|
||||
|
||||
const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie)
|
||||
const base64 = Buffer.from(buffer).toString("base64")
|
||||
const mime = AppFileSystem.mimeType(shortest)
|
||||
const url = `data:${mime};base64,${base64}`
|
||||
yield* update({ projectID: input.id, icon: { url } })
|
||||
})
|
||||
|
||||
const list = Effect.fn("Project.list")(function* () {
|
||||
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
|
||||
})
|
||||
|
||||
const get = Effect.fn("Project.get")(function* (id: ProjectID) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
return row ? fromRow(row) : undefined
|
||||
})
|
||||
|
||||
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
|
||||
const result = yield* db((d) =>
|
||||
d
|
||||
.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, input.projectID))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
if (!result) throw new Error(`Project not found: ${input.projectID}`)
|
||||
const data = fromRow(result)
|
||||
yield* emitUpdated(data)
|
||||
return data
|
||||
})
|
||||
|
||||
const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
|
||||
if (input.project.vcs === "git") return input.project
|
||||
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) {
|
||||
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))
|
||||
const result: Info = {
|
||||
...existing,
|
||||
worktree: data.worktree,
|
||||
vcs: data.vcs as Info["vcs"],
|
||||
time: {
|
||||
...existing.time,
|
||||
updated: Date.now(),
|
||||
},
|
||||
}
|
||||
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
|
||||
result.sandboxes.push(data.sandbox)
|
||||
result.sandboxes = result.sandboxes.filter((x) => existsSync(x))
|
||||
const insert = {
|
||||
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,
|
||||
}
|
||||
const updateSet = {
|
||||
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,
|
||||
}
|
||||
Database.use((db) =>
|
||||
db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(),
|
||||
)
|
||||
// Runs after upsert so the target project row exists (FK constraint).
|
||||
// Runs on every startup because sessions created before git init
|
||||
// accumulate under "global" and need migrating whenever they appear.
|
||||
if (data.id !== ProjectID.global) {
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({ project_id: data.id })
|
||||
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
|
||||
.run(),
|
||||
)
|
||||
}
|
||||
GlobalBus.emit("event", {
|
||||
payload: {
|
||||
type: Event.Updated.type,
|
||||
properties: result,
|
||||
},
|
||||
})
|
||||
return { project: result, sandbox: data.sandbox }
|
||||
}
|
||||
|
||||
export function discover(input: Info) {
|
||||
return runPromise((svc) => svc.discover(input))
|
||||
export async function discover(input: Info) {
|
||||
if (input.vcs !== "git") return
|
||||
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() {
|
||||
@@ -492,29 +345,112 @@ export namespace Project {
|
||||
return fromRow(row)
|
||||
}
|
||||
|
||||
export function setInitialized(id: ProjectID) {
|
||||
Database.use((db) =>
|
||||
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
||||
export async function initGit(input: { directory: string; project: Info }) {
|
||||
if (input.project.vcs === "git") return input.project
|
||||
if (!which("git")) throw new Error("Git is not installed")
|
||||
|
||||
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 function initGit(input: { directory: string; project: Info }) {
|
||||
return runPromise((svc) => svc.initGit(input))
|
||||
}
|
||||
|
||||
export function update(input: UpdateInput) {
|
||||
return runPromise((svc) => svc.update(input))
|
||||
}
|
||||
|
||||
export function sandboxes(id: ProjectID) {
|
||||
return runPromise((svc) => svc.sandboxes(id))
|
||||
}
|
||||
|
||||
export function addSandbox(id: ProjectID, directory: string) {
|
||||
return runPromise((svc) => svc.addSandbox(id, directory))
|
||||
}
|
||||
|
||||
export function removeSandbox(id: ProjectID, directory: string) {
|
||||
return runPromise((svc) => svc.removeSandbox(id, directory))
|
||||
export async function removeSandbox(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.filter((s) => s !== 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Auto-generated by build.ts - do not edit
|
||||
export declare const snapshot: Record<string, unknown>
|
||||
File diff suppressed because one or more lines are too long
@@ -89,12 +89,15 @@ export namespace ModelsDev {
|
||||
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
|
||||
if (result) return result
|
||||
// @ts-ignore
|
||||
const snapshot = await import("./models-snapshot.js")
|
||||
const snapshot = await import("./models-snapshot")
|
||||
.then((m) => m.snapshot as Record<string, unknown>)
|
||||
.catch(() => undefined)
|
||||
if (snapshot) return snapshot
|
||||
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
|
||||
const json = await fetch(`${url()}/api.json`).then((x) => x.text())
|
||||
const json = await fetch(`${url()}/api.json`)
|
||||
.then((x) => x.text())
|
||||
.catch(() => undefined)
|
||||
if (!json) return {}
|
||||
return JSON.parse(json)
|
||||
})
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { createOpenAI } from "@ai-sdk/openai"
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
|
||||
import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
|
||||
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot"
|
||||
import { createMock } from "./sdk/mock"
|
||||
import { createXai } from "@ai-sdk/xai"
|
||||
import { createMistral } from "@ai-sdk/mistral"
|
||||
import { createGroq } from "@ai-sdk/groq"
|
||||
@@ -132,6 +133,7 @@ export namespace Provider {
|
||||
"gitlab-ai-provider": createGitLab,
|
||||
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
|
||||
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
|
||||
"@opencode/mock": createMock as any,
|
||||
}
|
||||
|
||||
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
|
||||
@@ -150,6 +152,11 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
||||
async mock() {
|
||||
return {
|
||||
autoload: true,
|
||||
}
|
||||
},
|
||||
async anthropic() {
|
||||
return {
|
||||
autoload: false,
|
||||
@@ -920,6 +927,42 @@ export namespace Provider {
|
||||
const modelsDev = await ModelsDev.get()
|
||||
const database = mapValues(modelsDev, fromModelsDevProvider)
|
||||
|
||||
// Register the built-in mock provider for testing
|
||||
database["mock"] = {
|
||||
id: "mock",
|
||||
name: "Mock",
|
||||
source: "custom",
|
||||
env: [],
|
||||
options: {},
|
||||
models: {
|
||||
"mock-model": {
|
||||
id: "mock-model",
|
||||
providerID: "mock",
|
||||
name: "Mock Model",
|
||||
api: {
|
||||
id: "mock-model",
|
||||
url: "",
|
||||
npm: "@opencode/mock",
|
||||
},
|
||||
status: "active",
|
||||
capabilities: {
|
||||
temperature: false,
|
||||
reasoning: false,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 128000, output: 4096 },
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2025-01-01",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# Mock RPC
|
||||
|
||||
Deterministic model scripting for tests.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The mock provider lets test harnesses script exactly what the model should emit. Instead of hitting a real API, the user message contains a JSON object that describes each step of the conversation. This makes test scenarios fully deterministic and reproducible.
|
||||
|
||||
---
|
||||
|
||||
## Understand the protocol
|
||||
|
||||
The user message text is a JSON object with a `steps` array. Each step is an array of actions that the model emits on that turn.
|
||||
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
[{ "type": "text", "content": "Hello" }],
|
||||
[{ "type": "text", "content": "Goodbye" }]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The mock model reads the **last** user message in the prompt to find this JSON.
|
||||
|
||||
---
|
||||
|
||||
## Know how steps are selected
|
||||
|
||||
The model picks which step to execute by counting messages with `role: "tool"` in the prompt. This count represents how many tool-result rounds have occurred.
|
||||
|
||||
- **Step 0** runs on the first call (no tool results yet).
|
||||
- **Step 1** runs after the first tool-result round.
|
||||
- **Step N** runs after the Nth tool-result round.
|
||||
|
||||
If the step index is out of bounds, the model emits an empty set of actions.
|
||||
|
||||
---
|
||||
|
||||
## Use the `text` action
|
||||
|
||||
Emits a text block.
|
||||
|
||||
```json
|
||||
{ "type": "text", "content": "Some response text" }
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|--------|----------------------|
|
||||
| `content` | string | The text to emit. |
|
||||
|
||||
---
|
||||
|
||||
## Use the `tool_call` action
|
||||
|
||||
Calls a tool. The input object is passed as-is.
|
||||
|
||||
```json
|
||||
{ "type": "tool_call", "name": "write", "input": { "filePath": "a.txt", "content": "hi" } }
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|---------|--------|---------------------------------|
|
||||
| `name` | string | Name of the tool to call. |
|
||||
| `input` | object | Arguments passed to the tool. |
|
||||
|
||||
---
|
||||
|
||||
## Use the `thinking` action
|
||||
|
||||
Emits a reasoning/thinking block.
|
||||
|
||||
```json
|
||||
{ "type": "thinking", "content": "Let me consider the options..." }
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|--------|----------------------------|
|
||||
| `content` | string | The thinking text to emit. |
|
||||
|
||||
---
|
||||
|
||||
## Use the `list_tools` action
|
||||
|
||||
Responds with a JSON text block listing all available tools and their schemas. Useful for test scripts that need to discover tool names. No additional fields.
|
||||
|
||||
```json
|
||||
{ "type": "list_tools" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Use the `error` action
|
||||
|
||||
Emits an error chunk.
|
||||
|
||||
```json
|
||||
{ "type": "error", "message": "something went wrong" }
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|--------|------------------------|
|
||||
| `message` | string | The error message. |
|
||||
|
||||
---
|
||||
|
||||
## Know the finish reason
|
||||
|
||||
The finish reason is auto-inferred from the actions in the current step. If any action has `type: "tool_call"`, the finish reason is `"tool-calls"`. Otherwise it is `"stop"`.
|
||||
|
||||
Token usage is always reported as `{ inputTokens: 10, outputTokens: 20, totalTokens: 30 }`.
|
||||
|
||||
---
|
||||
|
||||
## Handle invalid JSON
|
||||
|
||||
If the user message is not valid JSON or doesn't have a `steps` array, the model falls back to a default text response. This keeps backward compatibility with tests that don't use the RPC protocol.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Simple text response
|
||||
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
[{ "type": "text", "content": "Hello from the mock model" }]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Tool discovery
|
||||
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
[{ "type": "list_tools" }]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Single tool call
|
||||
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
[{ "type": "tool_call", "name": "read", "input": { "filePath": "config.json" } }]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Multi-turn tool use
|
||||
|
||||
Step 0 calls a tool. Step 1 runs after the tool result comes back and emits a text response.
|
||||
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
[{ "type": "tool_call", "name": "write", "input": { "filePath": "a.txt", "content": "hi" } }],
|
||||
[{ "type": "text", "content": "Done writing the file." }]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Thinking and text
|
||||
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
[
|
||||
{ "type": "thinking", "content": "The user wants a greeting." },
|
||||
{ "type": "text", "content": "Hey there!" }
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple actions in one step
|
||||
|
||||
A single step can contain any combination of actions.
|
||||
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
[
|
||||
{ "type": "text", "content": "I'll create two files." },
|
||||
{ "type": "tool_call", "name": "write", "input": { "filePath": "a.txt", "content": "aaa" } },
|
||||
{ "type": "tool_call", "name": "write", "input": { "filePath": "b.txt", "content": "bbb" } }
|
||||
],
|
||||
[
|
||||
{ "type": "text", "content": "Both files created." }
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Error simulation
|
||||
|
||||
```json
|
||||
{
|
||||
"steps": [
|
||||
[{ "type": "error", "message": "rate limit exceeded" }]
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,19 @@
|
||||
I got it to the point where it can run a full mock session
|
||||
|
||||
Run the server with `./src/provider/sdk/mock/run`. It will run it sandboxes to make sure it doesn't interact with the outside world unexpectedly.
|
||||
|
||||
Then run `bun run src/provider/sdk/mock/runner/index.ts` to drive a session and get a log
|
||||
|
||||
There is also `bun run src/provider/sdk/mock/runner/diff.ts` which will drive two sessions at once and compare them. This is annoying right now because you have to run two servers. This would let you compare the differences between versions though
|
||||
|
||||
## Coverage
|
||||
|
||||
I also have an experiment in `serve.test.ts` which runs the server as a bun test, which gives us access to coverage info. Run it like this:
|
||||
|
||||
```
|
||||
bun test --coverage --coverage-reporter=lcov --timeout 0 src/provider/sdk/mock/runner/serve.test.ts
|
||||
```
|
||||
|
||||
That will give you a `lcov.info` file. Convert it to HTML with this:
|
||||
|
||||
genhtml coverage/lcov.info -o coverage/html && open coverage/html/index.html
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { LanguageModelV2 } from "@ai-sdk/provider"
|
||||
import { MockLanguageModel } from "./model"
|
||||
|
||||
export { vfsPlugin } from "./plugin"
|
||||
export { Filesystem as VFilesystem } from "./vfs"
|
||||
|
||||
export interface MockProviderSettings {
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface MockProvider {
|
||||
(id: string): LanguageModelV2
|
||||
languageModel(id: string): LanguageModelV2
|
||||
}
|
||||
|
||||
export function createMock(options: MockProviderSettings = {}): MockProvider {
|
||||
const name = options.name ?? "mock"
|
||||
|
||||
const create = (id: string) => new MockLanguageModel(id, { provider: name })
|
||||
|
||||
const provider = Object.assign((id: string) => create(id), { languageModel: create })
|
||||
|
||||
return provider
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import type {
|
||||
LanguageModelV2,
|
||||
LanguageModelV2CallOptions,
|
||||
LanguageModelV2FunctionTool,
|
||||
LanguageModelV2StreamPart,
|
||||
} from "@ai-sdk/provider"
|
||||
|
||||
/**
|
||||
* Mock Model RPC Protocol
|
||||
*
|
||||
* The user message text is a JSON object that scripts exactly what the mock
|
||||
* model should emit. This lets test harnesses drive the model deterministically.
|
||||
*
|
||||
* Schema:
|
||||
* ```
|
||||
* {
|
||||
* "steps": [
|
||||
* // Step 0: executed on first call (no tool results yet)
|
||||
* [
|
||||
* { "type": "tool_call", "name": "write", "input": { "filePath": "a.txt", "content": "hi" } }
|
||||
* ],
|
||||
* // Step 1: executed after first tool-result round
|
||||
* [
|
||||
* { "type": "text", "content": "Done!" }
|
||||
* ]
|
||||
* ]
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Supported actions:
|
||||
*
|
||||
* { "type": "text", "content": "string" }
|
||||
* Emit a text block.
|
||||
*
|
||||
* { "type": "tool_call", "name": "toolName", "input": { ... } }
|
||||
* Call a tool. The input object is passed as-is.
|
||||
*
|
||||
* { "type": "thinking", "content": "string" }
|
||||
* Emit a reasoning/thinking block.
|
||||
*
|
||||
* { "type": "list_tools" }
|
||||
* Respond with a JSON text block listing all available tools and their
|
||||
* schemas. Useful for test scripts that need to discover tool names.
|
||||
*
|
||||
* { "type": "error", "message": "string" }
|
||||
* Emit an error chunk.
|
||||
*
|
||||
* Finish reason is auto-inferred: "tool-calls" when any tool_call action
|
||||
* exists in the step, "stop" otherwise. Override with a top-level "finish"
|
||||
* field on the script object.
|
||||
*
|
||||
* If the user message is not valid JSON or doesn't match the schema, the
|
||||
* model falls back to a default text response (backward compatible).
|
||||
*/
|
||||
|
||||
// ── Protocol types ──────────────────────────────────────────────────────
|
||||
|
||||
type TextAction = { type: "text"; content: string }
|
||||
type ToolCallAction = { type: "tool_call"; name: string; input: Record<string, unknown> }
|
||||
type ThinkingAction = { type: "thinking"; content: string }
|
||||
type ListToolsAction = { type: "list_tools" }
|
||||
type ErrorAction = { type: "error"; message: string }
|
||||
|
||||
type Action = TextAction | ToolCallAction | ThinkingAction | ListToolsAction | ErrorAction
|
||||
|
||||
type Script = {
|
||||
steps: Action[][]
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function text(options: LanguageModelV2CallOptions): string {
|
||||
for (const msg of [...options.prompt].reverse()) {
|
||||
if (msg.role !== "user") continue
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text") return part.text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/** Count tool-result rounds since the last user message. */
|
||||
function round(options: LanguageModelV2CallOptions): number {
|
||||
let count = 0
|
||||
for (const msg of [...options.prompt].reverse()) {
|
||||
if (msg.role === "user") break
|
||||
if (msg.role === "tool") count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function parse(raw: string): Script | undefined {
|
||||
try {
|
||||
const json = JSON.parse(raw)
|
||||
if (!json || !Array.isArray(json.steps)) return undefined
|
||||
return json as Script
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function tools(options: LanguageModelV2CallOptions): LanguageModelV2FunctionTool[] {
|
||||
if (!options.tools) return []
|
||||
return options.tools.filter((t): t is LanguageModelV2FunctionTool => t.type === "function")
|
||||
}
|
||||
|
||||
function emit(actions: Action[], options: LanguageModelV2CallOptions): LanguageModelV2StreamPart[] {
|
||||
const chunks: LanguageModelV2StreamPart[] = []
|
||||
let tid = 0
|
||||
let rid = 0
|
||||
let xid = 0
|
||||
|
||||
for (const action of actions) {
|
||||
switch (action.type) {
|
||||
case "text": {
|
||||
const id = `mock-text-${xid++}`
|
||||
chunks.push(
|
||||
{ type: "text-start", id },
|
||||
{ type: "text-delta", id, delta: action.content },
|
||||
{ type: "text-end", id },
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "tool_call": {
|
||||
const id = `mock-call-${tid++}`
|
||||
const input = JSON.stringify(action.input)
|
||||
chunks.push(
|
||||
{ type: "tool-input-start", id, toolName: action.name },
|
||||
{ type: "tool-input-delta", id, delta: input },
|
||||
{ type: "tool-input-end", id },
|
||||
{ type: "tool-call" as const, toolCallId: id, toolName: action.name, input },
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "thinking": {
|
||||
const id = `mock-reasoning-${rid++}`
|
||||
chunks.push(
|
||||
{ type: "reasoning-start", id },
|
||||
{ type: "reasoning-delta", id, delta: action.content },
|
||||
{ type: "reasoning-end", id },
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "list_tools": {
|
||||
const id = `mock-text-${xid++}`
|
||||
const defs = tools(options).map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
input: t.inputSchema,
|
||||
}))
|
||||
chunks.push(
|
||||
{ type: "text-start", id },
|
||||
{ type: "text-delta", id, delta: JSON.stringify(defs, null, 2) },
|
||||
{ type: "text-end", id },
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case "error": {
|
||||
chunks.push({ type: "error", error: new Error(action.message) })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
// ── Model ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class MockLanguageModel implements LanguageModelV2 {
|
||||
readonly specificationVersion = "v2" as const
|
||||
readonly provider: string
|
||||
readonly modelId: string
|
||||
readonly supportedUrls: Record<string, RegExp[]> = {}
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
readonly options: { provider: string },
|
||||
) {
|
||||
this.modelId = id
|
||||
this.provider = options.provider
|
||||
}
|
||||
|
||||
async doGenerate(options: LanguageModelV2CallOptions): Promise<never> {
|
||||
throw new Error("`doGenerate` not implemented")
|
||||
}
|
||||
|
||||
async doStream(options: LanguageModelV2CallOptions) {
|
||||
const raw = text(options)
|
||||
const script = parse(raw)
|
||||
const r = round(options)
|
||||
const actions = script ? (script.steps[r] ?? []) : undefined
|
||||
|
||||
const chunks: LanguageModelV2StreamPart[] = [
|
||||
{ type: "stream-start", warnings: [] },
|
||||
{
|
||||
type: "response-metadata",
|
||||
id: "mock-response",
|
||||
modelId: this.modelId,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]
|
||||
|
||||
if (actions) {
|
||||
chunks.push(...emit(actions, options))
|
||||
} else {
|
||||
// Fallback: plain text response (backward compatible)
|
||||
chunks.push(
|
||||
{ type: "text-start", id: "mock-text-0" },
|
||||
{
|
||||
type: "text-delta",
|
||||
id: "mock-text-0",
|
||||
delta: `[mock] This is a streamed mock response from model "${this.modelId}". `,
|
||||
},
|
||||
{
|
||||
type: "text-delta",
|
||||
id: "mock-text-0",
|
||||
delta: "The mock provider does not call any real API.",
|
||||
},
|
||||
{ type: "text-end", id: "mock-text-0" },
|
||||
)
|
||||
}
|
||||
|
||||
const called = actions?.some((a) => a.type === "tool_call")
|
||||
chunks.push({
|
||||
type: "finish",
|
||||
finishReason: called ? "tool-calls" : "stop",
|
||||
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
|
||||
})
|
||||
|
||||
const stream = new ReadableStream<LanguageModelV2StreamPart>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(chunk)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
return { stream }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { BunPlugin } from "bun"
|
||||
import { Filesystem } from "./vfs"
|
||||
|
||||
/**
|
||||
* Bun plugin that intercepts all loads of `util/filesystem.ts` and replaces
|
||||
* the real Filesystem namespace with the in-memory VFS implementation.
|
||||
*
|
||||
* Must be registered via preload before any application code runs.
|
||||
*/
|
||||
export const vfsPlugin: BunPlugin = {
|
||||
name: "vfs",
|
||||
setup(build) {
|
||||
build.onLoad({ filter: /util\/filesystem\.ts$/ }, () => ({
|
||||
exports: { Filesystem },
|
||||
loader: "object",
|
||||
}))
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { vfsPlugin } from "./plugin"
|
||||
Bun.plugin(vfsPlugin)
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
|
||||
ROOT="$(dirname "$0")"
|
||||
|
||||
cd "$ROOT/../../../.."
|
||||
sandbox-exec -f ./src/provider/sdk/mock/sandbox.sb -D HOME=$HOME bun --preload "$ROOT/preload.ts" "src/index.ts" serve
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Shared core for mock runners: HTTP, SSE, script generation, message handling.
|
||||
*/
|
||||
|
||||
import path from "path"
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type Tool = {
|
||||
id: string
|
||||
description: string
|
||||
parameters: {
|
||||
type: string
|
||||
properties?: Record<string, { type: string; description?: string }>
|
||||
required?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "tool_call"; name: string; input: Record<string, unknown> }
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "list_tools" }
|
||||
| { type: "error"; message: string }
|
||||
|
||||
export type Script = { steps: Action[][] }
|
||||
export type Event = { type: string; properties: Record<string, any> }
|
||||
export type Message = { info: Record<string, any>; parts: Record<string, any>[] }
|
||||
type Listener = (event: Event) => void
|
||||
|
||||
export type Instance = {
|
||||
name: string
|
||||
base: string
|
||||
sse: AbortController
|
||||
}
|
||||
|
||||
// ── HTTP ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function api<T = unknown>(base: string, method: string, path: string, body?: unknown): Promise<T> {
|
||||
const opts: RequestInit = {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
if (body !== undefined) opts.body = JSON.stringify(body)
|
||||
const res = await fetch(`${base}${path}`, opts)
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "")
|
||||
throw new Error(`${method} ${path} → ${res.status}: ${text}`)
|
||||
}
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json() as T
|
||||
}
|
||||
|
||||
// ── SSE ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const listeners = new Map<AbortController, Listener>()
|
||||
|
||||
function subscribe(base: string, cb: Listener): AbortController {
|
||||
const abort = new AbortController()
|
||||
;(async () => {
|
||||
const res = await fetch(`${base}/event`, {
|
||||
headers: { Accept: "text/event-stream" },
|
||||
signal: abort.signal,
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
log("SSE connect failed", base, res.status)
|
||||
return
|
||||
}
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ""
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const lines = buf.split("\n")
|
||||
buf = lines.pop()!
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue
|
||||
try {
|
||||
cb(JSON.parse(line.slice(6)))
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
})().catch(() => {})
|
||||
return abort
|
||||
}
|
||||
|
||||
export function startSSE(base: string): AbortController {
|
||||
const ctrl = subscribe(base, (evt) => {
|
||||
const fn = listeners.get(ctrl)
|
||||
fn?.(evt)
|
||||
})
|
||||
listeners.set(ctrl, () => {})
|
||||
return ctrl
|
||||
}
|
||||
|
||||
export function idle(sid: string, sse: AbortController, timeout = 60_000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`session ${sid} did not become idle within ${timeout}ms`))
|
||||
}, timeout)
|
||||
|
||||
const orig = listeners.get(sse)
|
||||
const handler = (evt: Event) => {
|
||||
orig?.(evt)
|
||||
if (evt.type !== "session.status") return
|
||||
if (evt.properties.sessionID !== sid) return
|
||||
if (evt.properties.status?.type === "idle") {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
listeners.set(sse, handler)
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timer)
|
||||
if (orig) listeners.set(sse, orig)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Tool discovery ──────────────────────────────────────────────────────
|
||||
|
||||
let cachedTools: Tool[] | undefined
|
||||
|
||||
export async function tools(base: string): Promise<Tool[]> {
|
||||
if (cachedTools) return cachedTools
|
||||
cachedTools = await api<Tool[]>(base, "GET", "/experimental/tool?provider=mock&model=mock-model")
|
||||
return cachedTools
|
||||
}
|
||||
|
||||
// ── Random generators ───────────────────────────────────────────────────
|
||||
|
||||
function pick<T>(arr: T[]): T {
|
||||
return arr[Math.floor(Math.random() * arr.length)]
|
||||
}
|
||||
|
||||
export function rand(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
const WORDS = [
|
||||
"foo",
|
||||
"bar",
|
||||
"baz",
|
||||
"qux",
|
||||
"hello",
|
||||
"world",
|
||||
"test",
|
||||
"alpha",
|
||||
"beta",
|
||||
"gamma",
|
||||
"delta",
|
||||
"src",
|
||||
"lib",
|
||||
"tmp",
|
||||
]
|
||||
const EXTS = [".ts", ".js", ".json", ".txt", ".md"]
|
||||
|
||||
function word() {
|
||||
return pick(WORDS)
|
||||
}
|
||||
|
||||
function sentence() {
|
||||
const n = rand(3, 12)
|
||||
return Array.from({ length: n }, () => word()).join(" ")
|
||||
}
|
||||
|
||||
function filepath() {
|
||||
const depth = rand(1, 3)
|
||||
const parts = Array.from({ length: depth }, () => word())
|
||||
return parts.join("/") + pick(EXTS)
|
||||
}
|
||||
|
||||
function fakeInput(tool: Tool): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
const props = tool.parameters.properties ?? {}
|
||||
for (const [key, schema] of Object.entries(props)) {
|
||||
switch (schema.type) {
|
||||
case "string":
|
||||
if (key.toLowerCase().includes("path") || key.toLowerCase().includes("file")) {
|
||||
result[key] = filepath()
|
||||
} else if (key.toLowerCase().includes("pattern") || key.toLowerCase().includes("regex")) {
|
||||
result[key] = word()
|
||||
} else if (key.toLowerCase().includes("command") || key.toLowerCase().includes("cmd")) {
|
||||
result[key] = `echo ${word()}`
|
||||
} else {
|
||||
result[key] = sentence()
|
||||
}
|
||||
break
|
||||
case "number":
|
||||
case "integer":
|
||||
result[key] = rand(1, 100)
|
||||
break
|
||||
case "boolean":
|
||||
result[key] = Math.random() > 0.5
|
||||
break
|
||||
case "object":
|
||||
result[key] = {}
|
||||
break
|
||||
case "array":
|
||||
result[key] = []
|
||||
break
|
||||
default:
|
||||
result[key] = sentence()
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Action generators ───────────────────────────────────────────────────
|
||||
|
||||
const SAFE_TOOLS = new Set(["read", "glob", "grep", "todowrite", "webfetch", "websearch", "codesearch"])
|
||||
const WRITE_TOOLS = new Set(["write", "edit", "bash"])
|
||||
|
||||
function textAction(): Action {
|
||||
return { type: "text", content: sentence() }
|
||||
}
|
||||
|
||||
function thinkingAction(): Action {
|
||||
return { type: "thinking", content: sentence() }
|
||||
}
|
||||
|
||||
function errorAction(): Action {
|
||||
return { type: "error", message: `mock error: ${word()}` }
|
||||
}
|
||||
|
||||
function listToolsAction(): Action {
|
||||
return { type: "list_tools" }
|
||||
}
|
||||
|
||||
async function toolAction(base: string): Promise<Action> {
|
||||
const all = await tools(base)
|
||||
const safe = all.filter((t) => SAFE_TOOLS.has(t.id) || WRITE_TOOLS.has(t.id))
|
||||
if (!safe.length) return textAction()
|
||||
const tool = pick(safe)
|
||||
return { type: "tool_call", name: tool.id, input: fakeInput(tool) }
|
||||
}
|
||||
|
||||
// ── Script generation ───────────────────────────────────────────────────
|
||||
|
||||
export async function script(base: string): Promise<Script> {
|
||||
const r = Math.random()
|
||||
|
||||
if (r < 0.4) {
|
||||
const call = await toolAction(base)
|
||||
return { steps: [[call], [textAction()]] }
|
||||
}
|
||||
if (r < 0.6) {
|
||||
return { steps: [[thinkingAction(), textAction()]] }
|
||||
}
|
||||
if (r < 0.75) {
|
||||
const n = rand(2, 4)
|
||||
const calls: Action[] = []
|
||||
for (let i = 0; i < n; i++) calls.push(await toolAction(base))
|
||||
return { steps: [calls, [textAction()]] }
|
||||
}
|
||||
if (r < 0.85) {
|
||||
return { steps: [[textAction()]] }
|
||||
}
|
||||
if (r < 0.9) {
|
||||
return { steps: [[listToolsAction()]] }
|
||||
}
|
||||
if (r < 0.95) {
|
||||
const call = await toolAction(base)
|
||||
return { steps: [[thinkingAction(), call], [textAction()]] }
|
||||
}
|
||||
return { steps: [[errorAction()]] }
|
||||
}
|
||||
|
||||
// ── Pre-generate all scripts for a session ──────────────────────────────
|
||||
|
||||
export async function generate(base: string, count: number): Promise<Script[]> {
|
||||
const scripts: Script[] = []
|
||||
for (let i = 0; i < count; i++) scripts.push(await script(base))
|
||||
return scripts
|
||||
}
|
||||
|
||||
// ── Messages ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function messages(base: string, sid: string): Promise<Message[]> {
|
||||
return api<Message[]>(base, "GET", `/session/${sid}/message`)
|
||||
}
|
||||
|
||||
// ── Run a full session: send all scripts, return all messages ───────────
|
||||
|
||||
export async function run(inst: Instance, scripts: Script[]): Promise<Message[]> {
|
||||
const info = await api<{ id: string }>(inst.base, "POST", "/session", {})
|
||||
const sid = info.id
|
||||
|
||||
for (const s of scripts) {
|
||||
const payload = JSON.stringify(s)
|
||||
const wait = idle(sid, inst.sse)
|
||||
await api(inst.base, "POST", `/session/${sid}/prompt_async`, {
|
||||
parts: [{ type: "text", text: payload }],
|
||||
model: { providerID: "mock", modelID: "mock-model" },
|
||||
})
|
||||
await wait
|
||||
}
|
||||
|
||||
return messages(inst.base, sid)
|
||||
}
|
||||
|
||||
// ── Connect to an instance ──────────────────────────────────────────────
|
||||
|
||||
export async function connect(name: string, port: string): Promise<Instance> {
|
||||
const base = `http://localhost:${port}`
|
||||
const health = await api<{ healthy: boolean; version: string }>(base, "GET", "/global/health")
|
||||
if (!health.healthy) throw new Error(`${name} not healthy`)
|
||||
log(`${name}: version ${health.version} at ${base}`)
|
||||
const sse = startSSE(base)
|
||||
return { name, base, sse }
|
||||
}
|
||||
|
||||
// ── Logging ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function log(...args: unknown[]) {
|
||||
const ts = new Date().toISOString().slice(11, 23)
|
||||
console.log(`[${ts}]`, ...args)
|
||||
}
|
||||
|
||||
export function summary(s: Script): string {
|
||||
const actions = s.steps.flat()
|
||||
const types = actions.map((a) => {
|
||||
if (a.type === "tool_call") return `tool:${a.name}`
|
||||
return a.type
|
||||
})
|
||||
return `${s.steps.length} step(s): ${types.join(", ")}`
|
||||
}
|
||||
|
||||
export function logMessages(msgs: Message[]) {
|
||||
for (const msg of msgs) {
|
||||
const role = msg.info.role
|
||||
const parts = msg.parts.map((p: any) => {
|
||||
if (p.type === "text") return `text: ${p.text?.slice(0, 80)}${p.text?.length > 80 ? "..." : ""}`
|
||||
if (p.type === "tool") return `tool:${p.tool}(${p.state?.status})`
|
||||
if (p.type === "reasoning") return `reasoning: ${p.text?.slice(0, 60)}${(p.text?.length ?? 0) > 60 ? "..." : ""}`
|
||||
if (p.type === "step-start") return "step-start"
|
||||
if (p.type === "step-finish") return `step-finish(${p.reason})`
|
||||
return p.type
|
||||
})
|
||||
log(` ${role} [${msg.info.id?.slice(0, 8)}] ${parts.join(" | ")}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Mock Runner — Dual Instance Diff
|
||||
*
|
||||
* Connects to TWO running opencode servers, runs identical mock RPC scripts
|
||||
* against both (all turns on A, then all turns on B), and diffs the results.
|
||||
* Each session run writes the full serialized messages and a unified diff
|
||||
* into a folder under ./errors/<id>/.
|
||||
*
|
||||
* Usage:
|
||||
* bun run src/provider/sdk/mock/runner/diff.ts <port1> <port2>
|
||||
*/
|
||||
|
||||
import path from "path"
|
||||
import { connect, generate, run, log, summary, tools, rand, type Message } from "./core"
|
||||
|
||||
const port1 = process.argv[2]
|
||||
const port2 = process.argv[3]
|
||||
|
||||
if (!port1 || !port2) {
|
||||
console.error("Usage: bun run src/provider/sdk/mock/runner/diff.ts <port1> <port2>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ERRORS_DIR = path.join(import.meta.dir, "errors")
|
||||
|
||||
// ── Normalize ───────────────────────────────────────────────────────────
|
||||
|
||||
function normalize(msgs: Message[]): object[] {
|
||||
return msgs.map((m) => ({
|
||||
role: m.info.role,
|
||||
parts: m.parts.map((p) => {
|
||||
const { id, sessionID, messageID, ...rest } = p
|
||||
if (rest.type === "tool" && rest.state) {
|
||||
const { time, ...state } = rest.state
|
||||
return { ...rest, state }
|
||||
}
|
||||
if (rest.type === "step-finish") {
|
||||
const { cost, tokens, ...finish } = rest
|
||||
return finish
|
||||
}
|
||||
if ("time" in rest) {
|
||||
const { time, ...without } = rest
|
||||
return without
|
||||
}
|
||||
return rest
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Write results ───────────────────────────────────────────────────────
|
||||
|
||||
async function writeResults(scripts: { steps: object[][] }[], a: Message[], b: Message[]): Promise<string | false> {
|
||||
const na = normalize(a)
|
||||
const nb = normalize(b)
|
||||
const ja = JSON.stringify(na, null, 2)
|
||||
const jb = JSON.stringify(nb, null, 2)
|
||||
|
||||
if (ja === jb) return false
|
||||
|
||||
const id = Math.random().toString(36).slice(2, 10)
|
||||
const dir = path.join(ERRORS_DIR, id)
|
||||
|
||||
const fileA = path.join(dir, "normalized_a.json")
|
||||
const fileB = path.join(dir, "normalized_b.json")
|
||||
|
||||
await Promise.all([
|
||||
Bun.write(path.join(dir, "messages_a.json"), JSON.stringify(a, null, 2)),
|
||||
Bun.write(path.join(dir, "messages_b.json"), JSON.stringify(b, null, 2)),
|
||||
Bun.write(fileA, ja),
|
||||
Bun.write(fileB, jb),
|
||||
])
|
||||
|
||||
// generate unified diff via system `diff`
|
||||
const proc = Bun.spawn(["diff", "-u", "--label", "instance_a", fileA, "--label", "instance_b", fileB], {
|
||||
stdout: "pipe",
|
||||
})
|
||||
const patch = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
await Bun.write(path.join(dir, "diff.patch"), patch)
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
// ── Session loop ────────────────────────────────────────────────────────
|
||||
|
||||
async function session(a: Awaited<ReturnType<typeof connect>>, b: Awaited<ReturnType<typeof connect>>) {
|
||||
const turns = rand(30, 100)
|
||||
const scripts = await generate(a.base, turns)
|
||||
|
||||
log(`${turns} turns generated: ${scripts.map((s) => summary(s)).join(", ")}`)
|
||||
|
||||
log(`running ${turns} turns on A...`)
|
||||
const msgsA = await run(a, scripts)
|
||||
log(`A: ${msgsA.length} messages`)
|
||||
|
||||
log(`running ${turns} turns on B...`)
|
||||
const msgsB = await run(b, scripts)
|
||||
log(`B: ${msgsB.length} messages`)
|
||||
|
||||
const dir = await writeResults(scripts, msgsA, msgsB)
|
||||
if (dir) {
|
||||
log(`DIFF → ${dir}`)
|
||||
} else {
|
||||
log(`OK — no differences`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const a = await connect("A", port1)
|
||||
const b = await connect("B", port2)
|
||||
|
||||
const t = await tools(a.base)
|
||||
log(`${t.length} tools: ${t.map((x) => x.id).join(", ")}`)
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await session(a, b)
|
||||
} catch (e: any) {
|
||||
log(`session failed: ${e.message}`)
|
||||
await Bun.sleep(2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Mock Runner — Single Instance (Log Mode)
|
||||
*
|
||||
* Connects to one running opencode server, sends mock RPC scripts,
|
||||
* and logs all user/assistant messages per turn.
|
||||
*
|
||||
* Usage:
|
||||
* bun run src/provider/sdk/mock/runner/index.ts <port>
|
||||
*/
|
||||
|
||||
import {
|
||||
connect,
|
||||
generate,
|
||||
run,
|
||||
log,
|
||||
summary,
|
||||
logMessages,
|
||||
tools,
|
||||
rand,
|
||||
api,
|
||||
idle,
|
||||
messages,
|
||||
type Message,
|
||||
} from "./core"
|
||||
|
||||
const port = process.argv[2] ?? "4096"
|
||||
|
||||
async function session(inst: Awaited<ReturnType<typeof connect>>) {
|
||||
const info = await api<{ id: string }>(inst.base, "POST", "/session", {})
|
||||
const sid = info.id
|
||||
const turns = rand(30, 100)
|
||||
const history: Message[] = []
|
||||
|
||||
log(`session ${sid} — ${turns} turns`)
|
||||
|
||||
const scripts = await generate(inst.base, turns)
|
||||
|
||||
for (let i = 0; i < scripts.length; i++) {
|
||||
const s = scripts[i]
|
||||
const payload = JSON.stringify(s)
|
||||
log(` [${i + 1}/${turns}] ${summary(s)}`)
|
||||
|
||||
try {
|
||||
const wait = idle(sid, inst.sse)
|
||||
await api(inst.base, "POST", `/session/${sid}/prompt_async`, {
|
||||
parts: [{ type: "text", text: payload }],
|
||||
model: { providerID: "mock", modelID: "mock-model" },
|
||||
})
|
||||
await wait
|
||||
|
||||
const all = await messages(inst.base, sid)
|
||||
const known = new Set(history.map((m) => m.info.id))
|
||||
const fresh = all.filter((m) => !known.has(m.info.id))
|
||||
|
||||
log(` → ${fresh.length} new message(s):`)
|
||||
logMessages(fresh)
|
||||
|
||||
history.push(...fresh)
|
||||
} catch (e: any) {
|
||||
log(` error on turn ${i + 1}: ${e.message}`)
|
||||
await Bun.sleep(1000)
|
||||
await api(inst.base, "POST", `/session/${sid}/abort`).catch(() => {})
|
||||
await Bun.sleep(500)
|
||||
}
|
||||
}
|
||||
|
||||
log(`session ${sid} — done (${history.length} messages total)`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const inst = await connect("server", port)
|
||||
|
||||
const t = await tools(inst.base)
|
||||
log(`${t.length} tools: ${t.map((x) => x.id).join(", ")}`)
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await session(inst)
|
||||
} catch (e: any) {
|
||||
log(`session failed: ${e.message}`)
|
||||
await Bun.sleep(2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { vfsPlugin } from "../plugin"
|
||||
Bun.plugin(vfsPlugin)
|
||||
|
||||
/**
|
||||
* Starts the mock opencode server inside bun:test so coverage instrumentation works.
|
||||
*
|
||||
* Usage:
|
||||
* bun test --coverage --coverage-reporter=lcov --timeout 0 src/provider/sdk/mock/runner/serve.test.ts
|
||||
*
|
||||
* The server runs until the process is killed (Ctrl-C).
|
||||
* Coverage data is flushed on exit.
|
||||
*/
|
||||
|
||||
import { test } from "bun:test"
|
||||
import { Log } from "../../../../util/log"
|
||||
import { Server } from "../../../../server/server"
|
||||
import { Global } from "../../../../global"
|
||||
import { Filesystem } from "../../../../util/filesystem"
|
||||
import { JsonMigration } from "../../../../storage/json-migration"
|
||||
import { Database } from "../../../../storage/db"
|
||||
import path from "path"
|
||||
|
||||
const PORT = 4096;
|
||||
|
||||
test("serve", async () => {
|
||||
process.env.AGENT = "1"
|
||||
process.env.OPENCODE = "1"
|
||||
process.env.OPENCODE_PID = String(process.pid)
|
||||
|
||||
await Log.init({ print: false, dev: true, level: "DEBUG" })
|
||||
|
||||
const marker = path.join(Global.Path.data, "opencode.db")
|
||||
if (!(await Filesystem.exists(marker))) {
|
||||
console.log("Running one-time database migration...")
|
||||
await JsonMigration.run(Database.Client().$client, {
|
||||
progress: (event) => {
|
||||
const pct = Math.floor((event.current / event.total) * 100)
|
||||
if (event.current === event.total || pct % 25 === 0) {
|
||||
console.log(` migration: ${pct}%`)
|
||||
}
|
||||
},
|
||||
})
|
||||
console.log("Migration complete.")
|
||||
}
|
||||
|
||||
const server = Server.listen({ port: PORT, hostname: "127.0.0.1" })
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
// keep alive until killed
|
||||
await new Promise(() => {})
|
||||
await server.stop()
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
(version 1)
|
||||
|
||||
;; Start permissive, then lock down network and filesystem
|
||||
(allow default)
|
||||
|
||||
;; ── Network: deny all, allow localhost ──
|
||||
(deny network*)
|
||||
(allow network* (local ip "localhost:*"))
|
||||
(allow network* (remote ip "localhost:*"))
|
||||
|
||||
;; ── Filesystem writes: deny all, then allow temp dirs ──
|
||||
(deny file-write*)
|
||||
|
||||
;; ── Filesystem reads: deny sensitive dirs ──
|
||||
(deny file-read*
|
||||
(subpath (string-append (param "HOME") "/.local"))
|
||||
(subpath (string-append (param "HOME") "/.config")))
|
||||
@@ -0,0 +1,243 @@
|
||||
import path from "path"
|
||||
import { lookup } from "mime-types"
|
||||
|
||||
const ROOT = '/mock'
|
||||
|
||||
// TODO:
|
||||
//
|
||||
// * Some places use the `glob` utility to scan the filesystem which
|
||||
// does not go through `Filesystem`. We should mock that too
|
||||
|
||||
function globToRegex(pattern: string, cwd: string): RegExp {
|
||||
const full = pattern.startsWith("/") ? pattern : cwd.replace(/\/$/, "") + "/" + pattern
|
||||
const escaped = full
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*\*/g, "<<<GLOBSTAR>>>")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/<<<GLOBSTAR>>>/g, ".*")
|
||||
.replace(/\?/g, "[^/]")
|
||||
return new RegExp("^" + escaped + "$")
|
||||
}
|
||||
|
||||
function enoent(p: string) {
|
||||
return Object.assign(new Error(`ENOENT: ${p}`), { code: "ENOENT" })
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory virtual filesystem that implements the same API as the
|
||||
* real `Filesystem` namespace from `util/filesystem`.
|
||||
*/
|
||||
export namespace Filesystem {
|
||||
const files = new Map<string, string>()
|
||||
|
||||
// seed
|
||||
// for (const [p, content] of Object.entries(SEED)) {
|
||||
// files.set(p, content)
|
||||
// }
|
||||
|
||||
function abs(p: string) {
|
||||
if (!path.isAbsolute(p)) return path.join(ROOT, p)
|
||||
return p
|
||||
}
|
||||
|
||||
function pfx(p: string) {
|
||||
const resolved = abs(p)
|
||||
return resolved.endsWith("/") ? resolved : resolved + "/"
|
||||
}
|
||||
|
||||
// -- Filesystem API --
|
||||
|
||||
export async function exists(p: string) {
|
||||
const resolved = abs(p)
|
||||
if (files.has(resolved)) return true
|
||||
const pre = pfx(p)
|
||||
for (const key of files.keys()) {
|
||||
if (key.startsWith(pre)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function isDir(p: string) {
|
||||
const resolved = abs(p)
|
||||
if (files.has(resolved)) return false
|
||||
const pre = pfx(p)
|
||||
for (const key of files.keys()) {
|
||||
if (key.startsWith(pre)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function stat(p: string) {
|
||||
const resolved = abs(p)
|
||||
const content = files.get(resolved)
|
||||
if (content !== undefined) {
|
||||
const sz = new TextEncoder().encode(content).byteLength
|
||||
return {
|
||||
isFile: () => true,
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
size: sz,
|
||||
mtimeMs: Date.now(),
|
||||
mtime: new Date(),
|
||||
}
|
||||
}
|
||||
// check directory
|
||||
const pre = pfx(p)
|
||||
for (const key of files.keys()) {
|
||||
if (key.startsWith(pre)) {
|
||||
return {
|
||||
isFile: () => false,
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
size: 0,
|
||||
mtimeMs: Date.now(),
|
||||
mtime: new Date(),
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function size(p: string) {
|
||||
const content = files.get(abs(p))
|
||||
if (content === undefined) return 0
|
||||
return new TextEncoder().encode(content).byteLength
|
||||
}
|
||||
|
||||
export async function readText(p: string) {
|
||||
console.log('reading', p)
|
||||
const content = files.get(abs(p))
|
||||
if (content === undefined) throw enoent(p)
|
||||
return content
|
||||
}
|
||||
|
||||
export async function readJson<T = any>(p: string): Promise<T> {
|
||||
console.log('reading', p)
|
||||
const content = files.get(abs(p))
|
||||
if (content === undefined) throw enoent(p)
|
||||
return JSON.parse(content)
|
||||
}
|
||||
|
||||
export async function readBytes(p: string) {
|
||||
console.log('reading', p)
|
||||
const content = files.get(abs(p))
|
||||
if (content === undefined) throw enoent(p)
|
||||
return Buffer.from(content)
|
||||
}
|
||||
|
||||
export async function readArrayBuffer(p: string) {
|
||||
const content = files.get(abs(p))
|
||||
if (content === undefined) throw enoent(p)
|
||||
const buf = Buffer.from(content)
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer
|
||||
}
|
||||
|
||||
export async function write(p: string, content: string | Buffer | Uint8Array, _mode?: number) {
|
||||
console.log('writing', p)
|
||||
files.set(abs(p), typeof content === "string" ? content : Buffer.from(content).toString("utf-8"))
|
||||
}
|
||||
|
||||
export async function writeJson(p: string, data: unknown, _mode?: number) {
|
||||
files.set(abs(p), JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
export async function writeStream(p: string, stream: ReadableStream<Uint8Array>, _mode?: number) {
|
||||
const reader = stream.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
}
|
||||
files.set(abs(p), Buffer.concat(chunks).toString("utf-8"))
|
||||
}
|
||||
|
||||
export function mimeType(p: string) {
|
||||
return lookup(p) || "application/octet-stream"
|
||||
}
|
||||
|
||||
export function normalizePath(p: string) {
|
||||
return p
|
||||
}
|
||||
|
||||
export function resolve(p: string) {
|
||||
return path.resolve(p)
|
||||
}
|
||||
|
||||
export function windowsPath(p: string) {
|
||||
return p
|
||||
}
|
||||
|
||||
export function overlaps(a: string, b: string) {
|
||||
const relA = path.relative(a, b)
|
||||
const relB = path.relative(b, a)
|
||||
return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
|
||||
}
|
||||
|
||||
export function contains(parent: string, child: string) {
|
||||
return !path.relative(parent, child).startsWith("..")
|
||||
}
|
||||
|
||||
export async function findUp(target: string, start: string, stop?: string) {
|
||||
let current = start
|
||||
const result: string[] = []
|
||||
while (true) {
|
||||
const search = path.join(current, target)
|
||||
if (await exists(search)) result.push(search)
|
||||
if (stop === current) break
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function* up(options: { targets: string[]; start: string; stop?: string }) {
|
||||
let current = options.start
|
||||
while (true) {
|
||||
for (const target of options.targets) {
|
||||
const search = path.join(current, target)
|
||||
if (await exists(search)) yield search
|
||||
}
|
||||
if (options.stop === current) break
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
export async function globUp(pattern: string, start: string, stop?: string) {
|
||||
let current = start
|
||||
const result: string[] = []
|
||||
while (true) {
|
||||
const dir = abs(current)
|
||||
const regex = globToRegex(pattern, dir)
|
||||
for (const key of files.keys()) {
|
||||
if (regex.test(key)) result.push(key)
|
||||
}
|
||||
if (stop === current) break
|
||||
const parent = path.dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// -- extra helpers for direct test manipulation --
|
||||
|
||||
export function _set(p: string, content: string) {
|
||||
files.set(abs(p), content)
|
||||
}
|
||||
|
||||
export function _get(p: string) {
|
||||
return files.get(abs(p))
|
||||
}
|
||||
|
||||
export function _remove(p: string) {
|
||||
files.delete(abs(p))
|
||||
}
|
||||
|
||||
export function _list() {
|
||||
return [...files.keys()].sort()
|
||||
}
|
||||
}
|
||||
@@ -194,10 +194,7 @@ export namespace ProviderTransform {
|
||||
}
|
||||
|
||||
for (const msg of unique([...system, ...final])) {
|
||||
const useMessageLevelOptions =
|
||||
model.providerID === "anthropic" ||
|
||||
model.providerID.includes("bedrock") ||
|
||||
model.api.npm === "@ai-sdk/amazon-bedrock"
|
||||
const useMessageLevelOptions = model.providerID === "anthropic" || model.providerID.includes("bedrock")
|
||||
const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
|
||||
|
||||
if (shouldUseContentOptions) {
|
||||
|
||||
@@ -108,7 +108,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.CreateInput.optional()),
|
||||
validator("json", Worktree.create.schema),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const worktree = await Worktree.create(body)
|
||||
@@ -155,7 +155,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.RemoveInput),
|
||||
validator("json", Worktree.remove.schema),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Worktree.remove(body)
|
||||
@@ -181,7 +181,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.ResetInput),
|
||||
validator("json", Worktree.reset.schema),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Worktree.reset(body)
|
||||
|
||||
@@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ projectID: ProjectID.zod })),
|
||||
validator("json", Project.UpdateInput.omit({ projectID: true })),
|
||||
validator("json", Project.update.schema.omit({ projectID: true })),
|
||||
async (c) => {
|
||||
const projectID = c.req.valid("param").projectID
|
||||
const body = c.req.valid("json")
|
||||
|
||||
@@ -19,8 +19,6 @@ import { PermissionID } from "@/permission/schema"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { Bus } from "../../bus"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -848,13 +846,7 @@ export const SessionRoutes = lazy(() =>
|
||||
return stream(c, async () => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
|
||||
log.error("prompt_async failed", { sessionID, error: err })
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
|
||||
})
|
||||
})
|
||||
SessionPrompt.prompt({ ...body, sessionID })
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { Log } from "../util/log"
|
||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
@@ -48,9 +47,6 @@ import { lazy } from "@/util/lazy"
|
||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
||||
const csp = (hash = "") =>
|
||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -510,13 +506,10 @@ export namespace Server {
|
||||
host: "app.opencode.ai",
|
||||
},
|
||||
})
|
||||
const match = response.headers.get("content-type")?.includes("text/html")
|
||||
? (await response.clone().text()).match(
|
||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||
)
|
||||
: undefined
|
||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||
response.headers.set("Content-Security-Policy", csp(hash))
|
||||
response.headers.set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
|
||||
)
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,20 +113,17 @@ export namespace LLM {
|
||||
options.instructions = system.join("\n")
|
||||
}
|
||||
|
||||
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
|
||||
const messages = isOpenaiOauth
|
||||
? input.messages
|
||||
: isWorkflow
|
||||
? input.messages
|
||||
: [
|
||||
...system.map(
|
||||
(x): ModelMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...input.messages,
|
||||
]
|
||||
: [
|
||||
...system.map(
|
||||
(x): ModelMessage => ({
|
||||
role: "system",
|
||||
content: x,
|
||||
}),
|
||||
),
|
||||
...input.messages,
|
||||
]
|
||||
|
||||
const params = await Plugin.trigger(
|
||||
"chat.params",
|
||||
@@ -193,7 +190,6 @@ export namespace LLM {
|
||||
// and results sent back over the WebSocket.
|
||||
if (language instanceof GitLabWorkflowLanguageModel) {
|
||||
const workflowModel = language
|
||||
workflowModel.systemPrompt = system.join("\n")
|
||||
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
||||
const t = tools[toolName]
|
||||
if (!t || !t.execute) {
|
||||
|
||||
@@ -418,16 +418,6 @@ export namespace SessionPrompt {
|
||||
)
|
||||
let executionError: Error | undefined
|
||||
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 = {
|
||||
agent: task.agent,
|
||||
messageID: assistantMessage.id,
|
||||
@@ -570,16 +560,6 @@ export namespace SessionPrompt {
|
||||
|
||||
// normal processing
|
||||
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 isLastStep = step >= maxSteps
|
||||
msgs = await insertReminders({
|
||||
@@ -984,18 +964,7 @@ export namespace SessionPrompt {
|
||||
}
|
||||
|
||||
async function createUserMessage(input: PromptInput) {
|
||||
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 agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
|
||||
|
||||
const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
|
||||
const full =
|
||||
@@ -1562,16 +1531,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
await SessionRevert.cleanup(session)
|
||||
}
|
||||
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 userMsg: MessageV2.User = {
|
||||
id: MessageID.ascending(),
|
||||
@@ -1824,14 +1783,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
log.info("command", input)
|
||||
const command = await Command.get(input.command)
|
||||
if (!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
|
||||
throw new NamedError.Unknown({ message: `Command not found: "${input.command}"` })
|
||||
}
|
||||
const agentName = command.agent ?? input.agent ?? (await Agent.defaultAgent())
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ export namespace Snapshot {
|
||||
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const prune = "7.days"
|
||||
const limit = 2 * 1024 * 1024
|
||||
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
||||
const cfg = ["-c", "core.autocrlf=false", ...core]
|
||||
const quote = [...cfg, "-c", "core.quotepath=false"]
|
||||
@@ -124,69 +123,20 @@ export namespace Snapshot {
|
||||
return file
|
||||
})
|
||||
|
||||
const sync = Effect.fnUntraced(function* (list: string[] = []) {
|
||||
const sync = Effect.fnUntraced(function* () {
|
||||
const file = yield* excludes()
|
||||
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.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
|
||||
if (!file) {
|
||||
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
|
||||
return
|
||||
}
|
||||
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const add = Effect.fnUntraced(function* () {
|
||||
yield* sync()
|
||||
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,
|
||||
})
|
||||
}
|
||||
yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory })
|
||||
})
|
||||
|
||||
const cleanup = Effect.fnUntraced(function* () {
|
||||
@@ -227,7 +177,7 @@ export namespace Snapshot {
|
||||
const patch = Effect.fnUntraced(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
@@ -295,7 +245,7 @@ export namespace Snapshot {
|
||||
|
||||
const diff = Effect.fnUntraced(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
|
||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
|
||||
@@ -28,6 +28,7 @@ const log = Log.create({ service: "db" })
|
||||
|
||||
export namespace Database {
|
||||
export const Path = iife(() => {
|
||||
return ':memory:'
|
||||
if (Flag.OPENCODE_DB) {
|
||||
if (path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
|
||||
return path.join(Global.Path.data, Flag.OPENCODE_DB)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Global } from "../global"
|
||||
@@ -7,15 +9,12 @@ import { Project } from "../project/project"
|
||||
import { Database, eq } from "../storage/db"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import type { ProjectID } from "../project/schema"
|
||||
import { fn } from "../util/fn"
|
||||
import { Log } from "../util/log"
|
||||
import { Slug } from "@opencode-ai/util/slug"
|
||||
import { Process } from "../util/process"
|
||||
import { git } from "../util/git"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
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 {
|
||||
const log = Log.create({ service: "worktree" })
|
||||
@@ -124,7 +123,77 @@ export namespace Worktree {
|
||||
}),
|
||||
)
|
||||
|
||||
function slugify(input: string) {
|
||||
const ADJECTIVES = [
|
||||
"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
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
@@ -133,8 +202,28 @@ export namespace Worktree {
|
||||
.replace(/-+$/, "")
|
||||
}
|
||||
|
||||
function failedRemoves(...chunks: string[]) {
|
||||
return chunks.filter(Boolean).flatMap((chunk) =>
|
||||
function randomName() {
|
||||
return `${pick(ADJECTIVES)}-${pick(NOUNS)}`
|
||||
}
|
||||
|
||||
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
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
@@ -148,484 +237,436 @@ export namespace Worktree {
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effect service
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Interface {
|
||||
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
|
||||
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
|
||||
readonly create: (input?: CreateInput) => Effect.Effect<Info>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
|
||||
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
|
||||
async function prune(root: string, entries: string[]) {
|
||||
const base = await canonical(root)
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const target = await canonical(path.resolve(root, entry))
|
||||
if (target === base) return
|
||||
if (!target.startsWith(`${base}${path.sep}`)) return
|
||||
await fs.rm(target, { recursive: true, force: true }).catch(() => undefined)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Worktree") {}
|
||||
async function sweep(root: string) {
|
||||
const first = await git(["clean", "-ffdx"], { cwd: root })
|
||||
if (first.exitCode === 0) return first
|
||||
|
||||
type GitResult = { code: number; text: string; stderr: string }
|
||||
const entries = failed(first)
|
||||
if (!entries.length) return first
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
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
|
||||
await prune(root, entries)
|
||||
return git(["clean", "-ffdx"], { cwd: root })
|
||||
}
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
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((e) =>
|
||||
Effect.succeed({ code: 1, text: "", stderr: e instanceof Error ? e.message : String(e) } satisfies GitResult),
|
||||
),
|
||||
)
|
||||
async function canonical(input: string) {
|
||||
const abs = path.resolve(input)
|
||||
const real = await fs.realpath(abs).catch(() => abs)
|
||||
const normalized = path.normalize(real)
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
const MAX_NAME_ATTEMPTS = 26
|
||||
const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
|
||||
for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
|
||||
const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create()
|
||||
const branch = `opencode/${name}`
|
||||
const directory = pathSvc.join(root, name)
|
||||
async function candidate(root: string, base?: string) {
|
||||
for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
|
||||
const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
|
||||
const branch = `opencode/${name}`
|
||||
const directory = path.join(root, name)
|
||||
|
||||
if (yield* fsys.exists(directory).pipe(Effect.orDie)) continue
|
||||
if (await exists(directory)) continue
|
||||
|
||||
const ref = `refs/heads/${branch}`
|
||||
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { 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" })
|
||||
const ref = `refs/heads/${branch}`
|
||||
const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (branchCheck.exitCode === 0) continue
|
||||
|
||||
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" })
|
||||
}
|
||||
return Info.parse({ name, branch, directory })
|
||||
}
|
||||
|
||||
const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
|
||||
yield* fsys.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
||||
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
|
||||
}
|
||||
|
||||
const base = name ? slugify(name) : ""
|
||||
return yield* candidate(root, base || undefined)
|
||||
async function runStartCommand(directory: string, cmd: string) {
|
||||
if (process.platform === "win32") {
|
||||
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)
|
||||
}
|
||||
|
||||
const setup = Effect.fnUntraced(function* (info: Info) {
|
||||
const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
if (created.code !== 0) {
|
||||
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
|
||||
}
|
||||
export async function makeWorktreeInfo(name?: string): Promise<Info> {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined))
|
||||
})
|
||||
const root = path.join(Global.Path.data, "worktree", Instance.project.id)
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
|
||||
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
||||
const projectID = Instance.project.id
|
||||
const extra = startCommand?.trim()
|
||||
const base = name ? slug(name) : ""
|
||||
return candidate(root, base || undefined)
|
||||
}
|
||||
|
||||
const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
|
||||
if (populated.code !== 0) {
|
||||
const message = populated.stderr || populated.text || "Failed to populate worktree"
|
||||
export async function createFromInfo(info: Info, startCommand?: string) {
|
||||
const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
||||
cwd: Instance.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 })
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
payload: { type: Event.Failed.type, properties: { message } },
|
||||
payload: {
|
||||
type: Event.Failed.type,
|
||||
properties: {
|
||||
message,
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const booted = yield* Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: info.directory,
|
||||
init: InstanceBootstrap,
|
||||
fn: () => undefined,
|
||||
const booted = await Instance.provide({
|
||||
directory: info.directory,
|
||||
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
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: info.directory,
|
||||
payload: {
|
||||
type: Event.Ready.type,
|
||||
properties: { name: info.name, branch: info.branch },
|
||||
properties: {
|
||||
name: info.name,
|
||||
branch: info.branch,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
}, [])
|
||||
await runStartScripts(info.directory, { projectID, extra })
|
||||
}
|
||||
|
||||
const locateWorktree = Effect.fnUntraced(function* (
|
||||
entries: { path?: string; branch?: string }[],
|
||||
directory: string,
|
||||
) {
|
||||
return start().catch((error) => {
|
||||
log.error("worktree start task failed", { directory: info.directory, error })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!item.path) continue
|
||||
const key = yield* canonical(item.path)
|
||||
const key = await canonical(item.path)
|
||||
if (key === directory) return item
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
function stopFsmonitor(target: string) {
|
||||
return fsys.exists(target).pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
|
||||
)
|
||||
const clean = (target: string) =>
|
||||
fs
|
||||
.rm(target, {
|
||||
recursive: true,
|
||||
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",
|
||||
})
|
||||
}
|
||||
|
||||
function cleanDirectory(target: string) {
|
||||
return Effect.promise(() =>
|
||||
import("fs/promises").then((fsp) =>
|
||||
fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
|
||||
),
|
||||
)
|
||||
const stale = await locate(next.stdout)
|
||||
if (stale?.path) {
|
||||
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
|
||||
}
|
||||
}
|
||||
|
||||
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" })
|
||||
}
|
||||
await clean(entry.path)
|
||||
|
||||
const directory = yield* canonical(input.directory)
|
||||
const branch = entry.branch?.replace(/^refs\/heads\//, "")
|
||||
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" })
|
||||
}
|
||||
}
|
||||
|
||||
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" })
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const entries = parseWorktreeList(list.text)
|
||||
const entry = yield* locateWorktree(entries, directory)
|
||||
export const reset = fn(ResetInput, async (input) => {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
|
||||
if (!entry?.path) {
|
||||
const directoryExists = yield* fsys.exists(directory).pipe(Effect.orDie)
|
||||
if (directoryExists) {
|
||||
yield* stopFsmonitor(directory)
|
||||
yield* cleanDirectory(directory)
|
||||
}
|
||||
return true
|
||||
}
|
||||
const directory = await canonical(input.directory)
|
||||
const primary = await canonical(Instance.worktree)
|
||||
if (directory === primary) {
|
||||
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
||||
}
|
||||
|
||||
yield* stopFsmonitor(entry.path)
|
||||
const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree })
|
||||
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 list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
|
||||
}
|
||||
|
||||
const stale = yield* locateWorktree(parseWorktreeList(next.text), directory)
|
||||
if (stale?.path) {
|
||||
throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
|
||||
}
|
||||
}
|
||||
const lines = outputText(list.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
|
||||
}, [])
|
||||
|
||||
yield* cleanDirectory(entry.path)
|
||||
const entry = await (async () => {
|
||||
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 branch = entry.branch?.replace(/^refs\/heads\//, "")
|
||||
if (branch) {
|
||||
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 remoteList = await git(["remote"], { cwd: Instance.worktree })
|
||||
if (remoteList.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
const remotes = outputText(remoteList.stdout)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const gitExpect = Effect.fnUntraced(function* (
|
||||
args: string[],
|
||||
opts: { cwd: string },
|
||||
error: (r: GitResult) => Error,
|
||||
) {
|
||||
const result = yield* git(args, opts)
|
||||
if (result.code !== 0) throw error(result)
|
||||
return result
|
||||
})
|
||||
const remote = remotes.includes("origin")
|
||||
? "origin"
|
||||
: remotes.length === 1
|
||||
? remotes[0]
|
||||
: remotes.includes("upstream")
|
||||
? "upstream"
|
||||
: ""
|
||||
|
||||
const runStartCommand = Effect.fnUntraced(
|
||||
function* (directory: string, cmd: string) {
|
||||
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 remoteHead = remote
|
||||
? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
|
||||
: { exitCode: 1, stdout: undefined, stderr: undefined }
|
||||
|
||||
const runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) {
|
||||
const text = cmd.trim()
|
||||
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 remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
|
||||
const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
|
||||
const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
|
||||
|
||||
const runStartScripts = Effect.fnUntraced(function* (
|
||||
directory: string,
|
||||
input: { projectID: ProjectID; extra?: string },
|
||||
) {
|
||||
const row = yield* Effect.sync(() =>
|
||||
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 = yield* runStartScript(directory, startup, "project")
|
||||
if (!ok) return false
|
||||
yield* runStartScript(directory, input.extra ?? "", "worktree")
|
||||
return true
|
||||
})
|
||||
const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
|
||||
|
||||
const prune = Effect.fnUntraced(function* (root: string, entries: string[]) {
|
||||
const base = yield* canonical(root)
|
||||
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" },
|
||||
)
|
||||
})
|
||||
const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
|
||||
if (!target) {
|
||||
throw new ResetFailedError({ message: "Default branch not found" })
|
||||
}
|
||||
|
||||
const sweep = Effect.fnUntraced(function* (root: string) {
|
||||
const first = yield* git(["clean", "-ffdx"], { cwd: root })
|
||||
if (first.code === 0) return first
|
||||
if (remoteBranch) {
|
||||
const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
|
||||
if (fetch.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
|
||||
}
|
||||
}
|
||||
|
||||
const entries = failedRemoves(first.stderr, first.text)
|
||||
if (!entries.length) return first
|
||||
if (!entry.path) {
|
||||
throw new ResetFailedError({ message: "Worktree path not found" })
|
||||
}
|
||||
|
||||
yield* prune(root, entries)
|
||||
return yield* git(["clean", "-ffdx"], { cwd: root })
|
||||
})
|
||||
const worktreePath = entry.path
|
||||
|
||||
const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath })
|
||||
if (resetToTarget.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
|
||||
}
|
||||
|
||||
const directory = yield* canonical(input.directory)
|
||||
const primary = yield* canonical(Instance.worktree)
|
||||
if (directory === primary) {
|
||||
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
||||
}
|
||||
const clean = await sweep(worktreePath)
|
||||
if (clean.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
|
||||
}
|
||||
|
||||
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
||||
if (list.code !== 0) {
|
||||
throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
|
||||
}
|
||||
const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
|
||||
if (update.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
|
||||
}
|
||||
|
||||
const entry = yield* locateWorktree(parseWorktreeList(list.text), directory)
|
||||
if (!entry?.path) {
|
||||
throw new ResetFailedError({ message: "Worktree not found" })
|
||||
}
|
||||
const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
|
||||
cwd: worktreePath,
|
||||
})
|
||||
if (subReset.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
|
||||
}
|
||||
|
||||
const worktreePath = entry.path
|
||||
const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
|
||||
cwd: worktreePath,
|
||||
})
|
||||
if (subClean.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
|
||||
}
|
||||
|
||||
const remoteList = yield* git(["remote"], { cwd: Instance.worktree })
|
||||
if (remoteList.code !== 0) {
|
||||
throw new ResetFailedError({ message: remoteList.stderr || remoteList.text || "Failed to list git remotes" })
|
||||
}
|
||||
const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
|
||||
if (status.exitCode !== 0) {
|
||||
throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
|
||||
}
|
||||
|
||||
const remotes = remoteList.text
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
const remote = remotes.includes("origin")
|
||||
? "origin"
|
||||
: remotes.length === 1
|
||||
? remotes[0]
|
||||
: remotes.includes("upstream")
|
||||
? "upstream"
|
||||
: ""
|
||||
const dirty = outputText(status.stdout)
|
||||
if (dirty) {
|
||||
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
|
||||
}
|
||||
|
||||
const remoteHead = remote
|
||||
? yield* git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
|
||||
: { code: 1, text: "", stderr: "" }
|
||||
const projectID = Instance.project.id
|
||||
queueStartScripts(worktreePath, { projectID })
|
||||
|
||||
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))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -350,6 +350,122 @@ describe("cross-spawn spawner", () => {
|
||||
expect(out).toContain("stderr")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"pipes output fd3 with { from: 'fd3' }",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('require("node:fs").writeSync(3, "hello from fd3\\n")', {
|
||||
additionalFds: { fd3: { type: "output" } },
|
||||
}).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: "fd3" },
|
||||
),
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
expect(out).toBe("hello from fd3")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"pipes stdout to fd3",
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
const handle = yield* js('process.stdout.write("hello from stdout")').pipe(
|
||||
ChildProcess.pipeTo(js('process.stdout.write(require("node:fs").readFileSync(3, "utf8"))'), { to: "fd3" }),
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
expect(out).toBe("hello from stdout")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("additional fds", () => {
|
||||
fx.effect(
|
||||
"reads data from output fd3",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('require("node:fs").writeSync(3, "hello from fd3\\n")', {
|
||||
additionalFds: { fd3: { type: "output" } },
|
||||
})
|
||||
const out = yield* decodeByteStream(handle.getOutputFd(3))
|
||||
yield* handle.exitCode
|
||||
expect(out).toBe("hello from fd3")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"writes data to input fd3",
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
const input = Stream.make(new TextEncoder().encode("data from parent"))
|
||||
const handle = yield* js('process.stdout.write(require("node:fs").readFileSync(3, "utf8"))', {
|
||||
additionalFds: { fd3: { type: "input", stream: input } },
|
||||
})
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
expect(out).toBe("data from parent")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"returns empty stream for unconfigured fd",
|
||||
Effect.gen(function* () {
|
||||
const handle =
|
||||
process.platform === "win32"
|
||||
? yield* js('process.stdout.write("test")')
|
||||
: yield* ChildProcess.make("echo", ["test"])
|
||||
const out = yield* decodeByteStream(handle.getOutputFd(3))
|
||||
yield* handle.exitCode
|
||||
expect(out).toBe("")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"works alongside normal stdout and stderr",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js(
|
||||
'require("node:fs").writeSync(3, "fd3\\n"); process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")',
|
||||
{
|
||||
additionalFds: { fd3: { type: "output" } },
|
||||
},
|
||||
)
|
||||
const stdout = yield* decodeByteStream(handle.stdout)
|
||||
const stderr = yield* decodeByteStream(handle.stderr)
|
||||
const fd3 = yield* decodeByteStream(handle.getOutputFd(3))
|
||||
yield* handle.exitCode
|
||||
expect(stdout).toBe("stdout")
|
||||
expect(stderr).toBe("stderr")
|
||||
expect(fd3).toBe("fd3")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("large output", () => {
|
||||
fx.effect(
|
||||
"does not deadlock on large stdout",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js("for (let i = 1; i <= 100000; i++) process.stdout.write(`${i}\\n`)")
|
||||
const out = yield* handle.stdout.pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.runFold(
|
||||
() => "",
|
||||
(acc, chunk) => acc + chunk,
|
||||
),
|
||||
)
|
||||
yield* handle.exitCode
|
||||
const lines = out.trim().split("\n")
|
||||
expect(lines.length).toBe(100000)
|
||||
expect(lines[0]).toBe("1")
|
||||
expect(lines[99999]).toBe("100000")
|
||||
}),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
})
|
||||
|
||||
describe("Windows-specific", () => {
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import path from "path"
|
||||
import * as Lsp from "../../src/lsp/index"
|
||||
import { LSPServer } from "../../src/lsp/server"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("lsp.spawn", () => {
|
||||
test("does not spawn builtin LSP for files outside instance", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Lsp.LSP.touchFile(path.join(tmp.path, "..", "outside.ts"))
|
||||
await Lsp.LSP.hover({
|
||||
file: path.join(tmp.path, "..", "hover.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(0)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
await Instance.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
test("would spawn builtin LSP for files inside instance", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Lsp.LSP.hover({
|
||||
file: path.join(tmp.path, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
await Instance.disposeAll()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -54,19 +54,3 @@ describe("plugin.auth-override", () => {
|
||||
expect(plainMethods[ProviderID.make("github-copilot")][0].label).not.toBe("Test Override Auth")
|
||||
}, 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,69 +1,78 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
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 })
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const gitModule = await import("../../src/util/git")
|
||||
const originalGit = gitModule.git
|
||||
|
||||
/**
|
||||
* Creates a mock ChildProcessSpawner layer that intercepts git subcommands
|
||||
* matching `failArg` and returns exit code 128, while delegating everything
|
||||
* else to the real CrossSpawnSpawner.
|
||||
*/
|
||||
function mockGitFailure(failArg: string) {
|
||||
return Layer.effect(
|
||||
ChildProcessSpawner.ChildProcessSpawner,
|
||||
Effect.gen(function* () {
|
||||
const real = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
return ChildProcessSpawner.make(
|
||||
Effect.fnUntraced(function* (command) {
|
||||
const std = ChildProcess.isStandardCommand(command) ? command : undefined
|
||||
if (std?.command === "git" && std.args.some((a) => a === failArg)) {
|
||||
return ChildProcessSpawner.makeHandle({
|
||||
pid: ChildProcessSpawner.ProcessId(0),
|
||||
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(128)),
|
||||
isRunning: Effect.succeed(false),
|
||||
kill: () => Effect.void,
|
||||
stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any,
|
||||
stdout: Stream.empty,
|
||||
stderr: Stream.make(encoder.encode("fatal: simulated failure\n")),
|
||||
all: Stream.empty,
|
||||
getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any,
|
||||
getOutputFd: () => Stream.empty,
|
||||
})
|
||||
}
|
||||
return yield* real.spawn(command)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(CrossSpawnSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||
type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail"
|
||||
let mode: Mode = "none"
|
||||
|
||||
mock.module("../../src/util/git", () => ({
|
||||
git: (args: string[], opts: { cwd: string; env?: Record<string, string> }) => {
|
||||
const cmd = ["git", ...args].join(" ")
|
||||
if (
|
||||
mode === "rev-list-fail" &&
|
||||
cmd.includes("git rev-list") &&
|
||||
cmd.includes("--max-parents=0") &&
|
||||
cmd.includes("HEAD")
|
||||
) {
|
||||
return Promise.resolve({
|
||||
exitCode: 128,
|
||||
text: () => Promise.resolve(""),
|
||||
stdout: Buffer.from(""),
|
||||
stderr: Buffer.from("fatal"),
|
||||
})
|
||||
}
|
||||
if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) {
|
||||
return Promise.resolve({
|
||||
exitCode: 128,
|
||||
text: () => Promise.resolve(""),
|
||||
stdout: Buffer.from(""),
|
||||
stderr: Buffer.from("fatal"),
|
||||
})
|
||||
}
|
||||
if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) {
|
||||
return Promise.resolve({
|
||||
exitCode: 128,
|
||||
text: () => Promise.resolve(""),
|
||||
stdout: Buffer.from(""),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
function projectLayerWithFailure(failArg: string) {
|
||||
return Project.layer.pipe(
|
||||
Layer.provide(mockGitFailure(failArg)),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
async function loadProject() {
|
||||
return (await import("../../src/project/project")).Project
|
||||
}
|
||||
|
||||
describe("Project.fromDirectory", () => {
|
||||
test("should handle git repository with no commits", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir()
|
||||
await $`git init`.cwd(tmp.path).quiet()
|
||||
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await p.fromDirectory(tmp.path)
|
||||
|
||||
expect(project).toBeDefined()
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
@@ -71,13 +80,15 @@ describe("Project.fromDirectory", () => {
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
|
||||
const opencodeFile = path.join(tmp.path, ".git", "opencode")
|
||||
expect(await Bun.file(opencodeFile).exists()).toBe(false)
|
||||
const fileExists = await Filesystem.exists(opencodeFile)
|
||||
expect(fileExists).toBe(false)
|
||||
})
|
||||
|
||||
test("should handle git repository with commits", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await p.fromDirectory(tmp.path)
|
||||
|
||||
expect(project).toBeDefined()
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
@@ -85,63 +96,54 @@ describe("Project.fromDirectory", () => {
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
|
||||
const opencodeFile = path.join(tmp.path, ".git", "opencode")
|
||||
expect(await Bun.file(opencodeFile).exists()).toBe(true)
|
||||
const fileExists = await Filesystem.exists(opencodeFile)
|
||||
expect(fileExists).toBe(true)
|
||||
})
|
||||
|
||||
test("returns global for non-git directory", async () => {
|
||||
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 () => {
|
||||
test("keeps git vcs when rev-list exits non-zero with empty output", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir()
|
||||
await $`git init`.cwd(tmp.path).quiet()
|
||||
|
||||
// rev-list fails because HEAD doesn't exist yet — this is the natural scenario
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
await withMode("rev-list-fail", async () => {
|
||||
const { project } = await p.fromDirectory(tmp.path)
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
})
|
||||
})
|
||||
|
||||
test("handles show-toplevel failure gracefully", async () => {
|
||||
test("keeps git vcs when show-toplevel exits non-zero with empty output", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const layer = projectLayerWithFailure("--show-toplevel")
|
||||
|
||||
const { project, sandbox } = await Effect.runPromise(
|
||||
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(tmp.path)
|
||||
await withMode("top-fail", async () => {
|
||||
const { project, sandbox } = await p.fromDirectory(tmp.path)
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(tmp.path)
|
||||
})
|
||||
})
|
||||
|
||||
test("handles git-common-dir failure gracefully", async () => {
|
||||
test("keeps git vcs when git-common-dir exits non-zero with empty output", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const layer = projectLayerWithFailure("--git-common-dir")
|
||||
|
||||
const { project, sandbox } = await Effect.runPromise(
|
||||
Project.Service.use((svc) => svc.fromDirectory(tmp.path)).pipe(Effect.provide(layer)),
|
||||
)
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(tmp.path)
|
||||
await withMode("common-dir-fail", async () => {
|
||||
const { project, sandbox } = await p.fromDirectory(tmp.path)
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(tmp.path)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Project.fromDirectory with worktrees", () => {
|
||||
test("should set worktree to root when called from root", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const { project, sandbox } = await Project.fromDirectory(tmp.path)
|
||||
const { project, sandbox } = await p.fromDirectory(tmp.path)
|
||||
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(tmp.path)
|
||||
@@ -149,13 +151,14 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
})
|
||||
|
||||
test("should set worktree to root when called from a worktree", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree")
|
||||
try {
|
||||
await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
|
||||
|
||||
const { project, sandbox } = await Project.fromDirectory(worktreePath)
|
||||
const { project, sandbox } = await p.fromDirectory(worktreePath)
|
||||
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(sandbox).toBe(worktreePath)
|
||||
@@ -170,21 +173,22 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
})
|
||||
|
||||
test("worktree should share project ID with main repo", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const { project: main } = await Project.fromDirectory(tmp.path)
|
||||
const { project: main } = await p.fromDirectory(tmp.path)
|
||||
|
||||
const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt-shared")
|
||||
try {
|
||||
await $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp.path).quiet()
|
||||
|
||||
const { project: wt } = await Project.fromDirectory(worktreePath)
|
||||
const { project: wt } = await p.fromDirectory(worktreePath)
|
||||
|
||||
expect(wt.id).toBe(main.id)
|
||||
|
||||
// Cache should live in the common .git dir, not the worktree's .git file
|
||||
const cache = path.join(tmp.path, ".git", "opencode")
|
||||
const exists = await Bun.file(cache).exists()
|
||||
const exists = await Filesystem.exists(cache)
|
||||
expect(exists).toBe(true)
|
||||
} finally {
|
||||
await $`git worktree remove ${worktreePath}`
|
||||
@@ -195,6 +199,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
})
|
||||
|
||||
test("separate clones of the same repo should share project ID", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
// Create a bare remote, push, then clone into a second directory
|
||||
@@ -204,8 +209,8 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
await $`git clone --bare ${tmp.path} ${bare}`.quiet()
|
||||
await $`git clone ${bare} ${clone}`.quiet()
|
||||
|
||||
const { project: a } = await Project.fromDirectory(tmp.path)
|
||||
const { project: b } = await Project.fromDirectory(clone)
|
||||
const { project: a } = await p.fromDirectory(tmp.path)
|
||||
const { project: b } = await p.fromDirectory(clone)
|
||||
|
||||
expect(b.id).toBe(a.id)
|
||||
} finally {
|
||||
@@ -214,6 +219,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
})
|
||||
|
||||
test("should accumulate multiple worktrees in sandboxes", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1")
|
||||
@@ -222,8 +228,8 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
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 Project.fromDirectory(worktree1)
|
||||
const { project } = await Project.fromDirectory(worktree2)
|
||||
await p.fromDirectory(worktree1)
|
||||
const { project } = await p.fromDirectory(worktree2)
|
||||
|
||||
expect(project.worktree).toBe(tmp.path)
|
||||
expect(project.sandboxes).toContain(worktree1)
|
||||
@@ -244,13 +250,14 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
|
||||
describe("Project.discover", () => {
|
||||
test("should discover favicon.png in root", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await p.fromDirectory(tmp.path)
|
||||
|
||||
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
await Bun.write(path.join(tmp.path, "favicon.png"), pngData)
|
||||
|
||||
await Project.discover(project)
|
||||
await p.discover(project)
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
expect(updated).toBeDefined()
|
||||
@@ -261,12 +268,13 @@ describe("Project.discover", () => {
|
||||
})
|
||||
|
||||
test("should not discover non-image files", async () => {
|
||||
const p = await loadProject()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
const { project } = await p.fromDirectory(tmp.path)
|
||||
|
||||
await Bun.write(path.join(tmp.path, "favicon.txt"), "not an image")
|
||||
|
||||
await Project.discover(project)
|
||||
await p.discover(project)
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
expect(updated).toBeDefined()
|
||||
@@ -336,6 +344,8 @@ describe("Project.update", () => {
|
||||
})
|
||||
|
||||
test("should throw error when project not found", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
|
||||
await expect(
|
||||
Project.update({
|
||||
projectID: ProjectID.make("nonexistent-project-id"),
|
||||
@@ -348,24 +358,22 @@ describe("Project.update", () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
|
||||
let eventFired = false
|
||||
let eventPayload: any = null
|
||||
const on = (data: any) => {
|
||||
|
||||
GlobalBus.on("event", (data) => {
|
||||
eventFired = true
|
||||
eventPayload = data
|
||||
}
|
||||
GlobalBus.on("event", on)
|
||||
})
|
||||
|
||||
try {
|
||||
await Project.update({
|
||||
projectID: project.id,
|
||||
name: "Updated Name",
|
||||
})
|
||||
await Project.update({
|
||||
projectID: project.id,
|
||||
name: "Updated Name",
|
||||
})
|
||||
|
||||
expect(eventPayload).not.toBeNull()
|
||||
expect(eventPayload.payload.type).toBe("project.updated")
|
||||
expect(eventPayload.payload.properties.name).toBe("Updated Name")
|
||||
} finally {
|
||||
GlobalBus.off("event", on)
|
||||
}
|
||||
expect(eventFired).toBe(true)
|
||||
expect(eventPayload.payload.type).toBe("project.updated")
|
||||
expect(eventPayload.payload.properties.name).toBe("Updated Name")
|
||||
})
|
||||
|
||||
test("should update multiple fields at once", async () => {
|
||||
@@ -385,75 +393,3 @@ describe("Project.update", () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
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,43 +1629,6 @@ 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", () => {
|
||||
const createModel = (overrides: Partial<any> = {}) =>
|
||||
({
|
||||
|
||||
@@ -117,16 +117,3 @@ 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,6 +1,5 @@
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
@@ -211,78 +210,3 @@ 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("file under size limit handling", async () => {
|
||||
test("large file handling", async () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
@@ -196,23 +196,6 @@ test("file under size limit 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 () => {
|
||||
await using tmp = await bootstrap()
|
||||
await Instance.provide({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
+7
-134
@@ -1,9 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import fs from "fs/promises"
|
||||
|
||||
const model = "opencode/gpt-5.3-codex"
|
||||
|
||||
interface PR {
|
||||
number: number
|
||||
@@ -53,76 +50,17 @@ async function cleanup() {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function lines(prs: PR[]) {
|
||||
return prs.map((x) => `- #${x.number}: ${x.title}`).join("\n") || "(none)"
|
||||
}
|
||||
|
||||
async function typecheck() {
|
||||
console.log(" Running typecheck...")
|
||||
|
||||
try {
|
||||
await $`bun typecheck`.cwd("packages/opencode")
|
||||
return true
|
||||
} catch (err) {
|
||||
console.log(`Typecheck failed: ${err}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function build() {
|
||||
console.log(" Running final build smoke check...")
|
||||
|
||||
try {
|
||||
await $`./script/build.ts --single`.cwd("packages/opencode")
|
||||
return true
|
||||
} catch (err) {
|
||||
console.log(`Build failed: ${err}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function install() {
|
||||
console.log(" Regenerating bun.lock...")
|
||||
|
||||
try {
|
||||
await fs.rm("bun.lock", { force: true })
|
||||
await $`bun install`
|
||||
await $`git add bun.lock`
|
||||
return true
|
||||
} catch (err) {
|
||||
console.log(`Install failed: ${err}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: number) {
|
||||
async function fix(pr: PR, files: string[]) {
|
||||
console.log(` Trying to auto-resolve ${files.length} conflict(s) with opencode...`)
|
||||
|
||||
const done = lines(prs.filter((x) => applied.includes(x.number)))
|
||||
const next = lines(prs.slice(idx + 1))
|
||||
|
||||
const prompt = [
|
||||
`Resolve the current git merge conflicts while merging PR #${pr.number} into the beta branch.`,
|
||||
`PR #${pr.number}: ${pr.title}`,
|
||||
`Start with these conflicted 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 bun.lock is conflicted, do not hand-merge it. Delete bun.lock and run bun install after the code conflicts are resolved.",
|
||||
"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.",
|
||||
"After resolving the conflicts, run `bun typecheck` in `packages/opencode`.",
|
||||
"If typecheck fails, you may also update any files reported by typecheck.",
|
||||
"Keep any non-conflict edits narrowly scoped to restoring a valid merged state for the current PR batch.",
|
||||
"Fix any merge-caused typecheck errors before finishing.",
|
||||
`Only touch these files: ${files.join(", ")}.`,
|
||||
"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 and a passing typecheck.",
|
||||
"When done, leave the working tree with no unmerged files.",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
await $`opencode run -m ${model} ${prompt}`
|
||||
await $`opencode run -m opencode/gpt-5.3-codex ${prompt}`
|
||||
} catch (err) {
|
||||
console.log(` opencode failed: ${err}`)
|
||||
return false
|
||||
@@ -134,68 +72,10 @@ async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: n
|
||||
return false
|
||||
}
|
||||
|
||||
if (files.includes("bun.lock") && !(await install())) return false
|
||||
|
||||
if (!(await typecheck())) return false
|
||||
|
||||
console.log(" Conflicts resolved with opencode")
|
||||
return true
|
||||
}
|
||||
|
||||
async function smoke(prs: PR[], applied: number[]) {
|
||||
console.log("\nRunning final smoke check with opencode...")
|
||||
|
||||
const done = lines(prs.filter((x) => applied.includes(x.number)))
|
||||
const prompt = [
|
||||
"The beta merge batch is complete.",
|
||||
`Merged PRs on HEAD:\n${done}`,
|
||||
"Run `bun typecheck` in `packages/opencode`.",
|
||||
"Run `./script/build.ts --single` in `packages/opencode`.",
|
||||
"Fix any merge-caused issues until both commands pass.",
|
||||
"Do not create a commit.",
|
||||
].join("\n")
|
||||
|
||||
try {
|
||||
await $`opencode run -m ${model} ${prompt}`
|
||||
} catch (err) {
|
||||
console.log(`Smoke fix failed: ${err}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(await typecheck())) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(await build())) {
|
||||
return false
|
||||
}
|
||||
|
||||
const out = await $`git status --porcelain`.text()
|
||||
if (!out.trim()) {
|
||||
console.log("Smoke check passed")
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
await $`git add -A`
|
||||
await $`git commit -m "Fix beta integration"`
|
||||
} catch (err) {
|
||||
console.log(`Failed to commit smoke fixes: ${err}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(await typecheck())) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!(await build())) {
|
||||
return false
|
||||
}
|
||||
|
||||
console.log("Smoke check passed")
|
||||
return true
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Fetching open PRs with beta label...")
|
||||
|
||||
@@ -219,8 +99,8 @@ async function main() {
|
||||
const applied: number[] = []
|
||||
const failed: FailedPR[] = []
|
||||
|
||||
for (const [idx, pr] of prs.entries()) {
|
||||
console.log(`\nProcessing PR ${idx + 1}/${prs.length} #${pr.number}: ${pr.title}`)
|
||||
for (const pr of prs) {
|
||||
console.log(`\nProcessing PR #${pr.number}: ${pr.title}`)
|
||||
|
||||
console.log(" Fetching PR head...")
|
||||
try {
|
||||
@@ -239,7 +119,7 @@ async function main() {
|
||||
const files = await conflicts()
|
||||
if (files.length > 0) {
|
||||
console.log(" Failed to merge (conflicts)")
|
||||
if (!(await fix(pr, files, prs, applied, idx))) {
|
||||
if (!(await fix(pr, files))) {
|
||||
await cleanup()
|
||||
failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" })
|
||||
await commentOnPR(pr.number, "Merge conflicts with dev branch")
|
||||
@@ -294,13 +174,6 @@ async function main() {
|
||||
throw new Error(`${failed.length} PR(s) failed to merge`)
|
||||
}
|
||||
|
||||
if (applied.length > 0) {
|
||||
const ok = await smoke(prs, applied)
|
||||
if (!ok) {
|
||||
throw new Error("Final smoke check failed")
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nChecking if beta branch has changes...")
|
||||
await $`git fetch origin beta`
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/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)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user