Compare commits

..

30 Commits

Author SHA1 Message Date
Dax Raad
add16af117 fix(opencode): fail on brew repo lookup errors 2026-03-06 01:33:59 -05:00
Dax Raad
054f848307 fix(opencode): honor nothrow on spawn errors 2026-03-06 01:30:12 -05:00
Dax Raad
3b2e3afebd fix(opencode): address migration review feedback 2026-03-06 01:25:04 -05:00
Dax Raad
f1c7d4cefb Merge remote-tracking branch 'origin/dev' into chore/remove-bun-shell-opencode
# Conflicts:
#	packages/opencode/src/file/index.ts
#	packages/opencode/src/worktree/index.ts
2026-03-06 01:15:26 -05:00
Dax Raad
1454dd1dc1 refactor(opencode): remove remaining Bun shell usage 2026-03-06 01:09:00 -05:00
Dax Raad
5d68b1b148 refactor(opencode): replace Bun shell in core flows 2026-03-06 00:54:11 -05:00
Luke Parker
aec6ca71fa fix(git): stop leaking fsmonitor daemons e.g. 60GB+ of commited memory after running tests (#16249) 2026-03-06 15:42:08 +10:00
opencode-agent[bot]
c04da45be5 chore: update nix node_modules hashes 2026-03-06 05:31:01 +00:00
Dax
74effa8eec refactor(opencode): replace Bun.which with npm which (#15012) 2026-03-06 05:18:29 +00:00
opencode
cb411248bf release: v1.2.19 2026-03-06 04:29:10 +00:00
Sadık
46d7d2fdc0 feat: add "gpt-5.4" to codex allowed models list (#16274) 2026-03-05 23:03:01 -05:00
Dax Raad
d68afcaa55 refactor: replace Bun.stderr and Bun.color with Node.js equivalents 2026-03-05 22:20:16 -05:00
Dax Raad
bf35a865ba refactor: replace Bun.connect with net.createConnection 2026-03-05 22:17:08 -05:00
Dax Raad
6733a5a822 fix: use sha1 for hash instead of unsupported xxhash3-xxh64 2026-03-05 22:12:10 -05:00
Dax Raad
7e28098365 refactor: use node:stream/consumers for stdin reading 2026-03-05 22:08:50 -05:00
Dax Raad
ae5c9ed3dd refactor: replace Bun.stdin.text with Node.js stream reading 2026-03-05 22:04:20 -05:00
Dax Raad
a9bf1c0505 refactor: replace Bun.hash with Hash.fast using xxhash3-xxh64 2026-03-05 22:03:24 -05:00
Dax Raad
dad248832d refactor: replace Bun.write with Filesystem.write in config files 2026-03-05 21:59:20 -05:00
Dax Raad
6e89d3e597 refactor: replace Bun.write/file with Filesystem utilities in snapshot 2026-03-05 21:56:41 -05:00
Dax
3ebba02d04 refactor: replace Bun.sleep with node timers (#15013) 2026-03-05 21:54:06 -05:00
Filip
cf425d114e fix(app): stale show (#16236) 2026-03-05 18:23:48 -06:00
David Hill
39691e5174 tui: remove keyboard shortcut tooltips from new session and new workspace buttons in the sidebar 2026-03-05 23:44:00 +00:00
Frank
adaee66364 zen: gpt 5.4 pro 2026-03-05 18:39:17 -05:00
Frank
a6978167ae ci: fix 2026-03-05 15:46:17 -05:00
Frank
80c36c788c zen: gpt5.3 codex spark 2026-03-05 15:40:51 -05:00
Jun
76cdc668e8 fix(console): follow-up for #13108 docs/en routing and locale cookie sync (#13608) 2026-03-05 14:31:38 -06:00
Adam
2ba1ecabc9 fix(app): load tab on open file 2026-03-05 13:40:25 -06:00
opencode-agent[bot]
e3b6d84b57 docs(i18n): sync locale docs from english changes 2026-03-05 19:01:10 +00:00
Frank
0638e49b02 zen: gpt5.4 2026-03-05 19:01:10 +00:00
opencode
2c58964a6b release: v1.2.18 2026-03-05 19:01:03 +00:00
99 changed files with 1588 additions and 1383 deletions

View File

@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -76,7 +76,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -110,7 +110,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -137,7 +137,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -161,7 +161,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -185,7 +185,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -218,7 +218,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -248,7 +248,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -277,7 +277,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -293,7 +293,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.17",
"version": "1.2.19",
"bin": {
"opencode": "./bin/opencode",
},
@@ -373,6 +373,7 @@
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
@@ -395,6 +396,7 @@
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
@@ -407,7 +409,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -427,7 +429,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.17",
"version": "1.2.19",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -438,7 +440,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -473,7 +475,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -519,7 +521,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"zod": "catalog:",
},
@@ -530,7 +532,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.17",
"version": "1.2.19",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -2120,6 +2122,8 @@
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/which": ["@types/which@3.0.4", "", {}, "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@types/yargs": ["@types/yargs@17.0.33", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA=="],
@@ -3236,7 +3240,7 @@
"isbinaryfile": ["isbinaryfile@5.0.7", "", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="],
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
@@ -4586,7 +4590,7 @@
"when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -5202,6 +5206,8 @@
"app-builder-lib/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
"app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
@@ -5388,6 +5394,8 @@
"node-gyp/nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
"node-gyp/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
@@ -5918,6 +5926,8 @@
"app-builder-lib/@electron/get/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"app-builder-lib/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"archiver-utils/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"archiver-utils/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
@@ -6000,6 +6010,8 @@
"node-gyp/nopt/abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
"node-gyp/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],

View File

@@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { spawn } from "node:child_process"
import { setTimeout as sleep } from "node:timers/promises"
type GitHubAuthor = {
login: string
@@ -281,7 +282,7 @@ async function assertOpencodeConnected() {
connected = true
break
} catch (e) {}
await Bun.sleep(300)
await sleep(300)
} while (retry++ < 30)
if (!connected) {

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-v83hWzYVg/g4zJiBpGsQ71wTdndPk3BQVZ2mjMApUIQ=",
"aarch64-linux": "sha256-inpMwkQqwBFP2wL8w/pTOP7q3fg1aOqvE0wgzVd3/B8=",
"aarch64-darwin": "sha256-r42LGrQWqDyIy62mBSU5Nf3M22dJ3NNo7mjN/1h8d8Y=",
"x86_64-darwin": "sha256-J6XrrdK5qBK3sQBQOO/B3ZluOnsAf5f65l4q/K1nDTI="
"x86_64-linux": "sha256-pBTIT8Pgdm3272YhBjiAZsmj0SSpHTklh6lGc8YcMoE=",
"aarch64-linux": "sha256-prt039++d5UZgtldAN6+RVOR557ifIeusiy5XpzN8QU=",
"aarch64-darwin": "sha256-Y3f+cXcIGLqz6oyc5fG22t6CLD4wGkvwqO6RNXjFriQ=",
"x86_64-darwin": "sha256-BjbBBhQUgGhrlP56skABcrObvutNUZSWnrnPCg1OTKE="
}
}

View File

@@ -197,6 +197,7 @@ export async function createTestProject() {
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
execSync("git init", { cwd: root, stdio: "ignore" })
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', {
cwd: root,
@@ -207,7 +208,10 @@ export async function createTestProject() {
}
export async function cleanupTestProject(directory: string) {
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
try {
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
} catch {}
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
}
export function sessionIDFromUrl(url: string) {

View File

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

View File

@@ -22,7 +22,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path"
@@ -1937,20 +1937,14 @@ export default function Layout(props: ParentProps) {
fallback={
<>
<div class="shrink-0 py-4 px-3">
<TooltipKeybind
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
placement="top"
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
>
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
>
{language.t("command.session.new")}
</Button>
</TooltipKeybind>
{language.t("command.session.new")}
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace
@@ -1965,15 +1959,9 @@ export default function Layout(props: ParentProps) {
>
<>
<div class="shrink-0 py-4 px-3">
<TooltipKeybind
title={language.t("workspace.new")}
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
{language.t("workspace.new")}
</Button>
</TooltipKeybind>
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
{language.t("workspace.new")}
</Button>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider

View File

@@ -720,6 +720,7 @@ export default function Page() {
showAllFiles,
tabForPath: file.tab,
openTab: tabs().open,
setActive: tabs().setActive,
loadFile: file.load,
})

View File

@@ -11,12 +11,13 @@ describe("createOpenReviewFile", () => {
return `file://${path}`
},
openTab: (tab) => calls.push(`open:${tab}`),
setActive: (tab) => calls.push(`active:${tab}`),
loadFile: (path) => calls.push(`load:${path}`),
})
openReviewFile("src/a.ts")
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"])
})
})

View File

@@ -24,15 +24,20 @@ export const createOpenReviewFile = (input: {
showAllFiles: () => void
tabForPath: (path: string) => string
openTab: (tab: string) => void
setActive: (tab: string) => void
loadFile: (path: string) => any | Promise<void>
}) => {
return (path: string) => {
batch(() => {
input.showAllFiles()
const maybePromise = input.loadFile(path)
const openTab = () => input.openTab(input.tabForPath(path))
if (maybePromise instanceof Promise) maybePromise.then(openTab)
else openTab()
const open = () => {
const tab = input.tabForPath(path)
input.openTab(tab)
input.setActive(tab)
}
if (maybePromise instanceof Promise) maybePromise.then(open)
else open()
})
}
}

View File

@@ -1,4 +1,4 @@
import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js"
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
@@ -160,7 +160,7 @@ function createTimelineStaging(input: TimelineStageInput) {
}
const currentTotal = input.messages().length
count = Math.min(currentTotal, count + input.config.batch)
startTransition(() => setState("count", count))
setState("count", count)
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined

View File

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

View File

@@ -108,6 +108,26 @@ const DOCS_SEGMENT = new Set([
"zh-tw",
])
const DOCS_LOCALE = {
ar: "ar",
da: "da",
de: "de",
en: "en",
es: "es",
fr: "fr",
it: "it",
ja: "ja",
ko: "ko",
nb: "no",
"pt-br": "br",
root: "en",
ru: "ru",
th: "th",
tr: "tr",
"zh-cn": "zh",
"zh-tw": "zht",
} as const satisfies Record<string, Locale>
function suffix(pathname: string) {
const index = pathname.search(/[?#]/)
if (index === -1) {
@@ -130,7 +150,12 @@ export function docs(locale: Locale, pathname: string) {
return `${next.path}${next.suffix}`
}
if (value === "root") return `${next.path}${next.suffix}`
if (value === "root") {
if (next.path === "/docs/en") return `/docs${next.suffix}`
if (next.path === "/docs/en/") return `/docs/${next.suffix}`
if (next.path.startsWith("/docs/en/")) return `/docs/${next.path.slice("/docs/en/".length)}${next.suffix}`
return `${next.path}${next.suffix}`
}
if (next.path === "/docs") return `/docs/${value}${next.suffix}`
if (next.path === "/docs/") return `/docs/${value}/${next.suffix}`
@@ -154,6 +179,15 @@ export function fromPathname(pathname: string) {
return parseLocale(fix(pathname).split("/")[1])
}
export function fromDocsPathname(pathname: string) {
const next = fix(pathname)
const value = next.split("/")[2]?.toLowerCase()
if (!value) return null
if (!next.startsWith("/docs/")) return null
if (!(value in DOCS_LOCALE)) return null
return DOCS_LOCALE[value as keyof typeof DOCS_LOCALE]
}
export function strip(pathname: string) {
const locale = fromPathname(pathname)
if (!locale) return fix(pathname)
@@ -272,6 +306,9 @@ export function localeFromRequest(request: Request) {
const fromPath = fromPathname(new URL(request.url).pathname)
if (fromPath) return fromPath
const fromDocsPath = fromDocsPathname(new URL(request.url).pathname)
if (fromDocsPath) return fromDocsPath
return (
localeFromCookieHeader(request.headers.get("cookie")) ??
detectFromAcceptLanguage(request.headers.get("accept-language"))

View File

@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
return response
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
}
export const GET = handler

View File

@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
return response
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
}
export const GET = handler

View File

@@ -1,6 +1,6 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode-ai/console-resource"
import { docs, localeFromRequest, tag } from "~/lib/language"
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
async function handler(evt: APIEvent) {
const req = evt.request.clone()
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
headers,
body: req.body,
})
return response
const next = new Response(response.body, response)
next.headers.append("set-cookie", cookie(locale))
return next
}
export const GET = handler

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.17",
"version": "1.2.19",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -43,6 +43,7 @@
"@types/mime-types": "3.0.1",
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@types/which": "3.0.4",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
@@ -127,6 +128,7 @@
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",

View File

@@ -31,6 +31,7 @@ import {
import { Log } from "../util/log"
import { pathToFileURL } from "bun"
import { Filesystem } from "../util/filesystem"
import { Hash } from "../util/hash"
import { ACPSessionManager } from "./session"
import type { ACPConfig } from "./types"
import { Provider } from "../provider/provider"
@@ -281,7 +282,7 @@ export namespace ACP {
const output = this.bashOutput(part)
const content: ToolCallContent[] = []
if (output) {
const hash = String(Bun.hash(output))
const hash = Hash.fast(output)
if (part.tool === "bash") {
if (this.bashSnapshots.get(part.callID) === hash) {
await this.connection

View File

@@ -13,6 +13,7 @@ import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
import { setTimeout as sleep } from "node:timers/promises"
type PluginAuth = NonNullable<Hooks["auth"]>
@@ -47,7 +48,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
const method = plugin.auth.methods[index]
// Handle prompts for all auth types
await Bun.sleep(10)
await sleep(10)
const inputs: Record<string, string> = {}
if (method.prompts) {
for (const prompt of method.prompts) {

View File

@@ -3,6 +3,7 @@ import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { Log } from "../../../util/log"
import { EOL } from "os"
import { setTimeout as sleep } from "node:timers/promises"
export const LSPCommand = cmd({
command: "lsp",
@@ -19,7 +20,7 @@ const DiagnosticsCommand = cmd({
async handler(args) {
await bootstrap(process.cwd(), async () => {
await LSP.touchFile(args.file, true)
await Bun.sleep(1000)
await sleep(1000)
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
})
},

View File

@@ -27,7 +27,9 @@ import { Provider } from "../../provider/provider"
import { Bus } from "../../bus"
import { MessageV2 } from "../../session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { $ } from "bun"
import { setTimeout as sleep } from "node:timers/promises"
import { Process } from "@/util/process"
import { git } from "@/util/git"
type GitHubAuthor = {
login: string
@@ -254,7 +256,7 @@ export const GithubInstallCommand = cmd({
}
// Get repo info
const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim()
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
@@ -353,7 +355,7 @@ export const GithubInstallCommand = cmd({
}
retries++
await Bun.sleep(1000)
await sleep(1000)
} while (true)
s.stop("Installed GitHub app")
@@ -492,6 +494,26 @@ export const GithubRunCommand = cmd({
? "pr_review"
: "issue"
: undefined
const gitText = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result.text().trim()
}
const gitRun = async (args: string[]) => {
const result = await git(args, { cwd: Instance.worktree })
if (result.exitCode !== 0) {
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
}
return result
}
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
const commitChanges = async (summary: string, actor?: string) => {
const args = ["commit", "-m", summary]
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
await gitRun(args)
}
try {
if (useGithubToken) {
@@ -552,7 +574,7 @@ export const GithubRunCommand = cmd({
}
const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
const branch = await checkoutNewBranch(branchPrefix)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const response = await chat(userPrompt, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, branch)
if (switched) {
@@ -586,7 +608,7 @@ export const GithubRunCommand = cmd({
// Local PR
if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
await checkoutLocalBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, prData.headRefName)
@@ -604,7 +626,7 @@ export const GithubRunCommand = cmd({
// Fork PR
else {
const forkBranch = await checkoutForkBranch(prData)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const dataPrompt = buildPromptDataForPR(prData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
const { dirty, uncommittedChanges, switched } = await branchIsDirty(head, forkBranch)
@@ -623,7 +645,7 @@ export const GithubRunCommand = cmd({
// Issue
else {
const branch = await checkoutNewBranch("issue")
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
const issueData = await fetchIssue()
const dataPrompt = buildPromptDataForIssue(issueData)
const response = await chat(`${userPrompt}\n\n${dataPrompt}`, promptFiles)
@@ -657,7 +679,7 @@ export const GithubRunCommand = cmd({
exitCode = 1
console.error(e instanceof Error ? e.message : String(e))
let msg = e
if (e instanceof $.ShellError) {
if (e instanceof Process.RunFailedError) {
msg = e.stderr.toString()
} else if (e instanceof Error) {
msg = e.message
@@ -1048,29 +1070,29 @@ export const GithubRunCommand = cmd({
const config = "http.https://github.com/.extraheader"
// actions/checkout@v6 no longer stores credentials in .git/config,
// so this may not exist - use nothrow() to handle gracefully
const ret = await $`git config --local --get ${config}`.nothrow()
const ret = await gitStatus(["config", "--local", "--get", config])
if (ret.exitCode === 0) {
gitConfig = ret.stdout.toString().trim()
await $`git config --local --unset-all ${config}`
await gitRun(["config", "--local", "--unset-all", config])
}
const newCredentials = Buffer.from(`x-access-token:${appToken}`, "utf8").toString("base64")
await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"`
await $`git config --global user.name "${AGENT_USERNAME}"`
await $`git config --global user.email "${AGENT_USERNAME}@users.noreply.github.com"`
await gitRun(["config", "--local", config, `AUTHORIZATION: basic ${newCredentials}`])
await gitRun(["config", "--global", "user.name", AGENT_USERNAME])
await gitRun(["config", "--global", "user.email", `${AGENT_USERNAME}@users.noreply.github.com`])
}
async function restoreGitConfig() {
if (gitConfig === undefined) return
const config = "http.https://github.com/.extraheader"
await $`git config --local ${config} "${gitConfig}"`
await gitRun(["config", "--local", config, gitConfig])
}
async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
console.log("Checking out new branch...")
const branch = generateBranchName(type)
await $`git checkout -b ${branch}`
await gitRun(["checkout", "-b", branch])
return branch
}
@@ -1080,8 +1102,8 @@ export const GithubRunCommand = cmd({
const branch = pr.headRefName
const depth = Math.max(pr.commits.totalCount, 20)
await $`git fetch origin --depth=${depth} ${branch}`
await $`git checkout ${branch}`
await gitRun(["fetch", "origin", `--depth=${depth}`, branch])
await gitRun(["checkout", branch])
}
async function checkoutForkBranch(pr: GitHubPullRequest) {
@@ -1091,9 +1113,9 @@ export const GithubRunCommand = cmd({
const localBranch = generateBranchName("pr")
const depth = Math.max(pr.commits.totalCount, 20)
await $`git remote add fork https://github.com/${pr.headRepository.nameWithOwner}.git`
await $`git fetch fork --depth=${depth} ${remoteBranch}`
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
await gitRun(["remote", "add", "fork", `https://github.com/${pr.headRepository.nameWithOwner}.git`])
await gitRun(["fetch", "fork", `--depth=${depth}`, remoteBranch])
await gitRun(["checkout", "-b", localBranch, `fork/${remoteBranch}`])
return localBranch
}
@@ -1114,28 +1136,23 @@ export const GithubRunCommand = cmd({
async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
console.log("Pushing to new branch...")
if (commit) {
await $`git add .`
await gitRun(["add", "."])
if (isSchedule) {
// No co-author for scheduled events - the schedule is operating as the repo
await $`git commit -m "${summary}"`
await commitChanges(summary)
} else {
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await commitChanges(summary, actor)
}
}
await $`git push -u origin ${branch}`
await gitRun(["push", "-u", "origin", branch])
}
async function pushToLocalBranch(summary: string, commit: boolean) {
console.log("Pushing to local branch...")
if (commit) {
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await gitRun(["add", "."])
await commitChanges(summary, actor)
}
await $`git push`
await gitRun(["push"])
}
async function pushToForkBranch(summary: string, pr: GitHubPullRequest, commit: boolean) {
@@ -1144,30 +1161,28 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
const remoteBranch = pr.headRefName
if (commit) {
await $`git add .`
await $`git commit -m "${summary}
Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
await gitRun(["add", "."])
await commitChanges(summary, actor)
}
await $`git push fork HEAD:${remoteBranch}`
await gitRun(["push", "fork", `HEAD:${remoteBranch}`])
}
async function branchIsDirty(originalHead: string, expectedBranch: string) {
console.log("Checking if branch is dirty...")
// Detect if the agent switched branches during chat (e.g. created
// its own branch, committed, and possibly pushed/created a PR).
const current = (await $`git rev-parse --abbrev-ref HEAD`).stdout.toString().trim()
const current = await gitText(["rev-parse", "--abbrev-ref", "HEAD"])
if (current !== expectedBranch) {
console.log(`Branch changed during chat: expected ${expectedBranch}, now on ${current}`)
return { dirty: true, uncommittedChanges: false, switched: true }
}
const ret = await $`git status --porcelain`
const ret = await gitStatus(["status", "--porcelain"])
const status = ret.stdout.toString().trim()
if (status.length > 0) {
return { dirty: true, uncommittedChanges: true, switched: false }
}
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const head = await gitText(["rev-parse", "HEAD"])
return {
dirty: head !== originalHead,
uncommittedChanges: false,
@@ -1179,11 +1194,11 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
// Falls back to fetching from origin when local refs are missing
// (common in shallow clones from actions/checkout).
async function hasNewCommits(base: string, head: string) {
const result = await $`git rev-list --count ${base}..${head}`.nothrow()
const result = await gitStatus(["rev-list", "--count", `${base}..${head}`])
if (result.exitCode !== 0) {
console.log(`rev-list failed, fetching origin/${base}...`)
await $`git fetch origin ${base} --depth=1`.nothrow()
const retry = await $`git rev-list --count origin/${base}..${head}`.nothrow()
await gitStatus(["fetch", "origin", base, "--depth=1"])
const retry = await gitStatus(["rev-list", "--count", `origin/${base}..${head}`])
if (retry.exitCode !== 0) return true // assume dirty if we can't tell
return parseInt(retry.stdout.toString().trim()) > 0
}
@@ -1372,7 +1387,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
} catch (e) {
if (retries > 0) {
console.log(`Retrying after ${delayMs}ms...`)
await Bun.sleep(delayMs)
await sleep(delayMs)
return withRetry(fn, retries - 1, delayMs)
}
throw e

View File

@@ -1,7 +1,8 @@
import { UI } from "../ui"
import { cmd } from "./cmd"
import { Instance } from "@/project/instance"
import { $ } from "bun"
import { Process } from "@/util/process"
import { git } from "@/util/git"
export const PrCommand = cmd({
command: "pr <number>",
@@ -27,21 +28,35 @@ export const PrCommand = cmd({
UI.println(`Fetching and checking out PR #${prNumber}...`)
// Use gh pr checkout with custom branch name
const result = await $`gh pr checkout ${prNumber} --branch ${localBranchName} --force`.nothrow()
const result = await Process.run(
["gh", "pr", "checkout", `${prNumber}`, "--branch", localBranchName, "--force"],
{
nothrow: true,
},
)
if (result.exitCode !== 0) {
if (result.code !== 0) {
UI.error(`Failed to checkout PR #${prNumber}. Make sure you have gh CLI installed and authenticated.`)
process.exit(1)
}
// Fetch PR info for fork handling and session link detection
const prInfoResult =
await $`gh pr view ${prNumber} --json headRepository,headRepositoryOwner,isCrossRepository,headRefName,body`.nothrow()
const prInfoResult = await Process.text(
[
"gh",
"pr",
"view",
`${prNumber}`,
"--json",
"headRepository,headRepositoryOwner,isCrossRepository,headRefName,body",
],
{ nothrow: true },
)
let sessionId: string | undefined
if (prInfoResult.exitCode === 0) {
const prInfoText = prInfoResult.text()
if (prInfoResult.code === 0) {
const prInfoText = prInfoResult.text
if (prInfoText.trim()) {
const prInfo = JSON.parse(prInfoText)
@@ -52,15 +67,19 @@ export const PrCommand = cmd({
const remoteName = forkOwner
// Check if remote already exists
const remotes = (await $`git remote`.nothrow().text()).trim()
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
if (!remotes.split("\n").includes(remoteName)) {
await $`git remote add ${remoteName} https://github.com/${forkOwner}/${forkName}.git`.nothrow()
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
cwd: Instance.worktree,
})
UI.println(`Added fork remote: ${remoteName}`)
}
// Set upstream to the fork so pushes go there
const headRefName = prInfo.headRefName
await $`git branch --set-upstream-to=${remoteName}/${headRefName} ${localBranchName}`.nothrow()
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
cwd: Instance.worktree,
})
}
// Check for opencode session link in PR body
@@ -71,9 +90,11 @@ export const PrCommand = cmd({
UI.println(`Found opencode session: ${sessionUrl}`)
UI.println(`Importing session...`)
const importResult = await $`opencode import ${sessionUrl}`.nothrow()
if (importResult.exitCode === 0) {
const importOutput = importResult.text().trim()
const importResult = await Process.text(["opencode", "import", sessionUrl], {
nothrow: true,
})
if (importResult.code === 0) {
const importOutput = importResult.text.trim()
// Extract session ID from the output (format: "Imported session: <session-id>")
const sessionIdMatch = importOutput.match(/Imported session: ([a-zA-Z0-9_-]+)/)
if (sessionIdMatch) {

View File

@@ -6,6 +6,7 @@ import { cmd } from "./cmd"
import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap"
import { EOL } from "os"
import { text as streamText } from "node:stream/consumers"
import { Filesystem } from "../../util/filesystem"
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server"
@@ -337,7 +338,7 @@ export const RunCommand = cmd({
}
}
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
if (!process.stdin.isTTY) message += "\n" + (await streamText(process.stdin))
if (message.trim().length === 0 && !args.command) {
UI.error("You must provide a message or a command")

View File

@@ -9,6 +9,7 @@ import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
import { EOL } from "os"
import path from "path"
import { which } from "../../util/which"
function pagerCmd(): string[] {
const lessOptions = ["-R", "-S"]
@@ -17,7 +18,7 @@ function pagerCmd(): string[] {
}
// user could have less installed via other options
const lessOnPath = Bun.which("less")
const lessOnPath = which("less")
if (lessOnPath) {
if (Filesystem.stat(lessOnPath)?.size) return [lessOnPath, ...lessOptions]
}
@@ -27,7 +28,7 @@ function pagerCmd(): string[] {
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]
}
const git = Bun.which("git")
const git = which("git")
if (git) {
const less = path.join(git, "..", "..", "usr", "bin", "less.exe")
if (Filesystem.stat(less)?.size) return [less, ...lessOptions]

View File

@@ -129,40 +129,14 @@ export function Session() {
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const localPermissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
const localQuestions = createMemo(() => sync.data.question[route.sessionID] ?? [])
const childSessions = createMemo(() => {
if (session()?.parentID) return []
return children().filter((x) => x.id !== route.sessionID)
})
const permissions = createMemo(() => {
if (session()?.parentID) return []
const child = childSessions().flatMap((x) => sync.data.permission[x.id] ?? [])
return [...localPermissions(), ...child]
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
})
const questions = createMemo(() => {
if (session()?.parentID) return []
const child = childSessions().flatMap((x) => sync.data.question[x.id] ?? [])
return [...localQuestions(), ...child]
return children().flatMap((x) => sync.data.question[x.id] ?? [])
})
const activeSubagents = createMemo(() =>
childSessions().flatMap((item) => {
const status = sync.data.session_status?.[item.id]
if (status?.type !== "busy" && status?.type !== "retry") return []
const count = (sync.data.message[item.id] ?? [])
.flatMap((message) => sync.data.part[message.id] ?? [])
.filter(
(part) => part.type === "tool" && (part.state.status === "completed" || part.state.status === "error"),
).length
return [
{
session: item,
status,
count,
},
]
}),
)
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -1176,29 +1150,6 @@ export function Session() {
</For>
</scrollbox>
<box flexShrink={0}>
<Show when={activeSubagents().length > 0}>
<box paddingLeft={3} paddingBottom={1} gap={0}>
<text fg={theme.text}>
<span style={{ fg: theme.textMuted }}>Subagents</span> {activeSubagents().length} running
<span style={{ fg: theme.textMuted }}> · {keybind.print("session_child_cycle")} open</span>
</text>
<For each={activeSubagents()}>
{(item) => (
<text
fg={theme.textMuted}
onMouseUp={() => {
navigate({
type: "session",
sessionID: item.session.id,
})
}}
>
{Locale.truncate(item.session.title, 36)} · {item.count} toolcalls
</text>
)}
</For>
</box>
</Show>
<Show when={permissions().length > 0}>
<PermissionPrompt request={permissions()[0]} />
</Show>
@@ -1206,7 +1157,7 @@ export function Session() {
<QuestionPrompt request={questions()[0]} />
</Show>
<Prompt
visible={!session()?.parentID && localPermissions().length === 0 && localQuestions().length === 0}
visible={!session()?.parentID && permissions().length === 0 && questions().length === 0}
ref={(r) => {
prompt = r
promptRef.set(r)
@@ -1215,7 +1166,7 @@ export function Session() {
r.set(route.initialPrompt)
}
}}
disabled={localPermissions().length > 0 || localQuestions().length > 0}
disabled={permissions().length > 0 || questions().length > 0}
onSubmit={() => {
toBottom()
}}
@@ -2002,8 +1953,10 @@ function WebSearch(props: ToolProps<any>) {
}
function Task(props: ToolProps<typeof TaskTool>) {
const { theme } = useTheme()
const keybind = useKeybind()
const { navigate } = useRoute()
const local = useLocal()
const sync = useSync()
onMount(() => {
@@ -2021,47 +1974,9 @@ function Task(props: ToolProps<typeof TaskTool>) {
)
})
const current = createMemo(() => tools().findLast((x) => x.state.status !== "pending"))
const background = createMemo(() => props.metadata.background === true)
const status = createMemo(() => {
const sessionID = props.metadata.sessionId
if (!sessionID) return
return sync.data.session_status?.[sessionID]
})
const counts = createMemo(() => {
const all = tools()
const done = all.filter((item) => item.state.status === "completed" || item.state.status === "error").length
return {
all: all.length,
done,
}
})
const childRunning = createMemo(() => status()?.type === "busy" || status()?.type === "retry")
const latest = createMemo(() => {
const user = messages().findLast((msg) => msg.role === "user")
const assistant = messages().findLast((msg) => msg.role === "assistant")
return {
user,
assistant,
}
})
const terminal = createMemo(() => {
const assistant = latest().assistant
if (!assistant) return false
const user = latest().user
if (user && user.id > assistant.id) return false
if (assistant.error) return true
return !!assistant.finish && !["tool-calls", "unknown"].includes(assistant.finish)
})
const backgroundRunning = createMemo(() => background() && childRunning())
const failed = createMemo(() => !!background() && terminal() && !!latest().assistant?.error)
const statusLabel = createMemo(() => {
if (backgroundRunning()) return "running in background"
if (!terminal()) return "background task pending sync"
if (failed()) return "background task failed"
return "background task finished"
})
const isRunning = createMemo(() => props.part.state.status === "running" || childRunning())
const current = createMemo(() => tools().findLast((x) => (x.state as any).title))
const isRunning = createMemo(() => props.part.state.status === "running")
const duration = createMemo(() => {
const first = messages().find((x) => x.role === "user")?.time.created
@@ -2072,21 +1987,16 @@ function Task(props: ToolProps<typeof TaskTool>) {
const content = createMemo(() => {
if (!props.input.description) return ""
const toolLabel = `${childRunning() ? counts().done : counts().all} toolcalls`
const content = [`Task ${props.input.description}`]
if (background()) content.push(`${statusLabel()}`)
let content = [`Task ${props.input.description}`]
if (isRunning() && tools().length > 0) {
const title = current() && (current()!.state as any).title
if (title) content.push(`${Locale.titlecase(current()!.tool)} ${title}`)
else content.push(`${toolLabel}`)
// content[0] += ` · ${tools().length} toolcalls`
if (current()) content.push(`${Locale.titlecase(current()!.tool)} ${(current()!.state as any).title}`)
else content.push(`${tools().length} toolcalls`)
}
if (props.part.state.status === "completed") {
content.push(`${toolLabel} · ${Locale.duration(duration())}`)
} else if (props.metadata.sessionId) {
content.push(`${keybind.print("session_child_cycle")} view subagents`)
content.push(`${tools().length} toolcalls · ${Locale.duration(duration())}`)
}
return content.join("\n")

View File

@@ -3,6 +3,7 @@ import { tui } from "./app"
import { Rpc } from "@/util/rpc"
import { type rpc } from "./worker"
import path from "path"
import { text as streamText } from "node:stream/consumers"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import { Log } from "@/util/log"
@@ -53,7 +54,7 @@ async function target() {
}
async function input(value?: string) {
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
const piped = process.stdin.isTTY ? undefined : await streamText(process.stdin)
if (!value) return piped
if (!piped) return value
return piped + "\n" + value

View File

@@ -1,11 +1,12 @@
import { $ } from "bun"
import { platform, release } from "os"
import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../../../../util/filesystem"
import { Process } from "../../../../util/process"
import { which } from "../../../../util/which"
/**
* Writes text to clipboard via OSC 52 escape sequence.
@@ -33,23 +34,38 @@ export namespace Clipboard {
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "opencode-clipboard.png")
try {
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
.nothrow()
.quiet()
await Process.run(
[
"osascript",
"-e",
'set imageData to the clipboard as "PNGf"',
"-e",
`set fileRef to open for access POSIX file "${tmpfile}" with write permission`,
"-e",
"set eof fileRef to 0",
"-e",
"write imageData to fileRef",
"-e",
"close access fileRef",
],
{ nothrow: true },
)
const buffer = await Filesystem.readBytes(tmpfile)
return { data: buffer.toString("base64"), mime: "image/png" }
} catch {
} finally {
await $`rm -f "${tmpfile}"`.nothrow().quiet()
await fs.rm(tmpfile, { force: true }).catch(() => {})
}
}
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
if (base64) {
const imageBuffer = Buffer.from(base64.trim(), "base64")
const base64 = await Process.text(["powershell.exe", "-NonInteractive", "-NoProfile", "-command", script], {
nothrow: true,
})
if (base64.text) {
const imageBuffer = Buffer.from(base64.text.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
@@ -57,13 +73,15 @@ export namespace Clipboard {
}
if (os === "linux") {
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
if (wayland && wayland.byteLength > 0) {
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true })
if (wayland.stdout.byteLength > 0) {
return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" }
}
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
if (x11 && x11.byteLength > 0) {
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], {
nothrow: true,
})
if (x11.stdout.byteLength > 0) {
return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" }
}
}
@@ -76,16 +94,16 @@ export namespace Clipboard {
const getCopyMethod = lazy(() => {
const os = platform()
if (os === "darwin" && Bun.which("osascript")) {
if (os === "darwin" && which("osascript")) {
console.log("clipboard: using osascript")
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
await Process.run(["osascript", "-e", `set the clipboard to "${escaped}"`], { nothrow: true })
}
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
if (process.env["WAYLAND_DISPLAY"] && which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
@@ -95,7 +113,7 @@ export namespace Clipboard {
await proc.exited.catch(() => {})
}
}
if (Bun.which("xclip")) {
if (which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
@@ -109,7 +127,7 @@ export namespace Clipboard {
await proc.exited.catch(() => {})
}
}
if (Bun.which("xsel")) {
if (which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {

View File

@@ -10,6 +10,7 @@ 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"
await Log.init({
print: process.argv.includes("--print-logs"),
@@ -75,7 +76,7 @@ const startEventStream = (directory: string) => {
).catch(() => undefined)
if (!events) {
await Bun.sleep(250)
await sleep(250)
continue
}
@@ -84,7 +85,7 @@ const startEventStream = (directory: string) => {
}
if (!signal.aborted) {
await Bun.sleep(250)
await sleep(250)
}
}
})().catch((error) => {

View File

@@ -3,11 +3,11 @@ import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { Installation } from "../../installation"
import { Global } from "../../global"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import os from "os"
import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
interface UninstallArgs {
keepConfig: boolean
@@ -192,16 +192,13 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar
const cmd = cmds[method]
if (cmd) {
spinner.start(`Running ${cmd.join(" ")}...`)
const result =
method === "choco"
? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow()
: await $`${cmd}`.quiet().nothrow()
if (result.exitCode !== 0) {
spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1)
if (
method === "choco" &&
result.stdout.toString("utf8").includes("not running from an elevated command shell")
) {
const result = await Process.run(method === "choco" ? ["choco", "uninstall", "opencode", "-y", "-r"] : cmd, {
nothrow: true,
})
if (result.code !== 0) {
spinner.stop(`Package manager uninstall failed: exit code ${result.code}`, 1)
const text = `${result.stdout.toString("utf8")}\n${result.stderr.toString("utf8")}`
if (method === "choco" && text.includes("not running from an elevated command shell")) {
prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`)
} else {
prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`)

View File

@@ -25,12 +25,12 @@ export namespace UI {
export function println(...message: string[]) {
print(...message)
Bun.stderr.write(EOL)
process.stderr.write(EOL)
}
export function print(...message: string[]) {
blank = false
Bun.stderr.write(message.join(" "))
process.stderr.write(message.join(" "))
}
let blank = false
@@ -44,7 +44,7 @@ export namespace UI {
const result: string[] = []
const reset = "\x1b[0m"
const left = {
fg: Bun.color("gray", "ansi") ?? "",
fg: "\x1b[90m",
shadow: "\x1b[38;5;235m",
bg: "\x1b[48;5;235m",
}

View File

@@ -1240,7 +1240,7 @@ export namespace Config {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
await Bun.write(options.path, updated).catch(() => {})
await Filesystem.write(options.path, updated).catch(() => {})
}
const data = parsed.data
if (data.plugin && isFile) {
@@ -1401,3 +1401,5 @@ export namespace Config {
return state().then((x) => x.directories)
}
}
Filesystem.write
Filesystem.write

View File

@@ -70,7 +70,7 @@ export async function migrateTuiConfig(input: MigrateInput) {
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
if (tui) Object.assign(payload, tui)
const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
const wrote = await Filesystem.write(target, JSON.stringify(payload, null, 2))
.then(() => true)
.catch((error) => {
log.warn("failed to write tui migration target", { from: file, to: target, error })
@@ -104,7 +104,7 @@ async function backupAndStripLegacy(file: string, source: string) {
const hasBackup = await Filesystem.exists(backup)
const backed = hasBackup
? true
: await Bun.write(backup, source)
: await Filesystem.write(backup, source)
.then(() => true)
.catch((error) => {
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
@@ -123,7 +123,7 @@ async function backupAndStripLegacy(file: string, source: string) {
return applyEdits(acc, edits)
}, source)
return Bun.write(file, text)
return Filesystem.write(file, text)
.then(() => {
log.info("stripped tui keys from server config", { path: file, backup })
return true

View File

@@ -1,6 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { $ } from "bun"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import fs from "fs"
@@ -11,6 +10,7 @@ import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
import { Global } from "../global"
import { git } from "@/util/git"
export namespace File {
const log = Log.create({ service: "file" })
@@ -418,11 +418,11 @@ export namespace File {
const project = Instance.project
if (project.vcs !== "git") return []
const diffOutput = await $`git -c core.quotepath=false diff --numstat HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const diffOutput = (
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
cwd: Instance.directory,
})
).text()
const changedFiles: Info[] = []
@@ -439,11 +439,14 @@ export namespace File {
}
}
const untrackedOutput = await $`git -c core.quotepath=false ls-files --others --exclude-standard`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const untrackedOutput = (
await git(
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard"],
{
cwd: Instance.directory,
},
)
).text()
if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n")
@@ -464,11 +467,14 @@ export namespace File {
}
// Get deleted files
const deletedOutput = await $`git -c core.quotepath=false diff --name-only --diff-filter=D HEAD`
.cwd(Instance.directory)
.quiet()
.nothrow()
.text()
const deletedOutput = (
await git(
["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--name-only", "--diff-filter=D", "HEAD"],
{
cwd: Instance.directory,
},
)
).text()
if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n")
@@ -539,10 +545,14 @@ export namespace File {
const content = (await Filesystem.readText(full).catch(() => "")).trim()
if (project.vcs === "git") {
let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text()
if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text()
let diff = (await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })).text()
if (!diff.trim()) {
diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: Instance.directory })
).text()
}
if (diff.trim()) {
const original = await $`git show HEAD:${file}`.cwd(Instance.directory).quiet().nothrow().text()
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,

View File

@@ -5,9 +5,10 @@ import fs from "fs/promises"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { text } from "node:stream/consumers"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
@@ -126,7 +127,7 @@ export namespace Ripgrep {
)
const state = lazy(async () => {
const system = Bun.which("rg")
const system = which("rg")
if (system) {
const stat = await fs.stat(system).catch(() => undefined)
if (stat?.isFile()) return { filepath: system }
@@ -337,7 +338,7 @@ export namespace Ripgrep {
limit?: number
follow?: boolean
}) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
const args = [`${await filepath()}`, "--json", "--hidden", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
if (input.glob) {
@@ -353,14 +354,16 @@ export namespace Ripgrep {
args.push("--")
args.push(input.pattern)
const command = args.join(" ")
const result = await $`${{ raw: command }}`.cwd(input.cwd).quiet().nothrow()
if (result.exitCode !== 0) {
const result = await Process.text(args, {
cwd: input.cwd,
nothrow: true,
})
if (result.code !== 0) {
return []
}
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = result.text().trim().split(/\r?\n/).filter(Boolean)
const lines = result.text.trim().split(/\r?\n/).filter(Boolean)
// Parse JSON lines from ripgrep output
return lines

View File

@@ -11,9 +11,9 @@ import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy"
import { withTimeout } from "@/util/timeout"
import type ParcelWatcher from "@parcel/watcher"
import { $ } from "bun"
import { Flag } from "@/flag/flag"
import { readdir } from "fs/promises"
import { git } from "@/util/git"
const SUBSCRIBE_TIMEOUT_MS = 10_000
@@ -88,13 +88,10 @@ export namespace FileWatcher {
}
if (Instance.project.vcs === "git") {
const vcsDir = await $`git rev-parse --git-dir`
.quiet()
.nothrow()
.cwd(Instance.worktree)
.text()
.then((x) => path.resolve(Instance.worktree, x.trim()))
.catch(() => undefined)
const result = await git(["rev-parse", "--git-dir"], {
cwd: Instance.worktree,
})
const vcsDir = result.exitCode === 0 ? path.resolve(Instance.worktree, result.text().trim()) : undefined
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const gitDirContents = await readdir(vcsDir).catch(() => [])
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")

View File

@@ -3,6 +3,7 @@ import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { which } from "../util/which"
import { Flag } from "@/flag/flag"
export interface Info {
@@ -18,7 +19,7 @@ export const gofmt: Info = {
command: ["gofmt", "-w", "$FILE"],
extensions: [".go"],
async enabled() {
return Bun.which("gofmt") !== null
return which("gofmt") !== null
},
}
@@ -27,7 +28,7 @@ export const mix: Info = {
command: ["mix", "format", "$FILE"],
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
async enabled() {
return Bun.which("mix") !== null
return which("mix") !== null
},
}
@@ -152,7 +153,7 @@ export const zig: Info = {
command: ["zig", "fmt", "$FILE"],
extensions: [".zig", ".zon"],
async enabled() {
return Bun.which("zig") !== null
return which("zig") !== null
},
}
@@ -171,7 +172,7 @@ export const ktlint: Info = {
command: ["ktlint", "-F", "$FILE"],
extensions: [".kt", ".kts"],
async enabled() {
return Bun.which("ktlint") !== null
return which("ktlint") !== null
},
}
@@ -180,7 +181,7 @@ export const ruff: Info = {
command: ["ruff", "format", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
if (!Bun.which("ruff")) return false
if (!which("ruff")) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
@@ -210,7 +211,7 @@ export const rlang: Info = {
command: ["air", "format", "$FILE"],
extensions: [".R"],
async enabled() {
const airPath = Bun.which("air")
const airPath = which("air")
if (airPath == null) return false
try {
@@ -239,7 +240,7 @@ export const uvformat: Info = {
extensions: [".py", ".pyi"],
async enabled() {
if (await ruff.enabled()) return false
if (Bun.which("uv") !== null) {
if (which("uv") !== null) {
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited
return code === 0
@@ -253,7 +254,7 @@ export const rubocop: Info = {
command: ["rubocop", "--autocorrect", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return Bun.which("rubocop") !== null
return which("rubocop") !== null
},
}
@@ -262,7 +263,7 @@ export const standardrb: Info = {
command: ["standardrb", "--fix", "$FILE"],
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async enabled() {
return Bun.which("standardrb") !== null
return which("standardrb") !== null
},
}
@@ -271,7 +272,7 @@ export const htmlbeautifier: Info = {
command: ["htmlbeautifier", "$FILE"],
extensions: [".erb", ".html.erb"],
async enabled() {
return Bun.which("htmlbeautifier") !== null
return which("htmlbeautifier") !== null
},
}
@@ -280,7 +281,7 @@ export const dart: Info = {
command: ["dart", "format", "$FILE"],
extensions: [".dart"],
async enabled() {
return Bun.which("dart") !== null
return which("dart") !== null
},
}
@@ -289,7 +290,7 @@ export const ocamlformat: Info = {
command: ["ocamlformat", "-i", "$FILE"],
extensions: [".ml", ".mli"],
async enabled() {
if (!Bun.which("ocamlformat")) return false
if (!which("ocamlformat")) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
return items.length > 0
},
@@ -300,7 +301,7 @@ export const terraform: Info = {
command: ["terraform", "fmt", "$FILE"],
extensions: [".tf", ".tfvars"],
async enabled() {
return Bun.which("terraform") !== null
return which("terraform") !== null
},
}
@@ -309,7 +310,7 @@ export const latexindent: Info = {
command: ["latexindent", "-w", "-s", "$FILE"],
extensions: [".tex"],
async enabled() {
return Bun.which("latexindent") !== null
return which("latexindent") !== null
},
}
@@ -318,7 +319,7 @@ export const gleam: Info = {
command: ["gleam", "format", "$FILE"],
extensions: [".gleam"],
async enabled() {
return Bun.which("gleam") !== null
return which("gleam") !== null
},
}
@@ -327,7 +328,7 @@ export const shfmt: Info = {
command: ["shfmt", "-w", "$FILE"],
extensions: [".sh", ".bash"],
async enabled() {
return Bun.which("shfmt") !== null
return which("shfmt") !== null
},
}
@@ -336,7 +337,7 @@ export const nixfmt: Info = {
command: ["nixfmt", "$FILE"],
extensions: [".nix"],
async enabled() {
return Bun.which("nixfmt") !== null
return which("nixfmt") !== null
},
}
@@ -345,7 +346,7 @@ export const rustfmt: Info = {
command: ["rustfmt", "$FILE"],
extensions: [".rs"],
async enabled() {
return Bun.which("rustfmt") !== null
return which("rustfmt") !== null
},
}
@@ -372,7 +373,7 @@ export const ormolu: Info = {
command: ["ormolu", "-i", "$FILE"],
extensions: [".hs"],
async enabled() {
return Bun.which("ormolu") !== null
return which("ormolu") !== null
},
}
@@ -381,7 +382,7 @@ export const cljfmt: Info = {
command: ["cljfmt", "fix", "--quiet", "$FILE"],
extensions: [".clj", ".cljs", ".cljc", ".edn"],
async enabled() {
return Bun.which("cljfmt") !== null
return which("cljfmt") !== null
},
}
@@ -390,6 +391,6 @@ export const dfmt: Info = {
command: ["dfmt", "-i", "$FILE"],
extensions: [".d"],
async enabled() {
return Bun.which("dfmt") !== null
return which("dfmt") !== null
},
}

View File

@@ -1,11 +1,12 @@
import { BusEvent } from "@/bus/bus-event"
import path from "path"
import { $ } from "bun"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Log } from "../util/log"
import { iife } from "@/util/iife"
import { Flag } from "../flag/flag"
import { Process } from "@/util/process"
import { buffer } from "node:stream/consumers"
declare global {
const OPENCODE_VERSION: string
@@ -15,6 +16,38 @@ declare global {
export namespace Installation {
const log = Log.create({ service: "installation" })
async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) {
return Process.text(cmd, {
cwd: opts.cwd,
env: opts.env,
nothrow: true,
}).then((x) => x.text)
}
async function upgradeCurl(target: string) {
const body = await fetch("https://opencode.ai/install").then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.text()
})
const proc = Process.spawn(["bash"], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
VERSION: target,
},
})
if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available")
proc.stdin.end(body)
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
code,
stdout,
stderr,
}
}
export type Method = Awaited<ReturnType<typeof method>>
export const Event = {
@@ -65,31 +98,31 @@ export namespace Installation {
const checks = [
{
name: "npm" as const,
command: () => $`npm list -g --depth=0`.throws(false).quiet().text(),
command: () => text(["npm", "list", "-g", "--depth=0"]),
},
{
name: "yarn" as const,
command: () => $`yarn global list`.throws(false).quiet().text(),
command: () => text(["yarn", "global", "list"]),
},
{
name: "pnpm" as const,
command: () => $`pnpm list -g --depth=0`.throws(false).quiet().text(),
command: () => text(["pnpm", "list", "-g", "--depth=0"]),
},
{
name: "bun" as const,
command: () => $`bun pm ls -g`.throws(false).quiet().text(),
command: () => text(["bun", "pm", "ls", "-g"]),
},
{
name: "brew" as const,
command: () => $`brew list --formula opencode`.throws(false).quiet().text(),
command: () => text(["brew", "list", "--formula", "opencode"]),
},
{
name: "scoop" as const,
command: () => $`scoop list opencode`.throws(false).quiet().text(),
command: () => text(["scoop", "list", "opencode"]),
},
{
name: "choco" as const,
command: () => $`choco list --limit-output opencode`.throws(false).quiet().text(),
command: () => text(["choco", "list", "--limit-output", "opencode"]),
},
]
@@ -121,61 +154,70 @@ export namespace Installation {
)
async function getBrewFormula() {
const tapFormula = await $`brew list --formula anomalyco/tap/opencode`.throws(false).quiet().text()
const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"])
if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode"
const coreFormula = await $`brew list --formula opencode`.throws(false).quiet().text()
const coreFormula = await text(["brew", "list", "--formula", "opencode"])
if (coreFormula.includes("opencode")) return "opencode"
return "opencode"
}
export async function upgrade(method: Method, target: string) {
let cmd
let result: Awaited<ReturnType<typeof upgradeCurl>> | undefined
switch (method) {
case "curl":
cmd = $`curl -fsSL https://opencode.ai/install | bash`.env({
...process.env,
VERSION: target,
})
result = await upgradeCurl(target)
break
case "npm":
cmd = $`npm install -g opencode-ai@${target}`
result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "pnpm":
cmd = $`pnpm install -g opencode-ai@${target}`
result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "bun":
cmd = $`bun install -g opencode-ai@${target}`
result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true })
break
case "brew": {
const formula = await getBrewFormula()
if (formula.includes("/")) {
cmd =
$`brew tap anomalyco/tap && cd "$(brew --repo anomalyco/tap)" && git pull --ff-only && brew upgrade ${formula}`.env(
{
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
},
)
break
}
cmd = $`brew upgrade ${formula}`.env({
const env = {
HOMEBREW_NO_AUTO_UPDATE: "1",
...process.env,
})
}
if (formula.includes("/")) {
const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
if (tap.code !== 0) {
result = tap
break
}
const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
if (repo.code !== 0) {
result = repo
break
}
const dir = repo.text.trim()
if (dir) {
const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true })
if (pull.code !== 0) {
result = pull
break
}
}
}
result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true })
break
}
case "choco":
cmd = $`echo Y | choco upgrade opencode --version=${target}`
result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true })
break
case "scoop":
cmd = $`scoop install opencode@${target}`
result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true })
break
default:
throw new Error(`Unknown method: ${method}`)
}
const result = await cmd.quiet().throws(false)
if (result.exitCode !== 0) {
const stderr = method === "choco" ? "not running from an elevated command shell" : result.stderr.toString("utf8")
if (!result || result.code !== 0) {
const stderr =
method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || ""
throw new UpgradeFailedError({
stderr: stderr,
})
@@ -186,7 +228,7 @@ export namespace Installation {
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
await $`${process.execPath} --version`.nothrow().quiet().text()
await Process.text([process.execPath, "--version"], { nothrow: true })
}
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
@@ -199,7 +241,7 @@ export namespace Installation {
if (detectedMethod === "brew") {
const formula = await getBrewFormula()
if (formula.includes("/")) {
const infoJson = await $`brew info --json=v2 ${formula}`.quiet().text()
const infoJson = await text(["brew", "info", "--json=v2", formula])
const info = JSON.parse(infoJson)
const version = info.formulae?.[0]?.versions?.stable
if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`)
@@ -215,7 +257,7 @@ export namespace Installation {
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const registry = await iife(async () => {
const r = (await $`npm config get registry`.quiet().nothrow().text()).trim()
const r = (await text(["npm", "config", "get", "registry"])).trim()
const reg = r || "https://registry.npmjs.org"
return reg.endsWith("/") ? reg.slice(0, -1) : reg
})

View File

@@ -4,7 +4,6 @@ import os from "os"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $ } from "bun"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
@@ -12,6 +11,7 @@ import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { Archive } from "../util/archive"
import { Process } from "../util/process"
import { which } from "../util/which"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -20,6 +20,8 @@ export namespace LSPServer {
.stat(p)
.then(() => true)
.catch(() => false)
const run = (cmd: string[], opts: Process.RunOptions = {}) => Process.run(cmd, { ...opts, nothrow: true })
const output = (cmd: string[], opts: Process.RunOptions = {}) => Process.text(cmd, { ...opts, nothrow: true })
export interface Handle {
process: ChildProcessWithoutNullStreams
@@ -75,7 +77,7 @@ export namespace LSPServer {
},
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
async spawn(root) {
const deno = Bun.which("deno")
const deno = which("deno")
if (!deno) {
log.info("deno not found, please install deno first")
return
@@ -122,7 +124,7 @@ export namespace LSPServer {
extensions: [".vue"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = Bun.which("vue-language-server")
let binary = which("vue-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
@@ -204,8 +206,8 @@ export namespace LSPServer {
await fs.rename(extractedPath, finalPath)
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
await $`${npmCmd} install`.cwd(finalPath).quiet()
await $`${npmCmd} run compile`.cwd(finalPath).quiet()
await Process.run([npmCmd, "install"], { cwd: finalPath })
await Process.run([npmCmd, "run", "compile"], { cwd: finalPath })
log.info("installed VS Code ESLint server", { serverPath })
}
@@ -260,7 +262,7 @@ export namespace LSPServer {
let lintBin = await resolveBin(lintTarget)
if (!lintBin) {
const found = Bun.which("oxlint")
const found = which("oxlint")
if (found) lintBin = found
}
@@ -281,7 +283,7 @@ export namespace LSPServer {
let serverBin = await resolveBin(serverTarget)
if (!serverBin) {
const found = Bun.which("oxc_language_server")
const found = which("oxc_language_server")
if (found) serverBin = found
}
if (serverBin) {
@@ -332,7 +334,7 @@ export namespace LSPServer {
let bin: string | undefined
if (await Filesystem.exists(localBin)) bin = localBin
if (!bin) {
const found = Bun.which("biome")
const found = which("biome")
if (found) bin = found
}
@@ -368,11 +370,11 @@ export namespace LSPServer {
},
extensions: [".go"],
async spawn(root) {
let bin = Bun.which("gopls", {
let bin = which("gopls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!Bun.which("go")) return
if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing gopls")
@@ -405,12 +407,12 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
let bin = Bun.which("rubocop", {
let bin = which("rubocop", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
const ruby = Bun.which("ruby")
const gem = Bun.which("gem")
const ruby = which("ruby")
const gem = which("gem")
if (!ruby || !gem) {
log.info("Ruby not found, please install Ruby first")
return
@@ -457,7 +459,7 @@ export namespace LSPServer {
return undefined
}
let binary = Bun.which("ty")
let binary = which("ty")
const initialization: Record<string, string> = {}
@@ -509,7 +511,7 @@ export namespace LSPServer {
extensions: [".py", ".pyi"],
root: NearestRoot(["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile", "pyrightconfig.json"]),
async spawn(root) {
let binary = Bun.which("pyright-langserver")
let binary = which("pyright-langserver")
const args = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
@@ -563,7 +565,7 @@ export namespace LSPServer {
extensions: [".ex", ".exs"],
root: NearestRoot(["mix.exs", "mix.lock"]),
async spawn(root) {
let binary = Bun.which("elixir-ls")
let binary = which("elixir-ls")
if (!binary) {
const elixirLsPath = path.join(Global.Path.bin, "elixir-ls")
binary = path.join(
@@ -574,7 +576,7 @@ export namespace LSPServer {
)
if (!(await Filesystem.exists(binary))) {
const elixir = Bun.which("elixir")
const elixir = which("elixir")
if (!elixir) {
log.error("elixir is required to run elixir-ls")
return
@@ -601,10 +603,11 @@ export namespace LSPServer {
recursive: true,
})
await $`mix deps.get && mix compile && mix elixir_ls.release2 -o release`
.quiet()
.cwd(path.join(Global.Path.bin, "elixir-ls-master"))
.env({ MIX_ENV: "prod", ...process.env })
const cwd = path.join(Global.Path.bin, "elixir-ls-master")
const env = { MIX_ENV: "prod", ...process.env }
await Process.run(["mix", "deps.get"], { cwd, env })
await Process.run(["mix", "compile"], { cwd, env })
await Process.run(["mix", "elixir_ls.release2", "-o", "release"], { cwd, env })
log.info(`installed elixir-ls`, {
path: elixirLsPath,
@@ -625,12 +628,12 @@ export namespace LSPServer {
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(root) {
let bin = Bun.which("zls", {
let bin = which("zls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
const zig = Bun.which("zig")
const zig = which("zig")
if (!zig) {
log.error("Zig is required to use zls. Please install Zig first.")
return
@@ -705,7 +708,7 @@ export namespace LSPServer {
})
if (!ok) return
} else {
await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
await run(["tar", "-xf", tempPath], { cwd: Global.Path.bin })
}
await fs.rm(tempPath, { force: true })
@@ -718,7 +721,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
log.info(`installed zls`, { bin })
@@ -737,11 +740,11 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
let bin = Bun.which("csharp-ls", {
let bin = which("csharp-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!Bun.which("dotnet")) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls")
return
}
@@ -776,11 +779,11 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
let bin = Bun.which("fsautocomplete", {
let bin = which("fsautocomplete", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) {
if (!Bun.which("dotnet")) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete")
return
}
@@ -817,7 +820,7 @@ export namespace LSPServer {
async spawn(root) {
// Check if sourcekit-lsp is available in the PATH
// This is installed with the Swift toolchain
const sourcekit = Bun.which("sourcekit-lsp")
const sourcekit = which("sourcekit-lsp")
if (sourcekit) {
return {
process: spawn(sourcekit, {
@@ -828,13 +831,13 @@ export namespace LSPServer {
// If sourcekit-lsp not found, check if xcrun is available
// This is specific to macOS where sourcekit-lsp is typically installed with Xcode
if (!Bun.which("xcrun")) return
if (!which("xcrun")) return
const lspLoc = await $`xcrun --find sourcekit-lsp`.quiet().nothrow()
const lspLoc = await output(["xcrun", "--find", "sourcekit-lsp"])
if (lspLoc.exitCode !== 0) return
if (lspLoc.code !== 0) return
const bin = lspLoc.text().trim()
const bin = lspLoc.text.trim()
return {
process: spawn(bin, {
@@ -877,7 +880,7 @@ export namespace LSPServer {
},
extensions: [".rs"],
async spawn(root) {
const bin = Bun.which("rust-analyzer")
const bin = which("rust-analyzer")
if (!bin) {
log.info("rust-analyzer not found in path, please install it")
return
@@ -896,7 +899,7 @@ export namespace LSPServer {
extensions: [".c", ".cpp", ".cc", ".cxx", ".c++", ".h", ".hpp", ".hh", ".hxx", ".h++"],
async spawn(root) {
const args = ["--background-index", "--clang-tidy"]
const fromPath = Bun.which("clangd")
const fromPath = which("clangd")
if (fromPath) {
return {
process: spawn(fromPath, args, {
@@ -1009,7 +1012,7 @@ export namespace LSPServer {
if (!ok) return
}
if (tar) {
await $`tar -xf ${archive}`.cwd(Global.Path.bin).quiet().nothrow()
await run(["tar", "-xf", archive], { cwd: Global.Path.bin })
}
await fs.rm(archive, { force: true })
@@ -1020,7 +1023,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {})
@@ -1041,7 +1044,7 @@ export namespace LSPServer {
extensions: [".svelte"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = Bun.which("svelteserver")
let binary = which("svelteserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
@@ -1088,7 +1091,7 @@ export namespace LSPServer {
}
const tsdk = path.dirname(tsserver)
let binary = Bun.which("astro-ls")
let binary = which("astro-ls")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
@@ -1132,18 +1135,15 @@ export namespace LSPServer {
root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]),
extensions: [".java"],
async spawn(root) {
const java = Bun.which("java")
const java = which("java")
if (!java) {
log.error("Java 21 or newer is required to run the JDTLS. Please install it first.")
return
}
const javaMajorVersion = await $`java -version`
.quiet()
.nothrow()
.then(({ stderr }) => {
const m = /"(\d+)\.\d+\.\d+"/.exec(stderr.toString())
return !m ? undefined : parseInt(m[1])
})
const javaMajorVersion = await run(["java", "-version"]).then((result) => {
const m = /"(\d+)\.\d+\.\d+"/.exec(result.stderr.toString())
return !m ? undefined : parseInt(m[1])
})
if (javaMajorVersion == null || javaMajorVersion < 21) {
log.error("JDTLS requires at least Java 21.")
return
@@ -1160,27 +1160,27 @@ export namespace LSPServer {
const archiveName = "release.tar.gz"
log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath })
const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow()
if (curlResult.exitCode !== 0) {
log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() })
const download = await fetch(releaseURL)
if (!download.ok || !download.body) {
log.error("Failed to download JDTLS", { status: download.status, statusText: download.statusText })
return
}
await Filesystem.writeStream(path.join(distPath, archiveName), download.body)
log.info("Extracting JDTLS archive")
const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow()
if (tarResult.exitCode !== 0) {
log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() })
const tarResult = await run(["tar", "-xzf", archiveName], { cwd: distPath })
if (tarResult.code !== 0) {
log.error("Failed to extract JDTLS", { exitCode: tarResult.code, stderr: tarResult.stderr.toString() })
return
}
await fs.rm(path.join(distPath, archiveName), { force: true })
log.info("JDTLS download and extraction completed")
}
const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar`
.cwd(launcherDir)
.quiet()
.nothrow()
.then(({ stdout }) => stdout.toString().trim())
const jarFileName =
(await fs.readdir(launcherDir).catch(() => []))
.find((item) => /^org\.eclipse\.equinox\.launcher_.*\.jar$/.test(item))
?.trim() ?? ""
const launcherJar = path.join(launcherDir, jarFileName)
if (!(await pathExists(launcherJar))) {
log.error(`Failed to locate the JDTLS launcher module in the installed directory: ${distPath}.`)
@@ -1293,7 +1293,15 @@ export namespace LSPServer {
await fs.mkdir(distPath, { recursive: true })
const archivePath = path.join(distPath, "kotlin-ls.zip")
await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow()
const download = await fetch(releaseURL)
if (!download.ok || !download.body) {
log.error("Failed to download Kotlin Language Server", {
status: download.status,
statusText: download.statusText,
})
return
}
await Filesystem.writeStream(archivePath, download.body)
const ok = await Archive.extractZip(archivePath, distPath)
.then(() => true)
.catch((error) => {
@@ -1303,7 +1311,7 @@ export namespace LSPServer {
if (!ok) return
await fs.rm(archivePath, { force: true })
if (process.platform !== "win32") {
await $`chmod +x ${launcherScript}`.quiet().nothrow()
await fs.chmod(launcherScript, 0o755).catch(() => {})
}
log.info("Installed Kotlin Language Server", { path: launcherScript })
}
@@ -1324,7 +1332,7 @@ export namespace LSPServer {
extensions: [".yaml", ".yml"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
let binary = Bun.which("yaml-language-server")
let binary = which("yaml-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(
@@ -1380,7 +1388,7 @@ export namespace LSPServer {
]),
extensions: [".lua"],
async spawn(root) {
let bin = Bun.which("lua-language-server", {
let bin = which("lua-language-server", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -1467,10 +1475,9 @@ export namespace LSPServer {
})
if (!ok) return
} else {
const ok = await $`tar -xzf ${tempPath} -C ${installDir}`
.quiet()
.then(() => true)
.catch((error) => {
const ok = await run(["tar", "-xzf", tempPath, "-C", installDir])
.then((result) => result.code === 0)
.catch((error: unknown) => {
log.error("Failed to extract lua-language-server archive", { error })
return false
})
@@ -1488,11 +1495,15 @@ export namespace LSPServer {
}
if (platform !== "win32") {
const ok = await $`chmod +x ${bin}`.quiet().catch((error) => {
log.error("Failed to set executable permission for lua-language-server binary", {
error,
const ok = await fs
.chmod(bin, 0o755)
.then(() => true)
.catch((error: unknown) => {
log.error("Failed to set executable permission for lua-language-server binary", {
error,
})
return false
})
})
if (!ok) return
}
@@ -1512,7 +1523,7 @@ export namespace LSPServer {
extensions: [".php"],
root: NearestRoot(["composer.json", "composer.lock", ".php-version"]),
async spawn(root) {
let binary = Bun.which("intelephense")
let binary = which("intelephense")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
@@ -1556,7 +1567,7 @@ export namespace LSPServer {
extensions: [".prisma"],
root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
async spawn(root) {
const prisma = Bun.which("prisma")
const prisma = which("prisma")
if (!prisma) {
log.info("prisma not found, please install prisma")
return
@@ -1574,7 +1585,7 @@ export namespace LSPServer {
extensions: [".dart"],
root: NearestRoot(["pubspec.yaml", "analysis_options.yaml"]),
async spawn(root) {
const dart = Bun.which("dart")
const dart = which("dart")
if (!dart) {
log.info("dart not found, please install dart first")
return
@@ -1592,7 +1603,7 @@ export namespace LSPServer {
extensions: [".ml", ".mli"],
root: NearestRoot(["dune-project", "dune-workspace", ".merlin", "opam"]),
async spawn(root) {
const bin = Bun.which("ocamllsp")
const bin = which("ocamllsp")
if (!bin) {
log.info("ocamllsp not found, please install ocaml-lsp-server")
return
@@ -1609,7 +1620,7 @@ export namespace LSPServer {
extensions: [".sh", ".bash", ".zsh", ".ksh"],
root: async () => Instance.directory,
async spawn(root) {
let binary = Bun.which("bash-language-server")
let binary = which("bash-language-server")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
@@ -1648,7 +1659,7 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) {
let bin = Bun.which("terraform-ls", {
let bin = which("terraform-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -1706,7 +1717,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
log.info(`installed terraform-ls`, { bin })
@@ -1731,7 +1742,7 @@ export namespace LSPServer {
extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) {
let bin = Bun.which("texlab", {
let bin = which("texlab", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -1789,7 +1800,7 @@ export namespace LSPServer {
if (!ok) return
}
if (ext === "tar.gz") {
await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
await run(["tar", "-xzf", tempPath], { cwd: Global.Path.bin })
}
await fs.rm(tempPath, { force: true })
@@ -1802,7 +1813,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
log.info("installed texlab", { bin })
@@ -1821,7 +1832,7 @@ export namespace LSPServer {
extensions: [".dockerfile", "Dockerfile"],
root: async () => Instance.directory,
async spawn(root) {
let binary = Bun.which("docker-langserver")
let binary = which("docker-langserver")
const args: string[] = []
if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
@@ -1860,7 +1871,7 @@ export namespace LSPServer {
extensions: [".gleam"],
root: NearestRoot(["gleam.toml"]),
async spawn(root) {
const gleam = Bun.which("gleam")
const gleam = which("gleam")
if (!gleam) {
log.info("gleam not found, please install gleam first")
return
@@ -1878,9 +1889,9 @@ export namespace LSPServer {
extensions: [".clj", ".cljs", ".cljc", ".edn"],
root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]),
async spawn(root) {
let bin = Bun.which("clojure-lsp")
let bin = which("clojure-lsp")
if (!bin && process.platform === "win32") {
bin = Bun.which("clojure-lsp.exe")
bin = which("clojure-lsp.exe")
}
if (!bin) {
log.info("clojure-lsp not found, please install clojure-lsp first")
@@ -1909,7 +1920,7 @@ export namespace LSPServer {
return Instance.directory
},
async spawn(root) {
const nixd = Bun.which("nixd")
const nixd = which("nixd")
if (!nixd) {
log.info("nixd not found, please install nixd first")
return
@@ -1930,7 +1941,7 @@ export namespace LSPServer {
extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]),
async spawn(root) {
let bin = Bun.which("tinymist", {
let bin = which("tinymist", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
@@ -1994,7 +2005,7 @@ export namespace LSPServer {
})
if (!ok) return
} else {
await $`tar -xzf ${tempPath} --strip-components=1`.cwd(Global.Path.bin).quiet().nothrow()
await run(["tar", "-xzf", tempPath, "--strip-components=1"], { cwd: Global.Path.bin })
}
await fs.rm(tempPath, { force: true })
@@ -2007,7 +2018,7 @@ export namespace LSPServer {
}
if (platform !== "win32") {
await $`chmod +x ${bin}`.quiet().nothrow()
await fs.chmod(bin, 0o755).catch(() => {})
}
log.info("installed tinymist", { bin })
@@ -2024,7 +2035,7 @@ export namespace LSPServer {
extensions: [".hs", ".lhs"],
root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]),
async spawn(root) {
const bin = Bun.which("haskell-language-server-wrapper")
const bin = which("haskell-language-server-wrapper")
if (!bin) {
log.info("haskell-language-server-wrapper not found, please install haskell-language-server")
return
@@ -2042,7 +2053,7 @@ export namespace LSPServer {
extensions: [".jl"],
root: NearestRoot(["Project.toml", "Manifest.toml", "*.jl"]),
async spawn(root) {
const julia = Bun.which("julia")
const julia = which("julia")
if (!julia) {
log.info("julia not found, please install julia first (https://julialang.org/downloads/)")
return

View File

@@ -1,3 +1,4 @@
import { createConnection } from "net"
import { Log } from "../util/log"
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
@@ -160,21 +161,12 @@ export namespace McpOAuthCallback {
export async function isPortInUse(): Promise<boolean> {
return new Promise((resolve) => {
Bun.connect({
hostname: "127.0.0.1",
port: OAUTH_CALLBACK_PORT,
socket: {
open(socket) {
socket.end()
resolve(true)
},
error() {
resolve(false)
},
data() {},
close() {},
},
}).catch(() => {
const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1")
socket.on("connect", () => {
socket.destroy()
resolve(true)
})
socket.on("error", () => {
resolve(false)
})
})

View File

@@ -4,6 +4,7 @@ import { Installation } from "../installation"
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
import os from "os"
import { ProviderTransform } from "@/provider/transform"
import { setTimeout as sleep } from "node:timers/promises"
const log = Log.create({ service: "plugin.codex" })
@@ -361,6 +362,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5.2",
"gpt-5.4",
"gpt-5.2-codex",
"gpt-5.3-codex",
"gpt-5.1-codex",
@@ -602,7 +604,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
return { type: "failed" as const }
}
await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
}
},
}

View File

@@ -1,6 +1,7 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { Installation } from "@/installation"
import { iife } from "@/util/iife"
import { setTimeout as sleep } from "node:timers/promises"
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
// Add a small safety buffer when polling to avoid hitting the server
@@ -270,7 +271,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
}
if (data.error === "authorization_pending") {
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
@@ -286,13 +287,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
newInterval = serverInterval * 1000
}
await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
if (data.error) return { type: "failed" as const }
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
continue
}
},

View File

@@ -14,6 +14,7 @@ import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
import { git } from "../util/git"
import { Glob } from "../util/glob"
import { which } from "../util/which"
export namespace Project {
const log = Log.create({ service: "project" })
@@ -97,7 +98,7 @@ export namespace Project {
if (dotgit) {
let sandbox = path.dirname(dotgit)
const gitBinary = Bun.which("git")
const gitBinary = which("git")
// cached id calculation
let id = await Filesystem.readText(path.join(dotgit, "opencode"))

View File

@@ -1,11 +1,11 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { $ } from "bun"
import path from "path"
import z from "zod"
import { Log } from "@/util/log"
import { Instance } from "./instance"
import { FileWatcher } from "@/file/watcher"
import { git } from "@/util/git"
const log = Log.create({ service: "vcs" })
@@ -29,13 +29,13 @@ export namespace Vcs {
export type Info = z.infer<typeof Info>
async function currentBranch() {
return $`git rev-parse --abbrev-ref HEAD`
.quiet()
.nothrow()
.cwd(Instance.worktree)
.text()
.then((x) => x.trim())
.catch(() => undefined)
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: Instance.worktree,
})
if (result.exitCode !== 0) return
const text = result.text().trim()
if (!text) return
return text
}
const state = Instance.state(

View File

@@ -6,9 +6,10 @@ import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { Hash } from "../util/hash"
import { Plugin } from "../plugin"
import { ModelsDev } from "./models"
import { NamedError } from "@opencode-ai/util/error"
import { ModelsDev } from "./models"
import { Auth } from "../auth"
import { Env } from "../env"
import { Instance } from "../project/instance"
@@ -795,7 +796,7 @@ export namespace Provider {
const modelLoaders: {
[providerID: string]: CustomModelLoader
} = {}
const sdk = new Map<number, SDK>()
const sdk = new Map<string, SDK>()
log.info("init")
@@ -1085,7 +1086,7 @@ export namespace Provider {
...model.headers,
}
const key = Bun.hash.xxHash32(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
const key = Hash.fast(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
const existing = s.sdk.get(key)
if (existing) return existing

View File

@@ -1,8 +1,10 @@
import { Flag } from "@/flag/flag"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
import { which } from "@/util/which"
import path from "path"
import { spawn, type ChildProcess } from "child_process"
import { setTimeout as sleep } from "node:timers/promises"
const SIGKILL_TIMEOUT_MS = 200
@@ -22,13 +24,13 @@ export namespace Shell {
try {
process.kill(-pid, "SIGTERM")
await Bun.sleep(SIGKILL_TIMEOUT_MS)
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
process.kill(-pid, "SIGKILL")
}
} catch (_e) {
proc.kill("SIGTERM")
await Bun.sleep(SIGKILL_TIMEOUT_MS)
await sleep(SIGKILL_TIMEOUT_MS)
if (!opts?.exited?.()) {
proc.kill("SIGKILL")
}
@@ -39,7 +41,7 @@ export namespace Shell {
function fallback() {
if (process.platform === "win32") {
if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
const git = Bun.which("git")
const git = which("git")
if (git) {
// git.exe is typically at: C:\Program Files\Git\cmd\git.exe
// bash.exe is at: C:\Program Files\Git\bin\bash.exe
@@ -49,7 +51,7 @@ export namespace Shell {
return process.env.COMSPEC || "cmd.exe"
}
if (process.platform === "darwin") return "/bin/zsh"
const bash = Bun.which("bash")
const bash = which("bash")
if (bash) return bash
return "/bin/sh"
}

View File

@@ -1,6 +1,6 @@
import { $ } from "bun"
import path from "path"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
import { Flag } from "../flag/flag"
import { Global } from "../global"
@@ -8,12 +8,17 @@ import z from "zod"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Scheduler } from "../scheduler"
import { Process } from "@/util/process"
export namespace Snapshot {
const log = Log.create({ service: "snapshot" })
const hour = 60 * 60 * 1000
const prune = "7.days"
function args(git: string, cmd: string[]) {
return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd]
}
export function init() {
Scheduler.register({
id: "snapshot.cleanup",
@@ -33,13 +38,13 @@ export namespace Snapshot {
.then(() => true)
.catch(() => false)
if (!exists) return
const result = await $`git --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}`
.quiet()
.cwd(Instance.directory)
.nothrow()
if (result.exitCode !== 0) {
const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], {
cwd: Instance.directory,
nothrow: true,
})
if (result.code !== 0) {
log.warn("cleanup failed", {
exitCode: result.exitCode,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
@@ -54,27 +59,27 @@ export namespace Snapshot {
if (cfg.snapshot === false) return
const git = gitdir()
if (await fs.mkdir(git, { recursive: true })) {
await $`git init`
.env({
await Process.run(["git", "init"], {
env: {
...process.env,
GIT_DIR: git,
GIT_WORK_TREE: Instance.worktree,
})
.quiet()
.nothrow()
},
nothrow: true,
})
// Configure git to not convert line endings on Windows
await $`git --git-dir ${git} config core.autocrlf false`.quiet().nothrow()
await $`git --git-dir ${git} config core.longpaths true`.quiet().nothrow()
await $`git --git-dir ${git} config core.symlinks true`.quiet().nothrow()
await $`git --git-dir ${git} config core.fsmonitor false`.quiet().nothrow()
await Process.run(["git", "--git-dir", git, "config", "core.autocrlf", "false"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.longpaths", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.symlinks", "true"], { nothrow: true })
await Process.run(["git", "--git-dir", git, "config", "core.fsmonitor", "false"], { nothrow: true })
log.info("initialized")
}
await add(git)
const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree`
.quiet()
.cwd(Instance.directory)
.nothrow()
.text()
const hash = await Process.text(["git", ...args(git, ["write-tree"])], {
cwd: Instance.directory,
nothrow: true,
}).then((x) => x.text)
log.info("tracking", { hash, cwd: Instance.directory, git })
return hash.trim()
}
@@ -88,19 +93,32 @@ export namespace Snapshot {
export async function patch(hash: string): Promise<Patch> {
const git = gitdir()
await add(git)
const result =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
// If git diff fails, return empty patch
if (result.exitCode !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.exitCode })
if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] }
}
const files = result.text()
const files = result.text
return {
hash,
files: files
@@ -115,20 +133,37 @@ export namespace Snapshot {
export async function restore(snapshot: string) {
log.info("restore", { commit: snapshot })
const git = gitdir()
const result =
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (result.exitCode !== 0) {
const result = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code === 0) {
const checkout = await Process.run(
["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkout.code === 0) return
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.exitCode,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
exitCode: checkout.code,
stderr: checkout.stderr.toString(),
stdout: checkout.stdout.toString(),
})
return
}
log.error("failed to restore snapshot", {
snapshot,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
}
export async function revert(patches: Patch[]) {
@@ -138,19 +173,37 @@ export namespace Snapshot {
for (const file of item.files) {
if (files.has(file)) continue
log.info("reverting", { file, hash: item.hash })
const result =
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout ${item.hash} -- ${file}`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (result.exitCode !== 0) {
const result = await Process.run(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["checkout", item.hash, "--", file]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.code !== 0) {
const relativePath = path.relative(Instance.worktree, file)
const checkTree =
await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} ls-tree ${item.hash} -- ${relativePath}`
.quiet()
.cwd(Instance.worktree)
.nothrow()
if (checkTree.exitCode === 0 && checkTree.text().trim()) {
const checkTree = await Process.text(
[
"git",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["ls-tree", item.hash, "--", relativePath]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (checkTree.code === 0 && checkTree.text.trim()) {
log.info("file existed in snapshot but checkout failed, keeping", {
file,
})
@@ -167,23 +220,36 @@ export namespace Snapshot {
export async function diff(hash: string) {
const git = gitdir()
await add(git)
const result =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .`
.quiet()
.cwd(Instance.worktree)
.nothrow()
const result = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", hash, "--", "."]),
],
{
cwd: Instance.worktree,
nothrow: true,
},
)
if (result.exitCode !== 0) {
if (result.code !== 0) {
log.warn("failed to get diff", {
hash,
exitCode: result.exitCode,
exitCode: result.code,
stderr: result.stderr.toString(),
stdout: result.stdout.toString(),
})
return ""
}
return result.text().trim()
return result.text.trim()
}
export const FileDiff = z
@@ -204,12 +270,24 @@ export namespace Snapshot {
const result: FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">()
const statuses =
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
.text()
const statuses = await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
).then((x) => x.text)
for (const line of statuses.trim().split("\n")) {
if (!line) continue
@@ -219,26 +297,57 @@ export namespace Snapshot {
status.set(file, kind)
}
for await (const line of $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
.quiet()
.cwd(Instance.directory)
.nothrow()
.lines()) {
for (const line of await Process.lines(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
"-c",
"core.quotepath=false",
...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)) {
if (!line) continue
const [additions, deletions, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-"
const before = isBinaryFile
? ""
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}`
.quiet()
.nothrow()
.text()
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${from}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const after = isBinaryFile
? ""
: await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}`
.quiet()
.nothrow()
.text()
: await Process.text(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["show", `${to}:${file}`]),
],
{ nothrow: true },
).then((x) => x.text)
const added = isBinaryFile ? 0 : parseInt(additions)
const deleted = isBinaryFile ? 0 : parseInt(deletions)
result.push({
@@ -260,10 +369,22 @@ export namespace Snapshot {
async function add(git: string) {
await syncExclude(git)
await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} add .`
.quiet()
.cwd(Instance.directory)
.nothrow()
await Process.run(
[
"git",
"-c",
"core.autocrlf=false",
"-c",
"core.longpaths=true",
"-c",
"core.symlinks=true",
...args(git, ["add", "."]),
],
{
cwd: Instance.directory,
nothrow: true,
},
)
}
async function syncExclude(git: string) {
@@ -271,21 +392,19 @@ export namespace Snapshot {
const target = path.join(git, "info", "exclude")
await fs.mkdir(path.join(git, "info"), { recursive: true })
if (!file) {
await Bun.write(target, "")
await Filesystem.write(target, "")
return
}
const text = await Bun.file(file)
.text()
.catch(() => "")
await Bun.write(target, text)
const text = await Filesystem.readText(file).catch(() => "")
await Filesystem.write(target, text)
}
async function excludes() {
const file = await $`git rev-parse --path-format=absolute --git-path info/exclude`
.quiet()
.cwd(Instance.worktree)
.nothrow()
.text()
const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: Instance.worktree,
nothrow: true,
}).then((x) => x.text)
if (!file.trim()) return
const exists = await fs
.stat(file.trim())

View File

@@ -5,10 +5,10 @@ import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { lazy } from "../util/lazy"
import { Lock } from "../util/lock"
import { $ } from "bun"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import { Glob } from "../util/glob"
import { git } from "@/util/git"
export namespace Storage {
const log = Log.create({ service: "storage" })
@@ -49,18 +49,15 @@ export namespace Storage {
}
if (!worktree) continue
if (!(await Filesystem.isDir(worktree))) continue
const [id] = await $`git rev-list --max-parents=0 --all`
.quiet()
.nothrow()
.cwd(worktree)
const result = await git(["rev-list", "--max-parents=0", "--all"], {
cwd: worktree,
})
const [id] = result
.text()
.then((x) =>
x
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted()
if (!id) continue
projectID = id

View File

@@ -7,8 +7,8 @@ import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter"
import fs from "fs/promises"
import { $ } from "bun"
import { Filesystem } from "@/util/filesystem"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts"
@@ -116,12 +116,7 @@ export const BashTool = Tool.define("bash", async () => {
if (["cd", "rm", "cp", "mv", "mkdir", "touch", "chmod", "chown", "cat"].includes(command[0])) {
for (const arg of command.slice(1)) {
if (arg.startsWith("-") || (command[0] === "chmod" && arg.startsWith("+"))) continue
const resolved = await $`realpath ${arg}`
.cwd(cwd)
.quiet()
.nothrow()
.text()
.then((x) => x.trim())
const resolved = await fs.realpath(path.resolve(cwd, arg)).catch(() => "")
log.info("resolved path", { arg, resolved })
if (resolved) {
const normalized =

View File

@@ -7,7 +7,6 @@ import { GrepTool } from "./grep"
import { BatchTool } from "./batch"
import { ReadTool } from "./read"
import { TaskTool } from "./task"
import { TaskStatusTool } from "./task_status"
import { TodoWriteTool, TodoReadTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
@@ -111,7 +110,6 @@ export namespace ToolRegistry {
EditTool,
WriteTool,
TaskTool,
TaskStatusTool,
WebFetchTool,
TodoWriteTool,
// TodoReadTool,

View File

@@ -6,7 +6,6 @@ import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { SessionStatus } from "../session/status"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
@@ -23,93 +22,8 @@ const parameters = z.object({
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
background: z
.boolean()
.optional()
.describe("When true, launch the subagent in the background and return immediately"),
})
function output(sessionID: string, text: string) {
return [
`task_id: ${sessionID} (for resuming to continue this task if needed)`,
"",
"<task_result>",
text,
"</task_result>",
].join("\n")
}
function backgroundOutput(sessionID: string) {
return [
`task_id: ${sessionID} (for polling this task with task_status)`,
"state: running",
"",
"<task_result>",
"Background task started. Continue your current work and call task_status when you need the result.",
"</task_result>",
].join("\n")
}
function backgroundMessage(input: {
sessionID: string
description: string
state: "completed" | "error"
text: string
}) {
const tag = input.state === "completed" ? "task_result" : "task_error"
const title =
input.state === "completed"
? `Background task completed: ${input.description}`
: `Background task failed: ${input.description}`
return [title, `task_id: ${input.sessionID}`, `state: ${input.state}`, `<${tag}>`, input.text, `</${tag}>`].join("\n")
}
function errorText(error: unknown) {
if (error instanceof Error) return error.message
return String(error)
}
function resultTaskID(input: unknown) {
if (!input || typeof input !== "object") return
const taskID = Reflect.get(input, "task_id")
if (typeof taskID === "string") return taskID
}
function polled(input: { message: MessageV2.WithParts; taskID: string }) {
if (input.message.info.role !== "assistant") return false
return input.message.parts.some((part) => {
if (part.type !== "tool") return false
if (part.tool !== "task_status") return false
if (part.state.status !== "completed") return false
return resultTaskID(part.state.input) === input.taskID
})
}
async function latestUser(sessionID: string) {
const [message] = await Session.messages({
sessionID,
limit: 1,
})
if (!message) return
if (message.info.role !== "user") return
return message.info.id
}
async function continueParent(input: { parentID: string; userID: string; taskID: string }) {
const message =
SessionStatus.get(input.parentID).type === "idle"
? undefined
: await SessionPrompt.loop({
sessionID: input.parentID,
}).catch(() => undefined)
if (message && polled({ message, taskID: input.taskID })) return
if (SessionStatus.get(input.parentID).type !== "idle") return
if ((await latestUser(input.parentID)) !== input.userID) return
await SessionPrompt.loop({
sessionID: input.parentID,
})
}
export const TaskTool = Tool.define("task", async (ctx) => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
@@ -189,110 +103,62 @@ export const TaskTool = Tool.define("task", async (ctx) => {
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
const parentModel = {
const model = agent.model ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
const model = agent.model ?? parentModel
const background = params.background === true
const metadata = {
sessionId: session.id,
model,
...(background ? { background: true } : {}),
}
ctx.metadata({
title: params.description,
metadata,
metadata: {
sessionId: session.id,
model,
},
})
const run = async () => {
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
const result = await SessionPrompt.prompt({
messageID: Identifier.ascending("message"),
sessionID: session.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: agent.name,
tools: {
todowrite: false,
todoread: false,
...(hasTaskPermission ? {} : { task: false }),
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
},
parts: promptParts,
})
return result.parts.findLast((x) => x.type === "text")?.text ?? ""
}
if (background) {
const inject = (state: "completed" | "error", text: string) =>
SessionPrompt.prompt({
sessionID: ctx.sessionID,
noReply: true,
model: {
modelID: parentModel.modelID,
providerID: parentModel.providerID,
},
agent: ctx.agent,
parts: [
{
type: "text",
synthetic: true,
text: backgroundMessage({
sessionID: session.id,
description: params.description,
state,
text,
}),
},
],
})
void run()
.then((text) =>
inject("completed", text)
.then((message) =>
continueParent({
parentID: ctx.sessionID,
userID: message.info.id,
taskID: session.id,
}),
)
.catch(() => {}),
)
.catch((error) =>
inject("error", errorText(error))
.then((message) =>
continueParent({
parentID: ctx.sessionID,
userID: message.info.id,
taskID: session.id,
}),
)
.catch(() => {}),
)
return {
title: params.description,
metadata,
output: backgroundOutput(session.id),
}
}
const messageID = Identifier.ascending("message")
function cancel() {
SessionPrompt.cancel(session.id)
}
ctx.abort.addEventListener("abort", cancel)
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))
const text = await run()
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
const result = await SessionPrompt.prompt({
messageID,
sessionID: session.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: agent.name,
tools: {
todowrite: false,
todoread: false,
...(hasTaskPermission ? {} : { task: false }),
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
},
parts: promptParts,
})
const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""
const output = [
`task_id: ${session.id} (for resuming to continue this task if needed)`,
"",
"<task_result>",
text,
"</task_result>",
].join("\n")
return {
title: params.description,
metadata,
output: output(session.id, text),
metadata: {
sessionId: session.id,
model,
},
output,
}
},
}

View File

@@ -17,13 +17,11 @@ When NOT to use the Task tool:
Usage notes:
1. Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
2. By default, task waits for completion and returns the result immediately, along with a task_id you can reuse later to continue the same subagent session.
3. Set background=true to launch asynchronously. In background mode, continue your current work without waiting.
4. For background runs, use task_status(task_id=..., wait=false) to poll, or wait=true to block until done (optionally with timeout_ms).
5. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
6. The agent's outputs should generally be trusted
7. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands).
8. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
2. When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result. The output includes a task_id you can reuse later to continue the same subagent session.
3. Each agent invocation starts with a fresh context unless you provide task_id to resume the same subagent session (which continues with its previous messages and tool outputs). When starting fresh, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
4. The agent's outputs should generally be trusted
5. Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent. Tell it how to verify its work if possible (e.g., relevant test commands).
6. If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
Example usage (NOTE: The agents below are fictional examples for illustration only - use the actual agents listed above):

View File

@@ -1,163 +0,0 @@
import z from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./task_status.txt"
import { Identifier } from "../id/id"
import { Session } from "../session"
import { SessionStatus } from "../session/status"
import { MessageV2 } from "../session/message-v2"
type State = "running" | "completed" | "error"
const DEFAULT_TIMEOUT = 60_000
const POLL_MS = 300
const parameters = z.object({
task_id: Identifier.schema("session").describe("The task_id returned by the task tool"),
wait: z.boolean().optional().describe("When true, wait until the task reaches a terminal state or timeout"),
timeout_ms: z
.number()
.int()
.positive()
.optional()
.describe("Maximum milliseconds to wait when wait=true (default: 60000)"),
})
function format(input: { taskID: string; state: State; text: string }) {
return [`task_id: ${input.taskID}`, `state: ${input.state}`, "", "<task_result>", input.text, "</task_result>"].join(
"\n",
)
}
function errorText(error: NonNullable<MessageV2.Assistant["error"]>) {
const data = error.data as Record<string, unknown> | undefined
const message = data?.message
if (typeof message === "string" && message) return message
return error.name
}
async function inspect(taskID: string) {
const status = SessionStatus.get(taskID)
if (status.type === "busy" || status.type === "retry") {
return {
state: "running" as const,
text: status.type === "retry" ? `Task is retrying: ${status.message}` : "Task is still running.",
}
}
let latestUser: MessageV2.User | undefined
let latestAssistant:
| {
info: MessageV2.Assistant
parts: MessageV2.Part[]
}
| undefined
for await (const item of MessageV2.stream(taskID)) {
if (!latestUser && item.info.role === "user") latestUser = item.info
if (!latestAssistant && item.info.role === "assistant") {
latestAssistant = {
info: item.info,
parts: item.parts,
}
}
if (latestUser && latestAssistant) break
}
if (!latestAssistant) {
return {
state: "running" as const,
text: "Task has started but has not produced output yet.",
}
}
if (latestUser && latestUser.id > latestAssistant.info.id) {
return {
state: "running" as const,
text: "Task is starting.",
}
}
const text = latestAssistant.parts.findLast((part) => part.type === "text")?.text ?? ""
if (latestAssistant.info.error) {
const summary = errorText(latestAssistant.info.error)
return {
state: "error" as const,
text: text || summary,
}
}
const done = latestAssistant.info.finish && !["tool-calls", "unknown"].includes(latestAssistant.info.finish)
if (done) {
return {
state: "completed" as const,
text,
}
}
return {
state: "running" as const,
text: text || "Task is still running.",
}
}
function sleep(ms: number, abort: AbortSignal) {
return new Promise<void>((resolve, reject) => {
if (abort.aborted) {
reject(new Error("Task status polling aborted"))
return
}
const onAbort = () => {
clearTimeout(timer)
reject(new Error("Task status polling aborted"))
}
const timer = setTimeout(() => {
abort.removeEventListener("abort", onAbort)
resolve()
}, ms)
abort.addEventListener("abort", onAbort, { once: true })
})
}
export const TaskStatusTool = Tool.define("task_status", {
description: DESCRIPTION,
parameters,
async execute(params, ctx) {
await Session.get(params.task_id)
let result = await inspect(params.task_id)
if (!params.wait || result.state !== "running") {
return {
title: "Task status",
metadata: {
task_id: params.task_id,
state: result.state,
timed_out: false,
},
output: format({ taskID: params.task_id, state: result.state, text: result.text }),
}
}
const timeout = params.timeout_ms ?? DEFAULT_TIMEOUT
const end = Date.now() + timeout
while (Date.now() < end) {
const left = end - Date.now()
await sleep(Math.min(POLL_MS, left), ctx.abort)
result = await inspect(params.task_id)
if (result.state !== "running") break
}
const done = result.state !== "running"
const text = done ? result.text : `Timed out after ${timeout}ms while waiting for task completion.`
return {
title: "Task status",
metadata: {
task_id: params.task_id,
state: result.state,
timed_out: !done,
},
output: format({ taskID: params.task_id, state: result.state, text }),
}
},
})

View File

@@ -1,13 +0,0 @@
Poll the status of a subagent task launched with the task tool.
Use this to check background tasks started with `task(background=true)`.
Parameters:
- `task_id` (required): the task session id returned by the task tool
- `wait` (optional): when true, wait for completion
- `timeout_ms` (optional): max wait duration in milliseconds when `wait=true`
Returns compact, parseable output:
- `task_id`
- `state` (`running`, `completed`, or `error`)
- `<task_result>...</task_result>` containing final output, error summary, or current progress text

View File

@@ -1,5 +1,5 @@
import { $ } from "bun"
import path from "path"
import { Process } from "./process"
export namespace Archive {
export async function extractZip(zipPath: string, destDir: string) {
@@ -8,9 +8,10 @@ export namespace Archive {
const winDestDir = path.resolve(destDir)
// $global:ProgressPreference suppresses PowerShell's blue progress bar popup
const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
await $`powershell -NoProfile -NonInteractive -Command ${cmd}`.quiet()
} else {
await $`unzip -o -q ${zipPath} -d ${destDir}`.quiet()
await Process.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd])
return
}
await Process.run(["unzip", "-o", "-q", zipPath, "-d", destDir])
}
}

View File

@@ -0,0 +1,7 @@
import { createHash } from "crypto"
export namespace Hash {
export function fast(input: string | Buffer): string {
return createHash("sha1").update(input).digest("hex")
}
}

View File

@@ -25,6 +25,10 @@ export namespace Process {
stderr: Buffer
}
export interface TextResult extends Result {
text: string
}
export class RunFailedError extends Error {
readonly cmd: string[]
readonly code: number
@@ -114,13 +118,33 @@ export namespace Process {
if (!proc.stdout || !proc.stderr) throw new Error("Process output not available")
const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
const out = {
code,
stdout,
stderr,
}
const out = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
.then(([code, stdout, stderr]) => ({
code,
stdout,
stderr,
}))
.catch((err: unknown) => {
if (!opts.nothrow) throw err
return {
code: 1,
stdout: Buffer.alloc(0),
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
}
})
if (out.code === 0 || opts.nothrow) return out
throw new RunFailedError(cmd, out.code, out.stdout, out.stderr)
}
export async function text(cmd: string[], opts: RunOptions = {}): Promise<TextResult> {
const out = await run(cmd, opts)
return {
...out,
text: out.stdout.toString(),
}
}
export async function lines(cmd: string[], opts: RunOptions = {}): Promise<string[]> {
return (await text(cmd, opts)).text.split(/\r?\n/).filter(Boolean)
}
}

View File

@@ -0,0 +1,10 @@
import whichPkg from "which"
export function which(cmd: string, env?: NodeJS.ProcessEnv) {
const result = whichPkg.sync(cmd, {
nothrow: true,
path: env?.PATH,
pathExt: env?.PATHEXT,
})
return typeof result === "string" ? result : null
}

View File

@@ -1,4 +1,3 @@
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import z from "zod"
@@ -11,6 +10,8 @@ import { Database, eq } from "../storage/db"
import { ProjectTable } from "../project/project.sql"
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"
@@ -248,14 +249,14 @@ export namespace Worktree {
}
async function sweep(root: string) {
const first = await $`git clean -ffdx`.quiet().nothrow().cwd(root)
const first = await git(["clean", "-ffdx"], { cwd: root })
if (first.exitCode === 0) return first
const entries = failed(first)
if (!entries.length) return first
await prune(root, entries)
return $`git clean -ffdx`.quiet().nothrow().cwd(root)
return git(["clean", "-ffdx"], { cwd: root })
}
async function canonical(input: string) {
@@ -274,7 +275,9 @@ export namespace Worktree {
if (await exists(directory)) continue
const ref = `refs/heads/${branch}`
const branchCheck = await $`git show-ref --verify --quiet ${ref}`.quiet().nothrow().cwd(Instance.worktree)
const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
cwd: Instance.worktree,
})
if (branchCheck.exitCode === 0) continue
return Info.parse({ name, branch, directory })
@@ -285,9 +288,9 @@ export namespace Worktree {
async function runStartCommand(directory: string, cmd: string) {
if (process.platform === "win32") {
return $`cmd /c ${cmd}`.nothrow().cwd(directory)
return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
}
return $`bash -lc ${cmd}`.nothrow().cwd(directory)
return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
}
type StartKind = "project" | "worktree"
@@ -297,7 +300,7 @@ export namespace Worktree {
if (!text) return true
const ran = await runStartCommand(directory, text)
if (ran.exitCode === 0) return true
if (ran.code === 0) return true
log.error("worktree start command failed", {
kind,
@@ -344,10 +347,9 @@ export namespace Worktree {
}
export async function createFromInfo(info: Info, startCommand?: string) {
const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}`
.quiet()
.nothrow()
.cwd(Instance.worktree)
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" })
}
@@ -359,7 +361,7 @@ export namespace Worktree {
return () => {
const start = async () => {
const populated = await $`git reset --hard`.quiet().nothrow().cwd(info.directory)
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 })
@@ -474,7 +476,12 @@ export namespace Worktree {
throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
})
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
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" })
}
@@ -484,14 +491,18 @@ export namespace Worktree {
if (!entry?.path) {
const directoryExists = await exists(directory)
if (directoryExists) {
await stop(directory)
await clean(directory)
}
return true
}
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
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`.quiet().nothrow().cwd(Instance.worktree)
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",
@@ -508,7 +519,7 @@ export namespace Worktree {
const branch = entry.branch?.replace(/^refs\/heads\//, "")
if (branch) {
const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree)
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" })
}
@@ -528,7 +539,7 @@ export namespace Worktree {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
if (list.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
}
@@ -561,7 +572,7 @@ export namespace Worktree {
throw new ResetFailedError({ message: "Worktree not found" })
}
const remoteList = await $`git remote`.quiet().nothrow().cwd(Instance.worktree)
const remoteList = await git(["remote"], { cwd: Instance.worktree })
if (remoteList.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
}
@@ -580,18 +591,19 @@ export namespace Worktree {
: ""
const remoteHead = remote
? await $`git symbolic-ref refs/remotes/${remote}/HEAD`.quiet().nothrow().cwd(Instance.worktree)
? 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`.quiet().nothrow().cwd(Instance.worktree)
const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
.quiet()
.nothrow()
.cwd(Instance.worktree)
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
@@ -600,7 +612,7 @@ export namespace Worktree {
}
if (remoteBranch) {
const fetch = await $`git fetch ${remote} ${remoteBranch}`.quiet().nothrow().cwd(Instance.worktree)
const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
if (fetch.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
}
@@ -612,7 +624,7 @@ export namespace Worktree {
const worktreePath = entry.path
const resetToTarget = await $`git reset --hard ${target}`.quiet().nothrow().cwd(worktreePath)
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" })
}
@@ -622,22 +634,26 @@ export namespace Worktree {
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
}
const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath)
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`.quiet().nothrow().cwd(worktreePath)
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`.quiet().nothrow().cwd(worktreePath)
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 status --porcelain=v1`.quiet().nothrow().cwd(worktreePath)
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" })
}

View File

@@ -0,0 +1,62 @@
import { $ } from "bun"
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
const wintest = process.platform === "win32" ? test : test.skip
describe("file fsmonitor", () => {
wintest("status does not start fsmonitor for readonly git checks", async () => {
await using tmp = await tmpdir({ git: true })
const target = path.join(tmp.path, "tracked.txt")
await fs.writeFile(target, "base\n")
await $`git add tracked.txt`.cwd(tmp.path).quiet()
await $`git commit -m init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor true`.cwd(tmp.path).quiet()
await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow()
await fs.writeFile(target, "next\n")
await fs.writeFile(path.join(tmp.path, "new.txt"), "new\n")
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(before.exitCode).not.toBe(0)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.status()
},
})
const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(after.exitCode).not.toBe(0)
})
wintest("read does not start fsmonitor for git diffs", async () => {
await using tmp = await tmpdir({ git: true })
const target = path.join(tmp.path, "tracked.txt")
await fs.writeFile(target, "base\n")
await $`git add tracked.txt`.cwd(tmp.path).quiet()
await $`git commit -m init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor true`.cwd(tmp.path).quiet()
await $`git fsmonitor--daemon stop`.cwd(tmp.path).quiet().nothrow()
await fs.writeFile(target, "next\n")
const before = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(before.exitCode).not.toBe(0)
await Instance.provide({
directory: tmp.path,
fn: async () => {
await File.read("tracked.txt")
},
})
const after = await $`git fsmonitor--daemon status`.cwd(tmp.path).quiet().nothrow()
expect(after.exitCode).not.toBe(0)
})
})

View File

@@ -0,0 +1,26 @@
import { $ } from "bun"
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import { tmpdir } from "./fixture"
describe("tmpdir", () => {
test("disables fsmonitor for git fixtures", async () => {
await using tmp = await tmpdir({ git: true })
const value = (await $`git config core.fsmonitor`.cwd(tmp.path).quiet().text()).trim()
expect(value).toBe("false")
})
test("removes directories on dispose", async () => {
const tmp = await tmpdir({ git: true })
const dir = tmp.path
await tmp[Symbol.asyncDispose]()
const exists = await fs
.stat(dir)
.then(() => true)
.catch(() => false)
expect(exists).toBe(false)
})
})

View File

@@ -9,6 +9,27 @@ function sanitizePath(p: string): string {
return p.replace(/\0/g, "")
}
function exists(dir: string) {
return fs
.stat(dir)
.then(() => true)
.catch(() => false)
}
function clean(dir: string) {
return fs.rm(dir, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
})
}
async function stop(dir: string) {
if (!(await exists(dir))) return
await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()
}
type TmpDirOptions<T> = {
git?: boolean
config?: Partial<Config.Info>
@@ -20,6 +41,7 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
await fs.mkdir(dirpath, { recursive: true })
if (options?.git) {
await $`git init`.cwd(dirpath).quiet()
await $`git config core.fsmonitor false`.cwd(dirpath).quiet()
await $`git commit --allow-empty -m "root commit ${dirpath}"`.cwd(dirpath).quiet()
}
if (options?.config) {
@@ -31,12 +53,16 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
}),
)
}
const extra = await options?.init?.(dirpath)
const realpath = sanitizePath(await fs.realpath(dirpath))
const extra = await options?.init?.(realpath)
const result = {
[Symbol.asyncDispose]: async () => {
await options?.dispose?.(dirpath)
// await fs.rm(dirpath, { recursive: true, force: true })
try {
await options?.dispose?.(realpath)
} finally {
if (options?.git) await stop(realpath).catch(() => undefined)
await clean(realpath).catch(() => undefined)
}
},
path: realpath,
extra: extra as T,

View File

@@ -3,6 +3,7 @@
import os from "os"
import path from "path"
import fs from "fs/promises"
import { setTimeout as sleep } from "node:timers/promises"
import { afterAll } from "bun:test"
// Set XDG env vars FIRST, before any src/ imports
@@ -15,7 +16,7 @@ afterAll(async () => {
typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY"
const rm = async (left: number): Promise<void> => {
Bun.gc(true)
await Bun.sleep(100)
await sleep(100)
return fs.rm(dir, { recursive: true, force: true }).catch((error) => {
if (!busy(error)) throw error
if (left <= 1) throw error

View File

@@ -7,6 +7,8 @@ import { Worktree } from "../../src/worktree"
import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
const wintest = process.platform === "win32" ? test : test.skip
describe("Worktree.remove", () => {
test("continues when git remove exits non-zero after detaching", async () => {
await using tmp = await tmpdir({ git: true })
@@ -62,4 +64,33 @@ describe("Worktree.remove", () => {
const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
expect(ref.exitCode).not.toBe(0)
})
wintest("stops fsmonitor before removing a worktree", async () => {
await using tmp = await tmpdir({ git: true })
const root = tmp.path
const name = `remove-fsmonitor-${Date.now().toString(36)}`
const branch = `opencode/${name}`
const dir = path.join(root, "..", name)
await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet()
await $`git reset --hard`.cwd(dir).quiet()
await $`git config core.fsmonitor true`.cwd(dir).quiet()
await $`git fsmonitor--daemon stop`.cwd(dir).quiet().nothrow()
await Bun.write(path.join(dir, "tracked.txt"), "next\n")
await $`git diff`.cwd(dir).quiet()
const before = await $`git fsmonitor--daemon status`.cwd(dir).quiet().nothrow()
expect(before.exitCode).toBe(0)
const ok = await Instance.provide({
directory: root,
fn: () => Worktree.remove({ directory: dir }),
})
expect(ok).toBe(true)
expect(await Filesystem.exists(dir)).toBe(false)
const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow()
expect(ref.exitCode).not.toBe(0)
})
})

View File

@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Pty } from "../../src/pty"
import { tmpdir } from "../fixture/fixture"
import { setTimeout as sleep } from "node:timers/promises"
describe("pty", () => {
test("does not leak output when websocket objects are reused", async () => {
@@ -43,7 +44,7 @@ describe("pty", () => {
// Output from a must never show up in b.
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
await sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
@@ -88,7 +89,7 @@ describe("pty", () => {
}
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
await sleep(100)
expect(outB.join("")).not.toContain("AAA")
} finally {
@@ -128,7 +129,7 @@ describe("pty", () => {
ctx.connId = 2
Pty.write(a.id, "AAA\n")
await Bun.sleep(100)
await sleep(100)
expect(out.join("")).toContain("AAA")
} finally {

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import type { NamedError } from "@opencode-ai/util/error"
import { APICallError } from "ai"
import { setTimeout as sleep } from "node:timers/promises"
import { SessionRetry } from "../../src/session/retry"
import { MessageV2 } from "../../src/session/message-v2"
@@ -135,7 +136,7 @@ describe("session.message-v2.fromError", () => {
new ReadableStream({
async pull(controller) {
controller.enqueue("Hello,")
await Bun.sleep(10000)
await sleep(10000)
controller.enqueue(" World!")
controller.close()
},

View File

@@ -1,231 +0,0 @@
import { describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Session } from "../../src/session"
import { Identifier } from "../../src/id/id"
import { SessionStatus } from "../../src/session/status"
import { TaskStatusTool } from "../../src/tool/task_status"
import { MessageV2 } from "../../src/session/message-v2"
const ctx = {
sessionID: "session_test",
messageID: "message_test",
callID: "call_test",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}
async function user(sessionID: string) {
await Session.updateMessage({
id: Identifier.ascending("message"),
sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: "build",
model: {
providerID: "test-provider",
modelID: "test-model",
},
})
}
async function assistant(input: { sessionID: string; text: string; error?: string }) {
const msg = await Session.updateMessage({
id: Identifier.ascending("message"),
sessionID: input.sessionID,
role: "assistant",
time: {
created: Date.now(),
completed: Date.now(),
},
parentID: Identifier.ascending("message"),
modelID: "test-model",
providerID: "test-provider",
mode: "build",
agent: "build",
path: {
cwd: process.cwd(),
root: process.cwd(),
},
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
finish: "stop",
...(input.error
? {
error: new MessageV2.APIError({
message: input.error,
isRetryable: false,
}).toObject(),
}
: {}),
})
await Session.updatePart({
id: Identifier.ascending("part"),
sessionID: input.sessionID,
messageID: msg.id,
type: "text",
text: input.text,
})
}
describe("tool.task_status", () => {
test("returns running while session status is busy", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const tool = await TaskStatusTool.init()
SessionStatus.set(session.id, { type: "busy" })
const result = await tool.execute({ task_id: session.id }, ctx)
expect(result.output).toContain("state: running")
SessionStatus.set(session.id, { type: "idle" })
},
})
})
test("returns completed with final task output", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const tool = await TaskStatusTool.init()
await assistant({
sessionID: session.id,
text: "all done",
})
const result = await tool.execute({ task_id: session.id }, ctx)
expect(result.output).toContain("state: completed")
expect(result.output).toContain("all done")
},
})
})
test("wait=true blocks until terminal status", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const tool = await TaskStatusTool.init()
SessionStatus.set(session.id, { type: "busy" })
const transition = Bun.sleep(150).then(async () => {
SessionStatus.set(session.id, { type: "idle" })
await assistant({
sessionID: session.id,
text: "finished later",
})
})
const result = await tool.execute(
{
task_id: session.id,
wait: true,
timeout_ms: 4_000,
},
ctx,
)
await transition
expect(result.output).toContain("state: completed")
expect(result.output).toContain("finished later")
},
})
})
test("returns error when child run fails", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const tool = await TaskStatusTool.init()
await assistant({
sessionID: session.id,
text: "",
error: "child failed",
})
const result = await tool.execute({ task_id: session.id }, ctx)
expect(result.output).toContain("state: error")
expect(result.output).toContain("child failed")
expect(result.metadata.state).toBe("error")
},
})
})
test("wait=true times out with timed_out metadata", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const tool = await TaskStatusTool.init()
SessionStatus.set(session.id, { type: "busy" })
const result = await tool.execute(
{
task_id: session.id,
wait: true,
timeout_ms: 80,
},
ctx,
)
expect(result.output).toContain("Timed out after 80ms")
expect(result.metadata.timed_out).toBe(true)
expect(result.metadata.state).toBe("running")
SessionStatus.set(session.id, { type: "idle" })
},
})
})
test("returns running for resumed task with a newer user turn", async () => {
await using tmp = await tmpdir({ git: true })
await Instance.provide({
directory: tmp.path,
fn: async () => {
const session = await Session.create({})
const tool = await TaskStatusTool.init()
await user(session.id)
await assistant({
sessionID: session.id,
text: "old done",
})
await user(session.id)
const result = await tool.execute({ task_id: session.id }, ctx)
expect(result.output).toContain("state: running")
expect(result.output).toContain("Task is starting.")
},
})
})
})

View File

@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { which } from "../../src/util/which"
import { tmpdir } from "../fixture/fixture"
async function cmd(dir: string, name: string, exec = true) {
const ext = process.platform === "win32" ? ".cmd" : ""
const file = path.join(dir, name + ext)
const body = process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"
await fs.writeFile(file, body)
if (process.platform !== "win32") {
await fs.chmod(file, exec ? 0o755 : 0o644)
}
return file
}
function env(PATH: string): NodeJS.ProcessEnv {
return {
PATH,
PATHEXT: process.env["PATHEXT"],
}
}
function same(a: string | null, b: string) {
if (process.platform === "win32") {
expect(a?.toLowerCase()).toBe(b.toLowerCase())
return
}
expect(a).toBe(b)
}
describe("util.which", () => {
test("returns null when command is missing", () => {
expect(which("opencode-missing-command-for-test")).toBeNull()
})
test("finds a command from PATH override", async () => {
await using tmp = await tmpdir()
const bin = path.join(tmp.path, "bin")
await fs.mkdir(bin)
const file = await cmd(bin, "tool")
same(which("tool", env(bin)), file)
})
test("uses first PATH match", async () => {
await using tmp = await tmpdir()
const a = path.join(tmp.path, "a")
const b = path.join(tmp.path, "b")
await fs.mkdir(a)
await fs.mkdir(b)
const first = await cmd(a, "dupe")
await cmd(b, "dupe")
same(which("dupe", env([a, b].join(path.delimiter))), first)
})
test("returns null for non-executable file on unix", async () => {
if (process.platform === "win32") return
await using tmp = await tmpdir()
const bin = path.join(tmp.path, "bin")
await fs.mkdir(bin)
await cmd(bin, "noexec", false)
expect(which("noexec", env(bin))).toBeNull()
})
test("uses PATHEXT on windows", async () => {
if (process.platform !== "win32") return
await using tmp = await tmpdir()
const bin = path.join(tmp.path, "bin")
await fs.mkdir(bin)
const file = path.join(bin, "pathext.CMD")
await fs.writeFile(file, "@echo off\r\n")
expect(which("pathext", { PATH: bin, PATHEXT: ".CMD" })).toBe(file)
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -59,6 +59,7 @@ OpenCode Zen هو بوابة للذكاء الاصطناعي تتيح لك ال
| النموذج | معرّف النموذج | نقطة النهاية | حزمة AI SDK |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -141,6 +142,7 @@ https://opencode.ai/zen/v1/models
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -184,6 +186,19 @@ https://opencode.ai/zen/v1/models
---
### نماذج مهملة
| النموذج | تاريخ الإيقاف |
| ---------------- | ------------- |
| Qwen3 Coder 480B | 6 فبراير 2026 |
| Kimi K2 Thinking | 6 مارس 2026 |
| Kimi K2 | 6 مارس 2026 |
| MiniMax M2.1 | 15 مارس 2026 |
| GLM 4.7 | 15 مارس 2026 |
| GLM 4.6 | 15 مارس 2026 |
---
## الخصوصية
تتم استضافة جميع نماذجنا في الولايات المتحدة. يلتزم مزوّدونا بسياسة عدم الاحتفاظ بالبيانات (zero-retention) ولا يستخدمون بياناتك لتدريب النماذج، مع الاستثناءات التالية:

View File

@@ -55,6 +55,7 @@ Nasim modelima mozete pristupiti i preko sljedecih API endpointa.
| Model | Model ID | Endpoint | AI SDK Package |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ Podrzavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -178,6 +180,19 @@ Na primjer, ako postavite mjesecni limit na $20, Zen nece potrositi vise od $20
---
### Zastarjeli modeli
| Model | Datum ukidanja |
| ---------------- | -------------- |
| Qwen3 Coder 480B | 6. feb. 2026. |
| Kimi K2 Thinking | 6. mart 2026. |
| Kimi K2 | 6. mart 2026. |
| MiniMax M2.1 | 15. mart 2026. |
| GLM 4.7 | 15. mart 2026. |
| GLM 4.6 | 15. mart 2026. |
---
## Privatnost
Svi nasi modeli su hostovani u SAD-u. Provajderi prate zero-retention politiku i ne koriste vase podatke za treniranje modela, uz sljedece izuzetke:

View File

@@ -64,6 +64,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
| Model | Model ID | Endpoint | AI SDK Pakke |
| ------------------- | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -147,6 +148,7 @@ Vi støtter en pay-as-you-go-model. Nedenfor er priserne **per 1 million tokens*
| Gemini 3 Pro (≤ 200K tokens) | $2,00 | $12,00 | $0,20 | - |
| Gemini 3 Pro (> 200K tokens) | $4,00 | $18,00 | $0,40 | - |
| Gemini 3 Flash | $0,50 | $3,00 | $0,05 | - |
| GPT 5.4 | $2,50 | $15,00 | $0,25 | - |
| GPT 5.3 Codex | $1,75 | $14,00 | $0,175 | - |
| GPT 5.2 | $1,75 | $14,00 | $0,175 | - |
| GPT 5.2 Codex | $1,75 | $14,00 | $0,175 | - |
@@ -192,6 +194,19 @@ at opkræve dig mere end $20, hvis din saldo går under $5.
---
### Udfasede modeller
| Model | Udfasningsdato |
| ---------------- | -------------- |
| Qwen3-koder 480B | 6. feb. 2026 |
| Kimi K2 Tenker | 6. marts 2026 |
| Kimi K2 | 6. marts 2026 |
| MiniMax M2.1 | 15. marts 2026 |
| GLM 4.7 | 15. marts 2026 |
| GLM 4.6 | 15. marts 2026 |
---
## Privatliv
Alle vores modeller er hostet i USA. Vores udbydere følger en nul-opbevaringspolitik og bruger ikke dine data til modeltræning, med følgende undtagelser:

View File

@@ -57,6 +57,7 @@ Du kannst unsere Modelle auch ueber die folgenden API-Endpunkte aufrufen.
| Model | Model ID | Endpoint | AI SDK Package |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -114,12 +115,12 @@ Unten siehst du die Preise **pro 1 Mio. Tokens**.
| --------------------------------- | ------ | ------ | ----------- | ------------ |
| Big Pickle | Free | Free | Free | - |
| MiniMax M2.5 Free | Free | Free | Free | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.08 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
@@ -140,6 +141,7 @@ Unten siehst du die Preise **pro 1 Mio. Tokens**.
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -184,6 +186,19 @@ Mit aktiviertem Auto-Reload kann die Abrechnung dennoch darueber liegen, falls d
---
### Veraltete Modelle
| Model | Datum der Abschaltung |
| ---------------- | --------------------- |
| Qwen3 Coder 480B | 6. Feb. 2026 |
| Kimi K2 Thinking | 6. Maerz 2026 |
| Kimi K2 | 6. Maerz 2026 |
| MiniMax M2.1 | 15. Maerz 2026 |
| GLM 4.7 | 15. Maerz 2026 |
| GLM 4.6 | 15. Maerz 2026 |
---
## Datenschutz
Alle Modelle werden in den USA gehostet.

View File

@@ -62,6 +62,7 @@ También puede acceder a nuestros modelos a través de los siguientes puntos fin
| Modelo | Model ID | Endpoint | AI SDK package |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -145,6 +146,7 @@ Apoyamos un modelo de pago por uso. A continuación se muestran los precios **po
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0,20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0,40 | - |
| Gemini 3 Flash | $0,50 | $3.00 | $0,05 | - |
| GPT 5.4 | $2,50 | $15,00 | $0,25 | - |
| GPT 5.3 Codex | $1,75 | $14.00 | $0,175 | - |
| GPT 5.2 | $1,75 | $14.00 | $0,175 | - |
| GPT 5.2 Codex | $1,75 | $14.00 | $0,175 | - |
@@ -190,6 +192,19 @@ cobrarle más de $20 si su saldo es inferior a $5.
---
### Modelos obsoletos
| Modelo | Fecha de retiro |
| ---------------- | ------------------- |
| Qwen3 Coder 480B | 6 de feb. de 2026 |
| Kimi K2 Thinking | 6 de marzo de 2026 |
| Kimi K2 | 6 de marzo de 2026 |
| MiniMax M2.1 | 15 de marzo de 2026 |
| GLM 4.7 | 15 de marzo de 2026 |
| GLM 4.6 | 15 de marzo de 2026 |
---
## Privacidad
Todos nuestros modelos están alojados en los EE. UU. Nuestros proveedores siguen una política de retención cero y no utilizan sus datos para la capacitación de modelos, con las siguientes excepciones:

View File

@@ -55,6 +55,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
| Modèle | ID du modèle | Point de terminaison | Package SDK IA |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ Nous soutenons un modèle de paiement à l'utilisation. Vous trouverez ci-dessou
| Gemini 3 Pro (≤ 200K jetons) | 2,00 $ | 12,00 $ | 0,20 $ | - |
| Gemini 3 Pro (> 200K jetons) | 4,00 $ | 18,00 $ | 0,40 $ | - |
| Gemini 3 Flash | 0,50 $ | 3,00 $ | 0,05 $ | - |
| GPT 5.4 | 2,50 $ | 15,00 $ | 0,25 $ | - |
| GPT 5.3 Codex | 1,75 $ | 14,00 $ | 0,175 $ | - |
| GPT 5.2 | 1,75 $ | 14,00 $ | 0,175 $ | - |
| GPT 5.2 Codex | 1,75 $ | 14,00 $ | 0,175 $ | - |
@@ -178,6 +180,19 @@ Par exemple, disons que vous définissez une limite d'utilisation mensuelle à 2
---
### Modèles obsolètes
| Modèle | Date de dépréciation |
| ---------------- | -------------------- |
| Qwen3 Coder 480B | 6 février 2026 |
| Kimi K2 Thinking | 6 mars 2026 |
| Kimi K2 | 6 mars 2026 |
| MiniMax M2.1 | 15 mars 2026 |
| GLM 4.7 | 15 mars 2026 |
| GLM 4.6 | 15 mars 2026 |
---
## Confidentialité
Tous nos modèles sont hébergés aux États-Unis. Nos fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour la formation de modèles, avec les exceptions suivantes :

View File

@@ -55,6 +55,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
| Modello | ID modello | Endpoint | Pacchetto AI SDK |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -178,6 +180,19 @@ Per esempio, se imposti un limite mensile a $20, Zen non usera piu di $20 in un
---
### Modelli deprecati
| Modello | Data di deprecazione |
| ---------------- | -------------------- |
| Qwen3 Coder 480B | 6 feb 2026 |
| Kimi K2 Thinking | 6 mar 2026 |
| Kimi K2 | 6 mar 2026 |
| MiniMax M2.1 | 15 mar 2026 |
| GLM 4.7 | 15 mar 2026 |
| GLM 4.6 | 15 mar 2026 |
---
## Privacy
Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una policy di zero-retention e non usano i tuoi dati per training dei modelli, con le seguenti eccezioni:

View File

@@ -54,6 +54,7 @@ OpenCode Zen は、OpenCode の他のプロバイダーと同様に機能しま
| Model | Model ID | Endpoint | AI SDK Package |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -137,6 +138,7 @@ https://opencode.ai/zen/v1/models
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -179,6 +181,19 @@ https://opencode.ai/zen/v1/models
---
### 非推奨モデル
| Model | Deprecation date |
| ---------------- | ---------------- |
| Qwen3 Coder 480B | 2026年2月6日 |
| Kimi K2 Thinking | 2026年3月6日 |
| Kimi K2 | 2026年3月6日 |
| MiniMax M2.1 | 2026年3月15日 |
| GLM 4.7 | 2026年3月15日 |
| GLM 4.6 | 2026年3月15日 |
---
## プライバシー
すべてのモデルは米国でホストされています。当社のプロバイダーはゼロ保持ポリシーに従い、次の例外を除いて、モデルのトレーニングにデータを使用しません。

View File

@@ -55,6 +55,7 @@ OpenCode Zen은 OpenCode의 다른 제공자와 동일한 방식으로 작동합
| 모델 | 모델 ID | 엔드포인트 | AI SDK 패키지 |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -111,12 +112,12 @@ https://opencode.ai/zen/v1/models
| --------------------------------- | ------ | ------ | --------- | --------- |
| Big Pickle | Free | Free | Free | - |
| MiniMax M2.5 Free | Free | Free | Free | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.08 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
@@ -137,6 +138,7 @@ https://opencode.ai/zen/v1/models
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -180,6 +182,19 @@ https://opencode.ai/zen/v1/models
---
### 지원 중단 모델
| 모델 | 지원 중단일 |
| ---------------- | --------------- |
| Qwen3 Coder 480B | 2026년 2월 6일 |
| Kimi K2 Thinking | 2026년 3월 6일 |
| Kimi K2 | 2026년 3월 6일 |
| MiniMax M2.1 | 2026년 3월 15일 |
| GLM 4.7 | 2026년 3월 15일 |
| GLM 4.6 | 2026년 3월 15일 |
---
## 개인정보 보호
당사의 모든 모델은 미국에서 호스팅됩니다. 당사 제공자는 데이터 무보존(zero-retention) 정책을 따르며, 아래의 예외를 제외하고는 귀하의 데이터를 모델 학습에 사용하지 않습니다.

View File

@@ -64,6 +64,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
| Modell | Modell ID | Endepunkt | AI SDK Pakke |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -121,7 +122,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1 million tokens*
| --------------------------------- | ------- | ------ | ------------- | --------------- |
| Big Pickle | Gratis | Gratis | Gratis | - |
| MiniMax M2.5 Free | Gratis | Gratis | Gratis | - |
| MiniMax M2.5 | $0,30 | $1,20 | $0,06 | - |
| MiniMax M2.5 | $0,30 | $1,20 | $0,06 | $0,375 |
| MiniMax M2.1 | $0,30 | $1,20 | $0,10 | - |
| GLM 5 | $1,00 | $3,20 | $0,20 | - |
| GLM 4.7 | $0,60 | $2,20 | $0,10 | - |
@@ -147,6 +148,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1 million tokens*
| Gemini 3 Pro (≤ 200K tokens) | $2,00 | $12,00 | $0,20 | - |
| Gemini 3 Pro (> 200K tokens) | $4,00 | $18,00 | $0,40 | - |
| Gemini 3 Flash | $0,50 | $3,00 | $0,05 | - |
| GPT 5.4 | $2,50 | $15,00 | $0,25 | - |
| GPT 5.3 Codex | $1,75 | $14,00 | $0,175 | - |
| GPT 5.2 | $1,75 | $14,00 | $0,175 | - |
| GPT 5.2 Codex | $1,75 | $14,00 | $0,175 | - |
@@ -192,6 +194,19 @@ belaster deg mer enn $20 hvis saldoen din går under $5.
---
### Utfasede modeller
| Modell | Utfasingdato |
| ---------------- | ------------- |
| Qwen3 Coder 480B | 6. feb. 2026 |
| Kimi K2 Thinking | 6. mars 2026 |
| Kimi K2 | 6. mars 2026 |
| MiniMax M2.1 | 15. mars 2026 |
| GLM 4.7 | 15. mars 2026 |
| GLM 4.6 | 15. mars 2026 |
---
## Personvern
Alle våre modeller er hostet i USA. Leverandørene våre følger retningslinjer om ingen datalagring og bruker ikke dataene dine til modellopplæring, med følgende unntak:

View File

@@ -1,21 +1,21 @@
---
title: Zen
description: Wyselekcjonowana lista modeli dostarczonych przez opencode.
description: Wyselekcjonowana lista modeli dostarczonych przez OpenCode.
---
import config from "../../../../config.mjs"
export const console = config.console
export const email = `mailto:${config.email}`
OpenCode Zen to lista przetestowanych i zweryfikowanych modeli udostępniona przez zespół opencode.
OpenCode Zen to lista przetestowanych i zweryfikowanych modeli udostępniona przez zespół OpenCode.
:::note
OpenCode Zen is currently in beta.
OpenCode Zen jest obecnie w wersji beta.
:::
Zen działa jak każdy inny dostawca opencode. Logujesz się do OpenCode Zen i dostajesz
Twój klucz API. Jest **całkowicie opcjonalny** i nie musisz go używać, aby z niego korzystać
opencode.
Zen działa jak każdy inny dostawca w OpenCode. Logujesz się do OpenCode Zen i otrzymujesz
swój klucz API. Jest to **całkowicie opcjonalne** i nie musisz tego używać, aby korzystać z
OpenCode.
---
@@ -23,23 +23,23 @@ opencode.
Istnieje ogromna liczba modeli, ale tylko kilka z nich
działa dobrze jako agenci kodujący. Dodatkowo większość dostawców jest
skonfigurowana bardzo różnie; więc otrzymujesz zupełnie inną wydajność i jakość.
skonfigurowana bardzo różnie, więc otrzymujesz bardzo różną wydajność i jakość.
:::tip
Przetestowaliśmy wybraną grupę modeli i dostawców, którzy dobrze współpracują z opencode.
Przetestowaliśmy wybraną grupę modeli i dostawców, którzy dobrze współpracują z OpenCode.
:::
Jeśli więc używasz modelu za pośrednictwem czegoś takiego jak OpenRouter, nigdy nie będzie to możliwe
Jeśli więc używasz modelu za pośrednictwem czegoś takiego jak OpenRouter, nigdy nie możesz być
pewien, czy otrzymujesz najlepszą wersję modelu, jaki chcesz.
Aby to naprawić, zrobiliśmy kilka rzeczy:
1. Przetestowaliśmy wybraną grupę modeli i rozmawialiśmy z ich zespołami o tym, jak to zrobić
najlepiej je uruchom.
1. Przetestowaliśmy wybraną grupę modeli i rozmawialiśmy z ich zespołami o tym, jak
najlepiej je uruchamiać.
2. Następnie współpracowaliśmy z kilkoma dostawcami, aby upewnić się, że są one obsługiwane
correctly.
3. Na koniec porównaliśmy kombinację modelu/dostawcy i otrzymaliśmy wynik
z listą, którą z przyjemnością polecamy.
poprawnie.
3. Na koniec sprawdziliśmy wydajność kombinacji modelu/dostawcy i stworzyliśmy
listę, którą z czystym sumieniem polecamy.
OpenCode Zen to brama AI, która zapewnia dostęp do tych modeli.
@@ -47,14 +47,14 @@ OpenCode Zen to brama AI, która zapewnia dostęp do tych modeli.
## Jak to działa
OpenCode Zen działa jak każdy inny dostawca opencode.
OpenCode Zen działa jak każdy inny dostawca w OpenCode.
1. Logujesz się do **<a href={console}>OpenCode Zen</a>**, dodajesz swoje rozliczenia
szczegóły i skopiuj klucz API.
1. Logujesz się do **<a href={console}>OpenCode Zen</a>**, dodajesz dane rozliczeniowe
i kopiujesz swój klucz API.
2. Uruchamiasz polecenie `/connect` w TUI, wybierasz OpenCode Zen i wklejasz klucz API.
3. Uruchom `/models` w TUI, aby zobaczyć listę zalecanych przez nas modeli.
Opłata jest pobierana za każde żądanie i możesz dodać kredyty do swojego konta.
Opłata jest pobierana za każde żądanie i możesz dodać środki do swojego konta.
---
@@ -64,6 +64,7 @@ Dostęp do naszych modeli można również uzyskać za pośrednictwem następuj
| Model | Identyfikator modelu | Punkt końcowy | Pakiet SDK AI |
| ------------------ | -------------------- | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -97,9 +98,9 @@ Dostęp do naszych modeli można również uzyskać za pośrednictwem następuj
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
[Identyfikator modelu](/docs/config/#models) w konfiguracji opencode
używa formatu `opencode/<model-id>`. Na przykład w przypadku Kodeksu GPT 5.2 zrobiłbyś to
użyj `opencode/gpt-5.2-codex` w swojej konfiguracji.
[Identyfikator modelu](/docs/config/#models) w konfiguracji OpenCode
używa formatu `opencode/<model-id>`. Na przykład w przypadku GPT 5.2 Codex użyłbyś
`opencode/gpt-5.2-codex` w swojej konfiguracji.
---
@@ -121,12 +122,12 @@ Wspieramy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów**.
| --------------------------------- | ------- | ------- | --------------------------- | -------------------------- |
| Big Pickle | Free | Free | Free | - |
| MiniMax M2.5 Free | Free | Free | Free | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.08 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
@@ -147,6 +148,7 @@ Wspieramy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów**.
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -158,10 +160,10 @@ Wspieramy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów**.
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
Możesz zauważyć _Claude Haiku 3.5_ w swojej historii użytkowania. To jest [model niskokosztowy](/docs/config/#models), który służy do generowania tytułów sesji.
Możesz zauważyć _Claude Haiku 3.5_ w swojej historii użytkowania. Jest to [tani model](/docs/config/#models), który jest używany do generowania tytułów Twoich sesji.
:::note
Opłaty za karty kredytowe są przenoszone na koszt (4,4% + 0,30 USD za transakcję); nie pobieramy żadnych dodatkowych opłat.
Opłaty za karty kredytowe są przenoszone po kosztach (4,4% + 0,30 USD za transakcję); nie pobieramy nic poza tym.
:::
Darmowe modele:
@@ -177,18 +179,31 @@ Darmowe modele:
Jeśli Twoje saldo spadnie poniżej 5 USD, Zen automatycznie doładuje 20 USD.
Możesz zmienić kwotę automatycznego doładowania. Możesz także całkowicie wyłączyć automatyczne przeładowywanie.
Możesz zmienić kwotę automatycznego doładowania. Możesz także całkowicie wyłączyć automatyczne doładowanie.
---
### Limity miesięczne
Możesz także ustawić miesięczny limit wykorzystania dla całego obszaru roboczego i dla każdego z nich
członek Twojego zespołu.
Możesz także ustawić miesięczny limit użytkowania dla całego obszaru roboczego i dla każdego
członka Twojego zespołu.
Załóżmy na przykład, że ustawiłeś miesięczny limit użytkowania na 20 USD, Zen nie będzie z niego korzystał
ponad 20 dolarów miesięcznie. Ale jeśli masz włączone automatyczne przeładowywanie, Zen może się skończyć
obciąży Cię kwotą wyższą niż 20 USD, jeśli saldo spadnie poniżej 5 USD.
Na przykład, jeśli ustawisz miesięczny limit użytkowania na 20 USD, Zen nie zużyje
więcej niż 20 dolarów w miesiącu. Ale jeśli masz włączone automatyczne doładowanie, Zen może
obciążyć Cię kwotą wyższą niż 20 USD, jeśli saldo spadnie poniżej 5 USD.
---
### Przestarzałe modele
| Model | Data wycofania |
| ---------------- | -------------- |
| Qwen3 Coder 480B | 6 lutego 2026 |
| Kimi K2 Thinking | 6 marca 2026 |
| Kimi K2 | 6 marca 2026 |
| MiniMax M2.1 | 15 marca 2026 |
| GLM 4.7 | 15 marca 2026 |
| GLM 4.6 | 15 marca 2026 |
---
@@ -198,22 +213,22 @@ Wszystkie nasze modele są hostowane w USA. Nasi dostawcy przestrzegają polityk
- Big Pickle: W okresie bezpłatnym zebrane dane mogą zostać wykorzystane do udoskonalenia modelu.
- MiniMax M2.5 Free: W okresie bezpłatnym zebrane dane mogą zostać wykorzystane do udoskonalenia modelu.
- Interfejsy API OpenAI: żądania są przechowywane przez 30 dni zgodnie z [Zasadami dotyczącymi danych OpenAI](https://platform.openai.com/docs/guides/your-data).
- Interfejsy API Anthropic: żądania są przechowywane przez 30 dni zgodnie z [Zasadami dotyczącymi danych firmy Anthropic](https://docs.anthropic.com/en/docs/claude-code/data-usage).
- API OpenAI: Żądania są przechowywane przez 30 dni zgodnie z [Zasadami dotyczącymi danych OpenAI](https://platform.openai.com/docs/guides/your-data).
- API Anthropic: Żądania są przechowywane przez 30 dni zgodnie z [Zasadami dotyczącymi danych Anthropic](https://docs.anthropic.com/en/docs/claude-code/data-usage).
---
## Dla zespołów
Zen świetnie sprawdza się także w zespołach. Możesz zapraszać członków zespołu, przypisywać role, zarządz
Zen działa świetnie także dla zespołów. Możesz zapraszać członków zespołu, przypisywać role, dobier
modele, z których korzysta Twój zespół i nie tylko.
:::note
Obszary robocze są obecnie bezpłatne dla zespołów w ramach wersji beta.
:::
Zarządzanie obszarem roboczym jest obecnie bezpłatne dla zespołów w ramach wersji beta. Będziemy
wkrótce udostępnimy więcej szczegółów na temat cen.
Zarządzanie obszarem roboczym jest obecnie bezpłatne dla zespołów w ramach wersji beta.
Wkrótce udostępnimy więcej szczegółów na temat cen.
---
@@ -221,8 +236,8 @@ wkrótce udostępnimy więcej szczegółów na temat cen.
Możesz zapraszać członków zespołu do swojego obszaru roboczego i przypisywać role:
- **Administrator**: Zarządzaj modelami, członkami, kluczami API i rozliczeniami
- **Członek**: Zarządzaj tylko własnymi kluczami API
- **Admin**: Zarządzanie modelami, członkami, kluczami API i rozliczeniami
- **Członek**: Zarządzanie tylko własnymi kluczami API
Administratorzy mogą także ustawić miesięczne limity wydatków dla każdego członka, aby utrzymać koszty pod kontrolą.
@@ -233,7 +248,7 @@ Administratorzy mogą także ustawić miesięczne limity wydatków dla każdego
Administratorzy mogą włączać i wyłączać określone modele w obszarze roboczym. Żądania skierowane do wyłączonego modelu zwrócą błąd.
Jest to przydatne w przypadkach, gdy chcesz wyłączyć korzystanie z modelu, który
collects data.
zbiera dane.
---
@@ -253,6 +268,6 @@ i chcesz go używać zamiast tego, który zapewnia Zen.
Stworzyliśmy OpenCode Zen, aby:
1. **Testować** (Benchmark) najlepsze modele/dostawców dla agentów kodujących.
2. Miej dostęp do opcji **najwyższej jakości**, a nie obniżaj wydajności ani nie kieruj się do tańszych dostawców.
3. Przekaż wszelkie **obniżki cen**, sprzedając po kosztach; więc jedyną marżą jest pokrycie naszych opłat manipulacyjnych.
4. Nie **nie blokuj**, umożliwiając używanie go z dowolnym innym agentem kodującym. I zawsze pozwalaj na korzystanie z opencode dowolnego innego dostawcy.
2. Mieć dostęp do opcji **najwyższej jakości**, a nie obniżać wydajności ani nie kierować do tańszych dostawców.
3. Przekazywać wszelkie **obniżki cen**, sprzedając po kosztach; więc jedyną marżą jest pokrycie naszych opłat manipulacyjnych.
4. Nie **mieć blokady** (no lock-in), umożliwiając używanie go z dowolnym innym agentem kodującym. I zawsze pozwalać na korzystanie z dowolnego innego dostawcy w OpenCode.

View File

@@ -55,6 +55,7 @@ Você também pode acessar nossos modelos através dos seguintes endpoints da AP
| Modelo | ID do Modelo | Endpoint | Pacote AI SDK |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ Nós suportamos um modelo de pagamento conforme o uso. Abaixo estão os preços
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -178,6 +180,19 @@ Por exemplo, digamos que você defina um limite de uso mensal de $20, o Zen não
---
### Modelos obsoletos
| Modelo | Data de descontinuação |
| ---------------- | ---------------------- |
| Qwen3 Coder 480B | 6 de fev. de 2026 |
| Kimi K2 Thinking | 6 de mar. de 2026 |
| Kimi K2 | 6 de mar. de 2026 |
| MiniMax M2.1 | 15 de mar. de 2026 |
| GLM 4.7 | 15 de mar. de 2026 |
| GLM 4.6 | 15 de mar. de 2026 |
---
## Privacidade
Todos os nossos modelos estão hospedados nos EUA. Nossos provedores seguem uma política de zero retenção e não usam seus dados para treinamento de modelos, com as seguintes exceções:

View File

@@ -63,6 +63,7 @@ OpenCode Zen работает так же, как и любой другой п
| Модель | Идентификатор модели | Конечная точка | Пакет AI SDK |
| ------------------ | -------------------- | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -146,6 +147,7 @@ https://opencode.ai/zen/v1/models
| Gemini 3 Pro (≤ 200 тыс. токенов) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200 тыс. токенов) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -191,6 +193,19 @@ https://opencode.ai/zen/v1/models
---
### Устаревшие модели
| Модель | Дата отключения |
| ---------------- | ---------------- |
| Qwen3 Coder 480B | 6 февр. 2026 г. |
| Kimi K2 Thinking | 6 марта 2026 г. |
| Kimi K2 | 6 марта 2026 г. |
| MiniMax M2.1 | 15 марта 2026 г. |
| GLM 4.7 | 15 марта 2026 г. |
| GLM 4.6 | 15 марта 2026 г. |
---
## Конфиденциальность
Все наши модели размещены в США. Наши поставщики придерживаются политики нулевого хранения и не используют ваши данные для обучения моделей, за следующими исключениями:

View File

@@ -64,6 +64,7 @@ OpenCode Zen ทำงานเหมือนกับผู้ให้บร
| Model | Model ID | Endpoint | แพ็คเกจ AI SDK |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -121,12 +122,12 @@ https://opencode.ai/zen/v1/models
| --------------------------------- | ---------- | -------- | ------- | ---------- |
| Big Pickle | ฟรี | ฟรี | ฟรี | - |
| MiniMax M2.5 Free | ฟรี | ฟรี | ฟรี | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.08 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
@@ -147,6 +148,7 @@ https://opencode.ai/zen/v1/models
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -192,11 +194,24 @@ https://opencode.ai/zen/v1/models
---
### โมเดลที่เลิกใช้แล้ว
| Model | วันที่เลิกใช้ |
| ---------------- | ------------- |
| Qwen3 Coder 480B | 6 ก.พ. 2026 |
| Kimi K2 Thinking | 6 มี.ค. 2026 |
| Kimi K2 | 6 มี.ค. 2026 |
| MiniMax M2.1 | 15 มี.ค. 2026 |
| GLM 4.7 | 15 มี.ค. 2026 |
| GLM 4.6 | 15 มี.ค. 2026 |
---
## ความเป็นส่วนตัว
โมเดลทั้งหมดของเราโฮสต์ในสหรัฐอเมริกา ผู้ให้บริการของเราปฏิบัติตามนโยบายการเก็บรักษาเป็นศูนย์ และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมีข้อยกเว้นต่อไปนี้:
- Big Pickle: ในช่วงระยะเวลาว่าง ข้อมูลที่รวบรวมอาจนำไปใช้ในการปรับปรุงโมเดลได้
- Big Pickle: ในช่วงระยะเวลาฟรี ข้อมูลที่รวบรวมอาจนำไปใช้ในการปรับปรุงโมเดลได้
- MiniMax M2.5 Free: ในช่วงระยะเวลาฟรี ข้อมูลที่รวบรวมอาจนำไปใช้ในการปรับปรุงโมเดล
- OpenAI API: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [นโยบายข้อมูลของ OpenAI](https://platform.openai.com/docs/guides/your-data)
- Anthropic API: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [นโยบายข้อมูลของ Anthropic](https://docs.anthropic.com/en/docs/claude-code/data-usage)

View File

@@ -55,6 +55,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin
| Model | Model ID | Endpoint | AI SDK Package |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1 milyon token başına*
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -178,6 +180,19 @@ Ayrıca tüm çalışma alanı ve ekibinizin her üyesi için aylık kullanım l
---
### Kullanımdan kaldırılan modeller
| Model | Kullanımdan kaldırılma tarihi |
| ---------------- | ----------------------------- |
| Qwen3 Coder 480B | 6 Şub 2026 |
| Kimi K2 Thinking | 6 Mar 2026 |
| Kimi K2 | 6 Mar 2026 |
| MiniMax M2.1 | 15 Mar 2026 |
| GLM 4.7 | 15 Mar 2026 |
| GLM 4.6 | 15 Mar 2026 |
---
## Gizlilik
Tüm modellerimiz ABD'de barındırılmaktadır. Sağlayıcılarımız sıfır saklama politikasını izler ve aşağıdaki istisnalar dışında verilerinizi model eğitimi için kullanmaz:

View File

@@ -62,44 +62,47 @@ You are charged per request and you can add credits to your account.
You can also access our models through the following API endpoints.
| Model | Model ID | Endpoint | AI SDK Package |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiniMax M2.1 | minimax-m2.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| GLM 4.7 | glm-4.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Model | Model ID | Endpoint | AI SDK Package |
| ------------------- | ------------------- | -------------------------------------------------- | --------------------------- |
| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| MiniMax M2.1 | minimax-m2.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| GLM 4.7 | glm-4.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
The [model id](/docs/config/#models) in your OpenCode config
uses the format `opencode/<model-id>`. For example, for GPT 5.2 Codex, you would
use `opencode/gpt-5.2-codex` in your config.
uses the format `opencode/<model-id>`. For example, for GPT 5.3 Codex, you would
use `opencode/gpt-5.3-codex` in your config.
---
@@ -117,46 +120,49 @@ https://opencode.ai/zen/v1/models
We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Model | Input | Output | Cached Read | Cached Write |
| --------------------------------- | ------ | ------ | ----------- | ------------ |
| Big Pickle | Free | Free | Free | - |
| MiniMax M2.5 Free | Free | Free | Free | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.6 (≤ 200K tokens) | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 (> 200K tokens) | $10.00 | $37.50 | $1.00 | $12.50 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
| Claude Sonnet 4.6 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
| Claude Sonnet 4.6 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
| Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
| Claude Sonnet 4 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
| Claude Sonnet 4 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 |
| Claude Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 |
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.1 | $1.07 | $8.50 | $0.107 | - |
| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - |
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
| Model | Input | Output | Cached Read | Cached Write |
| --------------------------------- | ------ | ------- | ----------- | ------------ |
| Big Pickle | Free | Free | Free | - |
| MiniMax M2.5 Free | Free | Free | Free | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
| Kimi K2 | $0.40 | $2.50 | - | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.6 (≤ 200K tokens) | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 (> 200K tokens) | $10.00 | $37.50 | $1.00 | $12.50 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
| Claude Sonnet 4.6 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
| Claude Sonnet 4.6 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
| Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
| Claude Sonnet 4 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
| Claude Sonnet 4 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 |
| Claude Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 |
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 Pro | $30.00 | $180.00 | $30.00 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex Spark | $1.75 | $14.00 | $0.175 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.1 | $1.07 | $8.50 | $0.107 | - |
| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - |
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
| GPT 5 Nano | Free | Free | Free | - |
You might notice _Claude Haiku 3.5_ in your usage history. This is a [low cost model](/docs/config/#models) that's used to generate the titles of your sessions.

View File

@@ -55,6 +55,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
| 模型 | 模型 ID | 端点 | AI SDK 包 |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ https://opencode.ai/zen/v1/models
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -178,6 +180,19 @@ https://opencode.ai/zen/v1/models
---
### 已弃用模型
| 模型 | 弃用日期 |
| ---------------- | ------------------ |
| Qwen3 Coder 480B | 2026 年 2 月 6 日 |
| Kimi K2 Thinking | 2026 年 3 月 6 日 |
| Kimi K2 | 2026 年 3 月 6 日 |
| MiniMax M2.1 | 2026 年 3 月 15 日 |
| GLM 4.7 | 2026 年 3 月 15 日 |
| GLM 4.6 | 2026 年 3 月 15 日 |
---
## 隐私
我们所有的模型都托管在美国。我们的提供商遵循零保留政策,不会将你的数据用于模型训练,但以下情况除外:

View File

@@ -55,6 +55,7 @@ OpenCode Zen 的工作方式與 OpenCode 中的任何其他供應商相同。
| 模型 | 模型 ID | 端點 | AI SDK 套件 |
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
@@ -136,6 +137,7 @@ https://opencode.ai/zen/v1/models
| Gemini 3 Pro (≤ 200K Token) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K Token) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
@@ -178,6 +180,19 @@ https://opencode.ai/zen/v1/models
---
### 已棄用的模型
| 模型 | 棄用日期 |
| ---------------- | ------------------ |
| Qwen3 Coder 480B | 2026 年 2 月 6 日 |
| Kimi K2 Thinking | 2026 年 3 月 6 日 |
| Kimi K2 | 2026 年 3 月 6 日 |
| MiniMax M2.1 | 2026 年 3 月 15 日 |
| GLM 4.7 | 2026 年 3 月 15 日 |
| GLM 4.6 | 2026 年 3 月 15 日 |
---
## 隱私
我們所有的模型都託管在美國。我們的供應商遵循零保留政策,不會將你的資料用於模型訓練,但以下情況除外:

View File

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