Compare commits

...

29 Commits

Author SHA1 Message Date
Kit Langton
65d6fa9880 Merge branch 'dev' into kit/effectify-worktree 2026-03-20 09:17:40 -04:00
Luke Parker
0bbf26a1ce deslopity deslopity (#18343) 2026-03-20 05:24:27 +00:00
opencode-agent[bot]
83cdb4de64 chore: update nix node_modules hashes 2026-03-20 05:14:32 +00:00
Brendan Allan
4989632245 patch solid to try fix memo undefined under transition bug (#18338) 2026-03-20 14:58:35 +10:00
Luke Parker
d460614cd7 fix: lots of desktop stability, better e2e error logging (#18300) 2026-03-20 00:12:06 -04:00
Luke Parker
7866dbcfcc fix: avoid truncate permission import cycle (#18292) 2026-03-19 23:52:04 -04:00
opencode-agent[bot]
e71a21e0a8 chore: update nix node_modules hashes 2026-03-20 02:21:29 +00:00
Dax
1071aca91f fix: miscellaneous small fixes (#18328) 2026-03-19 22:20:29 -04:00
Jaaneek
b3d0446d13 feat: switch xai provider to responses API (#18175)
Co-authored-by: Jaaneek <jankiewiczmilosz@gmail.com>
2026-03-19 21:09:49 -05:00
opencode-agent[bot]
949191ab74 chore: update nix node_modules hashes 2026-03-20 01:36:22 +00:00
Dax
92cd908fb5 feat: add Node.js entry point and build script (#18324) 2026-03-19 21:35:07 -04:00
Kit Langton
b3023f0fa0 effectify Worktree service with Effect layer and facades
Migrate Worktree from direct Instance ALS reads to the Effect service
pattern: define Interface, Service, layer (yielding InstanceContext),
and promise facades using runPromiseInstance. Wire into instances.ts.
2026-03-19 21:23:30 -04:00
Dax
6fcc970def fix: include cache bin directory in which() lookups (#18320) 2026-03-19 21:21:55 -04:00
Dax
52a7a04ad8 refactor: replace Bun shell execution with portable Process utilities (#18318) 2026-03-19 21:17:06 -04:00
Dax
37b8662a9d refactor: abstract SQLite behind runtime-conditional #db import (#18316) 2026-03-19 21:15:35 -04:00
Dax
ddcb32ae0b refactor(tui): replace Bun-specific APIs with portable alternatives (#18304) 2026-03-19 19:32:59 -04:00
Frank
2c056c90da doc: update translator to gpt model 2026-03-19 19:07:02 -04:00
Dax
812d1bb32a chore: inline tool descriptions, remove separate .txt files (#18303) 2026-03-19 19:02:42 -04:00
Frank
9a58c43ef4 go: upi translation 2026-03-19 18:54:32 -04:00
Dax
63585db6a7 refactor: replace Bun.sleep with node:timers/promises sleep (#18301) 2026-03-19 18:50:40 -04:00
Frank
bd44489ada go: upi payment 2026-03-19 18:44:24 -04:00
opencode-agent[bot]
a6ef9e9206 chore: generate 2026-03-19 20:21:10 +00:00
Kit Langton
6e09a1d904 fix(account): handle pending console login polling (#18281) 2026-03-19 16:18:28 -04:00
opencode-agent[bot]
4f21757e0d chore: generate 2026-03-19 20:05:33 +00:00
jorge g
2dbcd79fd2 fix: stabilize agent and skill ordering in prompt descriptions (#18261)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-19 15:04:03 -05:00
James Long
48a7f0fd93 Fix base64Decode import in workspaces.spec.ts (#18274) 2026-03-19 15:38:54 -04:00
James Long
d69962b0f7 fix(core): disable chunk timeout by default (#18264) 2026-03-19 14:30:08 -04:00
opencode-agent[bot]
a6f23cb08e chore: generate 2026-03-19 17:52:50 +00:00
James Long
0540751897 fix(core): use a queue to process events in event routes (#18259) 2026-03-19 13:51:14 -04:00
102 changed files with 1926 additions and 884 deletions

View File

@@ -50,20 +50,17 @@ jobs:
e2e:
name: e2e (${{ matrix.settings.name }})
needs: unit
strategy:
fail-fast: false
matrix:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
- name: windows
host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install
runs-on: ${{ matrix.settings.host }}
env:
PLAYWRIGHT_BROWSERS_PATH: 0
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults:
run:
shell: bash
@@ -76,9 +73,28 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install Playwright browsers
- name: Read Playwright version
id: playwright-version
run: |
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/.playwright-browsers
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: runner.os == 'Linux'
working-directory: packages/app
run: ${{ matrix.settings.playwright }}
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests
run: bun --cwd packages/app test:e2e:local

View File

@@ -1,4 +1,6 @@
plans/
bun.lock
node_modules
plans
package.json
package-lock.json
bun.lock
.gitignore
package-lock.json

View File

@@ -1,7 +1,7 @@
---
description: Translate content for a specified locale while preserving technical terms
mode: subagent
model: opencode/gemini-3.1-pro
model: opencode/gpt-5.4
---
You are a professional translator and localization specialist.

View File

@@ -1,7 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
@@ -24,7 +22,16 @@ interface PR {
}
export default tool({
description: DESCRIPTION,
description: `Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),

View File

@@ -1,10 +0,0 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.

View File

@@ -1,7 +1,5 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
const TEAM = {
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
zen: ["fwang", "MrMushrooooom"],
@@ -40,7 +38,12 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
}
export default tool({
description: DESCRIPTION,
description: `Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: {
assignee: tool.schema
.enum(ASSIGNEES as [string, ...string[]])

View File

@@ -1,6 +0,0 @@
Use this tool to assign and/or label a GitHub issue.
Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.

View File

@@ -355,7 +355,7 @@
"cross-spawn": "^7.0.6",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
@@ -409,8 +409,8 @@
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -586,6 +586,8 @@
],
"patchedDependencies": {
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
},
"overrides": {
@@ -616,8 +618,8 @@
"ai": "5.0.124",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.35",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
@@ -2736,9 +2738,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-beta.16-ea816b6", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GiJQqCNPZP8Kk+i7/sFa3rtXbq26tLDNi3LbMx9aoLuwF2ofk8CS7cySUGdI+r4J3q0a568quC8FZeaFTCw4IA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="],
"drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
"drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -3020,6 +3022,8 @@
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
@@ -4108,6 +4112,8 @@
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
"restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="],
@@ -5386,6 +5392,8 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.16-ea816b6", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-k9gT4f0O9Qvah5YK/zL+FZonQ8TPyVxcG/ojN4dzO0fHP8hs8tBno8lqmJo53g0JLWv3Q2nsTUoyBRKM2TljFw=="],
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],

View File

@@ -122,6 +122,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
properties: {
product: zenLiteProduct.id,
price: zenLitePrice.id,
priceInr: 92900,
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
},
})

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-yfA50QKqylmaioxi+6d++W8Xv4Wix1hl3hEF6Zz7Ue0=",
"aarch64-linux": "sha256-b5sO7V+/zzJClHHKjkSz+9AUBYC8cb7S3m5ab1kpAyk=",
"aarch64-darwin": "sha256-V66nmRX6kAjrc41ARVeuTElWK7KD8qG/DVk9K7Fu+J8=",
"x86_64-darwin": "sha256-cFyh60WESiqZ5XWZi1+g3F/beSDL1+UPG8KhRivhK8w="
"x86_64-linux": "sha256-Gv0pHYCinlj0SQXRQ/a9ozYPxECwdrC99ssTzpeOr1I=",
"aarch64-linux": "sha256-WzVt5goOrxoGe26juzRf73PWPqwnB1URu2TYjxye/Aw=",
"aarch64-darwin": "sha256-18Nn0TR1wK2gRUF/FFP4vFMY/td49XkfjOwFbD5iJNc=",
"x86_64-darwin": "sha256-zk2yaulPzUUiCerCPJaCOCLhklXKMp9mSv7v0N8AMfA="
}
}

View File

@@ -43,8 +43,8 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.35",
"ai": "5.0.124",
"hono": "4.10.7",
@@ -112,6 +112,8 @@
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
}
}

View File

@@ -9,6 +9,7 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
projectSwitchSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
@@ -23,6 +24,16 @@ import {
workspaceMenuTriggerSelector,
} from "./selectors"
const phase = new WeakMap<Page, "test" | "cleanup">()
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
phase.set(page, value)
}
export function healthPhase(page: Page) {
return phase.get(page) ?? "test"
}
export async function defocus(page: Page) {
await page
.evaluate(() => {
@@ -196,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
}
export async function isSidebarClosed(page: Page) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
await expect(button).toBeVisible()
const button = await waitSidebarButton(page, "isSidebarClosed")
return (await button.getAttribute("aria-expanded")) !== "true"
}
async function errorBoundaryText(page: Page) {
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
if (!(await title.isVisible().catch(() => false))) return
const description = await page
.getByText(/an error occurred while loading the application\./i)
.first()
.textContent()
.catch(() => "")
const detail = await page
.getByRole("textbox", { name: /error details/i })
.first()
.inputValue()
.catch(async () =>
(
(await page
.getByRole("textbox", { name: /error details/i })
.first()
.textContent()
.catch(() => "")) ?? ""
).trim(),
)
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
}
export async function assertHealthy(page: Page, context: string) {
const text = await errorBoundaryText(page)
if (!text) return
console.log(`[e2e:error-boundary][${context}]\n${text}`)
throw new Error(`Error boundary during ${context}\n${text}`)
}
async function waitSidebarButton(page: Page, context: string) {
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
await assertHealthy(page, context)
return button
}
export async function toggleSidebar(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+B`)
@@ -209,7 +260,7 @@ export async function toggleSidebar(page: Page) {
export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const button = await waitSidebarButton(page, "openSidebar")
await button.click()
const opened = await expect(button)
@@ -226,7 +277,7 @@ export async function openSidebar(page: Page) {
export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const button = await waitSidebarButton(page, "closeSidebar")
await button.click()
const closed = await expect(button)
@@ -241,6 +292,7 @@ export async function closeSidebar(page: Page) {
}
export async function openSettings(page: Page) {
await assertHealthy(page, "openSettings")
await defocus(page)
const dialog = page.getByRole("dialog")
@@ -253,6 +305,8 @@ export async function openSettings(page: Page) {
if (opened) return dialog
await assertHealthy(page, "openSettings")
await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible()
return dialog
@@ -314,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
export async function createTestProject() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}`
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
execSync("git init", { cwd: root, stdio: "ignore" })
await fs.writeFile(path.join(root, ".git", "opencode"), id)
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
@@ -339,12 +395,24 @@ export function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
}
async function probeSession(page: Page) {
return page
.evaluate(() => {
const win = window as E2EWindow
const current = win.__opencode_e2e?.model?.current
if (!current) return null
return { dir: current.dir, sessionID: current.sessionID }
})
.catch(() => null as { dir?: string; sessionID?: string } | null)
}
export async function waitSlug(page: Page, skip: string[] = []) {
let prev = ""
let next = ""
await expect
.poll(
() => {
async () => {
await assertHealthy(page, "waitSlug")
const slug = slugFromUrl(page.url())
if (!slug) return ""
if (skip.includes(slug)) return ""
@@ -374,6 +442,7 @@ export async function waitDir(page: Page, directory: string) {
await expect
.poll(
async () => {
await assertHealthy(page, "waitDir")
const slug = slugFromUrl(page.url())
if (!slug) return ""
return resolveSlug(slug)
@@ -386,6 +455,69 @@ export async function waitDir(page: Page, directory: string) {
return { directory: target, slug: base64Encode(target) }
}
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
const target = await resolveDirectory(input.directory)
await expect
.poll(
async () => {
await assertHealthy(page, "waitSession")
const slug = slugFromUrl(page.url())
if (!slug) return false
const resolved = await resolveSlug(slug).catch(() => undefined)
if (!resolved || resolved.directory !== target) return false
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
const state = await probeSession(page)
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (state?.dir) {
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
if (dir !== target) return false
}
return page
.locator(promptSelector)
.first()
.isVisible()
.catch(() => false)
},
{ timeout: 45_000 },
)
.toBe(true)
return { directory: target, slug: base64Encode(target) }
}
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
const sdk = createSdk(directory)
const target = await resolveDirectory(directory)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID })
.then((x) => x.data)
.catch(() => undefined)
if (!data?.directory) return ""
return resolveDirectory(data.directory).catch(() => data.directory)
},
{ timeout },
)
.toBe(target)
await expect
.poll(
async () => {
const items = await sdk.session
.messages({ sessionID, limit: 20 })
.then((x) => x.data ?? [])
.catch(() => [])
return items.some((item) => item.info.role === "user")
},
{ timeout },
)
.toBe(true)
}
export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1]
@@ -797,8 +929,14 @@ export async function openStatusPopover(page: Page) {
}
export async function openProjectMenu(page: Page, projectSlug: string) {
await openSidebar(page)
const item = page.locator(projectSwitchSelector(projectSlug)).first()
await expect(item).toBeVisible()
await item.hover()
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1)
await expect(trigger).toBeVisible()
const menu = page
.locator(dropdownMenuContentSelector)
@@ -807,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
const clicked = await trigger
.click({ timeout: 1500 })
.click({ force: true, timeout: 1500 })
.then(() => true)
.catch(() => false)

View File

@@ -1,7 +1,16 @@
import { test as base, expect, type Page } from "@playwright/test"
import type { E2EWindow } from "../src/testing/terminal"
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
import { promptSelector } from "./selectors"
import {
healthPhase,
cleanupSession,
cleanupTestProject,
createTestProject,
setHealthPhase,
seedProjects,
sessionIDFromUrl,
waitSlug,
waitSession,
} from "./actions"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3"
@@ -27,6 +36,29 @@ type WorkerFixtures = {
}
export const test = base.extend<TestFixtures, WorkerFixtures>({
page: async ({ page }, use) => {
let boundary: string | undefined
setHealthPhase(page, "test")
const consoleHandler = (msg: { text(): string }) => {
const text = msg.text()
if (!text.includes("[e2e:error-boundary]")) return
if (healthPhase(page) === "cleanup") {
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
return
}
boundary ||= text
console.log(text)
}
const pageErrorHandler = (err: Error) => {
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
}
page.on("console", consoleHandler)
page.on("pageerror", pageErrorHandler)
await use(page)
page.off("console", consoleHandler)
page.off("pageerror", pageErrorHandler)
if (boundary) throw new Error(boundary)
},
directory: [
async ({}, use) => {
const directory = await getWorktree()
@@ -48,21 +80,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
await waitSession(page, { directory, sessionID })
}
await use(gotoSession)
},
withProject: async ({ page }, use) => {
await use(async (callback, options) => {
const root = await createTestProject()
const slug = dirSlug(root)
const sessions = new Map<string, string>()
const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
await waitSession(page, { directory: root, sessionID })
const current = sessionIDFromUrl(page.url())
if (current) trackSession(current)
}
@@ -77,13 +108,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
try {
await gotoSession()
const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
} finally {
setHealthPhase(page, "cleanup")
await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
)
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root)
setHealthPhase(page, "test")
}
})
},

View File

@@ -1,5 +1,4 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import {
defocus,
@@ -7,43 +6,14 @@ import {
cleanupTestProject,
openSidebar,
sessionIDFromUrl,
waitDir,
setWorkspacesEnabled,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils"
async function workspaces(page: Page, directory: string, enabled: boolean) {
await page.evaluate(
({ directory, enabled }: { directory: string; enabled: boolean }) => {
const key = "opencode.global.dat:layout"
const raw = localStorage.getItem(key)
const data = raw ? JSON.parse(raw) : {}
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
const current =
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
? sidebar.workspaces
: {}
const next = { ...current }
if (enabled) next[directory] = true
if (!enabled) delete next[directory]
localStorage.setItem(
key,
JSON.stringify({
...data,
sidebar: {
...sidebar,
workspaces: next,
},
}),
)
},
{ directory, enabled },
)
}
test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -84,9 +54,7 @@ test("switching back to a project opens the latest workspace session", async ({
await withProject(
async ({ directory, slug, trackSession, trackDirectory }) => {
await defocus(page)
await workspaces(page, directory, true)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await setWorkspacesEnabled(page, slug, true)
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
@@ -108,8 +76,7 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(btn).toBeVisible()
await btn.click({ force: true })
await waitSlug(page)
await waitDir(page, space)
await waitSession(page, { directory: space })
// Create a session by sending a prompt
const prompt = page.locator(promptSelector)
@@ -123,6 +90,7 @@ test("switching back to a project opens the latest workspace session", async ({
const created = sessionIDFromUrl(page.url())
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
trackSession(created, space)
await waitSessionSaved(space, created)
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
@@ -130,15 +98,14 @@ test("switching back to a project opens the latest workspace session", async ({
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
await otherButton.click({ force: true })
await waitSession(page, { directory: other })
const rootButton = page.locator(projectSwitchSelector(slug)).first()
await expect(rootButton).toBeVisible()
await rootButton.click()
await rootButton.click({ force: true })
await waitDir(page, space)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
await waitSession(page, { directory: space, sessionID: created })
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
},
{ extra: [other] },

View File

@@ -1,6 +1,15 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitDir,
waitSession,
waitSessionSaved,
waitSlug,
} from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
@@ -14,20 +23,7 @@ function button(space: { slug: string; raw: string }) {
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
await openSidebar(page)
await expect
.poll(
async () => {
const row = page.locator(item(space)).first()
try {
await row.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
}
async function createWorkspace(page: Page, root: string, seen: string[]) {
@@ -49,7 +45,8 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
await expect(next).toBeVisible()
await next.click({ force: true })
return waitDir(page, space.directory)
await waitSession(page, { directory: space.directory })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
}
async function createSessionFromWorkspace(
@@ -57,39 +54,28 @@ async function createSessionFromWorkspace(
space: { slug: string; raw: string; directory: string },
text: string,
) {
const next = await openWorkspaceNewSession(page, space)
await openWorkspaceNewSession(page, space)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click()
await expect(prompt).toBeFocused()
await prompt.fill(text)
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
await waitDir(page, next.directory)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
await page.keyboard.press("Enter")
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
return { sessionID, slug: next.slug }
}
async function sessionDirectory(directory: string, sessionID: string) {
const info = await createSdk(directory)
.session.get({ sessionID })
.then((x) => x.data)
await waitSessionSaved(space.directory, sessionID)
await createSdk(space.directory)
.session.abort({ sessionID })
.catch(() => undefined)
if (!info) return ""
return info.directory
return sessionID
}
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, root, true)
@@ -101,17 +87,8 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
trackDirectory(second.directory)
await waitWorkspaceReady(page, second)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(firstSession.sessionID, first.directory)
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
trackSession(secondSession.sessionID, second.directory)
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
trackSession(thirdSession.sessionID, first.directory)
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
})
})

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"

View File

@@ -1,6 +1,14 @@
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
import {
openSidebar,
resolveSlug,
sessionIDFromUrl,
setWorkspacesEnabled,
waitSession,
waitSessionIdle,
waitSlug,
} from "../actions"
import {
promptAgentSelector,
promptModelSelector,
@@ -29,8 +37,6 @@ const text = async (locator: Locator) => ((await locator.textContent()) ?? "").t
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
const dirKey = (state: Probe | null) => state?.dir ?? ""
async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => {
const win = window as Window & {
@@ -44,21 +50,6 @@ async function probe(page: Page): Promise<Probe | null> {
})
}
async function currentDir(page: Page) {
let hit = ""
await expect
.poll(
async () => {
const next = dirKey(await probe(page))
if (next) hit = next
return next
},
{ timeout: 30_000 },
)
.not.toBe("")
return hit
}
async function read(page: Page): Promise<Footer> {
return {
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
@@ -187,8 +178,7 @@ async function chooseOtherModel(page: Page): Promise<Footer> {
async function goto(page: Page, directory: string, sessionID?: string) {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
await expect.poll(async () => dirKey(await probe(page)), { timeout: 30_000 }).toBe(directory)
await waitSession(page, { directory, sessionID })
}
async function submit(page: Page, value: string) {
@@ -224,7 +214,7 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await page.getByRole("button", { name: "New workspace" }).first().click()
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
await waitSession(page, { directory: next.directory })
return next
}
@@ -256,9 +246,7 @@ async function newWorkspaceSession(page: Page, slug: string) {
await button.click({ force: true })
const next = await resolveSlug(await waitSlug(page))
await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
return currentDir(page)
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
}
test("session model and variant restore per session without leaking into new sessions", async ({
@@ -277,7 +265,7 @@ test("session model and variant restore per session without leaking into new ses
await waitUser(directory, first)
await page.reload()
await expect(page.locator(promptSelector)).toBeVisible()
await waitSession(page, { directory, sessionID: first })
await waitFooter(page, firstState)
await gotoSession()

View File

@@ -378,6 +378,7 @@ function createGlobalSync() {
return globalStore.error
},
child: children.child,
peek: children.peek,
bootstrap,
updateConfig,
project: projectApi,

View File

@@ -226,6 +226,15 @@ export function createChildStoreManager(input: {
return childStore
}
function peek(directory: string, options: ChildOptions = {}) {
const childStore = ensureChild(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
}
return childStore
}
function projectMeta(directory: string, patch: ProjectMeta) {
const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(directory)
@@ -256,6 +265,7 @@ export function createChildStoreManager(input: {
children,
ensureChild,
child,
peek,
projectMeta,
projectIcon,
mark,

View File

@@ -1,11 +1,12 @@
import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component, Show } from "solid-js"
import { Component, Show, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon"
import type { E2EWindow } from "@/testing/terminal"
export type InitError = {
name: string
@@ -226,6 +227,13 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
actionError: undefined as string | undefined,
})
onMount(() => {
const win = window as E2EWindow
if (!win.__opencode_e2e) return
const detail = formatError(props.error, language.t)
console.error(`[e2e:error-boundary] ${window.location.pathname}\n${detail}`)
})
async function checkForUpdates() {
if (!platform.checkUpdate) return
setStore("checking", true)

View File

@@ -129,6 +129,16 @@ export default function Layout(props: ParentProps) {
const theme = useTheme()
const language = useLanguage()
const initialDirectory = decode64(params.dir)
const route = createMemo(() => {
const slug = params.dir
if (!slug) return { slug, dir: "" }
const dir = decode64(slug)
if (!dir) return { slug, dir: "" }
return {
slug,
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
}
})
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
@@ -137,7 +147,7 @@ export default function Layout(props: ParentProps) {
dark: "theme.scheme.dark",
}
const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
const currentDir = createMemo(() => decode64(params.dir) ?? "")
const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({
autoselect: !initialDirectory,
@@ -484,8 +494,8 @@ export default function Layout(props: ParentProps) {
}
const currentSession = params.id
if (directory === currentDir() && props.sessionID === currentSession) return
if (directory === currentDir() && session?.parentID === currentSession) return
if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
dismissSessionAlert(sessionKey)
@@ -620,7 +630,7 @@ export default function Layout(props: ParentProps) {
const activeDir = currentDir()
return workspaceIds(project).filter((directory) => {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = directory === activeDir
const active = workspaceKey(directory) === workspaceKey(activeDir)
return expanded || active
})
})
@@ -687,7 +697,7 @@ export default function Layout(props: ParentProps) {
seen: lru,
keep: sessionID,
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
preserve: directory === params.dir && params.id ? [params.id] : undefined,
preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
})
}
@@ -700,7 +710,7 @@ export default function Layout(props: ParentProps) {
})
createEffect(() => {
params.dir
route()
globalSDK.url
prefetchToken.value += 1
@@ -1692,13 +1702,10 @@ export default function Layout(props: ParentProps) {
createEffect(
on(
() => {
const dir = params.dir
const directory = dir ? decode64(dir) : undefined
const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : ""
return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const
return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
},
([ready, dir, id, root, directory, resolved]) => {
if (!ready || !dir || !directory) {
([ready, slug, id, root, dir]) => {
if (!ready || !slug || !dir) {
activeRoute.session = ""
activeRoute.sessionProject = ""
activeRoute.directory = ""
@@ -1712,29 +1719,28 @@ export default function Layout(props: ParentProps) {
return
}
const next = resolved || directory
const session = `${dir}/${id}`
const session = `${slug}/${id}`
if (!root) {
activeRoute.session = session
activeRoute.directory = next
activeRoute.directory = dir
activeRoute.sessionProject = ""
return
}
if (server.projects.last() !== root) server.projects.touch(root)
const changed = session !== activeRoute.session || next !== activeRoute.directory
const changed = session !== activeRoute.session || dir !== activeRoute.directory
if (changed) {
activeRoute.session = session
activeRoute.directory = next
activeRoute.sessionProject = syncSessionRoute(next, id, root)
activeRoute.directory = dir
activeRoute.sessionProject = syncSessionRoute(dir, id, root)
return
}
if (root === activeRoute.sessionProject) return
activeRoute.directory = next
activeRoute.sessionProject = rememberSessionRoute(next, id, root)
activeRoute.directory = dir
activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
},
),
)
@@ -1927,6 +1933,7 @@ export default function Layout(props: ParentProps) {
const projectSidebarCtx: ProjectSidebarContext = {
currentDir,
currentProject,
sidebarOpened: () => layout.sidebar.opened(),
sidebarHovering,
hoverProject: () => state.hoverProject,

View File

@@ -40,10 +40,10 @@ export const latestRootSession = (stores: SessionStore[], now: number) =>
stores.flatMap(roots).sort(sortSessions(now))[0]
export function hasProjectPermissions<T>(
request: Record<string, T[] | undefined>,
request: Record<string, T[] | undefined> | undefined,
include: (item: T) => boolean = () => true,
) {
return Object.values(request).some((list) => list?.some(include))
return Object.values(request ?? {}).some((list) => list?.some(include))
}
export const childMapByParent = (sessions: Session[] | undefined) => {

View File

@@ -15,6 +15,7 @@ import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
export type ProjectSidebarContext = {
currentDir: Accessor<string>
currentProject: Accessor<LocalProject | undefined>
sidebarOpened: Accessor<boolean>
sidebarHovering: Accessor<boolean>
hoverProject: Accessor<string | undefined>
@@ -278,11 +279,7 @@ export const SortableProject = (props: {
const globalSync = useGlobalSync()
const language = useLanguage()
const sortable = createSortable(props.project.worktree)
const selected = createMemo(
() =>
props.project.worktree === props.ctx.currentDir() ||
props.project.sandboxes?.includes(props.ctx.currentDir()) === true,
)
const selected = createMemo(() => props.ctx.currentProject()?.worktree === props.project.worktree)
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))

View File

@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { childMapByParent, sortedRootSessions } from "./helpers"
import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
type InlineEditorComponent = (props: {
id: string
@@ -323,7 +323,7 @@ export const SortableWorkspace = (props: {
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const children = createMemo(() => childMapByParent(workspaceStore.session))
const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => props.ctx.currentDir() === props.directory)
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
const workspaceValue = createMemo(() => {
const branch = workspaceStore.vcs?.branch
const name = branch ?? getFilename(props.directory)

View File

@@ -76,6 +76,19 @@ export function IconAlipay(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
)
}
export function IconUpi(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="10 16 100 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M95.678 42.9 110 29.835l-6.784-13.516Z" />
<path d="M90.854 42.9 105.176 29.835l-6.784-13.516Z" />
<path
d="M22.41 16.47 16.38 37.945l21.407.15 5.88-21.625h5.427l-7.05 25.14c-.27.96-1.298 1.74-2.295 1.74H12.31c-1.664 0-2.65-1.3-2.2-2.9l6.724-23.98Zm66.182-.15h5.427l-7.538 27.03h-5.58ZM49.698 27.582l27.136-.15 1.81-5.707H51.054l1.658-5.256 29.4-.27c1.83-.017 2.92 1.4 2.438 3.167L81.78 29.49c-.483 1.766-2.36 3.197-4.19 3.197H53.316L50.454 43.8h-5.28Z"
fill-rule="evenodd"
/>
</svg>
)
}
export function IconWechat(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">

View File

@@ -62,5 +62,6 @@
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
text-align: center;
}
}

View File

@@ -644,6 +644,8 @@ export const dict = {
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. قد تتغير الأسعار وحدود الاستخدام بناءً على تعلمنا من الاستخدام المبكر والملاحظات.",
"workspace.lite.promo.subscribe": "الاشتراك في Go",
"workspace.lite.promo.subscribing": "جارٍ إعادة التوجيه...",
"workspace.lite.promo.otherMethods": "طرق دفع أخرى",
"workspace.lite.promo.selectMethod": "اختر طريقة الدفع",
"download.title": "OpenCode | تنزيل",
"download.meta.description": "نزّل OpenCode لـ macOS، Windows، وLinux",

View File

@@ -654,6 +654,8 @@ export const dict = {
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Preços e limites de uso podem mudar conforme aprendemos com o uso inicial e feedback.",
"workspace.lite.promo.subscribe": "Assinar Go",
"workspace.lite.promo.subscribing": "Redirecionando...",
"workspace.lite.promo.otherMethods": "Outros métodos de pagamento",
"workspace.lite.promo.selectMethod": "Selecionar método de pagamento",
"download.title": "OpenCode | Baixar",
"download.meta.description": "Baixe o OpenCode para macOS, Windows e Linux",

View File

@@ -651,6 +651,8 @@ export const dict = {
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Priser og forbrugsgrænser kan ændre sig, efterhånden som vi lærer af tidlig brug og feedback.",
"workspace.lite.promo.subscribe": "Abonner på Go",
"workspace.lite.promo.subscribing": "Omdirigerer...",
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
"workspace.lite.promo.selectMethod": "Vælg betalingsmetode",
"download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode til macOS, Windows og Linux",

View File

@@ -654,6 +654,8 @@ export const dict = {
"Der Plan wurde hauptsächlich für internationale Nutzer entwickelt, wobei die Modelle in den USA, der EU und Singapur gehostet werden, um einen stabilen weltweiten Zugriff zu gewährleisten. Preise und Nutzungslimits können sich ändern, während wir aus der frühen Nutzung und dem Feedback lernen.",
"workspace.lite.promo.subscribe": "Go abonnieren",
"workspace.lite.promo.subscribing": "Leite weiter...",
"workspace.lite.promo.otherMethods": "Andere Zahlungsmethoden",
"workspace.lite.promo.selectMethod": "Zahlungsmethode auswählen",
"download.title": "OpenCode | Download",
"download.meta.description": "Lade OpenCode für macOS, Windows und Linux herunter",

View File

@@ -646,6 +646,8 @@ export const dict = {
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Pricing and usage limits may change as we learn from early usage and feedback.",
"workspace.lite.promo.subscribe": "Subscribe to Go",
"workspace.lite.promo.subscribing": "Redirecting...",
"workspace.lite.promo.otherMethods": "Other payment methods",
"workspace.lite.promo.selectMethod": "Select payment method",
"download.title": "OpenCode | Download",
"download.meta.description": "Download OpenCode for macOS, Windows, and Linux",

View File

@@ -654,6 +654,8 @@ export const dict = {
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., la UE y Singapur para un acceso global estable. Los precios y los límites de uso pueden cambiar a medida que aprendemos del uso inicial y los comentarios.",
"workspace.lite.promo.subscribe": "Suscribirse a Go",
"workspace.lite.promo.subscribing": "Redirigiendo...",
"workspace.lite.promo.otherMethods": "Otros métodos de pago",
"workspace.lite.promo.selectMethod": "Seleccionar método de pago",
"download.title": "OpenCode | Descargar",
"download.meta.description": "Descarga OpenCode para macOS, Windows y Linux",

View File

@@ -661,6 +661,8 @@ export const dict = {
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Les tarifs et les limites d'utilisation peuvent changer à mesure que nous apprenons des premières utilisations et des commentaires.",
"workspace.lite.promo.subscribe": "S'abonner à Go",
"workspace.lite.promo.subscribing": "Redirection...",
"workspace.lite.promo.otherMethods": "Autres méthodes de paiement",
"workspace.lite.promo.selectMethod": "Sélectionner la méthode de paiement",
"download.title": "OpenCode | Téléchargement",
"download.meta.description": "Téléchargez OpenCode pour macOS, Windows et Linux",

View File

@@ -652,6 +652,8 @@ export const dict = {
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati in US, EU e Singapore per un accesso globale stabile. I prezzi e i limiti di utilizzo potrebbero cambiare man mano che impariamo dall'utilizzo iniziale e dal feedback.",
"workspace.lite.promo.subscribe": "Abbonati a Go",
"workspace.lite.promo.subscribing": "Reindirizzamento...",
"workspace.lite.promo.otherMethods": "Altri metodi di pagamento",
"workspace.lite.promo.selectMethod": "Seleziona metodo di pagamento",
"download.title": "OpenCode | Download",
"download.meta.description": "Scarica OpenCode per macOS, Windows e Linux",

View File

@@ -653,6 +653,8 @@ export const dict = {
"このプランは主にグローバルユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。料金と利用制限は、初期の利用状況やフィードバックに基づいて変更される可能性があります。",
"workspace.lite.promo.subscribe": "Goを購読する",
"workspace.lite.promo.subscribing": "リダイレクト中...",
"workspace.lite.promo.otherMethods": "その他の支払い方法",
"workspace.lite.promo.selectMethod": "支払い方法を選択",
"download.title": "OpenCode | ダウンロード",
"download.meta.description": "OpenCode を macOS、Windows、Linux 向けにダウンロード",

View File

@@ -645,6 +645,8 @@ export const dict = {
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU 및 싱가포르에 모델이 호스팅되어 있습니다. 가격 및 사용 한도는 초기 사용을 통해 학습하고 피드백을 수집함에 따라 변경될 수 있습니다.",
"workspace.lite.promo.subscribe": "Go 구독하기",
"workspace.lite.promo.subscribing": "리디렉션 중...",
"workspace.lite.promo.otherMethods": "기타 결제 수단",
"workspace.lite.promo.selectMethod": "결제 수단 선택",
"download.title": "OpenCode | 다운로드",
"download.meta.description": "macOS, Windows, Linux용 OpenCode 다운로드",

View File

@@ -651,6 +651,8 @@ export const dict = {
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Priser og bruksgrenser kan endres etter hvert som vi lærer fra tidlig bruk og tilbakemeldinger.",
"workspace.lite.promo.subscribe": "Abonner på Go",
"workspace.lite.promo.subscribing": "Omdirigerer...",
"workspace.lite.promo.otherMethods": "Andre betalingsmetoder",
"workspace.lite.promo.selectMethod": "Velg betalingsmetode",
"download.title": "OpenCode | Last ned",
"download.meta.description": "Last ned OpenCode for macOS, Windows og Linux",

View File

@@ -652,6 +652,8 @@ export const dict = {
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Ceny i limity użycia mogą ulec zmianie w miarę analizy wczesnego użycia i zbierania opinii.",
"workspace.lite.promo.subscribe": "Subskrybuj Go",
"workspace.lite.promo.subscribing": "Przekierowywanie...",
"workspace.lite.promo.otherMethods": "Inne metody płatności",
"workspace.lite.promo.selectMethod": "Wybierz metodę płatności",
"download.title": "OpenCode | Pobierz",
"download.meta.description": "Pobierz OpenCode na macOS, Windows i Linux",

View File

@@ -658,6 +658,8 @@ export const dict = {
"План предназначен в первую очередь для международных пользователей. Модели размещены в США, ЕС и Сингапуре для стабильного глобального доступа. Цены и лимиты использования могут меняться по мере того, как мы изучаем раннее использование и собираем отзывы.",
"workspace.lite.promo.subscribe": "Подписаться на Go",
"workspace.lite.promo.subscribing": "Перенаправление...",
"workspace.lite.promo.otherMethods": "Другие способы оплаты",
"workspace.lite.promo.selectMethod": "Выберите способ оплаты",
"download.title": "OpenCode | Скачать",
"download.meta.description": "Скачать OpenCode для macOS, Windows и Linux",

View File

@@ -648,6 +648,8 @@ export const dict = {
"แผนนี้ออกแบบมาสำหรับผู้ใช้งานต่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์อยู่ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงที่เสถียรทั่วโลก ราคาและขีดจำกัดการใช้งานอาจมีการเปลี่ยนแปลงตามที่เราได้เรียนรู้จากการใช้งานในช่วงแรกและข้อเสนอแนะ",
"workspace.lite.promo.subscribe": "สมัครสมาชิก Go",
"workspace.lite.promo.subscribing": "กำลังเปลี่ยนเส้นทาง...",
"workspace.lite.promo.otherMethods": "วิธีการชำระเงินอื่นๆ",
"workspace.lite.promo.selectMethod": "เลือกวิธีการชำระเงิน",
"download.title": "OpenCode | ดาวน์โหลด",
"download.meta.description": "ดาวน์โหลด OpenCode สำหรับ macOS, Windows และ Linux",

View File

@@ -655,6 +655,8 @@ export const dict = {
"Plan öncelikle uluslararası kullanıcılar için tasarlanmıştır; modeller istikrarlı küresel erişim için ABD, AB ve Singapur'da barındırılmaktadır. Erken kullanımdan öğrendikçe ve geri bildirim topladıkça fiyatlandırma ve kullanım limitleri değişebilir.",
"workspace.lite.promo.subscribe": "Go'ya Abone Ol",
"workspace.lite.promo.subscribing": "Yönlendiriliyor...",
"workspace.lite.promo.otherMethods": "Diğer ödeme yöntemleri",
"workspace.lite.promo.selectMethod": "Ödeme yöntemini seçin",
"download.title": "OpenCode | İndir",
"download.meta.description": "OpenCode'u macOS, Windows ve Linux için indirin",

View File

@@ -626,6 +626,8 @@ export const dict = {
"该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保全球范围内的稳定访问体验。定价和使用额度可能会根据早期用户的使用情况和反馈持续调整与优化。",
"workspace.lite.promo.subscribe": "订阅 Go",
"workspace.lite.promo.subscribing": "正在重定向...",
"workspace.lite.promo.otherMethods": "其他付款方式",
"workspace.lite.promo.selectMethod": "选择付款方式",
"download.title": "OpenCode | 下载",
"download.meta.description": "下载适用于 macOS, Windows, 和 Linux 的 OpenCode",

View File

@@ -626,6 +626,8 @@ export const dict = {
"該計畫主要面向國際用戶設計,模型部署在美國、歐盟和新加坡,以確保全球範圍內的穩定存取體驗。定價和使用額度可能會根據早期用戶的使用情況和回饋持續調整與優化。",
"workspace.lite.promo.subscribe": "訂閱 Go",
"workspace.lite.promo.subscribing": "重新導向中...",
"workspace.lite.promo.otherMethods": "其他付款方式",
"workspace.lite.promo.selectMethod": "選擇付款方式",
"download.title": "OpenCode | 下載",
"download.meta.description": "下載適用於 macOS、Windows 與 Linux 的 OpenCode",

View File

@@ -244,6 +244,7 @@ export async function POST(input: APIEvent) {
customerID,
enrichment: {
type: productID === LiteData.productID() ? "lite" : "subscription",
currency: body.data.object.currency === "inr" ? "inr" : undefined,
couponID,
},
}),
@@ -331,16 +332,17 @@ export async function POST(input: APIEvent) {
)
if (!workspaceID) throw new Error("Workspace ID not found")
const amount = await Database.use((tx) =>
const payment = await Database.use((tx) =>
tx
.select({
amount: PaymentTable.amount,
enrichment: PaymentTable.enrichment,
})
.from(PaymentTable)
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
.then((rows) => rows[0]?.amount),
.then((rows) => rows[0]),
)
if (!amount) throw new Error("Payment not found")
if (!payment) throw new Error("Payment not found")
await Database.transaction(async (tx) => {
await tx
@@ -350,12 +352,15 @@ export async function POST(input: APIEvent) {
})
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${amount}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
// deduct balance only for top up
if (!payment.enrichment?.type) {
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${payment.amount}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
}
})
}
})()

View File

@@ -3,7 +3,7 @@ import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
import { createStore } from "solid-js/store"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import { IconAlipay, IconCreditCard, IconStripe, IconWechat } from "~/component/icon"
import { IconAlipay, IconCreditCard, IconStripe, IconUpi, IconWechat } from "~/component/icon"
import styles from "./billing-section.module.css"
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
import { useI18n } from "~/context/i18n"
@@ -211,6 +211,9 @@ export function BillingSection() {
<Match when={billingInfo()?.paymentMethodType === "wechat_pay"}>
<IconWechat style={{ width: "24px", height: "24px" }} />
</Match>
<Match when={billingInfo()?.paymentMethodType === "upi"}>
<IconUpi style={{ width: "auto", height: "16px" }} />
</Match>
</Switch>
</div>
<div data-slot="card-details">

View File

@@ -6,6 +6,14 @@ import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./payment-section.module.css"
import { useI18n } from "~/context/i18n"
function money(amount: number, currency?: string) {
const formatter =
currency === "inr"
? new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" })
: new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
return formatter.format(amount / 100_000_000)
}
const getPaymentsInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
@@ -81,6 +89,10 @@ export function PaymentSection() {
const date = new Date(payment.timeCreated)
const amount =
payment.enrichment?.type === "subscription" && payment.enrichment.couponID ? 0 : payment.amount
const currency =
payment.enrichment?.type === "subscription" || payment.enrichment?.type === "lite"
? payment.enrichment.currency
: undefined
return (
<tr>
<td data-slot="payment-date" title={formatDateUTC(date)}>
@@ -88,7 +100,7 @@ export function PaymentSection() {
</td>
<td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount" data-refunded={!!payment.timeRefunded}>
${((amount ?? 0) / 100000000).toFixed(2)}
{money(amount, currency)}
<Switch>
<Match when={payment.enrichment?.type === "credit"}>
{" "}

View File

@@ -188,8 +188,45 @@
line-height: 1.4;
}
[data-slot="subscribe-button"] {
align-self: flex-start;
[data-slot="subscribe-actions"] {
display: flex;
align-items: center;
gap: var(--space-4);
margin-top: var(--space-4);
}
[data-slot="subscribe-button"] {
align-self: stretch;
}
[data-slot="other-methods"] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
[data-slot="other-methods-icons"] {
display: inline-flex;
align-items: center;
gap: 4px;
}
[data-slot="modal-actions"] {
display: flex;
gap: var(--space-3);
margin-top: var(--space-4);
button {
flex: 1;
}
}
[data-slot="method-button"] {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--space-2);
height: 48px;
}
}

View File

@@ -1,6 +1,7 @@
import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router"
import { createStore } from "solid-js/store"
import { createMemo, For, Show } from "solid-js"
import { Modal } from "~/component/modal"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable, LiteTable } from "@opencode-ai/console-core/schema/billing.sql.js"
@@ -14,6 +15,8 @@ import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formError } from "~/lib/form-error"
import { IconAlipay, IconUpi } from "~/component/icon"
const queryLiteSubscription = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
@@ -78,22 +81,25 @@ function formatResetTime(seconds: number, i18n: ReturnType<typeof useI18n>) {
return `${minutes} ${minutes === 1 ? i18n.t("workspace.lite.time.minute") : i18n.t("workspace.lite.time.minutes")}`
}
const createLiteCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
"use server"
return json(
await withActor(
() =>
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
)
}, "liteCheckoutUrl")
const createLiteCheckoutUrl = action(
async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => {
"use server"
return json(
await withActor(
() =>
Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: [queryBillingInfo.key, queryLiteSubscription.key] },
)
},
"liteCheckoutUrl",
)
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
@@ -147,23 +153,30 @@ export function LiteSection() {
const checkoutSubmission = useSubmission(createLiteCheckoutUrl)
const useBalanceSubmission = useSubmission(setLiteUseBalance)
const [store, setStore] = createStore({
redirecting: false,
loading: undefined as undefined | "session" | "checkout" | "alipay" | "upi",
showModal: false,
})
const busy = createMemo(() => !!store.loading)
async function onClickSession() {
setStore("loading", "session")
const result = await sessionAction(params.id!, window.location.href)
if (result.data) {
setStore("redirecting", true)
window.location.href = result.data
return
}
setStore("loading", undefined)
}
async function onClickSubscribe() {
const result = await checkoutAction(params.id!, window.location.href, window.location.href)
async function onClickSubscribe(method?: "alipay" | "upi") {
setStore("loading", method ?? "checkout")
const result = await checkoutAction(params.id!, window.location.href, window.location.href, method)
if (result.data) {
setStore("redirecting", true)
window.location.href = result.data
return
}
setStore("loading", undefined)
}
return (
@@ -179,12 +192,8 @@ export function LiteSection() {
<div data-slot="section-title">
<div data-slot="title-row">
<p>{i18n.t("workspace.lite.subscription.message")}</p>
<button
data-color="primary"
disabled={sessionSubmission.pending || store.redirecting}
onClick={onClickSession}
>
{sessionSubmission.pending || store.redirecting
<button data-color="primary" disabled={sessionSubmission.pending || busy()} onClick={onClickSession}>
{store.loading === "session"
? i18n.t("workspace.lite.loading")
: i18n.t("workspace.lite.subscription.manage")}
</button>
@@ -282,16 +291,64 @@ export function LiteSection() {
<li>MiniMax M2.7</li>
</ul>
<p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p>
<button
data-slot="subscribe-button"
data-color="primary"
disabled={checkoutSubmission.pending || store.redirecting}
onClick={onClickSubscribe}
<div data-slot="subscribe-actions">
<button
data-slot="subscribe-button"
data-color="primary"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe()}
>
{store.loading === "checkout"
? i18n.t("workspace.lite.promo.subscribing")
: i18n.t("workspace.lite.promo.subscribe")}
</button>
<button
type="button"
data-slot="other-methods"
data-color="ghost"
onClick={() => setStore("showModal", true)}
>
<span>{i18n.t("workspace.lite.promo.otherMethods")}</span>
<span data-slot="other-methods-icons">
<span> </span>
<IconAlipay style={{ width: "16px", height: "16px" }} />
<span> </span>
<IconUpi style={{ width: "auto", height: "10px" }} />
</span>
</button>
</div>
<Modal
open={store.showModal}
onClose={() => setStore("showModal", false)}
title={i18n.t("workspace.lite.promo.selectMethod")}
>
{checkoutSubmission.pending || store.redirecting
? i18n.t("workspace.lite.promo.subscribing")
: i18n.t("workspace.lite.promo.subscribe")}
</button>
<div data-slot="modal-actions">
<button
type="button"
data-slot="method-button"
data-color="ghost"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe("alipay")}
>
<Show when={store.loading !== "alipay"}>
<IconAlipay style={{ width: "24px", height: "24px" }} />
</Show>
{store.loading === "alipay" ? i18n.t("workspace.lite.promo.subscribing") : "Alipay"}
</button>
<button
type="button"
data-slot="method-button"
data-color="ghost"
disabled={checkoutSubmission.pending || busy()}
onClick={() => onClickSubscribe("upi")}
>
<Show when={store.loading !== "upi"}>
<IconUpi style={{ width: "auto", height: "16px" }} />
</Show>
{store.loading === "upi" ? i18n.t("workspace.lite.promo.subscribing") : "UPI"}
</button>
</div>
</Modal>
</section>
</Show>
</>

View File

@@ -239,10 +239,11 @@ export namespace Billing {
z.object({
successUrl: z.string(),
cancelUrl: z.string(),
method: z.enum(["alipay", "upi"]).optional(),
}),
async (input) => {
const user = Actor.assert("user")
const { successUrl, cancelUrl } = input
const { successUrl, cancelUrl, method } = input
const email = await User.getAuthEmail(user.properties.userID)
const billing = await Billing.get()
@@ -250,38 +251,102 @@ export namespace Billing {
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
const session = await Billing.stripe().checkout.sessions.create({
mode: "subscription",
billing_address_collection: "required",
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
...(billing.customerID
? {
customer: billing.customerID,
customer_update: {
name: "auto",
address: "auto",
},
const createSession = () =>
Billing.stripe().checkout.sessions.create({
mode: "subscription",
discounts: [{ coupon: LiteData.firstMonth50Coupon() }],
...(billing.customerID
? {
customer: billing.customerID,
customer_update: {
name: "auto",
address: "auto",
},
}
: {
customer_email: email!,
}),
...(() => {
if (method === "alipay") {
return {
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
payment_method_types: ["alipay"],
adaptive_pricing: {
enabled: false,
},
}
}
: {
customer_email: email!,
}),
currency: "usd",
tax_id_collection: {
enabled: true,
},
success_url: successUrl,
cancel_url: cancelUrl,
subscription_data: {
metadata: {
workspaceID: Actor.workspace(),
userID: user.properties.userID,
type: "lite",
if (method === "upi") {
return {
line_items: [
{
price_data: {
currency: "inr",
product: LiteData.productID(),
recurring: {
interval: "month",
interval_count: 1,
},
unit_amount: LiteData.priceInr(),
},
quantity: 1,
},
],
payment_method_types: ["upi"] as any,
adaptive_pricing: {
enabled: false,
},
}
}
return {
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
billing_address_collection: "required",
}
})(),
tax_id_collection: {
enabled: true,
},
},
})
success_url: successUrl,
cancel_url: cancelUrl,
subscription_data: {
metadata: {
workspaceID: Actor.workspace(),
userID: user.properties.userID,
type: "lite",
},
},
})
return session.url
try {
const session = await createSession()
return session.url
} catch (e: any) {
if (
e.type !== "StripeInvalidRequestError" ||
!e.message.includes("You cannot combine currencies on a single customer")
)
throw e
// get pending payment intent
const intents = await Billing.stripe().paymentIntents.search({
query: `-status:'canceled' AND -status:'processing' AND -status:'succeeded' AND customer:'${billing.customerID}'`,
})
if (intents.data.length === 0) throw e
for (const intent of intents.data) {
// get checkout session
const sessions = await Billing.stripe().checkout.sessions.list({
customer: billing.customerID!,
payment_intent: intent.id,
})
// delete pending payment intent
await Billing.stripe().checkout.sessions.expire(sessions.data[0].id)
}
const session = await createSession()
return session.url
}
},
)

View File

@@ -10,6 +10,7 @@ export namespace LiteData {
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
export const firstMonth50Coupon = fn(z.void(), () => Resource.ZEN_LITE_PRICE.firstMonth50Coupon)
export const planName = fn(z.void(), () => "lite")
}

View File

@@ -88,6 +88,7 @@ export const PaymentTable = mysqlTable(
enrichment: json("enrichment").$type<
| {
type: "subscription" | "lite"
currency?: "inr"
couponID?: string
}
| {

View File

@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"type": "sst.sst.Linkable"
}

View File

@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"type": "sst.sst.Linkable"
}

View File

@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"type": "sst.sst.Linkable"
}

View File

@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"type": "sst.sst.Linkable"
}

View File

@@ -145,6 +145,7 @@ declare module "sst" {
"ZEN_LITE_PRICE": {
"firstMonth50Coupon": string
"price": string
"priceInr": number
"product": string
"type": "sst.sst.Linkable"
}

View File

@@ -26,6 +26,13 @@
"exports": {
"./*": "./src/*.ts"
},
"imports": {
"#db": {
"bun": "./src/storage/db.bun.ts",
"node": "./src/storage/db.node.ts",
"default": "./src/storage/db.bun.ts"
}
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.79.0",
@@ -50,8 +57,8 @@
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -82,6 +89,7 @@
"@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
@@ -97,7 +105,6 @@
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87",
"@effect/platform-node": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -113,7 +120,7 @@
"cross-spawn": "^7.0.6",
"decimal.js": "10.5.0",
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
@@ -144,6 +151,6 @@
"zod-to-json-schema": "3.24.5"
},
"overrides": {
"drizzle-orm": "1.0.0-beta.16-ea816b6"
"drizzle-orm": "catalog:"
}
}

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bun
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const dir = path.resolve(__dirname, "..")
process.chdir(dir)
// Load migrations from migration directories
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
withFileTypes: true,
})
)
.filter((entry) => entry.isDirectory() && /^\d{4}\d{2}\d{2}\d{2}\d{2}\d{2}/.test(entry.name))
.map((entry) => entry.name)
.sort()
const migrations = await Promise.all(
migrationDirs.map(async (name) => {
const file = path.join(dir, "migration", name, "migration.sql")
const sql = await Bun.file(file).text()
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(name)
const timestamp = match
? Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
)
: 0
return { sql, timestamp, name }
}),
)
console.log(`Loaded ${migrations.length} migrations`)
await Bun.build({
target: "node",
entrypoints: ["./src/node.ts"],
outdir: "./dist",
format: "esm",
external: ["jsonc-parser"],
define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
},
})
console.log("Build complete")

View File

@@ -129,7 +129,7 @@ Still open and likely worth migrating:
- [ ] `Plugin`
- [ ] `ToolRegistry`
- [ ] `Pty`
- [ ] `Worktree`
- [x] `Worktree`
- [ ] `Installation`
- [ ] `Bus`
- [ ] `Command`

View File

@@ -148,6 +148,12 @@ export namespace AccountEffect {
mapAccountServiceError("HTTP request failed"),
)
const executeEffect = <E>(request: Effect.Effect<HttpClientRequest.HttpClientRequest, E>) =>
request.pipe(
Effect.flatMap((req) => http.execute(req)),
mapAccountServiceError("HTTP request failed"),
)
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
@@ -290,7 +296,7 @@ export namespace AccountEffect {
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
const response = yield* executeEffectOk(
const response = yield* executeEffect(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(

View File

@@ -260,7 +260,10 @@ export namespace Agent {
return pipe(
await state(),
values(),
sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
}

View File

@@ -51,8 +51,8 @@ export namespace Bus {
})
const pending = []
for (const key of [def.type, "*"]) {
const match = state().subscriptions.get(key)
for (const sub of match ?? []) {
const match = [...(state().subscriptions.get(key) ?? [])]
for (const sub of match) {
pending.push(sub(payload))
}
}

View File

@@ -9,6 +9,7 @@ import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { setTimeout as sleep } from "node:timers/promises"
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
@@ -56,7 +57,7 @@ async function openWorkspace(input: {
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await Bun.sleep(1000)
await sleep(1000)
continue
}
if (!result.data) {

View File

@@ -907,12 +907,12 @@ export function Session() {
const filename = options.filename.trim()
const filepath = path.join(exportDir, filename)
await Bun.write(filepath, transcript)
await Filesystem.write(filepath, transcript)
// Open with EDITOR if available
const result = await Editor.open({ value: transcript, renderer })
if (result !== undefined) {
await Bun.write(filepath, result)
await Filesystem.write(filepath, result)
}
toast.show({ message: `Session exported to ${filename}`, variant: "success" })

View File

@@ -8,7 +8,6 @@ import { upgrade } from "@/cli/upgrade"
import { Config } from "@/config/config"
import { GlobalBus } from "@/bus/global"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
import type { BunWebSocketData } from "hono/bun"
import { Flag } from "@/flag/flag"
import { setTimeout as sleep } from "node:timers/promises"
@@ -38,7 +37,7 @@ GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
})
let server: Bun.Server<BunWebSocketData> | undefined
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
const eventStream = {
abort: undefined as AbortController | undefined,
@@ -120,7 +119,7 @@ export const rpc = {
},
async server(input: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
if (server) await server.stop(true)
server = Server.listen(input)
server = await Server.listen(input)
return { url: server.url.toString() }
},
async checkUpgrade(input: { directory: string }) {
@@ -143,7 +142,7 @@ export const rpc = {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
await Instance.disposeAll()
if (server) server.stop(true)
if (server) await server.stop(true)
},
}

View File

@@ -1,4 +1,5 @@
import z from "zod"
import { setTimeout as sleep } from "node:timers/promises"
import { fn } from "@/util/fn"
import { Database, eq } from "@/storage/db"
import { Project } from "@/project/project"
@@ -117,7 +118,7 @@ export namespace Workspace {
const adaptor = await getAdaptor(space.type)
const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
if (!res || !res.ok || !res.body) {
await Bun.sleep(1000)
await sleep(1000)
continue
}
await parseSSE(res.body, stop, (event) => {
@@ -127,7 +128,7 @@ export namespace Workspace {
})
})
// Wait 250ms and retry if SSE connection fails
await Bun.sleep(250)
await sleep(250)
}
}

View File

@@ -10,6 +10,7 @@ import { ProviderAuth } from "@/provider/auth"
import { Question } from "@/question"
import { Skill } from "@/skill/skill"
import { Snapshot } from "@/snapshot"
import { Worktree } from "@/worktree"
import { InstanceContext } from "./instance-context"
import { registerDisposer } from "./instance-registry"
@@ -26,6 +27,7 @@ export type InstanceServices =
| File.Service
| Skill.Service
| Snapshot.Service
| Worktree.Service
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need
// the full instance context (directory, worktree, project). We read from the
@@ -46,6 +48,7 @@ function lookup(_key: string) {
Layer.fresh(File.layer),
Layer.fresh(Skill.defaultLayer),
Layer.fresh(Snapshot.defaultLayer),
Layer.fresh(Worktree.layer),
).pipe(Layer.provide(ctx))
}

View File

@@ -18,7 +18,7 @@ export namespace Global {
return process.env.OPENCODE_TEST_HOME || os.homedir()
},
data,
bin: path.join(data, "bin"),
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
cache,
config,

View File

@@ -11,6 +11,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod/v4"
import { Instance } from "../project/instance"
@@ -166,14 +167,10 @@ export namespace MCP {
const queue = [pid]
while (queue.length > 0) {
const current = queue.shift()!
const proc = Bun.spawn(["pgrep", "-P", String(current)], { stdout: "pipe", stderr: "pipe" })
const [code, out] = await Promise.all([proc.exited, new Response(proc.stdout).text()]).catch(
() => [-1, ""] as const,
)
if (code !== 0) continue
for (const tok of out.trim().split(/\s+/)) {
const lines = await Process.lines(["pgrep", "-P", String(current)], { nothrow: true })
for (const tok of lines) {
const cpid = parseInt(tok, 10)
if (!isNaN(cpid) && pids.indexOf(cpid) === -1) {
if (!isNaN(cpid) && !pids.includes(cpid)) {
pids.push(cpid)
queue.push(cpid)
}

View File

@@ -0,0 +1 @@
export { Server } from "./server/server"

View File

@@ -0,0 +1,15 @@
import { Wildcard } from "@/util/wildcard"
type Rule = {
permission: string
pattern: string
action: "allow" | "deny" | "ask"
}
export function evaluate(permission: string, pattern: string, ...rulesets: Rule[][]): Rule {
const rules = rulesets.flat()
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
}

View File

@@ -13,6 +13,7 @@ import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
import os from "os"
import z from "zod"
import { evaluate as evalRule } from "./evaluate"
import { PermissionID } from "./schema"
export namespace PermissionNext {
@@ -125,12 +126,8 @@ export namespace PermissionNext {
}
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
const rules = rulesets.flat()
log.info("evaluate", { permission, pattern, ruleset: rules })
const match = rules.findLast(
(rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern),
)
return match ?? { action: "ask", permission, pattern: "*" }
log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() })
return evalRule(permission, pattern, ...rulesets)
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/PermissionNext") {}

View File

@@ -47,8 +47,6 @@ import { ProviderTransform } from "./transform"
import { Installation } from "../installation"
import { ModelID, ProviderID } from "./schema"
const DEFAULT_CHUNK_TIMEOUT = 300_000
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -186,6 +184,15 @@ export namespace Provider {
options: {},
}
},
xai: async () => {
return {
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.responses(modelID)
},
options: {},
}
},
"github-copilot": async () => {
return {
autoload: false,
@@ -1130,7 +1137,7 @@ export namespace Provider {
if (existing) return existing
const customFetch = options["fetch"]
const chunkTimeout = options["chunkTimeout"] || DEFAULT_CHUNK_TIMEOUT
const chunkTimeout = options["chunkTimeout"]
delete options["chunkTimeout"]
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {

View File

@@ -0,0 +1,85 @@
import { Hono } from "hono"
import { describeRoute, resolver } from "hono-openapi"
import { streamSSE } from "hono/streaming"
import { Log } from "@/util/log"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { lazy } from "../../util/lazy"
import { AsyncQueue } from "../../util/queue"
import { Instance } from "@/project/instance"
const log = Log.create({ service: "server" })
export const EventRoutes = lazy(() =>
new Hono().get(
"/event",
describeRoute({
summary: "Subscribe to events",
description: "Get events",
operationId: "event.subscribe",
responses: {
200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(BusEvent.payloads()),
},
},
},
},
}),
async (c) => {
log.info("event connected")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
const q = new AsyncQueue<string | null>()
let done = false
q.push(
JSON.stringify({
type: "server.connected",
properties: {},
}),
)
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
q.push(
JSON.stringify({
type: "server.heartbeat",
properties: {},
}),
)
}, 10_000)
const unsub = Bus.subscribeAll((event) => {
q.push(JSON.stringify(event))
if (event.type === Bus.InstanceDisposed.type) {
stop()
}
})
const stop = () => {
if (done) return
done = true
clearInterval(heartbeat)
unsub()
q.push(null)
log.info("event disconnected")
}
stream.onAbort(stop)
try {
for await (const data of q) {
if (data === null) return
await stream.writeSSE({ data })
}
} finally {
stop()
}
})
},
),
)

View File

@@ -4,6 +4,7 @@ import { streamSSE } from "hono/streaming"
import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { AsyncQueue } from "@/util/queue"
import { Instance } from "../../project/instance"
import { Installation } from "@/installation"
import { Log } from "../../util/log"
@@ -69,41 +70,54 @@ export const GlobalRoutes = lazy(() =>
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
stream.writeSSE({
data: JSON.stringify({
const q = new AsyncQueue<string | null>()
let done = false
q.push(
JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
})
async function handler(event: any) {
await stream.writeSSE({
data: JSON.stringify(event),
})
}
GlobalBus.on("event", handler)
)
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
stream.writeSSE({
data: JSON.stringify({
q.push(
JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
})
)
}, 10_000)
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
GlobalBus.off("event", handler)
resolve()
log.info("global event disconnected")
})
})
async function handler(event: any) {
q.push(JSON.stringify(event))
}
GlobalBus.on("event", handler)
const stop = () => {
if (done) return
done = true
clearInterval(heartbeat)
GlobalBus.off("event", handler)
q.push(null)
log.info("event disconnected")
}
stream.onAbort(stop)
try {
for await (const data of q) {
if (data === null) return
await stream.writeSSE({ data })
}
} finally {
stop()
}
})
},
)

View File

@@ -29,7 +29,7 @@ export const ProjectRoutes = lazy(() =>
},
}),
async (c) => {
const projects = await Project.list()
const projects = Project.list()
return c.json(projects)
},
)

View File

@@ -1,10 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Log } from "../util/log"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
import { cors } from "hono/cors"
import { streamSSE } from "hono/streaming"
import { proxy } from "hono/proxy"
import { basicAuth } from "hono/basic-auth"
import z from "zod"
@@ -34,6 +31,7 @@ import { FileRoutes } from "./routes/file"
import { ConfigRoutes } from "./routes/config"
import { ExperimentalRoutes } from "./routes/experimental"
import { ProviderRoutes } from "./routes/provider"
import { EventRoutes } from "./routes/event"
import { InstanceBootstrap } from "../project/bootstrap"
import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status"
@@ -251,6 +249,7 @@ export namespace Server {
.route("/question", QuestionRoutes())
.route("/provider", ProviderRoutes())
.route("/", FileRoutes())
.route("/", EventRoutes())
.route("/mcp", McpRoutes())
.route("/tui", TuiRoutes())
.post(
@@ -498,64 +497,6 @@ export namespace Server {
return c.json(await Format.status())
},
)
.get(
"/event",
describeRoute({
summary: "Subscribe to events",
description: "Get events",
operationId: "event.subscribe",
responses: {
200: {
description: "Event stream",
content: {
"text/event-stream": {
schema: resolver(BusEvent.payloads()),
},
},
},
},
}),
async (c) => {
log.info("event connected")
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
stream.writeSSE({
data: JSON.stringify({
type: "server.connected",
properties: {},
}),
})
const unsub = Bus.subscribeAll(async (event) => {
await stream.writeSSE({
data: JSON.stringify(event),
})
if (event.type === Bus.InstanceDisposed.type) {
stream.close()
}
})
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
stream.writeSSE({
data: JSON.stringify({
type: "server.heartbeat",
properties: {},
}),
})
}, 10_000)
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(heartbeat)
unsub()
resolve()
log.info("event disconnected")
})
})
})
},
)
.all("/*", async (c) => {
const path = c.req.path

View File

@@ -13,7 +13,7 @@ import { STATUS_CODES } from "http"
import { Storage } from "@/storage/storage"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
import { type SystemError } from "bun"
import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"

View File

@@ -28,11 +28,11 @@ import { MCP } from "../mcp"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
import { FileTime } from "../file/time"
import { NotFoundError } from "@/storage/db"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
import { spawn } from "child_process"
import { Command } from "../command"
import { $ } from "bun"
import { pathToFileURL, fileURLToPath } from "url"
import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
@@ -48,6 +48,7 @@ import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncate"
import { decodeDataUrl } from "@/util/data-url"
import { Process } from "@/util/process"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -1812,15 +1813,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
template = template + "\n\n" + input.arguments
}
const shell = ConfigMarkdown.shell(template)
if (shell.length > 0) {
const shellMatches = ConfigMarkdown.shell(template)
if (shellMatches.length > 0) {
const sh = Shell.preferred()
const results = await Promise.all(
shell.map(async ([, cmd]) => {
try {
return await $`${{ raw: cmd }}`.quiet().nothrow().text()
} catch (error) {
return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
}
shellMatches.map(async ([, cmd]) => {
const out = await Process.text([cmd], { shell: sh, nothrow: true })
return out.text
}),
)
let index = 0
@@ -1990,7 +1989,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (!cleaned) return
const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
return Session.setTitle({ sessionID: input.session.id, title })
return Session.setTitle({ sessionID: input.session.id, title }).catch((err) => {
if (NotFoundError.isInstance(err)) return
throw err
})
}
}
}

View File

@@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot"
import { Storage } from "@/storage/storage"
import { Bus } from "@/bus"
import { NotFoundError } from "@/storage/db"
export namespace SessionSummary {
function unquoteGitPath(input: string) {
@@ -73,11 +74,17 @@ export namespace SessionSummary {
messageID: MessageID.zod,
}),
async (input) => {
const all = await Session.messages({ sessionID: input.sessionID })
await Promise.all([
summarizeSession({ sessionID: input.sessionID, messages: all }),
summarizeMessage({ messageID: input.messageID, messages: all }),
])
await Session.messages({ sessionID: input.sessionID })
.then((all) =>
Promise.all([
summarizeSession({ sessionID: input.sessionID, messages: all }),
summarizeMessage({ messageID: input.messageID, messages: all }),
]),
)
.catch((err) => {
if (NotFoundError.isInstance(err)) return
throw err
})
},
)
@@ -102,7 +109,8 @@ export namespace SessionSummary {
const messages = input.messages.filter(
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
)
const msgWithParts = messages.find((m) => m.info.id === input.messageID)!
const msgWithParts = messages.find((m) => m.info.id === input.messageID)
if (!msgWithParts) return
const userMsg = msgWithParts.info as MessageV2.User
const diffs = await computeDiff({ messages })
userMsg.summary = {

View File

@@ -204,7 +204,7 @@ export namespace Skill {
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
yield* Effect.promise(() => state.ensure())
const list = Object.values(state.skills)
const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
if (!agent) return list
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
})

View File

@@ -0,0 +1,8 @@
import { Database } from "bun:sqlite"
import { drizzle } from "drizzle-orm/bun-sqlite"
export function init(path: string) {
const sqlite = new Database(path, { create: true })
const db = drizzle({ client: sqlite })
return db
}

View File

@@ -0,0 +1,8 @@
import { DatabaseSync } from "node:sqlite"
import { drizzle } from "drizzle-orm/node-sqlite"
export function init(path: string) {
const sqlite = new DatabaseSync(path)
const db = drizzle({ client: sqlite })
return db
}

View File

@@ -1,5 +1,4 @@
import { Database as BunDatabase } from "bun:sqlite"
import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import { migrate } from "drizzle-orm/bun-sqlite/migrator"
import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
export * from "drizzle-orm"
@@ -11,10 +10,10 @@ import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import path from "path"
import { readFileSync, readdirSync, existsSync } from "fs"
import * as schema from "./schema"
import { Installation } from "../installation"
import { Flag } from "../flag/flag"
import { iife } from "@/util/iife"
import { init } from "#db"
declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined
@@ -36,17 +35,12 @@ export namespace Database {
return path.join(Global.Path.data, `opencode-${safe}.db`)
})
type Schema = typeof schema
export type Transaction = SQLiteTransaction<"sync", void, Schema>
export type Transaction = SQLiteTransaction<"sync", void>
type Client = SQLiteBunDatabase
type Journal = { sql: string; timestamp: number; name: string }[]
const state = {
sqlite: undefined as BunDatabase | undefined,
}
function time(tag: string) {
const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
if (!match) return 0
@@ -83,17 +77,14 @@ export namespace Database {
export const Client = lazy(() => {
log.info("opening database", { path: Path })
const sqlite = new BunDatabase(Path, { create: true })
state.sqlite = sqlite
const db = init(Path)
sqlite.run("PRAGMA journal_mode = WAL")
sqlite.run("PRAGMA synchronous = NORMAL")
sqlite.run("PRAGMA busy_timeout = 5000")
sqlite.run("PRAGMA cache_size = -64000")
sqlite.run("PRAGMA foreign_keys = ON")
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
const db = drizzle({ client: sqlite })
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA synchronous = NORMAL")
db.run("PRAGMA busy_timeout = 5000")
db.run("PRAGMA cache_size = -64000")
db.run("PRAGMA foreign_keys = ON")
db.run("PRAGMA wal_checkpoint(PASSIVE)")
// Apply schema migrations
const entries =
@@ -117,14 +108,11 @@ export namespace Database {
})
export function close() {
const sqlite = state.sqlite
if (!sqlite) return
sqlite.close()
state.sqlite = undefined
Client().$client.close()
Client.reset()
}
export type TxOrDb = SQLiteTransaction<"sync", void, any, any> | Client
export type TxOrDb = Transaction | Client
const ctx = Context.create<{
tx: TxOrDb

View File

@@ -46,7 +46,7 @@ export namespace ToolRegistry {
if (matches.length) await Config.waitForDependencies()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
const mod = await import(pathToFileURL(match).href)
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
}

View File

@@ -33,10 +33,11 @@ export const TaskTool = Tool.define("task", async (ctx) => {
const accessibleAgents = caller
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = DESCRIPTION.replace(
"{agents}",
accessibleAgents
list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
)

View File

@@ -3,7 +3,7 @@ import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
import { AppFileSystem } from "@/filesystem"
import { PermissionNext } from "../permission"
import { evaluate } from "@/permission/evaluate"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { ToolID } from "./schema"
@@ -28,7 +28,7 @@ export namespace TruncateEffect {
function hasTaskTool(agent?: Agent.Info) {
if (!agent?.permission) return false
return PermissionNext.evaluate("task", "*", agent.permission).action !== "deny"
return evaluate("task", "*", agent.permission).action !== "deny"
}
export interface Interface {

View File

@@ -61,9 +61,9 @@ export namespace Process {
const proc = launch(cmd[0], cmd.slice(1), {
cwd: opts.cwd,
shell: opts.shell,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
shell: opts.shell,
windowsHide: process.platform === "win32",
})

View File

@@ -1,9 +1,13 @@
import whichPkg from "which"
import path from "path"
import { Global } from "../global"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const base = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""
const full = base ? base + path.delimiter + Global.Path.bin : Global.Path.bin
const result = whichPkg.sync(cmd, {
nothrow: true,
path: env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path,
path: full,
pathExt: env?.PATHEXT ?? env?.PathExt ?? process.env.PATHEXT ?? process.env.PathExt,
})
return typeof result === "string" ? result : null

View File

@@ -9,12 +9,14 @@ 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 { Process } from "../util/process"
import { git } from "../util/git"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
import { Effect, Layer, ServiceMap } from "effect"
export namespace Worktree {
const log = Log.create({ service: "worktree" })
@@ -267,7 +269,7 @@ export namespace Worktree {
return process.platform === "win32" ? normalized.toLowerCase() : normalized
}
async function candidate(root: string, base?: string) {
async function candidateName(worktreeDir: string, 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}`
@@ -277,7 +279,7 @@ export namespace Worktree {
const ref = `refs/heads/${branch}`
const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
cwd: Instance.worktree,
cwd: worktreeDir,
})
if (branchCheck.exitCode === 0) continue
@@ -335,338 +337,419 @@ export namespace Worktree {
}, 0)
}
// ---------------------------------------------------------------------------
// Effect service
// ---------------------------------------------------------------------------
export interface Interface {
readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<() => Promise<void>>
readonly create: (input?: CreateInput) => Effect.Effect<Info>
readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
readonly reset: (input: ResetInput) => Effect.Effect<boolean>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Worktree") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const instance = yield* InstanceContext
const makeWorktreeInfoEffect = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
return yield* Effect.promise(async () => {
if (instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const root = path.join(Global.Path.data, "worktree", instance.project.id)
await fs.mkdir(root, { recursive: true })
const base = name ? slug(name) : ""
return candidateName(instance.worktree, root, base || undefined)
})
})
const createFromInfoEffect = Effect.fn("Worktree.createFromInfo")(function* (
info: Info,
startCommand?: string,
) {
return yield* Effect.promise(async (): Promise<() => Promise<void>> => {
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,
},
},
})
return
}
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
})
if (!booted) return
GlobalBus.emit("event", {
directory: info.directory,
payload: {
type: Event.Ready.type,
properties: {
name: info.name,
branch: info.branch,
},
},
})
await runStartScripts(info.directory, { projectID, extra })
}
return start().catch((error) => {
log.error("worktree start task failed", { directory: info.directory, error })
})
}
})
})
const createEffect = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
const parsed = input ? CreateInput.optional().parse(input) : undefined
const info = yield* makeWorktreeInfoEffect(parsed?.name)
const bootstrap = yield* createFromInfoEffect(info, parsed?.startCommand)
// This is needed due to how worktrees currently work in the
// desktop app
setTimeout(() => {
bootstrap()
}, 0)
return info
})
const removeEffect = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
return yield* Effect.promise(async () => {
const parsed = RemoveInput.parse(input)
if (instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = await canonical(parsed.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 = await canonical(item.path)
if (key === directory) return item
}
})()
}
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",
})
}
const stale = await locate(next.stdout)
if (stale?.path) {
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
}
}
await clean(entry.path)
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" })
}
}
return true
})
})
const resetEffect = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
return yield* Effect.promise(async () => {
const parsed = ResetInput.parse(input)
if (instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const directory = await canonical(parsed.directory)
const primary = await canonical(instance.worktree)
if (directory === primary) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
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 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
}, [])
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 remoteList = await git(["remote"], { cwd: instance.worktree })
if (remoteList.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
}
const remotes = outputText(remoteList.stdout)
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
const remote = remotes.includes("origin")
? "origin"
: remotes.length === 1
? remotes[0]
: remotes.includes("upstream")
? "upstream"
: ""
const remoteHead = remote
? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: instance.worktree })
: { exitCode: 1, stdout: undefined, stderr: undefined }
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 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 target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
if (!target) {
throw new ResetFailedError({ message: "Default branch not found" })
}
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}` })
}
}
if (!entry.path) {
throw new ResetFailedError({ message: "Worktree path not found" })
}
const worktreePath = entry.path
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 cleanResult = await sweep(worktreePath)
if (cleanResult.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(cleanResult) || "Failed to clean worktree" })
}
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 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 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 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 dirty = outputText(status.stdout)
if (dirty) {
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
}
const projectID = instance.project.id
queueStartScripts(worktreePath, { projectID })
return true
})
})
return Service.of({
makeWorktreeInfo: makeWorktreeInfoEffect,
createFromInfo: createFromInfoEffect,
create: createEffect,
remove: removeEffect,
reset: resetEffect,
})
}),
)
// ---------------------------------------------------------------------------
// Promise facades
// ---------------------------------------------------------------------------
export async function makeWorktreeInfo(name?: string): Promise<Info> {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const root = path.join(Global.Path.data, "worktree", Instance.project.id)
await fs.mkdir(root, { recursive: true })
const base = name ? slug(name) : ""
return candidate(root, base || undefined)
return runPromiseInstance(Service.use((svc) => svc.makeWorktreeInfo(name)))
}
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,
},
},
})
return
}
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
})
if (!booted) return
GlobalBus.emit("event", {
directory: info.directory,
payload: {
type: Event.Ready.type,
properties: {
name: info.name,
branch: info.branch,
},
},
})
await runStartScripts(info.directory, { projectID, extra })
}
return start().catch((error) => {
log.error("worktree start task failed", { directory: info.directory, error })
})
}
return runPromiseInstance(Service.use((svc) => svc.createFromInfo(info, startCommand)))
}
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 create = Object.assign(
async (input?: CreateInput) => {
return runPromiseInstance(Service.use((svc) => svc.create(input)))
},
{ schema: CreateInput.optional() },
)
export const remove = fn(RemoveInput, async (input) => {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
export const remove = Object.assign(
async (input: RemoveInput) => {
return runPromiseInstance(Service.use((svc) => svc.remove(input)))
},
{ schema: RemoveInput },
)
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 = await canonical(item.path)
if (key === directory) return item
}
})()
}
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",
})
}
const stale = await locate(next.stdout)
if (stale?.path) {
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
}
}
await clean(entry.path)
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" })
}
}
return true
})
export const reset = fn(ResetInput, 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 primary = await canonical(Instance.worktree)
if (directory === primary) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
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 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
}, [])
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 remoteList = await git(["remote"], { cwd: Instance.worktree })
if (remoteList.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
}
const remotes = outputText(remoteList.stdout)
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
const remote = remotes.includes("origin")
? "origin"
: remotes.length === 1
? remotes[0]
: remotes.includes("upstream")
? "upstream"
: ""
const remoteHead = remote
? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
: { exitCode: 1, stdout: undefined, stderr: undefined }
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 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 target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
if (!target) {
throw new ResetFailedError({ message: "Default branch not found" })
}
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}` })
}
}
if (!entry.path) {
throw new ResetFailedError({ message: "Worktree path not found" })
}
const worktreePath = entry.path
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 clean = await sweep(worktreePath)
if (clean.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
}
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 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 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 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 dirty = outputText(status.stdout)
if (dirty) {
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
}
const projectID = Instance.project.id
queueStartScripts(worktreePath, { projectID })
return true
})
export const reset = Object.assign(
async (input: ResetInput) => {
return runPromiseInstance(Service.use((svc) => svc.reset(input)))
},
{ schema: ResetInput },
)
}

View File

@@ -34,6 +34,26 @@ const encodeOrg = Schema.encodeSync(Org)
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
const login = () =>
new Login({
code: DeviceCode.make("device-code"),
user: UserCode.make("user-code"),
url: "https://one.example.com/verify",
server: "https://one.example.com",
expiry: Duration.seconds(600),
interval: Duration.seconds(5),
})
const deviceTokenClient = (body: unknown, status = 400) =>
HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404),
),
)
const poll = (body: unknown, status = 400) =>
AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
it.effect("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () {
yield* AccountRepo.use((r) =>
@@ -172,15 +192,6 @@ it.effect("config sends the selected org header", () =>
it.effect("poll stores the account and first org on success", () =>
Effect.gen(function* () {
const login = new Login({
code: DeviceCode.make("device-code"),
user: UserCode.make("user-code"),
url: "https://one.example.com/verify",
server: "https://one.example.com",
expiry: Duration.seconds(600),
interval: Duration.seconds(5),
})
const client = HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/auth/device/token"
@@ -198,7 +209,7 @@ it.effect("poll stores the account and first org on success", () =>
),
)
const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
const res = yield* AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") {
@@ -215,3 +226,59 @@ it.effect("poll stores the account and first org on success", () =>
)
}),
)
for (const [name, body, expectedTag] of [
[
"pending",
{
error: "authorization_pending",
error_description: "The authorization request is still pending",
},
"PollPending",
],
[
"slow",
{
error: "slow_down",
error_description: "Polling too frequently, please slow down",
},
"PollSlow",
],
[
"denied",
{
error: "access_denied",
error_description: "The authorization request was denied",
},
"PollDenied",
],
[
"expired",
{
error: "expired_token",
error_description: "The device code has expired",
},
"PollExpired",
],
] as const) {
it.effect(`poll returns ${name} for ${body.error}`, () =>
Effect.gen(function* () {
const result = yield* poll(body)
expect(result._tag).toBe(expectedTag)
}),
)
}
it.effect("poll returns poll error for other OAuth errors", () =>
Effect.gen(function* () {
const result = yield* poll({
error: "server_error",
error_description: "An unexpected error occurred",
})
expect(result._tag).toBe("PollError")
if (result._tag === "PollError") {
expect(String(result.cause)).toContain("server_error")
}
}),
)

View File

@@ -384,6 +384,32 @@ test("multiple custom agents can be defined", async () => {
})
})
test("Agent.list keeps the default agent first and sorts the rest by name", async () => {
await using tmp = await tmpdir({
config: {
default_agent: "plan",
agent: {
zebra: {
description: "Zebra",
mode: "subagent",
},
alpha: {
description: "Alpha",
mode: "subagent",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const names = (await Agent.list()).map((a) => a.name)
expect(names[0]).toBe("plan")
expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b)))
},
})
})
test("Agent.get returns undefined for non-existent agent", async () => {
await using tmp = await tmpdir()
await Instance.provide({

View File

@@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
import { SystemPrompt } from "../../src/session/system"
import { tmpdir } from "../fixture/fixture"
describe("session.system", () => {
test("skills output is sorted by name and stable across calls", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
for (const [name, description] of [
["zeta-skill", "Zeta skill."],
["alpha-skill", "Alpha skill."],
["middle-skill", "Middle skill."],
]) {
const skillDir = path.join(dir, ".opencode", "skill", name)
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: ${name}
description: ${description}
---
# ${name}
`,
)
}
},
})
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const first = await SystemPrompt.skills(build!)
const second = await SystemPrompt.skills(build!)
expect(first).toBe(second)
const alpha = first!.indexOf("<name>alpha-skill</name>")
const middle = first!.indexOf("<name>middle-skill</name>")
const zeta = first!.indexOf("<name>zeta-skill</name>")
expect(alpha).toBeGreaterThan(-1)
expect(middle).toBeGreaterThan(alpha)
expect(zeta).toBeGreaterThan(middle)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})
})

View File

@@ -54,6 +54,56 @@ description: Skill for tool tests.
}
})
test("description sorts skills by name and is stable across calls", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
for (const [name, description] of [
["zeta-skill", "Zeta skill."],
["alpha-skill", "Alpha skill."],
["middle-skill", "Middle skill."],
]) {
const skillDir = path.join(dir, ".opencode", "skill", name)
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: ${name}
description: ${description}
---
# ${name}
`,
)
}
},
})
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const first = await SkillTool.init()
const second = await SkillTool.init()
expect(first.description).toBe(second.description)
const alpha = first.description.indexOf("**alpha-skill**: Alpha skill.")
const middle = first.description.indexOf("**middle-skill**: Middle skill.")
const zeta = first.description.indexOf("**zeta-skill**: Zeta skill.")
expect(alpha).toBeGreaterThan(-1)
expect(middle).toBeGreaterThan(alpha)
expect(zeta).toBeGreaterThan(middle)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})
test("execute returns skill content block with files", async () => {
await using tmp = await tmpdir({
git: true,

View File

@@ -0,0 +1,45 @@
import { describe, expect, test } from "bun:test"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
import { TaskTool } from "../../src/tool/task"
import { tmpdir } from "../fixture/fixture"
describe("tool.task", () => {
test("description sorts subagents by name and is stable across calls", async () => {
await using tmp = await tmpdir({
config: {
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const first = await TaskTool.init({ agent: build })
const second = await TaskTool.init({ agent: build })
expect(first.description).toBe(second.description)
const alpha = first.description.indexOf("- alpha: Alpha agent")
const explore = first.description.indexOf("- explore:")
const general = first.description.indexOf("- general:")
const zebra = first.description.indexOf("- zebra: Zebra agent")
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)
expect(general).toBeGreaterThan(explore)
expect(zebra).toBeGreaterThan(general)
},
})
})
})

View File

@@ -4,12 +4,14 @@ import { Effect, FileSystem, Layer } from "effect"
import { Truncate } from "../../src/tool/truncate"
import { TruncateEffect } from "../../src/tool/truncate-effect"
import { Identifier } from "../../src/id/id"
import { Process } from "../../src/util/process"
import { Filesystem } from "../../src/util/filesystem"
import path from "path"
import { testEffect } from "../lib/effect"
import { writeFileStringScoped } from "../lib/filesystem"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
const ROOT = path.resolve(import.meta.dir, "..", "..")
describe("Truncate", () => {
describe("output", () => {
@@ -125,6 +127,14 @@ describe("Truncate", () => {
if (result.truncated) throw new Error("expected not truncated")
expect("outputPath" in result).toBe(false)
})
test("loads truncate effect in a fresh process", async () => {
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate-effect.ts")], {
cwd: ROOT,
})
expect(out.code).toBe(0)
}, 20000)
})
describe("cleanup", () => {

View File

@@ -2845,6 +2845,38 @@ export class File extends HeyApiClient {
}
}
export class Event extends HeyApiClient {
/**
* Subscribe to events
*
* Get events
*/
public subscribe<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
url: "/event",
...options,
...params,
})
}
}
export class Auth2 extends HeyApiClient {
/**
* Remove MCP OAuth
@@ -3866,38 +3898,6 @@ export class Formatter extends HeyApiClient {
}
}
export class Event extends HeyApiClient {
/**
* Subscribe to events
*
* Get events
*/
public subscribe<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).sse.get<EventSubscribeResponses, unknown, ThrowOnError>({
url: "/event",
...options,
...params,
})
}
}
export class OpencodeClient extends HeyApiClient {
public static readonly __registry = new HeyApiRegistry<OpencodeClient>()
@@ -3981,6 +3981,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._file ??= new File({ client: this.client }))
}
private _event?: Event
get event(): Event {
return (this._event ??= new Event({ client: this.client }))
}
private _mcp?: Mcp
get mcp(): Mcp {
return (this._mcp ??= new Mcp({ client: this.client }))
@@ -4025,9 +4030,4 @@ export class OpencodeClient extends HeyApiClient {
get formatter(): Formatter {
return (this._formatter ??= new Formatter({ client: this.client }))
}
private _event?: Event
get event(): Event {
return (this._event ??= new Event({ client: this.client }))
}
}

View File

@@ -4229,6 +4229,25 @@ export type FileStatusResponses = {
export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses]
export type EventSubscribeData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/event"
}
export type EventSubscribeResponses = {
/**
* Event stream
*/
200: Event
}
export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]
export type McpStatusData = {
body?: never
path?: never
@@ -4979,22 +4998,3 @@ export type FormatterStatusResponses = {
}
export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]
export type EventSubscribeData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/event"
}
export type EventSubscribeResponses = {
/**
* Event stream
*/
200: Event
}
export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]

View File

@@ -5243,6 +5243,47 @@
]
}
},
"/event": {
"get": {
"operationId": "event.subscribe",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "Subscribe to events",
"description": "Get events",
"responses": {
"200": {
"description": "Event stream",
"content": {
"text/event-stream": {
"schema": {
"$ref": "#/components/schemas/Event"
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})"
}
]
}
},
"/mcp": {
"get": {
"operationId": "mcp.status",
@@ -6894,47 +6935,6 @@
}
]
}
},
"/event": {
"get": {
"operationId": "event.subscribe",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
}
}
],
"summary": "Subscribe to events",
"description": "Get events",
"responses": {
"200": {
"description": "Event stream",
"content": {
"text/event-stream": {
"schema": {
"$ref": "#/components/schemas/Event"
}
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})"
}
]
}
}
},
"components": {

View File

@@ -0,0 +1,108 @@
diff --git a/dist/index.mjs b/dist/index.mjs
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -959,7 +959,7 @@
model: z4.string().nullish(),
object: z4.literal("response"),
output: z4.array(outputItemSchema),
- usage: xaiResponsesUsageSchema,
+ usage: xaiResponsesUsageSchema.nullish(),
status: z4.string()
});
var xaiResponsesChunkSchema = z4.union([
\ No newline at end of file
@@ -1143,6 +1143,18 @@
z4.object({
type: z4.literal("response.completed"),
response: xaiResponsesResponseSchema
+ }),
+ z4.object({
+ type: z4.literal("response.function_call_arguments.delta"),
+ item_id: z4.string(),
+ output_index: z4.number(),
+ delta: z4.string()
+ }),
+ z4.object({
+ type: z4.literal("response.function_call_arguments.done"),
+ item_id: z4.string(),
+ output_index: z4.number(),
+ arguments: z4.string()
})
]);
\ No newline at end of file
@@ -1940,6 +1952,9 @@
if (response2.status) {
finishReason = mapXaiResponsesFinishReason(response2.status);
}
+ if (seenToolCalls.size > 0 && finishReason !== "tool-calls") {
+ finishReason = "tool-calls";
+ }
return;
}
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
\ No newline at end of file
@@ -2024,7 +2039,7 @@
}
}
} else if (part.type === "function_call") {
- if (!seenToolCalls.has(part.call_id)) {
+ if (event.type === "response.output_item.done" && !seenToolCalls.has(part.call_id)) {
seenToolCalls.add(part.call_id);
controller.enqueue({
type: "tool-input-start",
\ No newline at end of file
diff --git a/dist/index.js b/dist/index.js
--- a/dist/index.js
+++ b/dist/index.js
@@ -964,7 +964,7 @@
model: import_v44.z.string().nullish(),
object: import_v44.z.literal("response"),
output: import_v44.z.array(outputItemSchema),
- usage: xaiResponsesUsageSchema,
+ usage: xaiResponsesUsageSchema.nullish(),
status: import_v44.z.string()
});
var xaiResponsesChunkSchema = import_v44.z.union([
\ No newline at end of file
@@ -1148,6 +1148,18 @@
import_v44.z.object({
type: import_v44.z.literal("response.completed"),
response: xaiResponsesResponseSchema
+ }),
+ import_v44.z.object({
+ type: import_v44.z.literal("response.function_call_arguments.delta"),
+ item_id: import_v44.z.string(),
+ output_index: import_v44.z.number(),
+ delta: import_v44.z.string()
+ }),
+ import_v44.z.object({
+ type: import_v44.z.literal("response.function_call_arguments.done"),
+ item_id: import_v44.z.string(),
+ output_index: import_v44.z.number(),
+ arguments: import_v44.z.string()
})
]);
\ No newline at end of file
@@ -1935,6 +1947,9 @@
if (response2.status) {
finishReason = mapXaiResponsesFinishReason(response2.status);
}
+ if (seenToolCalls.size > 0 && finishReason !== "tool-calls") {
+ finishReason = "tool-calls";
+ }
return;
}
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
\ No newline at end of file
@@ -2019,7 +2034,7 @@
}
}
} else if (part.type === "function_call") {
- if (!seenToolCalls.has(part.call_id)) {
+ if (event.type === "response.output_item.done" && !seenToolCalls.has(part.call_id)) {
seenToolCalls.add(part.call_id);
controller.enqueue({
type: "tool-input-start",
\ No newline at end of file

Some files were not shown because too many files have changed in this diff Show More