Compare commits

..

4 Commits

Author SHA1 Message Date
opencode-agent[bot] b1927969e8 fix(opencode): preserve effect logging during cleanup 2026-06-05 15:12:26 +00:00
opencode-agent[bot] 45f257da73 refactor(core): remove logger handles 2026-06-05 15:08:01 +00:00
opencode-agent[bot] 8123247d7a refactor(core): remove util log initialization 2026-06-05 14:30:25 +00:00
opencode-agent[bot] ce69ddb88e feat(core): write logs as state jsonl 2026-06-05 14:07:45 +00:00
335 changed files with 3016 additions and 15170 deletions
+38 -46
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -121,7 +121,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -148,7 +148,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -170,7 +170,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -194,7 +194,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.15.11",
"version": "0.0.0",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -214,7 +214,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.11",
"version": "1.15.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -275,7 +275,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
@@ -330,7 +330,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -344,7 +344,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -374,7 +374,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -390,7 +390,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -403,7 +403,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -421,7 +421,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.15.11",
"version": "1.15.10",
"bin": {
"opencode": "./bin/opencode",
},
@@ -478,7 +478,6 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@types/ws": "8.18.1",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "3.1.2",
@@ -520,7 +519,6 @@
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"ws": "8.21.0",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
@@ -561,7 +559,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -577,9 +575,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.16",
"@opentui/keymap": ">=0.2.16",
"@opentui/solid": ">=0.2.16",
"@opentui/core": ">=0.2.15",
"@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.15",
},
"optionalPeers": [
"@opentui/core",
@@ -599,7 +597,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -614,7 +612,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -627,7 +625,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.15.11",
"version": "1.14.50",
"dependencies": {
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -650,7 +648,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.15.11",
"version": "1.14.50",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -669,7 +667,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.15.11",
"version": "1.14.50",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -709,7 +707,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -758,7 +756,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -826,9 +824,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.16",
"@opentui/keymap": "0.2.16",
"@opentui/solid": "0.2.16",
"@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.15",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1718,23 +1716,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.16", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="],
"@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="],
"@opentui/keymap": ["@opentui/keymap@0.2.16", "", { "dependencies": { "@opentui/core": "0.2.16" }, "peerDependencies": { "@opentui/react": "0.2.16", "@opentui/solid": "0.2.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YBLQfNLbU2kx49bjEY9rrFoNlvIoi5qNJfRcOt6frvnR3C6MLl0/8hZY+vMQ2PEQWeEiNejFnl1lQw+z4Nk2FQ=="],
"@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="],
"@opentui/solid": ["@opentui/solid@0.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="],
"@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -5140,7 +5138,7 @@
"write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
@@ -5498,8 +5496,6 @@
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
@@ -5962,8 +5958,6 @@
"miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
"miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -6080,8 +6074,6 @@
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"storybook/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"storybook-solidjs-vite/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+2 -2
View File
@@ -209,8 +209,8 @@ const ingestConfig = new sst.Linkable("LakeIngestConfig", {
const ingestService = new sst.aws.Service("LakeIngestService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "1 vCPU",
memory: "4 GB",
cpu: "0.5 vCPU",
memory: "1 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
+3 -1
View File
@@ -5,8 +5,10 @@ export const domain = (() => {
})()
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
// Dev owns the shared AWS lake/stats infra for all non-production stages.
export const awsStage = $app.stage === "production" ? "production" : "dev"
export const deployAws = $app.stage === awsStage
// Temporarily omit AWS infra so SST removes the lake/stats resources.
export const deployAws = false
new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain,
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-KFVkxs0jtN1EXvwuxFMOlWg6BDr8L6K/wJZ/N73p8cI=",
"aarch64-linux": "sha256-7OLglRBBt5prwb9e0I0iMTXi3MrDfDI+8xcDrxtq/JI=",
"aarch64-darwin": "sha256-Q7iE6xJyomDnvqUgsHh+FlCnJFI6vK0zJSfI9BBYitQ=",
"x86_64-darwin": "sha256-9lVsq2Bw74Vgphg6GSo/HxCtDXUyabxcS/vE1Um/Hfw="
"x86_64-linux": "sha256-FT8N4SBP7OmVu73OwNyPJvBoxFd2+IXzNnFubB8y6J0=",
"aarch64-linux": "sha256-rGst01voY1/qr/fIMEgfL2zep/+W2RhjJh5pKs+/P3s=",
"aarch64-darwin": "sha256-+oTdR3CuR88Ra3QQsDn7ixNwJf0YjOxySTSiRsPPUOg=",
"x86_64-darwin": "sha256-3ZkB83KWtp6N7u9xhzmCouv27UuisXYvr2SBap4k++E="
}
}
+4 -4
View File
@@ -10,7 +10,7 @@
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun sst shell --stage=dev -- bun run --cwd packages/stats/app dev",
"dev:stats": "bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
@@ -38,9 +38,9 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.16",
"@opentui/keymap": "0.2.16",
"@opentui/solid": "0.2.16",
"@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.15",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.15.11",
"version": "1.15.10",
"description": "",
"type": "module",
"exports": {
+6 -19
View File
@@ -14,7 +14,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
type Component,
createEffect,
createMemo,
createResource,
createSignal,
@@ -41,7 +40,7 @@ import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { SettingsProvider, useSettings } from "@/context/settings"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
@@ -49,6 +48,11 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { ServersProvider } from "./context/servers"
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "prod") {
document.body.classList.remove("text-12-regular")
document.body.classList.add("font-(family-name:--font-family-text)", "text-[13px]", "font-[440]")
}
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -93,26 +97,9 @@ function QueryProvider(props: ParentProps) {
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}
function BodyDesignClass() {
const settings = useSettings()
createEffect(() => {
if (typeof document === "undefined") return
const enabled = settings.general.newLayoutDesigns()
document.body.classList.toggle("text-12-regular", !enabled)
document.body.classList.toggle("font-(family-name:--font-family-text)", enabled)
document.body.classList.toggle("text-[13px]", enabled)
document.body.classList.toggle("font-[440]", enabled)
})
return null
}
function AppShellProviders(props: ParentProps) {
return (
<SettingsProvider>
<BodyDesignClass />
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
+4 -2
View File
@@ -79,6 +79,8 @@ import { pathKey } from "@/utils/path-key"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { displayName } from "@/pages/layout/helpers"
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
interface PromptInputProps {
class?: string
variant?: "dock" | "new-session"
@@ -1454,7 +1456,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<Switch>
<Match when={settings.general.newLayoutDesigns()}>
<Match when={USE_V2_INPUT}>
<div class="flex flex-col gap-3">
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
@@ -1526,7 +1528,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
@@ -45,6 +45,8 @@ const OPEN_APPS = [
"sublime-text",
] as const
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type OpenApp = (typeof OPEN_APPS)[number]
type OS = "macos" | "windows" | "linux" | "unknown"
@@ -155,11 +157,11 @@ export function SessionHeader() {
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isDesktopV2 = createMemo(() => platform.platform === "desktop" && settings.general.newLayoutDesigns())
const search = createMemo(() => (isDesktopV2() ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2() ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2() ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2() ? settings.general.showStatus() : true))
const isDesktopV2 = platform.platform === "desktop" && USE_V2_TITLEBAR
const search = createMemo(() => (isDesktopV2 ? settings.general.showSearch() : true))
const tree = createMemo(() => (isDesktopV2 ? settings.general.showFileTree() : true))
const term = createMemo(() => (isDesktopV2 ? settings.general.showTerminal() : true))
const status = createMemo(() => (isDesktopV2 ? settings.general.showStatus() : true))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
@@ -399,18 +399,6 @@ export const SettingsGeneral: Component = () => {
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.newLayoutDesigns.title")}
description={language.t("settings.general.row.newLayoutDesigns.description")}
>
<div data-action="settings-new-layout-designs">
<Switch
checked={settings.general.newLayoutDesigns()}
onChange={(checked) => settings.general.setNewLayoutDesigns(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
)
@@ -814,7 +802,7 @@ export const SettingsGeneral: Component = () => {
<DisplaySection />
<Show when={desktop()}>
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<AdvancedSection />
</Show>
</div>
+11 -9
View File
@@ -55,6 +55,7 @@ const legacyTitlebarHeight = 40
const v2TitlebarHeight = 44
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
@@ -74,7 +75,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
@@ -85,7 +85,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => {
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
const height = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight
if (mac()) return `${height / zoom()}px`
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
return undefined
@@ -119,7 +119,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const nav = createMemo(() => (USE_V2_TITLEBAR ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => {
const version = props.update?.version()
return {
@@ -222,8 +222,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<header
classList={{
"shrink-0 relative overflow-hidden flex flex-row": true,
"h-11 bg-v2-background-bg-deep": useV2Titlebar(),
"h-10 bg-background-base": !useV2Titlebar(),
"h-11 bg-v2-background-bg-deep": USE_V2_TITLEBAR,
"h-10 bg-background-base": !USE_V2_TITLEBAR,
}}
style={{
"min-height": minHeight(),
@@ -239,7 +239,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
onDblClick={maximize}
>
<Switch>
<Match when={useV2Titlebar()}>
<Match when={USE_V2_TITLEBAR}>
{(_) => {
const serverSync = useServerSync()
const navigate = useNavigate()
@@ -490,6 +490,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
project={projectForSession(tab.info, projects(), projectByID())}
directory={tab.dir}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
@@ -736,13 +737,14 @@ function TabNavItem(props: {
title: string
project?: LocalProject
directory: string
hideClose?: boolean
onClose: () => void
}) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
return (
<div
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
data-active={isActive()}
>
<a
@@ -753,7 +755,7 @@ function TabNavItem(props: {
<span class="text-clip leading-5">{props.title}</span>
</a>
<div class="absolute not-group-hover:not-group-data-[active=true]:left-52 group-hover:right-0 group-data-[active=true]:right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{
@@ -764,7 +766,7 @@ function TabNavItem(props: {
<IconButtonV2
size="small"
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100 z-10"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
onClick={props.onClose}
icon={<IconV2 name="xmark-small" />}
/>
-6
View File
@@ -33,7 +33,6 @@ export interface Settings {
editToolPartsExpanded: boolean
showSessionProgressBar: boolean
showCustomAgents: boolean
newLayoutDesigns?: boolean
}
updates: {
startup: boolean
@@ -55,7 +54,6 @@ export interface Settings {
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
@@ -244,10 +242,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
newLayoutDesigns: withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault),
setNewLayoutDesigns(value: boolean) {
setStore("general", "newLayoutDesigns", value)
},
},
updates: {
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
-2
View File
@@ -791,8 +791,6 @@ export const dict = {
"settings.general.row.showSessionProgressBar.title": "Show session progress bar",
"settings.general.row.showSessionProgressBar.description":
"Display the animated progress bar at the top of the session when the agent is working",
"settings.general.row.newLayoutDesigns.title": "New layout and designs",
"settings.general.row.newLayoutDesigns.description": "Enable the redesigned layout, home, composer, and session UI",
"settings.general.row.pinchZoom.title": "Pinch to zoom",
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
+104 -109
View File
@@ -31,8 +31,8 @@ import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useServers } from "@/context/servers"
import { useSettings } from "@/context/settings"
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const HOME_SESSION_LIMIT = 15
const HOME_ROW =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
@@ -52,12 +52,8 @@ type HomeSessionGroup = {
}
export default function Home() {
const settings = useSettings()
return (
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
<HomeDesign />
</Show>
)
if (USE_HOME_DESIGN) return <HomeDesign />
return <LegacyHome />
}
function HomeDesign() {
@@ -72,11 +68,13 @@ function HomeDesign() {
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
const projects = createMemo(() => layout.projects.list())
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project))
const selectedProject = createMemo(
() => projects().find((project) => project.worktree === state.project) ?? projects()[0],
)
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
const projectDirectories = createMemo(() => {
const project = selectedProject()
if (!project) return [...projects().flatMap((project) => directories(project))]
if (!project) return []
return directories(project)
})
const search = createMemo(() => state.search.trim())
@@ -91,8 +89,8 @@ function HomeDesign() {
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const records = createMemo(() => {
return [
const records = createMemo(() =>
[
...new Map(
projectDirectories()
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
@@ -114,8 +112,8 @@ function HomeDesign() {
if (!value) return true
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
})
.slice(0, HOME_SESSION_LIMIT)
})
.slice(0, HOME_SESSION_LIMIT),
)
const groups = createMemo(() => groupSessions(records(), language))
function selectProject(directory: string) {
@@ -222,7 +220,7 @@ function HomeDesign() {
aria-label={language.t("sidebar.project.recentSessions")}
>
<Show
when={projectDirectories().length > 0}
when={selectedProject()}
fallback={
<HomeEmptyState
icon="folder-add-left"
@@ -300,8 +298,8 @@ function HomeProjectColumn(props: {
const layout = useLayout()
const projects = createMemo(() => layout.projects.list())
return (
<aside class="flex min-w-0 flex-col lg:pt-[52px] gap-4" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-1.5">
<aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<IconButtonV2
data-action="home-add-project"
@@ -313,8 +311,8 @@ function HomeProjectColumn(props: {
aria-label={props.language.t("home.project.add")}
/>
</div>
<Show
when={servers.list().length > 1}
<For
each={servers.list()}
fallback={
<ProjectList
projects={projects()}
@@ -330,63 +328,61 @@ function HomeProjectColumn(props: {
/>
}
>
<For each={servers.list()}>
{(server) => {
const key = ServerConnection.key(server)
const healthy = () => !!servers.health[key]?.healthy
const [open, setOpen] = createSignal(true)
{(server) => {
const key = ServerConnection.key(server)
const healthy = () => !!servers.health[key]?.healthy
const [open, setOpen] = createSignal(true)
return (
<div class="max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div class="relative h-7 group">
<button
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
disabled={!healthy()}
onClick={() => setOpen((o) => !o)}
>
<div class="size-4 flex items-center justify-center">
<ServerHealthIndicator health={servers.health[key]} />
</div>
<div class="flex flex-row items-center gap-1">
<span>{server.displayName ?? new URL(server.http.url).host}</span>
<Show when={healthy()}>
<IconV2
name="outline-chevron-down"
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
data-open={open()}
/>
</Show>
</div>
</button>
<IconButtonV2
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
name="out"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
/>
</div>
<Show when={healthy() && open()}>
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
<ProjectList
projects={projects()}
selectedProject={props.selectedProject}
onSelectedProjectChange={props.selectProject}
onChooseProject={props.chooseProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
unseenCount={props.unseenCount}
language={props.language}
/>
</Show>
return (
<div class="mt-4 max-h-[min(572px,calc(100vh_-_300px))] min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div class="relative h-7 group">
<button
class="w-full h-full px-1.5 gap-2 flex flex-row items-center hover:not-disabled:bg-v2-overlay-simple-overlay-hover rounded-[4px]"
disabled={!healthy()}
onClick={() => setOpen((o) => !o)}
>
<div class="size-4 flex items-center justify-center">
<ServerHealthIndicator health={servers.health[key]} />
</div>
<div class="flex flex-row items-center gap-1">
<span>{server.displayName ?? new URL(server.http.url).host}</span>
<Show when={healthy()}>
<IconV2
name="outline-chevron-down"
class="text-v2-icon-icon-muted data-[open=false]:-rotate-90"
data-open={open()}
/>
</Show>
</div>
</button>
<IconButtonV2
class="absolute right-1 inset-y-1 opacity-0 group-hover:opacity-100"
name="out"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" class="text-v2-icon-icon-muted" />}
/>
</div>
)
}}
</For>
</Show>
<div class="flex min-w-0 flex-col gap-1">
<Show when={healthy() && open()}>
<div class="h-px bg-v2-border-border-base mx-3 my-1" />
<ProjectList
projects={projects()}
selectedProject={props.selectedProject}
onSelectedProjectChange={props.selectProject}
onChooseProject={props.chooseProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
unseenCount={props.unseenCount}
language={props.language}
/>
</Show>
</div>
)
}}
</For>
<div class="mt-4 flex min-w-0 flex-col gap-1">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
@@ -420,14 +416,12 @@ function HomeProjectRow(props: {
language: ReturnType<typeof useLanguage>
}) {
const name = createMemo(() => displayName(props.project))
const [menuOpen, setMenuOpen] = createSignal(false)
return (
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px]">
<div class="group/project relative flex h-8 min-w-0 items-center rounded-[6px] hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
<button
type="button"
data-component="home-project-row"
class={`${HOME_PROJECT_NAV_ROW} pr-16 peer`}
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected }}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
@@ -436,37 +430,7 @@ function HomeProjectRow(props: {
<HomeProjectAvatar project={props.project} />
<span>{name()}</span>
</button>
<div
class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 peer-focus-visible:opacity-100 focus-within:opacity-100 data-[menu=true]:opacity-100"
data-menu={menuOpen()}
>
<MenuV2 gutter={4} modal={false} placement="bottom-end" open={menuOpen()} onOpenChange={setMenuOpen}>
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
{props.language.t("common.edit")}
</MenuV2.Item>
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<div class="absolute right-1 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover/project:opacity-100 group-focus-within/project:opacity-100">
<IconButtonV2
data-action="home-project-new-session"
variant="ghost-muted"
@@ -478,6 +442,37 @@ function HomeProjectRow(props: {
props.openNewSession(props.project.worktree)
}}
/>
<MenuV2 gutter={4} modal={false} placement="bottom-end">
<MenuV2.Trigger
as={IconButtonV2}
data-action="home-project-menu"
variant="ghost-muted"
size="small"
icon={<IconV2 name="menu" />}
aria-label={props.language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
<Icon name="new-session" size="small" />
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
<Icon name="edit" size="small" />
{props.language.t("common.edit")}
</MenuV2.Item>
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
<Icon name="circle-check" size="small" />
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
<Icon name="close" size="small" />
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
</div>
)
@@ -566,7 +561,7 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
variant="ghost"
size="normal"
icon="edit"
class="h-7 px-2 text-v2-text-text-muted"
class="h-7 px-2 text-v2-text-text-muted [font-weight:530]"
onClick={onNewSession()}
>
{language.t("command.session.new")}
+174 -174
View File
@@ -89,6 +89,8 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
const USE_NEW_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
Persist.global("layout.page", ["layout.page.v1"]),
@@ -127,7 +129,6 @@ export default function Layout(props: ParentProps) {
const command = useCommand()
const theme = useTheme()
const language = useLanguage()
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
const initialDirectory = decode64(params.dir)
const location = useLocation()
const route = createMemo(() => {
@@ -153,7 +154,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({
autoselect: !initialDirectory && !newDesign(),
autoselect: !initialDirectory && !USE_NEW_DESIGN,
busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
@@ -1141,7 +1142,7 @@ export default function Layout(props: ParentProps) {
},
]
if (!newDesign())
if (!USE_NEW_DESIGN)
Array.from({ length: 9 }, (_, i) => {
const index = i
const number = index + 1
@@ -1820,7 +1821,7 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
document.documentElement.style.setProperty(
"--dialog-left-margin",
newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
USE_NEW_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
)
})
@@ -2362,180 +2363,179 @@ export default function Layout(props: ParentProps) {
/>
)
return (
<Show
when={!newDesign()}
fallback={
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
classList={{
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<Toast.Region />
</div>
}
>
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
if (USE_NEW_DESIGN) {
return (
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div>
<div
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
</div>
</div>
{import.meta.env.DEV && <DebugBar />}
</div>
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
classList={{
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<Toast.Region />
</div>
</Show>
)
}
return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-desktop"
classList={{
"hidden xl:block": true,
"absolute inset-y-0 left-0": true,
"z-10": true,
}}
style={{ width: `${side()}px` }}
ref={(el) => {
setState("nav", el)
}}
onMouseEnter={() => {
disarm()
}}
onMouseLeave={() => {
aim.reset()
if (!sidebarHovering()) return
arm()
}}
>
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
</nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }}
/>
<div class="xl:hidden">
<div
classList={{
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
}}
/>
<nav
aria-label={language.t("sidebar.nav.projectsAndSessions")}
data-component="sidebar-nav-mobile"
classList={{
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
onClick={(e) => e.stopPropagation()}
>
{sidebarContent(true)}
</nav>
</div>
<div
classList={{
"absolute inset-0": true,
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
"z-20": true,
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
!state.sizing,
}}
style={{
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}}
>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<div
classList={{
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
onMouseMove={disarm}
onMouseEnter={() => {
disarm()
aim.reset()
}}
onPointerDown={disarm}
onMouseLeave={() => {
arm()
}}
>
<Show when={peekProject()}>
<SidebarPanel project={peekProject} merged={false} />
</Show>
</div>
<div
classList={{
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
"transition-[opacity,transform] motion-reduce:transition-none": true,
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}}
style={{ left: `calc(4rem + ${panel()}px)` }}
>
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div>
</div>
</div>
{import.meta.env.DEV && <DebugBar />}
</div>
<Toast.Region />
</div>
)
}
+4 -4
View File
@@ -75,6 +75,7 @@ const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
@@ -197,7 +198,6 @@ export default function Page() {
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout()
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
createEffect(() => {
if (!prompt.ready()) return
@@ -265,7 +265,7 @@ export default function Page() {
const isDesktop = createMediaQuery("(min-width: 768px)")
const size = createSizing()
const isV2NewSessionPage = () =>
shouldUseV2NewSessionPage({ newLayoutDesigns: newSessionDesign(), sessionID: params.id })
shouldUseV2NewSessionPage({ channel: import.meta.env.VITE_OPENCODE_CHANNEL, sessionID: params.id })
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened() && !isV2NewSessionPage())
const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened() && !isV2NewSessionPage())
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@@ -1798,14 +1798,14 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<Show when={newSessionDesign()} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView>{composerRegion("inline")}</NewSessionDesignView>
</Show>
</Match>
</Switch>
</div>
<Show when={params.id || !newSessionDesign()}>{composerRegion("dock")}</Show>
<Show when={params.id || !USE_NEW_SESSION_DESIGN}>{composerRegion("dock")}</Show>
<Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}>
@@ -2,13 +2,13 @@ import { describe, expect, test } from "bun:test"
import { shouldUseV2NewSessionPage } from "./new-session-layout"
describe("shouldUseV2NewSessionPage", () => {
test("keeps disabled pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false, sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: false })).toBe(false)
test("keeps prod session pages on the legacy layout", () => {
expect(shouldUseV2NewSessionPage({ channel: "prod", sessionID: "ses_123" })).toBe(false)
expect(shouldUseV2NewSessionPage({ channel: "prod" })).toBe(false)
})
test("uses the v2 layout only for enabled new-session pages", () => {
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true })).toBe(true)
expect(shouldUseV2NewSessionPage({ newLayoutDesigns: true, sessionID: "ses_123" })).toBe(false)
test("uses the v2 layout only for non-prod new-session pages", () => {
expect(shouldUseV2NewSessionPage({ channel: "dev" })).toBe(true)
expect(shouldUseV2NewSessionPage({ channel: "dev", sessionID: "ses_123" })).toBe(false)
})
})
@@ -1,3 +1,3 @@
export function shouldUseV2NewSessionPage(input: { newLayoutDesigns: boolean; sessionID?: string }) {
return input.newLayoutDesigns && !input.sessionID
export function shouldUseV2NewSessionPage(input: { channel?: "dev" | "beta" | "prod"; sessionID?: string }) {
return input.channel !== "prod" && !input.sessionID
}
@@ -28,6 +28,8 @@ import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type S
import { setSessionHandoff } from "@/pages/session/handoff"
import { useSessionLayout } from "@/pages/session/session-layout"
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
@@ -58,7 +60,7 @@ export function SessionSidePanel(props: {
const { sessionKey, tabs, view, params } = useSessionLayout()
const isDesktop = createMediaQuery("(min-width: 768px)")
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
const shown = createMemo(() => (desktopV2() ? settings.general.showFileTree() : true))
const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
@@ -203,7 +205,7 @@ export function SessionSidePanel(props: {
})
return (
<Show when={isDesktop() && !(settings.general.newLayoutDesigns() && !params.id)}>
<Show when={isDesktop() && !(import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && !params.id)}>
<aside
id="review-panel"
aria-label={language.t("session.panel.reviewAndFiles")}
@@ -20,6 +20,8 @@ import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
const USE_DESKTOP_V2 = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
setActiveMessage: (message: UserMessage | undefined) => void
@@ -70,7 +72,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
})
const activeFileTab = tabState.activeFileTab
const closableTab = tabState.closableTab
const desktopV2 = () => platform.platform === "desktop" && settings.general.newLayoutDesigns()
const desktopV2 = () => platform.platform === "desktop" && USE_DESKTOP_V2
const shown = () => (desktopV2() ? settings.general.showFileTree() : true)
const messages = () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.15.11",
"version": "1.15.10",
"type": "module",
"license": "MIT",
"scripts": {
@@ -2,7 +2,6 @@ import { action, json, query, useAction, useSubmission } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import { Referral } from "@opencode-ai/console-core/referral.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { withActor } from "~/context/auth.withActor"
import { Modal } from "~/component/modal"
import { IconCheck, IconCopy } from "~/component/icon"
@@ -10,7 +9,6 @@ import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section"
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
import "./go-referral.css"
type GoReferralSummary = Awaited<ReturnType<typeof Referral.summary>>
@@ -27,21 +25,7 @@ const emptyUsagePreview = {
export const queryGoReferral = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const event = getRequestEvent()
const referralCode = referralCodeFromCookieHeader(event?.request.headers.get("cookie") ?? null)
if (referralCode) {
await Referral.createFromAccount({
accountID: Actor.account(),
referralCode,
}).catch((error) => {
console.error("Referral create failed", error)
})
event?.response.headers.append("set-cookie", clearReferralCookie())
}
return Referral.summary()
}, workspaceID)
return withActor(() => Referral.summary(), workspaceID)
}, "go.referral.get")
export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => {
@@ -81,8 +65,6 @@ function rewardDescriptionKey(source: GoReferralReward["source"]) {
function rewardActionKey(reward: GoReferralReward, hasActiveGo: boolean) {
if (reward.status === "applied") return "workspace.referral.reward.action.applied" as const
if (reward.status === "pending" && reward.source === "inviter")
return "workspace.referral.reward.source.pendingInviter" as const
if (reward.status === "pending" || !hasActiveGo) return "workspace.referral.reward.action.subscribeUnlock" as const
return "workspace.referral.reward.action.view" as const
}
@@ -1,9 +1,11 @@
import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server"
import { Referral } from "@opencode-ai/console-core/referral.js"
import { AuthClient } from "~/context/auth"
import { useAuthSession } from "~/context/auth"
import { i18n } from "~/i18n"
import { localeFromRequest, route } from "~/lib/language"
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
@@ -17,6 +19,7 @@ export async function GET(input: APIEvent) {
if (result.err) throw new Error(result.err.message)
const decoded = AuthClient.decode(result.tokens.access, {} as any)
if (decoded.err) throw new Error(decoded.err.message)
const referralCode = referralCodeFromCookieHeader(input.request.headers.get("cookie"))
const session = await useAuthSession()
const id = decoded.subject.properties.accountID
await session.update((value) => {
@@ -32,8 +35,15 @@ export async function GET(input: APIEvent) {
current: id,
}
})
if (decoded.subject.properties.newAccount && referralCode) {
await Referral.createFromAccount({ accountID: id, referralCode }).catch((error) => {
console.error("Referral create failed", error)
})
}
const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")
return redirect(route(locale, next))
const response = redirect(route(locale, next))
if (referralCode) response.headers.append("set-cookie", clearReferralCookie())
return response
} catch (e: any) {
return new Response(
JSON.stringify({
+1 -2
View File
@@ -64,12 +64,11 @@ function LimitsGraph(props: { href: string }) {
{ id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" },
{ id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "280ms" },
{ id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "150ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 1290, d: "150ms" },
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "280ms" },
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "300ms" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "200ms" },
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", req: 10200, d: "360ms" },
{ id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" },
]
@@ -4,6 +4,7 @@ import { IconCopy, IconCheck } from "~/component/icon"
import { Key } from "@opencode-ai/console-core/key.js"
import { withActor } from "~/context/auth.withActor"
import { createStore } from "solid-js/store"
import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./key-section.module.css"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { useI18n } from "~/context/i18n"
@@ -123,6 +124,7 @@ export function KeySection() {
<th>{i18n.t("workspace.keys.table.name")}</th>
<th>{i18n.t("workspace.keys.table.key")}</th>
<th>{i18n.t("workspace.keys.table.createdBy")}</th>
<th>{i18n.t("workspace.keys.table.lastUsed")}</th>
<th></th>
</tr>
</thead>
@@ -154,6 +156,9 @@ export function KeySection() {
</Show>
</td>
<td data-slot="key-user-email">{key.email}</td>
<td data-slot="key-last-used" title={key.timeUsed ? formatDateUTC(key.timeUsed) : undefined}>
{key.timeUsed ? formatDateForTable(key.timeUsed) : "-"}
</td>
<td data-slot="key-actions">
<form action={removeKey} method="post">
<input type="hidden" name="id" value={key.id} />
@@ -506,6 +506,7 @@ export async function handler(
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
let topPriority = Infinity
const providers = allProviders
.filter((provider) => !provider.disabled)
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
.filter((provider) => {
@@ -989,6 +990,10 @@ export async function handler(
return undefined
})(),
}),
db
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
...(() => {
if (billingSource === "subscription") {
const plan = authInfo.billing.subscription!.plan
@@ -1,3 +1,5 @@
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { FreeUsageLimitError } from "./error"
import { logger } from "./logger"
import { buildRateLimitKey, getRedis } from "./redis"
@@ -20,6 +22,7 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now()
const lifetimeInterval = ""
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
const retryAfter = getRetryAfterDay(now)
const redis = getRedis()
@@ -29,12 +32,33 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
return {
check: async () => {
const counts = await redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey])
const lifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
const dailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
const [counts, rows] = await Promise.all([
redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey]).catch(() => []),
Database.use((tx) =>
tx
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
.from(IpRateLimitTable)
.where(
and(
eq(IpRateLimitTable.ip, ip),
isDefaultModel
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
: inArray(IpRateLimitTable.interval, [dailyInterval]),
),
),
),
])
const redisLifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
const redisDailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
const databaseLifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
const databaseDailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
const lifetimeCount = Math.max(redisLifetimeCount, databaseLifetimeCount)
const dailyCount = Math.max(redisDailyCount, databaseDailyCount)
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
if (isDefaultModel && databaseLifetimeCount > redisLifetimeCount)
await redis.set(lifetimeKey, databaseLifetimeCount).catch(() => {})
if ((isNew && dailyCount >= dailyLimit * 2) || (!isNew && dailyCount >= dailyLimit))
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], retryAfter)
@@ -44,7 +68,18 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
pipeline.incr(dailyKey)
pipeline.expire(dailyKey, retryAfter)
if (isNew) pipeline.incr(lifetimeKey)
await pipeline.exec()
await Promise.all([
pipeline.exec().catch(() => {}),
Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values([
{ ip, interval: dailyInterval, count: 1 },
...(isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
])
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
),
])
},
}
}
@@ -1,5 +1,6 @@
import { Database, eq, and, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { KeyRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { RateLimitError } from "./error"
import { buildRateLimitKey, getRedis } from "./redis"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
@@ -18,20 +19,26 @@ export function createRateLimiter(
.replace(/[^0-9]/g, "")
.substring(0, 12)
const interval = `${modelId.substring(0, 27)}-${yyyyMMddHHmm}`
const redis = getRedis()
const key = buildRateLimitKey("key", zenApiKey, interval)
return {
check: async () => {
const count = Number((await redis.mget<(string | number | null)[]>([key]))[0] ?? 0)
const rows = await Database.use((tx) =>
tx
.select({ interval: KeyRateLimitTable.interval, count: KeyRateLimitTable.count })
.from(KeyRateLimitTable)
.where(and(eq(KeyRateLimitTable.key, zenApiKey), eq(KeyRateLimitTable.interval, interval))),
).then((rows) => rows[0])
const count = rows?.count ?? 0
if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60)
},
track: async () => {
const pipeline = redis.pipeline()
pipeline.incr(key)
pipeline.expire(key, 60)
await pipeline.exec()
await Database.use((tx) =>
tx
.insert(KeyRateLimitTable)
.values({ key: zenApiKey, interval, count: 1 })
.onDuplicateKeyUpdate({ set: { count: sql`${KeyRateLimitTable.count} + 1` } }),
)
},
}
}
-1
View File
@@ -17,7 +17,6 @@ export default defineConfig({
],
server: {
allowedHosts: true,
port: 3001,
},
build: {
rollupOptions: {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.15.11",
"version": "1.15.10",
"private": true,
"type": "module",
"license": "MIT",
+1 -9
View File
@@ -1,6 +1,5 @@
import { Context } from "./context"
import { UserRole } from "./schema/user.sql"
import { Log } from "./util/log"
export namespace Actor {
interface Account {
@@ -38,8 +37,6 @@ export namespace Actor {
const ctx = Context.create<Info>()
export const use = ctx.use
const log = Log.create().tag("namespace", "actor")
export function provide<R, T extends Info["type"]>(
type: T,
properties: Extract<Info, { type: T }>["properties"],
@@ -50,12 +47,7 @@ export namespace Actor {
type,
properties,
} as any,
() => {
return Log.provide({ ...properties }, () => {
log.info("provided")
return cb()
})
},
cb,
)
}
+4 -24
View File
@@ -1,8 +1,8 @@
import { z } from "zod"
import { and, asc, eq, inArray, isNull, sql, Database } from "./drizzle"
import { and, asc, eq, isNull, sql, Database } from "./drizzle"
import { Actor } from "./actor"
import { Identifier } from "./identifier"
import { LiteTable, PaymentTable } from "./schema/billing.sql"
import { LiteTable } from "./schema/billing.sql"
import { ReferralCodeTable, ReferralRewardTable, ReferralTable } from "./schema/referral.sql"
import { AuthTable } from "./schema/auth.sql"
import { UserTable } from "./schema/user.sql"
@@ -318,26 +318,6 @@ export namespace Referral {
.then((rows) => rows[0])
if (selfReferral) throw new Error("Self-referral is not allowed")
const workspaceIDs = await tx
.select({ workspaceID: UserTable.workspaceID })
.from(UserTable)
.where(and(eq(UserTable.accountID, input.accountID), isNull(UserTable.timeDeleted)))
.then((rows) => rows.map((row) => row.workspaceID))
if (workspaceIDs.length === 0) return
const litePayment = await tx
.select({ id: PaymentTable.id })
.from(PaymentTable)
.where(
and(
inArray(PaymentTable.workspaceID, workspaceIDs),
isNull(PaymentTable.timeDeleted),
sql`JSON_UNQUOTE(JSON_EXTRACT(${PaymentTable.enrichment}, '$.type')) = 'lite'`,
),
)
.then((rows) => rows[0])
if (litePayment) return
const referralID = Identifier.create("referral")
await tx.insert(ReferralTable).ignore().values({
workspaceID: code.workspaceID,
@@ -375,7 +355,7 @@ export namespace Referral {
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (!referral) return
if (!referral) throw new Error("Referral not found")
const result = await tx
.insert(ReferralRewardTable)
@@ -393,7 +373,7 @@ export namespace Referral {
},
])
if (result.rowsAffected === 0) return
if (result.rowsAffected === 0) throw new Error("Referral already completed")
})
}
-55
View File
@@ -1,55 +0,0 @@
import { Context } from "../context"
export namespace Log {
const ctx = Context.create<{
tags: Record<string, any>
}>()
export function create(tags?: Record<string, any>) {
tags = tags || {}
const result = {
info(message?: any, extra?: Record<string, any>) {
const prefix = Object.entries({
...use().tags,
...tags,
...extra,
})
.map(([key, value]) => `${key}=${value}`)
.join(" ")
console.log(prefix, message)
return result
},
tag(key: string, value: string) {
if (tags) tags[key] = value
return result
},
clone() {
return Log.create({ ...tags })
},
}
return result
}
export function provide<R>(tags: Record<string, any>, cb: () => R) {
const existing = use()
return ctx.provide(
{
tags: {
...existing.tags,
...tags,
},
},
cb,
)
}
function use() {
try {
return ctx.use()
} catch {
return { tags: {} }
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.15.11",
"version": "1.15.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.15.11",
"version": "1.15.10",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.15.11",
"version": "0.0.0",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.11",
"version": "1.15.10",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+137 -37
View File
@@ -1,39 +1,79 @@
import { Cause, Effect, Logger, References } from "effect"
import * as Log from "../util/log"
import { appendFileSync } from "fs"
import fs from "fs/promises"
import path from "path"
import { Cause, Effect, Layer, Logger, References, Schema } from "effect"
import * as Global from "../global"
import { ensureProcessMetadata } from "../util/opencode-process"
type Fields = Record<string, unknown>
const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key)
export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
identifier: "LogLevel",
description: "Log level",
})
export type Level = Schema.Schema.Type<typeof Level>
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly with: (extra: Fields) => Handle
const levelPriority: Record<Level, number> = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
}
const clean = (input?: Fields): Fields =>
const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key)
const clean = (input?: object): Fields =>
Object.fromEntries(
Object.entries(input ?? {})
.filter((entry) => entry[1] !== undefined && entry[1] !== null)
.map(([key, value]) => [normalizeKey(key), value]),
)
const text = (input: unknown): string => {
// oxlint-disable-next-line no-base-to-string
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
// oxlint-disable-next-line no-base-to-string
return input === undefined ? "" : String(input)
export function file() {
if (disabled(process.env.OPENCODE_LOG_FILE)) return ""
return process.env.OPENCODE_LOG_FILE || path.join(Global.Path.log, "log.jsonl")
}
const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
const ann = clean({ ...base, ...extra })
const fx = run(msg)
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
function shouldLog(input: Level): boolean {
return levelPriority[input] >= levelPriority[parseLevel(process.env.OPENCODE_LOG_LEVEL) ?? "INFO"]
}
export const logger = Logger.make((opts) => {
function write(input: { json: string; pretty: string }) {
const target = file()
if (target) {
try {
appendFileSync(target, input.json)
} catch {}
}
if (truthy(process.env.OPENCODE_PRINT_LOGS)) process.stderr.write(input.pretty)
}
function build(inputLevel: Level, ts: Date, message: unknown, fields: Fields): { json: string; pretty: string } {
const metadata = ensureProcessMetadata("main")
const service = typeof fields.service === "string" ? fields.service : undefined
if (service) delete fields.service
const text = stringifyMessage(message)
const record = {
ts: ts.toISOString(),
level: inputLevel,
message: text,
run_id: metadata.runID,
process_role: metadata.processRole,
pid: process.pid,
service,
fields: Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, normalize(value)])),
}
const prefix = Object.entries({ service, ...record.fields })
.filter((entry) => entry[1] !== undefined && entry[1] !== null)
.map(([key, value]) => `${key}=${typeof value === "object" ? safeStringify(value) : value}`)
.join(" ")
return {
json: safeStringify(record) + "\n",
pretty: [inputLevel.padEnd(5), ts.toISOString().split(".")[0], prefix, text].filter(Boolean).join(" ") + "\n",
}
}
const logger = Logger.make((opts) => {
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
const now = opts.date.getTime()
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
@@ -43,31 +83,91 @@ export const logger = Logger.make((opts) => {
extra.cause = Cause.pretty(opts.cause)
}
const svc = typeof extra.service === "string" ? extra.service : undefined
if (svc) delete extra.service
const log = svc ? Log.create({ service: svc }) : Log.Default
const msg = text(opts.message)
switch (opts.logLevel) {
case "Trace":
case "Debug":
return log.debug(msg, extra)
if (shouldLog("DEBUG")) write(build("DEBUG", opts.date, opts.message, extra))
return
case "Warn":
return log.warn(msg, extra)
if (shouldLog("WARN")) write(build("WARN", opts.date, opts.message, extra))
return
case "Error":
case "Fatal":
return log.error(msg, extra)
if (shouldLog("ERROR")) write(build("ERROR", opts.date, opts.message, extra))
return
default:
return log.info(msg, extra)
if (shouldLog("INFO")) write(build("INFO", opts.date, opts.message, extra))
}
})
export const layer = Logger.layer([logger], { mergeWithExisting: false })
type LoggerInput = Logger.Logger<unknown, unknown> | Effect.Effect<Logger.Logger<unknown, unknown>, unknown, unknown>
export const create = (base: Fields = {}): Handle => ({
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
with: (extra) => create({ ...base, ...extra }),
})
const makeLayer = <const Loggers extends ReadonlyArray<LoggerInput>>(loggers: Loggers) =>
Logger.layer(loggers, { mergeWithExisting: false }).pipe(
Layer.tap(() =>
Effect.promise(async () => {
const target = file()
if (target) await fs.mkdir(path.dirname(target), { recursive: true })
}),
),
)
export const layer = makeLayer([logger])
export const layerWith = <const Loggers extends ReadonlyArray<LoggerInput>>(loggers: Loggers) =>
makeLayer([logger, ...loggers] as const)
function truthy(value: string | undefined) {
return value?.toLowerCase() === "1" || value?.toLowerCase() === "true"
}
function disabled(value: string | undefined) {
const lower = value?.toLowerCase()
return lower === "0" || lower === "false" || lower === "off"
}
function parseLevel(value: string | undefined): Level | undefined {
if (value === "DEBUG" || value === "INFO" || value === "WARN" || value === "ERROR") return value
return undefined
}
function formatError(error: Error, depth = 0): string {
const result = error.message
return error.cause instanceof Error && depth < 10
? result + " Caused by: " + formatError(error.cause, depth + 1)
: result
}
function stringifyMessage(message: unknown): string {
if (message instanceof Error) return formatError(message)
if (message === undefined) return ""
if (typeof message === "string") return message
if (Array.isArray(message)) return message.map((item) => stringifyMessage(item)).join(" ")
if (typeof message === "object") return safeStringify(message)
return String(message)
}
function normalize(value: unknown): unknown {
if (value instanceof Error) {
return {
name: value.name,
message: formatError(value),
stack: value.stack,
}
}
if (typeof value === "bigint") return value.toString()
return value
}
function safeStringify(value: unknown) {
const seen = new WeakSet<object>()
return JSON.stringify(value, (_, item) => {
if (typeof item === "bigint") return item.toString()
if (item instanceof Error) return normalize(item)
if (typeof item === "object" && item !== null) {
if (seen.has(item)) return "[Circular]"
seen.add(item)
}
return item
})
}
+8 -12
View File
@@ -1,4 +1,4 @@
import { Effect, Layer, Logger } from "effect"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import * as EffectLogger from "./logger"
@@ -54,17 +54,13 @@ export function resource(): { serviceName: string; serviceVersion: string; attri
}
function logs() {
return Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource: resource(),
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
return EffectLogger.layerWith([
OtlpLogger.make({
url: `${base}/v1/logs`,
resource: resource(),
headers,
}),
]).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
}
const traces = async () => {
+1 -1
View File
@@ -19,7 +19,7 @@ const paths = {
},
data,
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
log: state,
repos: path.join(data, "repos"),
cache,
config,
+1 -1
View File
@@ -108,7 +108,7 @@ export const layer = Layer.effect(
if (!repo) return { id: ID.global, directory: input, vcs: undefined }
const previous = yield* cached(repo.store)
const id = (yield* remote(repo)) ?? previous ?? (yield* root(repo))
const id = previous ?? (yield* root(repo))
return {
previous,
-194
View File
@@ -1,194 +0,0 @@
export * as Log from "./log"
import path from "path"
import fs from "fs/promises"
import { createWriteStream } from "fs"
import * as Global from "../global"
import { Schema } from "effect"
import { Glob } from "./glob"
export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
identifier: "LogLevel",
description: "Log level",
})
export type Level = Schema.Schema.Type<typeof Level>
const levelPriority: Record<Level, number> = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
}
const keep = 10
const initializedRunID = "OPENCODE_LOG_INITIALIZED_RUN_ID"
let level: Level = "INFO"
function shouldLog(input: Level): boolean {
return levelPriority[input] >= levelPriority[level]
}
export type Logger = {
debug(message?: any, extra?: Record<string, any>): void
info(message?: any, extra?: Record<string, any>): void
error(message?: any, extra?: Record<string, any>): void
warn(message?: any, extra?: Record<string, any>): void
tag(key: string, value: string): Logger
clone(): Logger
time(
message: string,
extra?: Record<string, any>,
): {
stop(): void
[Symbol.dispose](): void
}
}
const loggers = new Map<string, Logger>()
export const Default = create({ service: "default" })
export interface Options {
print: boolean
dev?: boolean
level?: Level
}
let logpath = ""
export function file() {
return logpath
}
let write = (msg: any) => {
process.stderr.write(msg)
return msg.length
}
export async function init(options: Options) {
if (options.level) level = options.level
void cleanup(Global.Path.log)
if (options.print) return
logpath = path.join(
Global.Path.log,
options.dev ? "dev.log" : new Date().toISOString().split(".")[0].replace(/:/g, "") + ".log",
)
const runID = process.env.OPENCODE_RUN_ID
const shouldTruncate = !options.dev || !runID || process.env[initializedRunID] !== runID
if (shouldTruncate) await fs.truncate(logpath).catch(() => {})
if (options.dev && runID) process.env[initializedRunID] = runID
const stream = createWriteStream(logpath, { flags: "a" })
write = async (msg: any) => {
return new Promise((resolve, reject) => {
stream.write(msg, (err) => {
if (err) reject(err)
else resolve(msg.length)
})
})
}
}
async function cleanup(dir: string) {
const files = (
await Glob.scan("????-??-??T??????.log", {
cwd: dir,
absolute: false,
include: "file",
}).catch(() => [])
)
.filter((file) => path.basename(file) === file)
.sort()
if (files.length <= keep) return
const doomed = files.slice(0, -keep)
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
}
function formatError(error: Error, depth = 0): string {
const result = error.message
return error.cause instanceof Error && depth < 10
? result + " Caused by: " + formatError(error.cause, depth + 1)
: result
}
let last = Date.now()
export function create(tags?: Record<string, any>) {
tags = tags || {}
const service = tags["service"]
if (service && typeof service === "string") {
const cached = loggers.get(service)
if (cached) {
return cached
}
}
function build(message: any, extra?: Record<string, any>) {
const prefix = Object.entries({
...tags,
...extra,
})
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => {
const prefix = `${key}=`
if (value instanceof Error) return prefix + formatError(value)
if (typeof value === "object") return prefix + JSON.stringify(value)
return prefix + value
})
.join(" ")
const next = new Date()
const diff = next.getTime() - last
last = next.getTime()
return [next.toISOString().split(".")[0], "+" + diff + "ms", prefix, message].filter(Boolean).join(" ") + "\n"
}
const result: Logger = {
debug(message?: any, extra?: Record<string, any>) {
if (shouldLog("DEBUG")) {
write("DEBUG " + build(message, extra))
}
},
info(message?: any, extra?: Record<string, any>) {
if (shouldLog("INFO")) {
write("INFO " + build(message, extra))
}
},
error(message?: any, extra?: Record<string, any>) {
if (shouldLog("ERROR")) {
write("ERROR " + build(message, extra))
}
},
warn(message?: any, extra?: Record<string, any>) {
if (shouldLog("WARN")) {
write("WARN " + build(message, extra))
}
},
tag(key: string, value: string) {
if (tags) tags[key] = value
return result
},
clone() {
return create({ ...tags })
},
time(message: string, extra?: Record<string, any>) {
const now = Date.now()
result.info(message, { status: "started", ...extra })
function stop() {
result.info(message, {
status: "completed",
duration: Date.now() - now,
...extra,
})
}
return {
stop,
[Symbol.dispose]() {
stop()
},
}
},
}
if (service && typeof service === "string") {
loggers.set(service, result)
}
return result
}
+6 -34
View File
@@ -5,16 +5,11 @@ import path from "path"
import { Effect } from "effect"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Hash } from "@opencode-ai/core/util/hash"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(Project.defaultLayer)
function remoteID(remote: string) {
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
}
function abs(value: string) {
return AbsolutePath.make(value)
}
@@ -91,7 +86,7 @@ describe("ProjectV2.resolve", () => {
}),
)
it.live("prefers normalized origin over root commit", () =>
it.live("uses root commit when origin exists", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -102,36 +97,13 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(remoteID("github.com/Acme/App"))
expect(result.id).not.toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.directory).toBe(yield* real(tmp.path))
expect(result.vcs?.type).toBe("git")
}),
)
it.live("normalizes ssh and https remotes to the same id", () =>
Effect.gen(function* () {
const ssh = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const https = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(ssh.path, { commit: true, remote: "git@github.com:owner/repo.git" }))
yield* Effect.promise(() => initRepo(https.path, { commit: true, remote: "https://github.com/owner/repo.git" }))
const project = yield* Project.Service
const a = yield* project.resolve(abs(ssh.path))
const b = yield* project.resolve(abs(https.path))
expect(a.id).toBe(remoteID("github.com/owner/repo"))
expect(b.id).toBe(a.id)
}),
)
it.live("ignores file remotes and falls back to root commit", () =>
it.live("uses root commit when local remote exists", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -146,7 +118,7 @@ describe("ProjectV2.resolve", () => {
}),
)
it.live("returns previous cached id from common dir", () =>
it.live("prefers previous cached id over origin", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -159,7 +131,7 @@ describe("ProjectV2.resolve", () => {
const result = yield* project.resolve(abs(tmp.path))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.id).toBe(Project.ID.make("old-id"))
}),
)
@@ -213,7 +185,7 @@ describe("ProjectV2.resolve", () => {
expect(result.directory).toBe(yield* real(worktree))
expect(result.previous).toBe(Project.ID.make("old-id"))
expect(result.id).toBe(remoteID("github.com/owner/repo"))
expect(result.id).toBe(Project.ID.make("old-id"))
expect(result.vcs?.type).toBe("git")
}),
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.15.11",
"version": "1.15.10",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
-3
View File
@@ -15,9 +15,6 @@ declare module "virtual:opencode-server" {
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
}
export namespace Log {
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
}
export namespace Database {
export const getPath: typeof import("../../../opencode/dist/types/src/node").Database.getPath
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
+2 -2
View File
@@ -57,8 +57,8 @@ async function start(command: StartCommand) {
ensureLoopbackNoProxy()
useSystemCertificates()
useEnvProxy()
const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server")
await Log.init({ level: "WARN" })
process.env.OPENCODE_LOG_LEVEL = "WARN"
const { Database, JsonMigration, Server } = await import("virtual:opencode-server")
if (command.needsMigration) {
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.11",
"version": "1.15.10",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.15.11",
"version": "1.15.10",
"private": true,
"type": "module",
"license": "MIT",
-1
View File
@@ -29,7 +29,6 @@ export default defineConfig({
server: {
host: "0.0.0.0",
allowedHosts: true,
port: 3002,
},
worker: {
format: "es",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.15.11"
version = "1.15.10"
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.15.11/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.11/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/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.15.11/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/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.15.11/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.10/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.15.11",
"version": "1.15.10",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.11",
"version": "1.15.10",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.11",
"version": "1.15.10",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.11",
"version": "1.15.10",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -122,7 +122,6 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@types/ws": "8.18.1",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "3.1.2",
@@ -164,7 +163,6 @@
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"ws": "8.21.0",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:"
+3 -19
View File
@@ -1,5 +1,4 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type {
Event,
EventMessagePartDelta,
@@ -11,7 +10,6 @@ import type {
} from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { ACPNextSession } from "./session"
import { ACPNextPermission } from "./permission"
import {
duplicateRunningToolUpdate,
errorToolUpdate,
@@ -20,11 +18,7 @@ import {
shellOutputSnapshot,
completedToolUpdate,
} from "./tool"
const log = Log.create({ service: "acp-next-event" })
type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
type Connection = Pick<AgentSideConnection, "sessionUpdate">
type GlobalEventEnvelope = {
payload?: Event
}
@@ -42,7 +36,6 @@ export class Subscription {
private readonly abort = new AbortController()
private readonly shellSnapshots = new Map<string, string>()
private readonly toolStarts = new Set<string>()
private readonly permission: ACPNextPermission.Handler
private started = false
constructor(
@@ -51,16 +44,13 @@ export class Subscription {
connection: Connection
session: ACPNextSession.Interface
},
) {
this.permission = new ACPNextPermission.Handler(input)
}
) {}
start() {
if (this.started) return
this.started = true
this.run().catch((error: unknown) => {
if (this.abort.signal.aborted) return
log.error("event subscription failed", { error })
})
}
@@ -70,9 +60,6 @@ export class Subscription {
async handle(event: Event) {
switch (event.type) {
case "permission.asked":
this.permission.handle(event)
return
case "message.part.updated":
return this.handlePartUpdated(event)
case "message.part.delta":
@@ -100,9 +87,7 @@ export class Subscription {
for await (const event of events.stream) {
if (this.abort.signal.aborted) return
if (!event.payload) continue
await this.handle(event.payload).catch((error: unknown) => {
log.error("failed to handle event", { error, type: event.payload?.type })
})
await this.handle(event.payload).catch((error: unknown) => {})
}
if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000))
}
@@ -190,7 +175,6 @@ export class Subscription {
)
.then((response) => response.data)
.catch((error: unknown) => {
log.error("unexpected error when fetching message for delta metadata", { error, messageId, partId })
return undefined
})
if (!message) return
@@ -1,145 +0,0 @@
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { exists, readText } from "@/util/filesystem"
import type { ACPNextSession } from "./session"
import { toLocations, toToolKind, type ToolInput } from "./tool"
import { Effect } from "effect"
const log = Log.create({ service: "acp-next-permission" })
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type Reply = "once" | "always" | "reject"
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
const permissionOptions: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
export class Handler {
private readonly queues = new Map<string, Promise<void>>()
constructor(
private readonly input: {
sdk: OpencodeClient
connection: Connection
session: ACPNextSession.Interface
},
) {}
handle(event: PermissionEvent) {
const permission = event.properties
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
const next = previous
.then(() => this.process(event))
.catch((error: unknown) => {
log.error("failed to handle permission", { error, permissionID: permission.id })
})
.finally(() => {
if (this.queues.get(permission.sessionID) === next) {
this.queues.delete(permission.sessionID)
}
})
this.queues.set(permission.sessionID, next)
}
private async process(event: PermissionEvent) {
const permission = event.properties
const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
if (!session) return
if (!this.input.connection.requestPermission) {
log.error("ACP connection cannot request permission", {
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd)
return
}
const result = await this.input.connection
.requestPermission({
sessionId: permission.sessionID,
toolCall: {
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
options: permissionOptions,
})
.catch(async (error: unknown) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd)
return undefined
})
if (!result) return
const reply = selectedReply(result)
if (reply !== "once" && reply !== "always") {
await this.reply(permission.id, "reject", session.cwd)
return
}
if (permission.permission === "edit") {
await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => {
log.error("failed to write proposed edit through ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
})
}
await this.reply(permission.id, reply, session.cwd)
}
private async reply(requestID: string, reply: Reply, directory: string) {
await this.input.sdk.permission.reply({
requestID,
reply,
directory,
})
}
private async writeProposedEdit(sessionId: string, metadata: ToolInput) {
const filepath = stringValue(metadata.filepath)
const diff = stringValue(metadata.diff)
if (!filepath || !diff || !this.input.connection.writeTextFile) return
const content = (await exists(filepath)) ? await readText(filepath) : ""
const next = applyPatch(content, diff)
if (next === false) {
log.error("Failed to apply unified diff (context mismatch)")
return
}
void this.input.connection.writeTextFile({
sessionId,
path: filepath,
content: next,
})
}
}
function selectedReply(result: RequestPermissionResponse): Reply {
if (result.outcome.outcome !== "selected") return "reject"
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
return "reject"
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
export * as ACPNextPermission from "./permission"
-42
View File
@@ -1,42 +0,0 @@
const enabled = process.env.OPENCODE_ACP_PROFILE === "1"
const started = performance.now()
export function mark(name: string, fields?: Record<string, string | number | boolean | undefined>) {
if (!enabled) return
write(`${name}.mark`, performance.now() - started, fields)
}
export function duration(
name: string,
startedAt: number,
fields?: Record<string, string | number | boolean | undefined>,
) {
if (!enabled) return
write(name, performance.now() - startedAt, fields)
}
export async function measure<T>(
name: string,
fn: () => Promise<T>,
fields?: Record<string, string | number | boolean | undefined>,
) {
if (!enabled) return fn()
const start = performance.now()
try {
return await fn()
} finally {
write(name, performance.now() - start, fields)
}
}
function write(name: string, durationMs: number, fields?: Record<string, string | number | boolean | undefined>) {
const extra = fields
? Object.entries(fields)
.filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined)
.map(([key, value]) => `${key}=${value}`)
.join(" ")
: ""
console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`)
}
export * as ACPNextProfile from "./profile"
+100 -351
View File
@@ -30,27 +30,19 @@ import {
type SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import * as Log from "@opencode-ai/core/util/log"
import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPNextError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option"
import { promptContentToParts } from "./content"
import { Directory } from "./directory"
import { ACPNextEvent } from "./event"
import { ACPNextSession } from "./session"
import { UsageService } from "./usage"
import { ACPNextProfile } from "./profile"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import type { Command } from "@/command"
export const AuthMethodID = "opencode-login"
const log = Log.create({ service: "acp-next-service" })
export type Error = ACPNextError.Error
type ServiceConnection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
export type Interface = {
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
@@ -74,23 +66,20 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AC
export function make(input: {
sdk: OpencodeClient
connection?: ServiceConnection
connection?: Pick<AgentSideConnection, "sessionUpdate">
directory?: Directory.Interface
session?: ACPNextSession.Interface
usage?: UsageService.Interface
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
}): Interface {
const session = input.session ?? makeSessionService()
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
const registeredMcp = new Map<string, Set<string>>()
const sessionSnapshots = new Map<string, Directory.Snapshot>()
const events = input.connection
? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session })
: undefined
if (events) input.eventSubscription?.(events)
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
const started = performance.now()
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
@@ -107,7 +96,7 @@ export function make(input: {
}
}
const response = {
return {
protocolVersion: 1,
agentCapabilities: {
loadSession: true,
@@ -132,8 +121,6 @@ export function make(input: {
version: InstallationVersion,
},
}
ACPNextProfile.duration("acp.initialize", started)
return response
})
const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
@@ -144,28 +131,15 @@ export function make(input: {
})
const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) {
const started = performance.now()
const snapshot = yield* directoryService.get(cwd)
ACPNextProfile.duration("acp.directory.snapshot", started)
return snapshot
})
const configSnapshot = Effect.fn("ACPNext.configSnapshot")(function* (state: ACPNextSession.Info) {
const snapshot = sessionSnapshots.get(state.id)
if (snapshot) return snapshot
const loaded = yield* directorySnapshot(state.cwd)
sessionSnapshots.set(state.id, loaded)
return loaded
return yield* directoryService.get(cwd)
})
const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) {
const started = performance.now()
const snapshot = yield* directorySnapshot(params.cwd)
const selected = selectDefaultModel(snapshot)
const variant = selectVariant(snapshot, selected)
const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined
const created = yield* profiledRequest(
"acp.newSession.session.create",
const created = yield* request(
() =>
input.sdk.session.create(
{
@@ -189,12 +163,11 @@ export function make(input: {
variant,
modeId,
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
const response = {
return {
sessionId: state.id,
configOptions: configOptions(snapshot, {
model: state.model ?? selected,
@@ -202,8 +175,6 @@ export function make(input: {
modeId: state.modeId,
}),
}
ACPNextProfile.duration("acp.newSession", started)
return response
})
const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) {
@@ -230,7 +201,6 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@@ -259,35 +229,21 @@ export function make(input: {
),
"session",
)
const serverEntries = sessions.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.directory,
title: item.title,
updatedAt: new Date(item.time.updated).toISOString(),
}),
)
const liveEntries = (yield* session.list(params.cwd ?? undefined))
.filter((item) => !serverEntries.some((entry) => entry.sessionId === item.id))
.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.cwd,
updatedAt: item.createdAt.toISOString(),
}),
)
const sorted = [...liveEntries, ...serverEntries].toSorted(
(a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime(),
)
const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
const filtered =
cursor === undefined || !Number.isFinite(cursor)
? sorted
: sorted.filter((item) => new Date(item.updatedAt ?? 0).getTime() < cursor)
cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor)
const page = filtered.slice(0, limit)
const last = page.at(-1)
return {
sessions: page,
...(filtered.length > limit && last ? { nextCursor: String(new Date(last.updatedAt ?? 0).getTime()) } : {}),
sessions: page.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.directory,
title: item.title,
updatedAt: new Date(item.time.updated).toISOString(),
}),
),
...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}),
}
})
@@ -315,7 +271,6 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@@ -333,19 +288,12 @@ export function make(input: {
const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) {
const removed = yield* session.remove(params.sessionId)
registeredMcp.delete(params.sessionId)
sessionSnapshots.delete(params.sessionId)
if (!removed) return {}
yield* request(
() => input.sdk.session.abort({ directory: removed.cwd, sessionID: params.sessionId }, { throwOnError: true }),
"session",
).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
}),
),
)
).pipe(Effect.catch((error) => Effect.sync(() => {})))
return {}
})
@@ -377,7 +325,6 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@@ -397,7 +344,7 @@ export function make(input: {
params: SetSessionConfigOptionRequest,
) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* configSnapshot(current)
const snapshot = yield* directorySnapshot(current.cwd)
if (typeof params.value !== "string") {
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
}
@@ -452,7 +399,7 @@ export function make(input: {
const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* configSnapshot(current)
const snapshot = yield* directorySnapshot(current.cwd)
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
}
@@ -462,7 +409,7 @@ export function make(input: {
const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* configSnapshot(current)
const snapshot = yield* directorySnapshot(current.cwd)
const selected = yield* parseSelectedModel(snapshot, params.modelId)
yield* session
.setVariant(
@@ -487,81 +434,8 @@ export function make(input: {
setSessionConfigOption,
setSessionMode,
setSessionModel,
prompt: Effect.fn("ACPNext.prompt")(function* (params: PromptRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const selected = current.model ?? selectDefaultModel(snapshot)
if (!current.model) {
yield* session.setModel(params.sessionId, selected)
}
const variant = current.variant ?? selectVariant(snapshot, selected)
const modeId = current.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined)
const parts = promptContentToParts(params.prompt)
const command = detectSlashCommand(parts)
if (!command) {
const response = yield* request(
() =>
input.sdk.session.prompt(
{
sessionID: current.id,
model: {
providerID: selected.providerID,
modelID: selected.modelID,
},
...(variant ? { variant } : {}),
parts,
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
},
{ throwOnError: true },
),
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId)
}
const known = snapshot.availableCommands.find((item) => item.name === command.name)
if (known) {
const response = yield* request(
() =>
input.sdk.session.command(
{
sessionID: current.id,
command: known.name,
arguments: command.args,
model: `${selected.providerID}/${selected.modelID}`,
...(variant ? { variant } : {}),
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
},
{ throwOnError: true },
),
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId)
}
if (command.name === "compact") {
yield* request(
() =>
input.sdk.session.summarize(
{
sessionID: current.id,
directory: current.cwd,
providerID: selected.providerID,
modelID: selected.modelID,
},
{ throwOnError: true },
),
"session",
)
}
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(undefined, params.messageId)
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
}),
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
@@ -590,98 +464,11 @@ function makeDirectoryService(sdk: OpencodeClient) {
).runSync(Directory.Service.use((service) => Effect.succeed(service)))
}
function makeUsageService(sdk: OpencodeClient) {
const limits = new Map<string, Promise<number | undefined>>()
const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACPNext.promptUsage.contextLimit")(
function* (params) {
const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}`
const current = limits.get(key)
if (current) return yield* Effect.promise(() => current)
const next = sdk.config
.providers({ directory: params.directory }, { throwOnError: true })
.then((response) => {
const providers = Object.fromEntries(
(response.data?.providers ?? []).map((provider) => [provider.id, provider]),
) as Record<ProviderID, Provider.Info>
return UsageService.findContextLimit(providers, params.providerID, params.modelID)
})
.catch((error: unknown) => {
log.error("failed to get providers for usage context limit", { error })
return undefined
})
limits.set(key, next)
return yield* Effect.promise(() => next)
},
)
const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACPNext.promptUsage.sendUpdate")(
function* (params) {
const messages = yield* request(
() =>
sdk.session.messages(
{
sessionID: params.sessionID,
directory: params.directory,
},
{ throwOnError: true },
),
"session",
).pipe(
Effect.map((messages) => messages as readonly UsageService.SessionMessage[]),
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to fetch messages for usage update", { error })
return undefined
}),
),
)
if (!messages) return
const message = UsageService.latestAssistantMessage(messages)
if (!message?.providerID || !message.modelID) return
const size = yield* contextLimit({
directory: params.directory,
providerID: ProviderID.make(message.providerID),
modelID: ModelID.make(message.modelID),
})
if (!size) return
yield* Effect.promise(() =>
params.connection
.sessionUpdate({
sessionId: params.sessionID,
update: {
sessionUpdate: "usage_update",
used: message.tokens.input + message.tokens.cache.read,
size,
cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" },
},
})
.catch((error) => {
log.error("failed to send usage update", { error })
}),
)
},
)
return UsageService.Service.of({
buildUsage: UsageService.buildUsage,
latestAssistantMessage: UsageService.latestAssistantMessage,
totalSessionCost: UsageService.totalSessionCost,
contextLimit,
sendUpdate,
})
}
function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
if (!subscription) return Effect.void
return Effect.promise(async () => {
for (const message of messages) {
await subscription.replayMessage(message).catch((error: unknown) => {
log.error("failed to replay ACP message", { error, messageID: message.info.id })
})
await subscription.replayMessage(message).catch((error: unknown) => {})
}
})
}
@@ -707,8 +494,6 @@ type MessageInfo = {
readonly agent?: Message["agent"]
}
type AssistantInfo = UsageService.AssistantTokenCost | undefined
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return Effect.tryPromise({
try: async () => {
@@ -723,79 +508,66 @@ function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
})
}
function profiledRequest<T>(name: string, fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return request(() => ACPNextProfile.measure(name, fn), service)
}
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
return ACPNextProfile.measure("acp.directory.load", async () => {
const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([
ACPNextProfile.measure("acp.directory.provider.list", () =>
sdk.config.providers({ directory }, { throwOnError: true }),
),
ACPNextProfile.measure("acp.directory.mode.defaultAgent.load", () =>
sdk.app.agents({ directory }, { throwOnError: true }),
),
ACPNextProfile.measure("acp.directory.command.list", () =>
sdk.command.list({ directory }, { throwOnError: true }),
),
ACPNextProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })),
ACPNextProfile.measure("acp.directory.defaultModel.config", () =>
sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined),
),
])
const providersData = providersResponse.data!
const agents = agentsResponse.data!
const commandsData = commandsResponse.data!
const skills = skillsResponse.data!
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
ProviderID,
Provider.Info
>
const defaultModelStarted = performance.now()
const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers)
ACPNextProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel })
const modes = agents
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
.map((agent) => ({
id: agent.name,
name: agent.name,
...(agent.description ? { description: agent.description } : {}),
}))
const commands = [
...commandsData,
...skills
.filter((skill) => !commandsData.some((command) => command.name === skill.name))
.map((skill) => ({
name: skill.name,
description: skill.description,
source: "skill" as const,
template: skill.content,
hints: [],
})),
] as Command.Info[]
const [providersResponse, agentsResponse, commandsResponse, skillsResponse] = await Promise.all([
sdk.config.providers({ directory }, { throwOnError: true }),
sdk.app.agents({ directory }, { throwOnError: true }),
sdk.command.list({ directory }, { throwOnError: true }),
sdk.app.skills({ directory }, { throwOnError: true }),
])
const providersData = providersResponse.data!
const agents = agentsResponse.data!
const commandsData = commandsResponse.data!
const skills = skillsResponse.data!
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
ProviderID,
Provider.Info
>
const defaultModel = await defaultModelFromSdk(sdk, directory, providers)
const modes = agents
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
.map((agent) => ({
id: agent.name,
name: agent.name,
...(agent.description ? { description: agent.description } : {}),
}))
const commands = [
...commandsData,
...skills
.filter((skill) => !commandsData.some((command) => command.name === skill.name))
.map((skill) => ({
name: skill.name,
description: skill.description,
source: "skill" as const,
template: skill.content,
hints: [],
})),
] as Command.Info[]
return Directory.build({
directory,
providers,
modes,
defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
...(defaultModel ? { defaultModel } : {}),
})
return Directory.build({
directory,
providers,
modes,
defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
...(defaultModel ? { defaultModel } : {}),
})
}
function defaultModelFromConfig(
configuredModel: string | undefined,
async function defaultModelFromSdk(
sdk: OpencodeClient,
directory: string,
providers: Record<ProviderID, Provider.Info>,
): Directory.DefaultModel | undefined {
const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined
): Promise<Directory.DefaultModel | undefined> {
const configured = await sdk.config
.get({ directory }, { throwOnError: true })
.then((response) => (response.data?.model ? Provider.parseModel(response.data.model) : undefined))
.catch(() => undefined)
if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured
// First-session ACP startup must not scan historical sessions just to infer
// a default. Configured model, opencode provider, then sorted best model keep
// the protocol response deterministic without extra session/message reads.
const lastUsed = await lastUsedModel(sdk, directory, providers)
if (lastUsed) return lastUsed
const opencodeProvider = providers[ProviderID.make("opencode")]
const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined
if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id }
@@ -805,6 +577,30 @@ function defaultModelFromConfig(
if (configured) return configured
}
async function lastUsedModel(
sdk: OpencodeClient,
directory: string,
providers: Record<ProviderID, Provider.Info>,
): Promise<Directory.DefaultModel | undefined> {
const session = await sdk.session
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
.then((response) => response.data?.[0])
.catch(() => undefined)
if (!session) return
const lastUser = await sdk.session
.messages({ directory, sessionID: session.id, limit: 20 }, { throwOnError: true })
.then((response) => response.data?.findLast((message) => message.info.role === "user")?.info)
.catch(() => undefined)
if (lastUser?.role !== "user") return
if (!providers[ProviderID.make(lastUser.model.providerID)]?.models[ModelID.make(lastUser.model.modelID)]) return
return {
providerID: ProviderID.make(lastUser.model.providerID),
modelID: ModelID.make(lastUser.model.modelID),
}
}
function selectDefaultModel(snapshot: Directory.Snapshot) {
if (snapshot.defaultModel) return snapshot.defaultModel
const model = snapshot.modelOptions[0]
@@ -812,43 +608,6 @@ function selectDefaultModel(snapshot: Directory.Snapshot) {
return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID }
}
function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
const text = parts
.filter((part): part is Extract<(typeof parts)[number], { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("")
.trim()
if (!text.startsWith("/")) return
const [name, ...rest] = text.slice(1).split(/\s+/)
if (!name) return
return { name, args: rest.join(" ").trim() }
}
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
return {
stopReason: "end_turn",
...(info ? { usage: UsageService.buildUsage(info) } : {}),
...(messageId ? { userMessageId: messageId } : {}),
_meta: {},
}
}
function sendUsageUpdate(
usage: UsageService.Interface | undefined,
sdk: OpencodeClient,
connection: ServiceConnection | undefined,
sessionID: string,
directory: string,
) {
if (!connection) return Effect.void
return (usage ?? makeUsageService(sdk)).sendUpdate({
connection,
sessionID,
directory,
})
}
function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) {
const variants = Directory.variants(snapshot, model)
if (!variants) return
@@ -919,7 +678,6 @@ function registerMcpServers(
sessionId: string,
servers: readonly McpServer[],
) {
const started = performance.now()
const current = registered.get(sessionId) ?? new Set<string>()
registered.set(sessionId, current)
const pending = new Set<string>()
@@ -951,16 +709,7 @@ function registerMcpServers(
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.tap(() =>
Effect.sync(() =>
ACPNextProfile.duration("acp.mcp.register", started, {
count: pending.size,
}),
),
),
Effect.asVoid,
)
).pipe(Effect.asVoid)
}
function mcpRegistrationKey(name: string, config: ReturnType<typeof mcpConfig>) {
@@ -60,7 +60,6 @@ export type PartMetadataLookupInput = {
export type Interface = {
readonly create: (input: StoreInput) => Effect.Effect<Info>
readonly load: (input: StoreInput) => Effect.Effect<Info>
readonly list: (cwd?: string) => Effect.Effect<readonly Info[]>
readonly get: (sessionId: string) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
@@ -169,12 +168,6 @@ export const layer = Layer.effect(
return Service.of({
create: store,
load: store,
list: Effect.fn("ACPNext.Session.list")(function* (cwd?: string) {
return [...(yield* Ref.get(sessions)).values()]
.filter((session) => !cwd || session.cwd === cwd)
.map(snapshot)
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
}),
get,
tryGet,
remove,
+1 -1
View File
@@ -74,7 +74,7 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
case "read":
case "edit":
case "write":
return locationFrom(input.filePath ?? input.filepath)
return locationFrom(input.filePath)
case "grep":
case "glob":
+1 -9
View File
@@ -1,14 +1,10 @@
import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { AssistantMessage as OpenCodeAssistantMessage, Message } from "@opencode-ai/sdk/v2"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect"
const log = Log.create({ service: "acp-next-usage" })
export type AssistantTokenCost = Pick<OpenCodeAssistantMessage, "cost" | "tokens">
export type AssistantMessage = AssistantTokenCost &
@@ -157,7 +153,6 @@ export const layer = Layer.effect(
Effect.map((providers) => findContextLimit(providers, input.providerID, input.modelID)),
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to get providers for usage context limit", { error })
return undefined
}),
),
@@ -184,7 +179,6 @@ export const layer = Layer.effect(
const messages = yield* messageLoader.messages({ sessionID: input.sessionID, directory: input.directory }).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to fetch messages for usage update", { error })
return undefined
}),
),
@@ -213,9 +207,7 @@ export const layer = Layer.effect(
cost: { amount: totalSessionCost(messages), currency: "USD" },
},
})
.catch((error) => {
log.error("failed to send usage update", { error })
}),
.catch((error) => {}),
)
})
+22 -109
View File
@@ -33,7 +33,6 @@ import {
type Usage,
} from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import { pathToFileURL } from "url"
import { Filesystem } from "@/util/filesystem"
import { Hash } from "@opencode-ai/core/util/hash"
@@ -57,9 +56,6 @@ type ModelOption = { modelId: string; name: string }
const decodeTodos = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Array(Todo.Info)))
const DEFAULT_VARIANT_VALUE = "default"
const log = Log.create({ service: "acp-agent" })
async function getContextLimit(
sdk: OpencodeClient,
providerID: ProviderID,
@@ -70,7 +66,6 @@ async function getContextLimit(
.providers({ directory })
.then((x) => x.data?.providers ?? [])
.catch((error) => {
log.error("failed to get providers for context limit", { error })
return []
})
@@ -89,7 +84,6 @@ async function sendUsageUpdate(
.messages({ sessionID, directory }, { throwOnError: true })
.then((x) => x.data)
.catch((error) => {
log.error("failed to fetch messages for usage update", { error })
return undefined
})
@@ -124,9 +118,7 @@ async function sendUsageUpdate(
cost: { amount: totalCost, currency: "USD" },
},
})
.catch((error) => {
log.error("failed to send usage update", { error })
})
.catch((error) => {})
}
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
@@ -166,7 +158,6 @@ export class Agent implements ACPAgent {
this.eventStarted = true
this.runEventSubscription().catch((error) => {
if (this.eventAbort.signal.aborted) return
log.error("event subscription failed", { error })
})
}
@@ -180,9 +171,7 @@ export class Agent implements ACPAgent {
if (this.eventAbort.signal.aborted) return
const payload = event?.payload
if (!payload) continue
await this.handleEvent(payload as Event).catch((error) => {
log.error("failed to handle event", { error, type: payload.type })
})
await this.handleEvent(payload as Event).catch((error) => {})
}
}
}
@@ -213,11 +202,6 @@ export class Agent implements ACPAgent {
options: this.permissionOptions,
})
.catch(async (error) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.sdk.permission.reply({
requestID: permission.id,
reply: "reject",
@@ -258,9 +242,7 @@ export class Agent implements ACPAgent {
directory,
})
})
.catch((error) => {
log.error("failed to handle permission", { error, permissionID: permission.id })
})
.catch((error) => {})
.finally(() => {
if (this.permissionQueues.get(permission.sessionID) === next) {
this.permissionQueues.delete(permission.sessionID)
@@ -271,7 +253,6 @@ export class Agent implements ACPAgent {
}
case "message.part.updated": {
log.info("message part updated", { event: event.properties })
const props = event.properties
const part = props.part
const session = this.sessionManager.tryGet(part.sessionID)
@@ -306,9 +287,7 @@ export class Agent implements ACPAgent {
rawInput: part.state.input,
},
})
.catch((error) => {
log.error("failed to send tool in_progress to ACP", { error })
})
.catch((error) => {})
return
}
this.shellSnapshots.set(part.callID, hash)
@@ -335,9 +314,7 @@ export class Agent implements ACPAgent {
...(content.length > 0 && { content }),
},
})
.catch((error) => {
log.error("failed to send tool in_progress to ACP", { error })
})
.catch((error) => {})
return
case "completed": {
@@ -365,11 +342,8 @@ export class Agent implements ACPAgent {
}),
},
})
.catch((error) => {
log.error("failed to send session update for todo", { error })
})
.catch((error) => {})
} else {
log.error("failed to parse todo output", { error: parsedTodos.failure })
}
}
@@ -387,9 +361,7 @@ export class Agent implements ACPAgent {
rawOutput: completedToolRawOutput(part),
},
})
.catch((error) => {
log.error("failed to send tool completed to ACP", { error })
})
.catch((error) => {})
return
}
case "error":
@@ -420,9 +392,7 @@ export class Agent implements ACPAgent {
},
},
})
.catch((error) => {
log.error("failed to send tool error to ACP", { error })
})
.catch((error) => {})
return
}
}
@@ -452,7 +422,6 @@ export class Agent implements ACPAgent {
)
.then((x) => x.data)
.catch((error) => {
log.error("unexpected error when fetching message", { error })
return undefined
})
@@ -474,9 +443,7 @@ export class Agent implements ACPAgent {
},
},
})
.catch((error) => {
log.error("failed to send text delta to ACP", { error })
})
.catch((error) => {})
return
}
@@ -493,9 +460,7 @@ export class Agent implements ACPAgent {
},
},
})
.catch((error) => {
log.error("failed to send reasoning delta to ACP", { error })
})
.catch((error) => {})
}
return
}
@@ -503,8 +468,6 @@ export class Agent implements ACPAgent {
}
async initialize(params: InitializeRequest): Promise<InitializeResponse> {
log.info("initialize", { protocolVersion: params.protocolVersion })
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
@@ -561,9 +524,6 @@ export class Agent implements ACPAgent {
// Store ACP session state
const state = await this.sessionManager.create(params.cwd, params.mcpServers, model)
const sessionId = state.id
log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length })
const load = await this.loadSessionMode({
cwd: directory,
mcpServers: params.mcpServers,
@@ -600,9 +560,6 @@ export class Agent implements ACPAgent {
const messages = await this.loadSessionMessages(directory, sessionId)
this.restoreSessionStateFromMessages(sessionId, messages)
log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
const result = await this.loadSessionMode({
cwd: directory,
mcpServers: params.mcpServers,
@@ -610,7 +567,6 @@ export class Agent implements ACPAgent {
})
for (const msg of messages ?? []) {
log.debug("replay message", msg)
await this.processMessage(msg)
}
@@ -699,9 +655,6 @@ export class Agent implements ACPAgent {
const messages = await this.loadSessionMessages(directory, sessionId)
this.restoreSessionStateFromMessages(sessionId, messages)
log.info("fork_session", { sessionId, mcpServers: mcpServers.length })
const mode = await this.loadSessionMode({
cwd: directory,
mcpServers,
@@ -709,7 +662,6 @@ export class Agent implements ACPAgent {
})
for (const msg of messages ?? []) {
log.debug("replay message", msg)
await this.processMessage(msg)
}
@@ -738,9 +690,6 @@ export class Agent implements ACPAgent {
const messages = await this.loadSessionMessages(directory, sessionId, 20)
this.restoreSessionStateFromMessages(sessionId, messages)
log.info("resume_session", { sessionId, mcpServers: mcpServers.length })
const result = await this.loadSessionMode({
cwd: directory,
mcpServers,
@@ -773,17 +722,13 @@ export class Agent implements ACPAgent {
},
{ throwOnError: true },
)
.catch((error) => {
log.error("failed to abort session while closing ACP session", { error, sessionID: params.sessionId })
})
.catch((error) => {})
this.permissionQueues.delete(params.sessionId)
log.info("close_session", { sessionId: params.sessionId })
return {}
}
private async processMessage(message: SessionMessageResponse) {
log.debug("process message", message)
if (message.info.role !== "assistant" && message.info.role !== "user") return
const sessionId = message.info.sessionID
@@ -820,9 +765,7 @@ export class Agent implements ACPAgent {
...(runningContent.length > 0 && { content: runningContent }),
},
})
.catch((err) => {
log.error("failed to send tool in_progress to ACP", { error: err })
})
.catch((err) => {})
break
case "completed":
this.toolStarts.delete(part.callID)
@@ -849,11 +792,8 @@ export class Agent implements ACPAgent {
}),
},
})
.catch((err) => {
log.error("failed to send session update for todo", { error: err })
})
.catch((err) => {})
} else {
log.error("failed to parse todo output", { error: parsedTodos.failure })
}
}
@@ -871,9 +811,7 @@ export class Agent implements ACPAgent {
rawOutput: completedToolRawOutput(part),
},
})
.catch((err) => {
log.error("failed to send tool completed to ACP", { error: err })
})
.catch((err) => {})
break
case "error":
this.toolStarts.delete(part.callID)
@@ -903,9 +841,7 @@ export class Agent implements ACPAgent {
},
},
})
.catch((err) => {
log.error("failed to send tool error to ACP", { error: err })
})
.catch((err) => {})
break
}
} else if (part.type === "text") {
@@ -924,9 +860,7 @@ export class Agent implements ACPAgent {
},
},
})
.catch((err) => {
log.error("failed to send text to ACP", { error: err })
})
.catch((err) => {})
}
} else if (part.type === "file") {
// Replay file attachments as appropriate ACP content blocks.
@@ -952,9 +886,7 @@ export class Agent implements ACPAgent {
content: { type: "resource_link", uri: url, name: filename, mimeType: mime },
},
})
.catch((err) => {
log.error("failed to send resource_link to ACP", { error: err })
})
.catch((err) => {})
} else if (url.startsWith("data:")) {
// Embedded content - parse data URL and send as appropriate block type
const base64Match = url.match(/^data:([^;]+);base64,(.*)$/)
@@ -979,9 +911,7 @@ export class Agent implements ACPAgent {
},
},
})
.catch((err) => {
log.error("failed to send image to ACP", { error: err })
})
.catch((err) => {})
} else {
// Non-image: text types get decoded, binary types stay as blob
const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
@@ -1003,9 +933,7 @@ export class Agent implements ACPAgent {
content: { type: "resource", resource },
},
})
.catch((err) => {
log.error("failed to send resource to ACP", { error: err })
})
.catch((err) => {})
}
}
// URLs that don't match file:// or data: are skipped (unsupported)
@@ -1023,9 +951,7 @@ export class Agent implements ACPAgent {
},
},
})
.catch((err) => {
log.error("failed to send reasoning to ACP", { error: err })
})
.catch((err) => {})
}
}
}
@@ -1055,9 +981,7 @@ export class Agent implements ACPAgent {
rawInput: {},
},
})
.catch((error) => {
log.error("failed to send tool pending to ACP", { error })
})
.catch((error) => {})
}
private async loadAvailableModes(directory: string): Promise<ModeOption[]> {
@@ -1176,9 +1100,7 @@ export class Agent implements ACPAgent {
},
{ throwOnError: true },
)
.catch((error) => {
log.error("failed to add mcp server", { name: key, error })
})
.catch((error) => {})
}),
)
@@ -1400,9 +1322,6 @@ export class Agent implements ACPAgent {
break
}
}
log.info("parts", { parts })
const cmd = (() => {
const text = parts
.filter((p): p is { type: "text"; text: string } => p.type === "text")
@@ -1521,7 +1440,6 @@ export class Agent implements ACPAgent {
)
.then((x) => x.data)
.catch((error) => {
log.error("unexpected error when fetching message", { error })
return undefined
})
}
@@ -1673,7 +1591,6 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
return Provider.parseModel(cfg.model)
})
.catch((error) => {
log.error("failed to load user config for default model", { error })
return undefined
})
@@ -1681,7 +1598,6 @@ async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ provider
.providers({ directory }, { throwOnError: true })
.then((x) => x.data?.providers ?? [])
.catch((error) => {
log.error("failed to list providers for default model", { error })
return []
})
@@ -1728,7 +1644,6 @@ async function lastUsedModel(
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
.then((x) => x.data?.[0])
.catch((error) => {
log.error("failed to list sessions for default model", { error })
return undefined
})
if (!session) return
@@ -1737,7 +1652,6 @@ async function lastUsedModel(
.messages({ sessionID: session.id, directory, limit: 20 }, { throwOnError: true })
.then((x) => x.data?.findLast((message) => message.info.role === "user")?.info)
.catch((error) => {
log.error("failed to load session messages for default model", { error, sessionID: session.id })
return undefined
})
if (lastUser?.role !== "user") return
@@ -1792,7 +1706,6 @@ function parseUri(
function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
const result = applyPatch(fileOriginal, unifiedDiff)
if (result === false) {
log.error("Failed to apply unified diff (context mismatch)")
return undefined
}
return result
-9
View File
@@ -1,10 +1,6 @@
import { RequestError, type McpServer } from "@agentclientprotocol/sdk"
import type { ACPSessionState } from "./types"
import * as Log from "@opencode-ai/core/util/log"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
const log = Log.create({ service: "acp-session-manager" })
export class ACPSessionManager {
private sessions = new Map<string, ACPSessionState>()
private sdk: OpencodeClient
@@ -37,8 +33,6 @@ export class ACPSessionManager {
createdAt: new Date(),
model: resolvedModel,
}
log.info("creating_session", { state })
this.sessions.set(sessionId, state)
return state
}
@@ -68,8 +62,6 @@ export class ACPSessionManager {
createdAt: new Date(session.time.created),
model: resolvedModel,
}
log.info("loading_session", { state })
this.sessions.set(sessionId, state)
return state
}
@@ -77,7 +69,6 @@ export class ACPSessionManager {
get(sessionId: string): ACPSessionState {
const session = this.sessions.get(sessionId)
if (!session) {
log.error("session not found", { sessionId })
throw RequestError.invalidParams(JSON.stringify({ error: `Session not found: ${sessionId}` }))
}
return session
+23 -16
View File
@@ -1,6 +1,5 @@
import { Effect, Exit, Layer, PubSub, Scope, Context, Stream, Schema } from "effect"
import { EffectBridge } from "@/effect/bridge"
import * as Log from "@opencode-ai/core/util/log"
import { BusEvent } from "./bus-event"
import { GlobalBus } from "./global"
import { InstanceState } from "@/effect/instance-state"
@@ -9,9 +8,6 @@ import { serviceUse } from "@opencode-ai/core/effect/service-use"
import { Identifier } from "@/id/id"
import type { InstanceContext } from "@/project/instance-context"
import { InstanceRef } from "@/effect/instance-ref"
const log = Log.create({ service: "bus" })
type BusProperties<D extends BusEvent.Definition<string, Schema.Top>> = Schema.Schema.Type<D["properties"]>
export const InstanceDisposed = BusEvent.define(
@@ -101,7 +97,7 @@ export const layer = Layer.effect(
return Effect.gen(function* () {
const s = yield* InstanceState.get(state)
const payload: Payload = { id: options?.id ?? createID(), type: def.type, properties }
log.info("publishing", { type: def.type })
yield* Effect.logInfo("publishing").pipe(Effect.annotateLogs({ service: "bus", ...{ type: def.type } }))
const ps = s.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload)
@@ -124,26 +120,30 @@ export const layer = Layer.effect(
def: D,
): Effect.Effect<Stream.Stream<Payload<D>>, never, Scope.Scope> =>
Effect.gen(function* () {
log.info("subscribing", { type: def.type })
yield* Effect.logInfo("subscribing").pipe(Effect.annotateLogs({ service: "bus", ...{ type: def.type } }))
const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(s, def)
const subscription = yield* PubSub.subscribe(ps)
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: def.type })))
yield* Effect.addFinalizer(() =>
Effect.logInfo("unsubscribing").pipe(Effect.annotateLogs({ service: "bus", ...{ type: def.type } })),
)
return Stream.fromSubscription(subscription)
})
const subscribeAll = (): Effect.Effect<Stream.Stream<Payload>, never, Scope.Scope> =>
Effect.gen(function* () {
log.info("subscribing", { type: "*" })
yield* Effect.logInfo("subscribing").pipe(Effect.annotateLogs({ service: "bus", ...{ type: "*" } }))
const s = yield* InstanceState.get(state)
const subscription = yield* PubSub.subscribe(s.wildcard)
yield* Effect.addFinalizer(() => Effect.sync(() => log.info("unsubscribing", { type: "*" })))
yield* Effect.addFinalizer(() =>
Effect.logInfo("unsubscribing").pipe(Effect.annotateLogs({ service: "bus", ...{ type: "*" } })),
)
return Stream.fromSubscription(subscription)
})
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
return Effect.gen(function* () {
log.info("subscribing", { type })
yield* Effect.logInfo("subscribing").pipe(Effect.annotateLogs({ service: "bus", ...{ type } }))
const bridge = yield* EffectBridge.make()
const scope = yield* Scope.make()
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
@@ -153,18 +153,25 @@ export const layer = Layer.effect(
Stream.runForEach((msg) =>
Effect.tryPromise({
try: () => Promise.resolve().then(() => callback(msg)),
catch: (cause) => {
log.error("subscriber failed", { type, cause })
},
}).pipe(Effect.ignore),
catch: (cause) => cause,
}).pipe(
Effect.tapError((cause) =>
Effect.logError("subscriber failed").pipe(Effect.annotateLogs({ service: "bus", ...{ type, cause } })),
),
Effect.ignore,
),
),
Effect.forkScoped,
),
)
return () => {
log.info("unsubscribing", { type })
bridge.fork(Scope.close(scope, Exit.void))
bridge.fork(
Effect.logInfo("unsubscribing").pipe(
Effect.annotateLogs({ service: "bus", ...{ type } }),
Effect.andThen(Scope.close(scope, Exit.void)),
),
)
}
})
}
+2 -11
View File
@@ -1,4 +1,3 @@
import * as Log from "@opencode-ai/core/util/log"
import { Effect } from "effect"
import { effectCmd } from "../effect-cmd"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
@@ -9,10 +8,6 @@ import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ACPNextProfile } from "@/acp-next/profile"
const log = Log.create({ service: "acp-command" })
export const AcpCommand = effectCmd({
command: "acp",
describe: "start ACP (Agent Client Protocol) server",
@@ -24,13 +19,10 @@ export const AcpCommand = effectCmd({
})
},
handler: Effect.fn("Cli.acp")(function* (args) {
ACPNextProfile.mark("cli.acp.handler")
process.env.OPENCODE_CLIENT = "acp"
const flags = yield* RuntimeFlags.Service
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() =>
ACPNextProfile.measure("cli.acp.server.listen", () => Server.listen(opts)),
)
const server = yield* Effect.promise(() => Server.listen(opts))
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
@@ -64,11 +56,10 @@ export const AcpCommand = effectCmd({
const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk })
new AgentSideConnection((conn) => {
ACPNextProfile.mark("cli.acp.connection.create", { acpNext: flags.acpNext })
return agent.create(conn, { sdk })
}, stream)
log.info("setup connection")
yield* Effect.logInfo("setup connection").pipe(Effect.annotateLogs({ service: "acp-command" }))
process.stdin.resume()
yield* Effect.promise(
() =>
@@ -2,7 +2,6 @@ import { LSP } from "@/lsp/lsp"
import { Effect } from "effect"
import { effectCmd } from "../../effect-cmd"
import { cmd } from "../cmd"
import * as Log from "@opencode-ai/core/util/log"
import { EOL } from "os"
export const LSPCommand = cmd({
@@ -33,7 +32,6 @@ export const SymbolsCommand = effectCmd({
describe: "search workspace symbols",
builder: (yargs) => yargs.positional("query", { type: "string", demandOption: true }),
handler: Effect.fn("Cli.debug.lsp.symbols")(function* (args) {
using _ = Log.Default.time("symbols")
const results = yield* LSP.Service.use((lsp) => lsp.workspaceSymbol(args.query))
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}),
@@ -44,7 +42,6 @@ export const DocumentSymbolsCommand = effectCmd({
describe: "get symbols from a document",
builder: (yargs) => yargs.positional("uri", { type: "string", demandOption: true }),
handler: Effect.fn("Cli.debug.lsp.documentSymbols")(function* (args) {
using _ = Log.Default.time("document-symbols")
const results = yield* LSP.Service.use((lsp) => lsp.documentSymbol(args.uri))
process.stdout.write(JSON.stringify(results, null, 2) + EOL)
}),
@@ -1,6 +1,5 @@
import { EOL } from "os"
import { Project } from "@/project/project"
import * as Log from "@opencode-ai/core/util/log"
import { cmd } from "../cmd"
export const ScrapCommand = cmd({
@@ -8,9 +7,7 @@ export const ScrapCommand = cmd({
describe: "list all known projects",
builder: (yargs) => yargs,
async handler() {
const timer = Log.Default.time("scrap")
const list = await Project.list()
process.stdout.write(JSON.stringify(list, null, 2) + EOL)
timer.stop()
},
})
+1 -1
View File
@@ -1,7 +1,7 @@
// Dev-only JSONL event trace for direct interactive mode.
//
// Enable with OPENCODE_DIRECT_TRACE=1. Writes one JSON line per event to
// ~/.local/share/opencode/log/direct/<timestamp>-<pid>.jsonl. Also writes
// ~/.local/state/opencode/direct/<timestamp>-<pid>.jsonl. Also writes
// a latest.json pointer so you can quickly find the most recent trace.
//
// The trace captures the full closed loop: outbound prompts, inbound SDK
+2 -21
View File
@@ -41,7 +41,6 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
@@ -80,7 +79,8 @@ import {
import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant"
const appGlobalBindingCommands = [
const appBindingCommands = [
"command.palette.show",
"session.list",
"session.new",
"session.quick_switch.1",
@@ -92,10 +92,6 @@ const appGlobalBindingCommands = [
"session.quick_switch.7",
"session.quick_switch.8",
"session.quick_switch.9",
] as const
const appBindingCommands = [
"command.palette.show",
"model.list",
"model.cycle_recent",
"model.cycle_recent_reverse",
@@ -115,7 +111,6 @@ const appBindingCommands = [
"theme.mode.lock",
"help.show",
"docs.open",
"workspace.list",
"app.debug",
"app.console",
"app.heap_snapshot",
@@ -608,16 +603,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
{
name: "workspace.list",
title: "Manage workspaces",
category: "Workspace",
hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slashName: "workspaces",
run: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
@@ -944,10 +929,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
}))
useBindings(() => ({
bindings: tuiConfig.keybinds.gather("app.global", appGlobalBindingCommands),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: () => {
@@ -19,8 +19,6 @@ import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with {
import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" }
import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" }
import * as Log from "@opencode-ai/core/util/log"
type FocusState = "unknown" | "focused" | "blurred"
type AttentionRenderer = {
@@ -37,9 +35,6 @@ type RegisteredSoundPack = TuiAttentionSoundPack & {
type TuiAttentionHost = TuiAttention & {
dispose(): void
}
const log = Log.create({ service: "tui.attention" })
const DEFAULT_TITLE = "opencode"
const DEFAULT_PACK_ID = "opencode.default"
const KV_SOUND_PACK = "attention_sound_pack"
@@ -154,7 +149,6 @@ export function createTuiAttention(input: {
try {
for (const file of soundCandidates(name)) {
const current = await audio.loadSoundFile(file).catch((error) => {
log.debug("failed to load attention sound", { file, error })
return null
})
if (disposed) return false
@@ -163,7 +157,6 @@ export function createTuiAttention(input: {
}
return false
} catch (error) {
log.debug("failed to play attention sound", { error })
return false
}
}
@@ -189,7 +182,6 @@ export function createTuiAttention(input: {
normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT),
)
} catch (error) {
log.debug("failed to trigger attention notification", { error })
return false
}
})()
@@ -212,7 +204,6 @@ export function createTuiAttention(input: {
sound,
}
} catch (error) {
log.debug("failed to handle attention notification", { error })
return {
ok: false,
notification: false,
@@ -1,112 +0,0 @@
import type { Workspace } from "@opencode-ai/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useProject } from "@tui/context/project"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { createMemo, createSignal, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
type WorkspaceOption = { workspace: Workspace }
export function DialogWorkspaceList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const project = useProject()
const { theme } = useTheme()
const [deleting, setDeleting] = createSignal<string>()
const [removing, setRemoving] = createSignal<string>()
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
const current = createMemo(() => {
if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID
return project.workspace.current()
})
const options = createMemo<DialogSelectOption<WorkspaceOption>[]>(() =>
project.workspace
.list()
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((workspace) => {
const status = project.workspace.status(workspace.id)
return {
title:
removing() === workspace.id
? "Deleting..."
: deleting() === workspace.id
? `Delete ${workspace.name}? Press delete again`
: workspace.name,
value: { workspace },
footer: workspace.type,
details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined,
gutter: () => <text fg={status === "connected" ? theme.success : theme.error}></text>,
}
}),
)
function showDetails(workspace: Workspace) {
setExpanded(workspace.id, (open) => !open)
}
async function remove(workspace: Workspace) {
if (removing()) return
if (deleting() !== workspace.id) {
setDeleting(workspace.id)
return
}
setDeleting(undefined)
setRemoving(workspace.id)
const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({
error: err,
}))
if (result?.error) {
setRemoving(undefined)
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return
}
if (current() === workspace.id) {
project.workspace.set(undefined)
route.navigate({ type: "home" })
}
await project.workspace.sync()
await sync.bootstrap({ fatal: false }).catch(() => undefined)
setRemoving(undefined)
}
onMount(() => {
dialog.setSize("large")
void sdk.client.experimental.workspace.syncList().catch(() => undefined)
void project.workspace.sync()
})
return (
<DialogSelect
title="Workspaces"
options={options()}
onMove={(option) => {
setDeleting(undefined)
}}
onSelect={(option) => showDetails(option.value.workspace)}
actions={[
{
command: "session.delete",
title: "delete",
onTrigger: (option) => void remove(option.value.workspace),
},
]}
/>
)
}
@@ -512,10 +512,7 @@ export function Prompt(props: PromptProps) {
const content = await Editor.open({
value,
renderer,
cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() ||
process.cwd(),
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
})
if (!content) return
@@ -6,11 +6,7 @@ import { DiffStyle, ScrollAcceleration, ScrollSpeed } from "./tui-schema"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import * as ConfigPaths from "@/config/paths"
const log = Log.create({ service: "tui.migrate" })
const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
const decodeTheme = Schema.decodeUnknownOption(Schema.String)
@@ -33,7 +29,6 @@ export async function migrateTuiConfig(input: MigrateInput) {
const opencode = await opencodeFiles(input)
for (const file of opencode) {
const source = await Filesystem.readText(file).catch((error) => {
log.warn("failed to read config for tui migration", { path: file, error })
return undefined
})
if (!source) continue
@@ -66,17 +61,14 @@ export async function migrateTuiConfig(input: MigrateInput) {
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 })
return false
})
if (!wrote) continue
const stripped = await backupAndStripLegacy(file, source)
if (!stripped) {
log.warn("tui config migrated but source file was not stripped", { from: file, to: target })
continue
}
log.info("migrated tui config", { from: file, to: target })
}
}
@@ -107,7 +99,6 @@ async function backupAndStripLegacy(file: string, source: string) {
: await Filesystem.write(backup, source)
.then(() => true)
.catch((error) => {
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
return false
})
if (!backed) return false
@@ -125,11 +116,9 @@ async function backupAndStripLegacy(file: string, source: string) {
return Filesystem.write(file, text)
.then(() => {
log.info("stripped tui keys from server config", { path: file, backup })
return true
})
.catch((error) => {
log.warn("failed to strip legacy tui keys from server config", { path: file, backup, error })
return false
})
}
@@ -18,15 +18,11 @@ import { TuiKeybind } from "./keybind"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import { Filesystem } from "@/util/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@opencode-ai/core/npm"
import type { DeepMutable } from "@opencode-ai/core/schema"
import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
import { FormatError, FormatUnknownError } from "@/cli/error"
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
@@ -84,12 +80,6 @@ function dropUnknownKeybinds(input: Record<string, unknown>, configFilepath: str
const invalid = TuiKeybind.unknownKeys(input.keybinds)
if (!invalid.length) return input
log.warn("ignored unknown tui keybinds", {
path: configFilepath,
keybinds: invalid,
hint: "Remove these entries or rename them to keys from the tui.json schema.",
})
return {
...input,
keybinds: Object.fromEntries(Object.entries(input.keybinds).filter(([key]) => !invalid.includes(key))),
@@ -138,10 +128,6 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
Effect.sync(() => {
const error = Cause.squash(cause)
const reason = FormatError(error) ?? FormatUnknownError(error)
log.warn("skipping invalid tui config", {
path: configFilepath,
reason,
})
return {} as Info
}),
),
@@ -157,16 +143,14 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
Effect.sync(() => {
const error = Cause.squash(cause)
const reason = FormatError(error) ?? FormatUnknownError(error)
log.warn("failed to read tui config", {
path: filepath,
reason,
})
return undefined
}),
),
)
if (!text) return {} as Info
log.info("loading tui config", { path: filepath })
yield* Effect.logInfo("loading tui config").pipe(
Effect.annotateLogs({ service: "tui.config", ...{ path: filepath } }),
)
return yield* load(text, filepath)
})
@@ -175,7 +159,9 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
const data = yield* loadFile(file)
if (Object.keys(data).length) {
appliedOrder += 1
log.info("applying tui config", { path: file, order: appliedOrder })
yield* Effect.logInfo("applying tui config").pipe(
Effect.annotateLogs({ service: "tui.config", ...{ path: file, order: appliedOrder } }),
)
}
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
@@ -210,7 +196,9 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
if (Flag.OPENCODE_TUI_CONFIG) {
const configFile = Flag.OPENCODE_TUI_CONFIG
yield* mergeFile(acc, configFile)
log.debug("loaded custom tui config", { path: configFile })
yield* Effect.logDebug("loaded custom tui config").pipe(
Effect.annotateLogs({ service: "tui.config", ...{ path: configFile } }),
)
}
// 3. Project tui files, applied root-first so the closest file wins.
@@ -28,7 +28,6 @@ import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import * as Log from "@opencode-ai/core/util/log"
import { emptyConsoleState, type ConsoleState } from "@/config/console-state"
import path from "path"
import { useKV } from "./kv"
@@ -465,11 +464,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
})
.catch(async (e) => {
Log.Default.error("tui bootstrap failed", {
error: e instanceof Error ? e.message : String(e),
name: e instanceof Error ? e.name : undefined,
stack: e instanceof Error ? e.stack : undefined,
})
if (fatal) {
await exit(e)
} else {
@@ -14,7 +14,6 @@ import {
import path from "path"
import { fileURLToPath } from "url"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import * as Log from "@opencode-ai/core/util/log"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
import { resolveAttentionSoundPaths } from "../config/tui-schema"
@@ -116,8 +115,6 @@ type RuntimeState = {
pending: Map<string, ConfigPlugin.Origin>
dispose_timeout_ms: number
}
const log = Log.create({ service: "tui.plugin" })
const DISPOSE_TIMEOUT_MS = 5000
const KV_KEY = "plugin_enabled"
const EMPTY_TUI: TuiPluginModule = {
@@ -126,19 +123,16 @@ const EMPTY_TUI: TuiPluginModule = {
function fail(message: string, data: Record<string, unknown>) {
if (!("error" in data)) {
log.error(message, data)
console.error(`[tui.plugin] ${message}`, data)
return
}
const text = `${message}: ${errorMessage(data.error)}`
const next = { ...data, error: errorData(data.error) }
log.error(text, next)
console.error(`[tui.plugin] ${text}`, next)
}
function warn(message: string, data: Record<string, unknown>) {
log.warn(message, data)
console.warn(`[tui.plugin] ${message}`, data)
}
@@ -273,15 +267,7 @@ function createThemeInstaller(
await Flock.withLock(`tui-theme:${dest}`, async () => {
const save = async () => {
plugin.themes[name] = info
await PluginMeta.setTheme(plugin.id, name, info).catch((error) => {
log.warn("failed to track tui plugin theme", {
path: spec,
id: plugin.id,
theme: src,
dest,
error,
})
})
await PluginMeta.setTheme(plugin.id, name, info).catch((error) => {})
}
const exists = hasTheme(name)
@@ -297,7 +283,6 @@ function createThemeInstaller(
}
const text = await Filesystem.readText(src).catch((error) => {
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
return
})
if (text === undefined) return
@@ -306,27 +291,21 @@ function createThemeInstaller(
const data = await Promise.resolve(text)
.then((x) => JSON.parse(x))
.catch((error) => {
log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
return fail
})
if (data === fail) return
if (!isTheme(data)) {
log.warn("invalid tui plugin theme", { path: spec, theme: src })
return
}
if (exists || !(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text).catch((error) => {
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
})
await Filesystem.write(dest, text).catch((error) => {})
}
upsertTheme(name, data)
await save()
}).catch((error) => {
log.warn("failed to lock tui plugin theme install", { path: spec, theme: src, dest, error })
})
}).catch((error) => {})
}
}
@@ -689,9 +668,7 @@ async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => P
items: list,
kind: "tui",
wait: async () => {
await wait().catch((error) => {
log.warn("failed waiting for tui plugin dependencies", { error })
})
await wait().catch((error) => {})
},
finish: async (loaded, origin, retry) => {
const mod = await Promise.resolve()
@@ -762,9 +739,7 @@ async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => P
}
},
report: {
start(candidate, retry) {
log.info("loading tui plugin", { path: candidate.plan.spec, retry })
},
start(candidate, retry) {},
missing(candidate, retry, message) {
warn("tui plugin has no entrypoint", { path: candidate.plan.spec, retry, message })
},
@@ -798,7 +773,6 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
id: item.id,
})),
).catch((error) => {
log.warn("failed to track tui plugins", { error })
return undefined
})
@@ -809,14 +783,6 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
if (!entry) continue
const hit = meta?.[i]
if (hit && hit.state !== "same") {
log.info("tui plugin metadata updated", {
path: entry.spec,
retry: entry.retry,
state: hit.state,
source: hit.entry.source,
version: hit.entry.version,
modified: hit.entry.modified,
})
}
const info = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
@@ -1093,11 +1059,9 @@ async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: ()
)
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
for (const item of internalTuiPlugins(flags)) {
log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, {
@@ -54,7 +54,6 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor"
import { useDialog } from "../../ui/dialog"
import { DialogAlert } from "../../ui/dialog-alert"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
import type { PromptInfo } from "../../component/prompt/history"
@@ -134,6 +133,12 @@ const sessionBindingCommands = [
"session.toggle.actions",
"session.toggle.scrollbar",
"session.toggle.generic_tool_output",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
"session.last",
"session.messages_last_user",
@@ -148,17 +153,6 @@ const sessionBindingCommands = [
"session.child.previous",
] as const
const sessionGlobalBindingCommands = [
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
] as const
const sessionGlobalUnfocusedBindingCommands = ["session.first", "session.last"] as const
const context = createContext<{
width: number
sessionID: string
@@ -415,19 +409,15 @@ export function Session() {
const local = useLocal()
function enterChild(sessionID: string) {
navigate({
type: "session",
sessionID,
})
const status = sync.data.session_status[sessionID]
if (status?.type === "retry") void DialogAlert.show(dialog, "Retry Error", status.message)
}
function moveFirstChild() {
if (children().length === 1) return
const next = children().find((x) => !!x.parentID)
if (next) enterChild(next.id)
if (next) {
navigate({
type: "session",
sessionID: next.id,
})
}
}
function moveChild(direction: number) {
@@ -438,7 +428,12 @@ export function Session() {
if (next >= sessions.length) next = 0
if (next < 0) next = sessions.length - 1
if (sessions[next]) enterChild(sessions[next].id)
if (sessions[next]) {
navigate({
type: "session",
sessionID: sessions[next].id,
})
}
}
function childSessionHandler(func: () => void) {
@@ -973,10 +968,7 @@ export function Session() {
await Editor.open({
value: transcript,
renderer,
cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() ||
process.cwd(),
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
})
} else {
const exportDir = process.cwd()
@@ -989,10 +981,7 @@ export function Session() {
const result = await Editor.open({
value: transcript,
renderer,
cwd:
(project.instance.path().worktree === "/" ? undefined : project.instance.path().worktree) ||
project.instance.directory() ||
process.cwd(),
cwd: project.instance.path().worktree || project.instance.directory() || process.cwd(),
})
if (result !== undefined) {
await Filesystem.write(filepath, result)
@@ -1012,8 +1001,8 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
dialog.clear()
moveFirstChild()
dialog.clear()
},
},
{
@@ -1040,8 +1029,8 @@ export function Session() {
hidden: true,
enabled: !!session()?.parentID,
run: childSessionHandler(() => {
dialog.clear()
moveChild(1)
dialog.clear()
}),
},
{
@@ -1051,8 +1040,8 @@ export function Session() {
hidden: true,
enabled: !!session()?.parentID,
run: childSessionHandler(() => {
dialog.clear()
moveChild(-1)
dialog.clear()
}),
},
])
@@ -1072,15 +1061,6 @@ export function Session() {
commands: sessionCommands(),
}))
useBindings(() => ({
bindings: tuiConfig.keybinds.gather("session.global", sessionGlobalBindingCommands),
}))
useBindings(() => ({
enabled: () => renderer.currentFocusedEditor === null,
bindings: tuiConfig.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
@@ -1772,7 +1752,6 @@ function GenericTool(props: ToolProps<any>) {
function InlineTool(props: {
icon: string
iconColor?: RGBA
color?: RGBA
complete: any
pending: string
spinner?: boolean
@@ -1794,7 +1773,6 @@ function InlineTool(props: {
})
const fg = createMemo(() => {
if (props.color) return props.color
if (permission()) return theme.warning
if (hover() && props.onClick) return theme.text
if (props.complete) return theme.textMuted
@@ -2079,10 +2057,8 @@ function WebSearch(props: ToolProps<typeof WebSearchTool>) {
}
function Task(props: ToolProps<typeof TaskTool>) {
const { theme } = useTheme()
const { navigate } = useRoute()
const sync = useSync()
const dialog = useDialog()
onMount(() => {
if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length)
@@ -2104,11 +2080,6 @@ function Task(props: ToolProps<typeof TaskTool>) {
)
const isRunning = createMemo(() => props.part.state.status === "running")
const retry = createMemo(() => {
const status = sync.data.session_status[props.metadata.sessionId ?? ""]
if (status?.type !== "retry") return
return status
})
const duration = createMemo(() => {
const first = messages().find((x) => x.role === "user")?.time.created
@@ -2123,10 +2094,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
props.metadata.background === true ? `${props.input.description} (background)` : props.input.description
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`]
const retrying = retry()
if (isRunning() && retrying) {
content.push(`${Locale.truncate(retrying.message, 80)} [retrying attempt #${retrying.attempt}]`)
} else if (isRunning() && tools().length > 0) {
if (isRunning() && tools().length > 0) {
// content[0] += ` · ${tools().length} toolcalls`
if (current()) {
const state = current()!.state
@@ -2149,7 +2117,6 @@ function Task(props: ToolProps<typeof TaskTool>) {
return (
<InlineTool
icon="│"
color={retry() ? theme.error : undefined}
spinner={isRunning()}
complete={props.input.description}
pending="Delegating..."
@@ -2158,8 +2125,6 @@ function Task(props: ToolProps<typeof TaskTool>) {
if (props.metadata.sessionId) {
navigate({ type: "session", sessionID: props.metadata.sessionId })
}
const status = retry()
if (status) void DialogAlert.show(dialog, "Retry Error", status.message)
}}
>
{content()}
+4 -23
View File
@@ -4,7 +4,6 @@ import { type rpc } from "./worker"
import path from "path"
import { fileURLToPath } from "url"
import { UI } from "@/cli/ui"
import * as Log from "@opencode-ai/core/util/log"
import { errorMessage } from "@/util/error"
import { withTimeout } from "@/util/timeout"
import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network"
@@ -146,26 +145,12 @@ export const TuiThreadCommand = cmd({
const worker = new Worker(file, {
env,
})
worker.onerror = (e) => {
Log.Default.error("thread error", {
message: e.message,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
error: e.error,
})
}
worker.onerror = () => {}
const client = Rpc.client<typeof rpc>(worker)
const error = (e: unknown) => {
Log.Default.error("process error", { error: errorMessage(e) })
}
const error = () => {}
const reload = () => {
client.call("reload", undefined).catch((err) => {
Log.Default.warn("worker reload failed", {
error: errorMessage(err),
})
})
client.call("reload", undefined).catch(() => {})
}
process.on("uncaughtException", error)
process.on("unhandledRejection", error)
@@ -178,11 +163,7 @@ export const TuiThreadCommand = cmd({
process.off("uncaughtException", error)
process.off("unhandledRejection", error)
process.off("SIGUSR2", reload)
await withTimeout(client.call("shutdown", undefined), 5000).catch((error) => {
Log.Default.warn("worker shutdown failed", {
error: errorMessage(error),
})
})
await withTimeout(client.call("shutdown", undefined), 5000).catch(() => {})
worker.terminate()
}
@@ -51,7 +51,6 @@ export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
details?: string[]
footer?: JSX.Element | string
category?: string
categoryView?: JSX.Element
@@ -168,7 +167,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (!category) return acc
return acc + (i > 0 ? 2 : 1)
}, 0)
return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers)
return flat().length + headers
})
const dimensions = useTerminalDimensions()
@@ -427,7 +426,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return (
<box
id={JSON.stringify(option.value)}
flexDirection="column"
flexDirection="row"
position="relative"
onMouseMove={() => {
setStore("input", "mouse")
@@ -447,37 +446,24 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (index === -1) return
moveTo(index)
}}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={current() || option.gutter ? 1 : 3}
paddingRight={3}
gap={1}
>
<box
flexDirection="row"
paddingLeft={current() || option.gutter ? 1 : 3}
paddingRight={3}
gap={1}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
>
<Show when={!current() && option.margin}>
<box position="absolute" left={1} flexShrink={0}>
{option.margin}
</box>
</Show>
<Option
title={option.title}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
gutter={option.gutter}
/>
</box>
<For each={option.details}>
{(detail) => (
<box paddingLeft={3} paddingRight={3}>
<text fg={theme.textMuted} wrapMode="none">
{Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))}
</text>
</box>
)}
</For>
<Show when={!current() && option.margin}>
<box position="absolute" left={1} flexShrink={0}>
{option.margin}
</box>
</Show>
<Option
title={option.title}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
gutter={option.gutter}
/>
</box>
)
}}
@@ -1,8 +1,4 @@
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "tui.audio" })
let audio: Audio | null | undefined
const sounds = new Map<string, Promise<AudioSound | null>>()
@@ -10,13 +6,10 @@ function getAudio() {
if (audio !== undefined) return audio
try {
const next = Audio.create({ autoStart: false })
next.on("error", (error: Error, context: AudioErrorContext) => {
log.debug("tui audio error", { error, context })
})
next.on("error", (error: Error, context: AudioErrorContext) => {})
audio = next
return next
} catch (error) {
log.debug("failed to create tui audio", { error })
audio = null
return null
}
@@ -31,7 +24,6 @@ export function loadSoundFile(file: string) {
.bytes()
.then((bytes) => current.loadSound(bytes))
.catch((error) => {
log.debug("failed to load tui sound", { file, error })
return null
})
sounds.set(file, task)
+1 -24
View File
@@ -1,6 +1,5 @@
import { Installation } from "@/installation"
import { Server } from "@/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { InstanceRuntime } from "@/project/instance-runtime"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
@@ -15,30 +14,10 @@ import { Effect } from "effect"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
ensureProcessMetadata("worker")
await Log.init({
print: process.argv.includes("--print-logs"),
dev: Installation.isLocal(),
level: (() => {
if (Installation.isLocal()) return "DEBUG"
return "INFO"
})(),
})
if (!process.env.OPENCODE_LOG_LEVEL) process.env.OPENCODE_LOG_LEVEL = Installation.isLocal() ? "DEBUG" : "INFO"
Heap.start()
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
e: e instanceof Error ? e.message : e,
})
})
process.on("uncaughtException", (e) => {
Log.Default.error("exception", {
e: e instanceof Error ? e.message : e,
})
})
// Subscribe to global events and forward them via RPC
GlobalBus.on("event", (event) => {
Rpc.emit("global.event", event)
@@ -89,8 +68,6 @@ export const rpc = {
)
},
async shutdown() {
Log.Default.info("worker shutting down")
await InstanceRuntime.disposeAllInstances()
if (server) await server.stop(true)
},
+1 -15
View File
@@ -2,9 +2,6 @@ import path from "path"
import { writeHeapSnapshot } from "node:v8"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "heap" })
const MINUTE = 60_000
const LIMIT = 2 * 1024 * 1024 * 1024
@@ -32,20 +29,9 @@ export function start() {
Global.Path.log,
`heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`,
)
log.warn("heap usage exceeded limit", {
rss: stat.rss,
heap: stat.heapUsed,
file,
})
await Promise.resolve()
.then(() => writeHeapSnapshot(file))
.catch((err) => {
log.error("failed to write heap snapshot", {
error: err instanceof Error ? err.message : String(err),
file,
})
})
.catch((err) => {})
lock = false
}
-6
View File
@@ -3,16 +3,12 @@ export * as ConfigAgent from "./agent"
import path from "path"
import { Exit, Schema, SchemaGetter } from "effect"
import { PositiveInt } from "@opencode-ai/core/schema"
import * as Log from "@opencode-ai/core/util/log"
import { Glob } from "@opencode-ai/core/util/glob"
import { configEntryNameFromPath } from "./entry-name"
import * as ConfigMarkdown from "./markdown"
import { ConfigModelID } from "./model-id"
import { ConfigParse } from "./parse"
import { ConfigPermission } from "./permission"
const log = Log.create({ service: "config" })
const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
@@ -112,7 +108,6 @@ export async function load(dir: string) {
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch((err) => {
log.error("failed to load agent", { agent: item, err })
return undefined
})
if (!md) continue
@@ -138,7 +133,6 @@ export async function loadMode(dir: string) {
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch((err) => {
log.error("failed to load mode", { mode: item, err })
return undefined
})
if (!md) continue
-5
View File
@@ -1,16 +1,12 @@
export * as ConfigCommand from "./command"
import path from "path"
import * as Log from "@opencode-ai/core/util/log"
import { Cause, Exit, Schema } from "effect"
import { Glob } from "@opencode-ai/core/util/glob"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
import { ConfigModelID } from "./model-id"
const log = Log.create({ service: "config" })
export const Info = Schema.Struct({
template: Schema.String,
description: Schema.optional(Schema.String),
@@ -32,7 +28,6 @@ export async function load(dir: string) {
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch((err) => {
log.error("failed to load command", { command: item, err })
return undefined
})
if (!md) continue
+35 -23
View File
@@ -1,4 +1,3 @@
import * as Log from "@opencode-ai/core/util/log"
import { serviceUse } from "@opencode-ai/core/effect/service-use"
import path from "path"
import { pathToFileURL } from "url"
@@ -43,9 +42,6 @@ import { ConfigSkills } from "./skills"
import { ConfigVariable } from "./variable"
import { Npm } from "@opencode-ai/core/npm"
import { withTransientReadRetry } from "@/util/effect-http-client"
const log = Log.create({ service: "config" })
// Custom merge function that concatenates array fields instead of replacing them
// Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here.
function mergeConfig(target: Info, source: Info): Info {
@@ -68,7 +64,6 @@ function normalizeLoadedConfig(data: unknown, source: string) {
delete copy.theme
delete copy.keybinds
delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
return copy
}
@@ -434,7 +429,7 @@ export const layer = Layer.effect(
})
const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) {
log.info("loading", { path: filepath })
yield* Effect.logInfo("loading").pipe(Effect.annotateLogs({ service: "config", ...{ path: filepath } }))
const text = yield* readConfigFile(filepath)
if (!text) return {} as Info
return yield* loadConfig(text, { path: filepath }, env)
@@ -478,7 +473,11 @@ export const layer = Layer.effect(
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
loadGlobal().pipe(
Effect.tapError((error) =>
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
Effect.sync(() =>
Effect.logError("failed to load global config, using defaults").pipe(
Effect.annotateLogs({ service: "config", ...{ error: String(error) } }),
),
),
),
Effect.orElseSucceed((): Info => ({})),
),
@@ -554,7 +553,9 @@ export const layer = Layer.effect(
const url = key.replace(/\/+$/, "")
authEnv[value.key] = value.token
const wellknownURL = `${url}/.well-known/opencode`
log.debug("fetching remote config", { url: wellknownURL })
yield* Effect.logDebug("fetching remote config").pipe(
Effect.annotateLogs({ service: "config", ...{ url: wellknownURL } }),
)
const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, WellKnownConfig)
const remote = yield* Effect.promise(() =>
substituteWellKnownRemoteConfig({
@@ -566,7 +567,9 @@ export const layer = Layer.effect(
)
const fetchedConfig = remote
? yield* Effect.gen(function* () {
log.debug("fetching remote config", { url: remote.url })
yield* Effect.logDebug("fetching remote config").pipe(
Effect.annotateLogs({ service: "config", ...{ url: remote.url } }),
)
const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json)
if (isRecord(data) && isRecord(data.config)) return data.config
if (isRecord(data)) return data
@@ -587,7 +590,9 @@ export const layer = Layer.effect(
authEnv,
)
yield* merge(source, next, "global")
log.debug("loaded remote config from well-known", { url })
yield* Effect.logDebug("loaded remote config from well-known").pipe(
Effect.annotateLogs({ service: "config", ...{ url } }),
)
}
}
@@ -596,7 +601,9 @@ export const layer = Layer.effect(
if (Flag.OPENCODE_CONFIG) {
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
yield* Effect.logDebug("loaded custom config").pipe(
Effect.annotateLogs({ service: "config", path: Flag.OPENCODE_CONFIG }),
)
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
@@ -612,7 +619,9 @@ export const layer = Layer.effect(
const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree)
if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
yield* Effect.logDebug("loading config from OPENCODE_CONFIG_DIR").pipe(
Effect.annotateLogs({ service: "config", path: Flag.OPENCODE_CONFIG_DIR }),
)
}
const deps: Fiber.Fiber<void>[] = []
@@ -621,7 +630,7 @@ export const layer = Layer.effect(
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
yield* Effect.logDebug(`loading config from ${source}`).pipe(Effect.annotateLogs({ service: "config" }))
yield* merge(source, yield* loadFile(source, authEnv))
result.agent ??= {}
result.mode ??= {}
@@ -644,9 +653,9 @@ export const layer = Layer.effect(
Effect.exit,
Effect.tap((exit) =>
Exit.isFailure(exit)
? Effect.sync(() => {
log.warn("background dependency install failed", { dir, error: String(exit.cause) })
})
? Effect.logWarning("background dependency install failed").pipe(
Effect.annotateLogs({ service: "config", dir, error: String(exit.cause) }),
)
: Effect.void,
),
Effect.asVoid,
@@ -657,7 +666,7 @@ export const layer = Layer.effect(
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir)))
result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir)))
// Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load
// Auto-discovered plugins under `.opencode/plugin(s }))` are already local files, so ConfigPlugin.load
// returns normalized Specs and we only need to attach origin metadata here.
const list = yield* Effect.promise(() => ConfigPlugin.load(dir))
yield* mergePluginOrigins(dir, list)
@@ -670,7 +679,9 @@ export const layer = Layer.effect(
source,
})
yield* merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
yield* Effect.logDebug("loaded custom config from OPENCODE_CONFIG_CONTENT").pipe(
Effect.annotateLogs({ service: "config" }),
)
}
const activeAccount = Option.getOrUndefined(
@@ -704,9 +715,6 @@ export const layer = Layer.effect(
}).pipe(
Effect.withSpan("Config.loadActiveOrgConfig"),
Effect.catch((err) => {
log.debug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
})
return Effect.void
}),
)
@@ -745,7 +753,9 @@ export const layer = Layer.effect(
try {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
} catch (err) {
log.warn("OPENCODE_PERMISSION contains invalid JSON, skipping", { err })
yield* Effect.logWarning("OPENCODE_PERMISSION contains invalid JSON, skipping").pipe(
Effect.annotateLogs({ service: "config", ...{ err } }),
)
}
}
@@ -766,7 +776,9 @@ export const layer = Layer.effect(
try {
result.username = os.userInfo().username || "user"
} catch (err) {
log.warn("failed to read system username, using fallback", { err })
yield* Effect.logWarning("failed to read system username, using fallback").pipe(
Effect.annotateLogs({ service: "config", ...{ err } }),
)
result.username = "user"
}
}
-7
View File
@@ -3,11 +3,7 @@ export * as ConfigManaged from "./managed"
import { existsSync } from "fs"
import os from "os"
import path from "path"
import * as Log from "@opencode-ai/core/util/log"
import { Process } from "@/util/process"
const log = Log.create({ service: "config" })
const MANAGED_PLIST_DOMAIN = "ai.opencode.managed"
// Keys injected by macOS/MDM into the managed plist that are not OpenCode config
@@ -50,7 +46,6 @@ export async function readManagedPreferences() {
try {
return os.userInfo().username || "user"
} catch (err) {
log.warn("failed to read system username, using fallback", { err })
return "user"
}
})()
@@ -61,10 +56,8 @@ export async function readManagedPreferences() {
for (const plist of paths) {
if (!existsSync(plist)) continue
log.info("reading macOS managed preferences", { path: plist })
const result = await Process.run(["plutil", "-convert", "json", "-o", "-", plist], { nothrow: true })
if (result.code !== 0) {
log.warn("failed to convert managed preferences plist", { path: plist })
continue
}
return {
+2 -9
View File
@@ -90,20 +90,13 @@ export const Info = Schema.Struct({
description: "Enable promptCacheKey for this provider (default false)",
}),
timeout: Schema.optional(
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
}),
).annotate({
description: "Timeout in milliseconds for full requests to this provider. Set to false to disable timeout.",
}),
headerTimeout: Schema.optional(
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
description:
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
}),
).annotate({
description:
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
}),
chunkTimeout: Schema.optional(PositiveInt).annotate({
description:
+151 -127
View File
@@ -12,7 +12,6 @@ import { Auth } from "@/auth"
import { SyncEvent } from "@/sync"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import * as Log from "@opencode-ai/core/util/log"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProjectID } from "@/project/schema"
import { Slug } from "@opencode-ai/core/util/slug"
@@ -76,9 +75,6 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
Effect.sync(() => Database.use(fn))
const log = Log.create({ service: "workspace-sync" })
export const CreateInput = Schema.Struct({
id: Schema.optional(WorkspaceID),
type: Info.fields.type,
@@ -292,24 +288,22 @@ export const layer = Layer.effect(
return yield* store.provide({ directory: target.directory }, input.local())
}
const response = yield* http.execute(input.remote({ workspace, target })).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.warn("workspace target request failed", {
workspaceID: workspace.id,
error: errorData(error),
})
}),
),
)
const response = yield* http
.execute(input.remote({ workspace, target }))
.pipe(Effect.catch((error) => Effect.sync(() => {})))
if (!response) return input.fallback
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("")))
log.warn("workspace target request failed", {
workspaceID: workspace.id,
status: response.status,
body,
})
yield* Effect.logWarning("workspace target request failed").pipe(
Effect.annotateLogs({
service: "workspace-sync",
...{
workspaceID: workspace.id,
status: response.status,
body,
},
}),
)
return input.fallback
}
@@ -318,10 +312,6 @@ export const layer = Layer.effect(
Effect.map((result) => result as A),
Effect.catch((error) =>
Effect.sync(() => {
log.warn("workspace target response decode failed", {
workspaceID: workspace.id,
error: errorData(error),
})
return input.fallback
}),
),
@@ -349,11 +339,16 @@ export const layer = Layer.effect(
)
: {}
log.info("syncing workspace history", {
workspaceID: space.id,
sessions: sessionIDs.length,
known: Object.keys(state).length,
})
yield* Effect.logInfo("syncing workspace history").pipe(
Effect.annotateLogs({
service: "workspace-sync",
...{
workspaceID: space.id,
sessions: sessionIDs.length,
known: Object.keys(state).length,
},
}),
)
const response = yield* http.execute(
HttpClientRequest.post(route(url, "/sync/history"), {
@@ -373,10 +368,15 @@ export const layer = Layer.effect(
const events = (yield* response.json) as HistoryEvent[]
log.info("workspace history synced", {
workspaceID: space.id,
events: events.length,
})
yield* Effect.logInfo("workspace history synced").pipe(
Effect.annotateLogs({
service: "workspace-sync",
...{
workspaceID: space.id,
events: events.length,
},
}),
)
yield* Effect.forEach(
events,
@@ -405,7 +405,9 @@ export const layer = Layer.effect(
let attempt = 0
while (true) {
log.info("connecting to global sync", { workspace: space.name })
yield* Effect.logInfo("connecting to global sync").pipe(
Effect.annotateLogs({ service: "workspace-sync", ...{ workspace: space.name } }),
)
setStatus(space.id, "connecting")
const stream = yield* connectSSE(target.url, target.headers).pipe(
@@ -413,10 +415,6 @@ export const layer = Layer.effect(
Effect.catch((err) =>
Effect.sync(() => {
setStatus(space.id, "error")
log.info("failed to connect to global sync", {
workspace: space.name,
err,
})
return null
}),
),
@@ -425,7 +423,9 @@ export const layer = Layer.effect(
if (stream) {
attempt = 0
log.info("global sync connected", { workspace: space.name })
yield* Effect.logInfo("global sync connected").pipe(
Effect.annotateLogs({ service: "workspace-sync", ...{ workspace: space.name } }),
)
setStatus(space.id, "connected")
yield* parseSSE(stream, (evt) =>
@@ -439,10 +439,6 @@ export const layer = Layer.effect(
Effect.as(false),
Effect.catchCause((error) =>
Effect.sync(() => {
log.info("failed to replay global event", {
workspaceID: space.id,
error,
})
return true
}),
),
@@ -459,15 +455,22 @@ export const layer = Layer.effect(
payload: event.payload,
})
} catch (error) {
log.info("failed to replay global event", {
workspaceID: space.id,
error,
})
yield* Effect.logInfo("failed to replay global event").pipe(
Effect.annotateLogs({
service: "workspace-sync",
...{
workspaceID: space.id,
error,
},
}),
)
}
}),
)
log.info("disconnected from global sync: " + space.id)
yield* Effect.logInfo("disconnected from global sync: " + space.id).pipe(
Effect.annotateLogs({ service: "workspace-sync" }),
)
setStatus(space.id, "disconnected")
}
@@ -485,10 +488,6 @@ export const layer = Layer.effect(
Effect.catch((error) =>
Effect.sync(() => {
setStatus(space.id, "error")
log.warn("workspace target failed", {
workspaceID: space.id,
error: errorData(error),
})
return null
}),
),
@@ -514,10 +513,6 @@ export const layer = Layer.effect(
Effect.catch((error) =>
Effect.sync(() => {
setStatus(space.id, "error")
log.warn("workspace listener failed", {
workspaceID: space.id,
error,
})
}),
),
),
@@ -598,10 +593,15 @@ export const layer = Layer.effect(
const sessionWarp = Effect.fn("Workspace.sessionWarp")(function* (input: SessionWarpInput) {
return yield* Effect.gen(function* () {
log.info("session warp requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
yield* Effect.logInfo("session warp requested").pipe(
Effect.annotateLogs({
service: "workspace-sync",
...{
workspaceID: input.workspaceID,
sessionID: input.sessionID,
},
}),
)
const current = yield* db((db) =>
db
@@ -618,15 +618,7 @@ export const layer = Layer.effect(
if (target.type === "remote") {
yield* syncHistory(previous, target.url, target.headers).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.warn("session warp final source sync failed", {
workspaceID: previous.id,
sessionID: input.sessionID,
error: errorData(error),
})
}),
),
Effect.catch((error) => Effect.sync(() => {})),
)
} else {
yield* prompt.cancel(input.sessionID)
@@ -676,11 +668,16 @@ export const layer = Layer.effect(
},
})
log.info("session warp complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
target: "local",
})
yield* Effect.logInfo("session warp complete").pipe(
Effect.annotateLogs({
service: "workspace-sync",
...{
workspaceID: input.workspaceID,
sessionID: input.sessionID,
target: "local",
},
}),
)
return
}
@@ -702,11 +699,16 @@ export const layer = Layer.effect(
},
})
log.info("session warp complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
target: target.directory,
})
yield* Effect.logInfo("session warp complete").pipe(
Effect.annotateLogs({
service: "workspace-sync",
...{
workspaceID: input.workspaceID,
sessionID: input.sessionID,
target: target.directory,
},
}),
)
return
}
@@ -733,15 +735,20 @@ export const layer = Layer.effect(
const batches = Iterable.chunksOf(rows, 10)
const total = Iterable.size(batches)
log.info("session warp prepared", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
target: String(route(target.url, "/sync/replay")),
events: rows.length,
batches: total,
first: rows[0]?.seq,
last: rows.at(-1)?.seq,
})
yield* Effect.logInfo("session warp prepared").pipe(
Effect.annotateLogs({
service: "workspace-sync",
...{
workspaceID: input.workspaceID,
sessionID: input.sessionID,
target: String(route(target.url, "/sync/replay")),
events: rows.length,
batches: total,
first: rows[0]?.seq,
last: rows.at(-1)?.seq,
},
}),
)
yield* Effect.forEach(
batches,
@@ -759,14 +766,19 @@ export const layer = Layer.effect(
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text
log.error("session warp batch failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: response.status,
body,
})
yield* Effect.logError("session warp batch failed").pipe(
Effect.annotateLogs({
service: "workspace-sync",
...{
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: response.status,
body,
},
}),
)
return yield* new SessionWarpHttpError({
message: `Failed to warp session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
workspaceID,
@@ -776,13 +788,18 @@ export const layer = Layer.effect(
})
}
log.info("session warp batch posted", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: response.status,
})
yield* Effect.logInfo("session warp batch posted").pipe(
Effect.annotateLogs({
service: "workspace-sync",
...{
workspaceID: input.workspaceID,
sessionID: input.sessionID,
step: i + 1,
total,
status: response.status,
},
}),
)
}),
{ discard: true },
)
@@ -795,12 +812,17 @@ export const layer = Layer.effect(
)
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text
log.error("session warp steal failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
})
yield* Effect.logError("session warp steal failed").pipe(
Effect.annotateLogs({
service: "workspace-sync",
...{
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: response.status,
body,
},
}),
)
return yield* new SessionWarpHttpError({
message: `Failed to steal session ${input.sessionID} into workspace ${workspaceID}: HTTP ${response.status} ${body}`,
workspaceID,
@@ -810,19 +832,29 @@ export const layer = Layer.effect(
})
}
log.info("session warp complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
})
yield* Effect.logInfo("session warp complete").pipe(
Effect.annotateLogs({
service: "workspace-sync",
...{
workspaceID: input.workspaceID,
sessionID: input.sessionID,
batches: total,
},
}),
)
}).pipe(
Effect.tapError((err) =>
Effect.sync(() =>
log.error("session warp failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
}),
Effect.logError("session warp failed").pipe(
Effect.annotateLogs({
service: "workspace-sync",
...{
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
},
}),
),
),
),
)
@@ -848,7 +880,6 @@ export const layer = Layer.effect(
WorkspaceAdapterRuntime.list(adapter).pipe(
Effect.catchCause((error) =>
Effect.sync(() => {
log.warn("workspace adapter list failed", { type, error })
return []
}),
),
@@ -927,10 +958,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
yield* WorkspaceAdapterRuntime.remove(info)
}),
() =>
Effect.sync(() => {
log.error("adapter not available when removing workspace", { type: row.type })
}),
() => Effect.sync(() => {}),
)
yield* db((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
@@ -996,10 +1024,6 @@ export const layer = Layer.effect(
Effect.catch((error) =>
Effect.sync(() => {
setStatus(workspace.id, "error")
log.warn("workspace sync failed to start", {
workspaceID: workspace.id,
error,
})
}),
),
Effect.forkDetach,
+3 -5
View File
@@ -1,7 +1,6 @@
import { Context, Effect, Layer } from "effect"
import { Database } from "./storage/db"
import { DataMigrationTable } from "./data-migration.sql"
import * as Log from "@opencode-ai/core/util/log"
import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"
import { MessageTable, SessionTable } from "./session/session.sql"
import type { SessionID } from "./session/schema"
@@ -10,9 +9,6 @@ export type Migration<R = never> = {
name: string
run: Effect.Effect<void, unknown, R>
}
const log = Log.create({ service: "data-migration" })
export interface Interface {}
export class Service extends Context.Service<Service, Interface>()("@opencode/DataMigration") {}
@@ -135,7 +131,9 @@ export const layer = Layer.effect(
)
if (completed) continue
log.info("running data migration", { name: migration.name })
yield* Effect.logInfo("running data migration").pipe(
Effect.annotateLogs({ service: "data-migration", ...{ name: migration.name } }),
)
yield* migration.run.pipe(Effect.withSpan("DataMigration", { attributes: { name: migration.name } }))
Database.use((db) =>
db
@@ -54,7 +54,6 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
}) {}
+5 -9
View File
@@ -12,7 +12,6 @@ import ignore from "ignore"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { containsPath } from "../project/instance-context"
import * as Log from "@opencode-ai/core/util/log"
import { Protected } from "./protected"
import { Ripgrep } from "./ripgrep"
import { NonNegativeInt, type DeepMutable } from "@opencode-ai/core/schema"
@@ -69,9 +68,6 @@ export const Event = {
}),
),
}
const log = Log.create({ service: "file" })
const binary = new Set([
"exe",
"dll",
@@ -284,7 +280,6 @@ const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(fil
function shouldEncode(mimeType: string) {
const type = mimeType.toLowerCase()
log.debug("shouldEncode", { type })
if (!type) return false
if (type.startsWith("text/")) return false
if (type.includes("charset=")) return false
@@ -499,7 +494,6 @@ export const layer = Layer.effect(
})
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
using _ = log.time("read", { file })
const ctx = yield* InstanceState.context
const full = path.join(ctx.directory, file)
@@ -621,7 +615,7 @@ export const layer = Layer.effect(
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind })
yield* Effect.logInfo("search").pipe(Effect.annotateLogs({ service: "file", ...{ query, kind } }))
const preferHidden = query.startsWith(".") || query.includes("/.")
@@ -636,11 +630,13 @@ export const layer = Layer.effect(
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length })
yield* Effect.logInfo("search").pipe(
Effect.annotateLogs({ service: "file", ...{ query, kind, results: output.length } }),
)
return output
})
log.info("init")
yield* Effect.logInfo("init").pipe(Effect.annotateLogs({ service: "file" }))
return Service.of({ init, status, read, list, search })
}),
)
+2 -5
View File
@@ -9,12 +9,9 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Global } from "@opencode-ai/core/global"
import * as Log from "@opencode-ai/core/util/log"
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
import { which } from "@/util/which"
import { NonNegativeInt } from "@opencode-ai/core/schema"
const log = Log.create({ service: "ripgrep" })
const VERSION = "15.1.0"
const PLATFORM = {
"arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
@@ -306,7 +303,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildPro
const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}`
const archive = path.join(Global.Path.bin, filename)
log.info("downloading ripgrep", { url })
yield* Effect.logInfo("downloading ripgrep").pipe(Effect.annotateLogs({ service: "ripgrep", ...{ url } }))
yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie)
const bytes = yield* HttpClientRequest.get(url).pipe(
@@ -417,7 +414,7 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildPro
})
const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) {
log.info("tree", input)
yield* Effect.logInfo("tree").pipe(Effect.annotateLogs({ service: "ripgrep", ...input }))
const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect))
interface Node {
+15 -9
View File
@@ -14,11 +14,8 @@ import { lazy } from "@/util/lazy"
import { Config } from "@/config/config"
import { FileIgnore } from "./ignore"
import { Protected } from "./protected"
import * as Log from "@opencode-ai/core/util/log"
declare const OPENCODE_LIBC: string | undefined
const log = Log.create({ service: "file.watcher" })
const SUBSCRIBE_TIMEOUT_MS = 10_000
export const Event = {
@@ -38,7 +35,6 @@ const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
@@ -77,18 +73,30 @@ export const layer = Layer.effect(
const ctx = yield* InstanceState.context
log.info("init", { directory: ctx.directory })
yield* Effect.logInfo("init").pipe(
Effect.annotateLogs({ service: "file.watcher", ...{ directory: ctx.directory } }),
)
const backend = getBackend()
if (!backend) {
log.error("watcher backend not supported", { directory: ctx.directory, platform: process.platform })
yield* Effect.logError("watcher backend not supported").pipe(
Effect.annotateLogs({
service: "file.watcher",
...{ directory: ctx.directory, platform: process.platform },
}),
)
return
}
const w = watcher()
if (!w) return
log.info("watcher backend", { directory: ctx.directory, platform: process.platform, backend })
yield* Effect.logInfo("watcher backend").pipe(
Effect.annotateLogs({
service: "file.watcher",
...{ directory: ctx.directory, platform: process.platform, backend },
}),
)
const bridge = yield* EffectBridge.make()
const subs: ParcelWatcher.AsyncSubscription[] = []
yield* Effect.addFinalizer(() =>
@@ -112,7 +120,6 @@ export const layer = Layer.effect(
}).pipe(
Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void
}),
@@ -148,7 +155,6 @@ export const layer = Layer.effect(
}
},
Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.void
}),
),
+14 -22
View File
@@ -8,11 +8,7 @@ import { mergeDeep } from "remeda"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { errorMessage } from "@/util/error"
import * as Log from "@opencode-ai/core/util/log"
import * as Formatter from "./formatter"
const log = Log.create({ service: "format" })
export const Status = Schema.Struct({
name: Schema.String,
extensions: Schema.Array(Schema.String),
@@ -60,10 +56,8 @@ export const layer = Layer.effect(
const matching = Object.values(formatters).filter((item) => item.extensions.includes(ext))
const checks = await Promise.all(
matching.map(async (item) => {
log.info("checking", { name: item.name, ext })
const cmd = await getCommand(item)
if (cmd) {
log.info("enabled", { name: item.name, ext })
}
return {
item,
@@ -78,13 +72,13 @@ export const layer = Layer.effect(
function formatFile(filepath: string) {
return Effect.gen(function* () {
log.info("formatting", { file: filepath })
yield* Effect.logInfo("formatting").pipe(Effect.annotateLogs({ service: "format", ...{ file: filepath } }))
const formatters = yield* Effect.promise(() => getFormatter(path.extname(filepath)))
if (!formatters.length) return false
for (const { item, cmd } of formatters) {
log.info("running", { command: cmd })
yield* Effect.logInfo("running").pipe(Effect.annotateLogs({ service: "format", ...{ command: cmd } }))
const replaced = cmd.map((x) => x.replace("$FILE", filepath))
const dir = yield* InstanceState.directory
const result = yield* appProcess
@@ -101,22 +95,20 @@ export const layer = Layer.effect(
.pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to format file", {
error: "spawn failed",
command: cmd,
...item.environment,
file: filepath,
cause: errorMessage(error.cause ?? error),
})
return undefined
}),
),
)
if (result && result.exitCode !== 0) {
log.error("failed", {
command: cmd,
...item.environment,
})
yield* Effect.logError("failed").pipe(
Effect.annotateLogs({
service: "format",
...{
command: cmd,
...item.environment,
},
}),
)
}
}
@@ -127,8 +119,8 @@ export const layer = Layer.effect(
const cfg = yield* config.get()
if (!cfg.formatter) {
log.info("all formatters are disabled")
log.info("init")
yield* Effect.logInfo("all formatters are disabled").pipe(Effect.annotateLogs({ service: "format" }))
yield* Effect.logInfo("init").pipe(Effect.annotateLogs({ service: "format" }))
return {
formatters,
isEnabled,
@@ -166,7 +158,7 @@ export const layer = Layer.effect(
}
}
log.info("init")
yield* Effect.logInfo("init").pipe(Effect.annotateLogs({ service: "format" }))
return {
formatters,
-11
View File
@@ -1,7 +1,6 @@
import { BusEvent } from "@/bus/bus-event"
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import * as Log from "@opencode-ai/core/util/log"
import { Process } from "@/util/process"
const SUPPORTED_IDES = [
@@ -11,9 +10,6 @@ const SUPPORTED_IDES = [
{ name: "Cursor" as const, cmd: "cursor" },
{ name: "VSCodium" as const, cmd: "codium" },
]
const log = Log.create({ service: "ide" })
export const Event = {
Installed: BusEvent.define(
"ide.installed",
@@ -52,13 +48,6 @@ export async function install(ide: (typeof SUPPORTED_IDES)[number]["name"]) {
})
const stdout = p.stdout.toString()
const stderr = p.stderr.toString()
log.info("installed", {
ide,
stdout,
stderr,
})
if (p.code !== 0) {
throw new InstallFailedError({ stderr })
}
+16 -11
View File
@@ -1,6 +1,5 @@
import { Config } from "@/config/config"
import type { MessageV2 } from "@/session/message-v2"
import * as Log from "@opencode-ai/core/util/log"
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
import { Context, Effect, Layer, Schema } from "effect"
import path from "node:path"
@@ -11,8 +10,6 @@ const MAX_WIDTH = 2000
const MAX_HEIGHT = 2000
const AUTO_RESIZE = true
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
const log = Log.create({ service: "image" })
export class ResizerUnavailableError extends Schema.TaggedErrorClass<ResizerUnavailableError>()(
"ImageResizerUnavailableError",
{},
@@ -68,7 +65,11 @@ export const layer = Layer.effect(
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
}).pipe(
Effect.andThen(() => Effect.tryPromise(() => import("@silvia-odwyer/photon-node"))),
Effect.tapError((error) => Effect.sync(() => log.warn("failed to load photon", { error }))),
Effect.tapError((error) =>
Effect.sync(() =>
Effect.logWarning("failed to load photon").pipe(Effect.annotateLogs({ service: "image", ...{ error } })),
),
),
Effect.mapError(() => new ResizerUnavailableError()),
),
)
@@ -92,7 +93,6 @@ export const layer = Layer.effect(
const decoded = yield* Effect.try({
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
catch: (error) => {
log.warn("failed to decode image", { error })
return new DecodeError()
},
})
@@ -140,12 +140,17 @@ export const layer = Layer.effect(
resized.free()
if (candidate) {
log.info("using resized image", {
from_mime: input.mime,
to_mime: candidate.mime,
from: `${originalWidth}x${originalHeight}`,
to: `${size.width}x${size.height}`,
})
yield* Effect.logInfo("using resized image").pipe(
Effect.annotateLogs({
service: "image",
...{
from_mime: input.mime,
to_mime: candidate.mime,
from: `${originalWidth}x${originalHeight}`,
to: `${size.width}x${size.height}`,
},
}),
)
return {
...input,
mime: candidate.mime,
+17 -67
View File
@@ -2,7 +2,6 @@ import yargs from "yargs"
import { hideBin } from "yargs/helpers"
import { RunCommand } from "./cli/cmd/run"
import { GenerateCommand } from "./cli/cmd/generate"
import * as Log from "@opencode-ai/core/util/log"
import { ConsoleCommand } from "./cli/cmd/account"
import { ProvidersCommand } from "./cli/cmd/providers"
import { AgentCommand } from "./cli/cmd/agent"
@@ -12,7 +11,6 @@ import { ModelsCommand } from "./cli/cmd/models"
import { UI } from "./cli/ui"
import { Installation } from "./installation"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { NamedError } from "@opencode-ai/core/util/error"
import { FormatError } from "./cli/error"
import { ServeCommand } from "./cli/cmd/serve"
import { Filesystem } from "@/util/filesystem"
@@ -38,24 +36,9 @@ import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
import { isRecord } from "@/util/record"
const processMetadata = ensureProcessMetadata("main")
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
e: errorMessage(e),
})
})
process.on("uncaughtException", (e) => {
Log.Default.error("exception", {
e: errorMessage(e),
})
})
const args = hideBin(process.argv)
type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR"
function show(out: string) {
const text = out.trimStart()
@@ -67,6 +50,11 @@ function show(out: string) {
process.stderr.write(out)
}
function parseLogLevel(value: string | undefined): LogLevel | undefined {
if (value === "DEBUG" || value === "INFO" || value === "WARN" || value === "ERROR") return value
return undefined
}
const cli = yargs(args)
.parserConfiguration({ "populate--": true })
.scriptName("opencode")
@@ -79,6 +67,10 @@ const cli = yargs(args)
describe: "print logs to stderr",
type: "boolean",
})
.option("log-file", {
describe: "path to JSONL log file",
type: "string",
})
.option("log-level", {
describe: "log level",
type: "string",
@@ -93,15 +85,12 @@ const cli = yargs(args)
process.env.OPENCODE_PURE = "1"
}
await Log.init({
print: process.argv.includes("--print-logs"),
dev: Installation.isLocal(),
level: (() => {
if (opts.logLevel) return opts.logLevel as Log.Level
if (Installation.isLocal()) return "DEBUG"
return "INFO"
})(),
})
if (opts.printLogs !== undefined) process.env.OPENCODE_PRINT_LOGS = opts.printLogs ? "1" : "0"
if (opts.logFile) process.env.OPENCODE_LOG_FILE = opts.logFile
const envLevel = parseLogLevel(process.env.OPENCODE_LOG_LEVEL)
process.env.OPENCODE_LOG_LEVEL = opts.logLevel
? (opts.logLevel as LogLevel)
: (envLevel ?? (Installation.isLocal() ? "DEBUG" : "INFO"))
Heap.start()
@@ -109,13 +98,6 @@ const cli = yargs(args)
process.env.OPENCODE = "1"
process.env.OPENCODE_PID = String(process.pid)
Log.Default.info("opencode", {
version: InstallationVersion,
args: process.argv.slice(2),
process_role: processMetadata.processRole,
run_id: processMetadata.runID,
})
const marker = path.join(Global.Path.data, "opencode.db")
if (!(await Filesystem.exists(marker))) {
const tty = process.stderr.isTTY
@@ -203,42 +185,10 @@ try {
await cli.parse()
}
} catch (e) {
let data: Record<string, any> = {}
if (e instanceof Error) {
Object.assign(data, {
name: e.name,
message: e.message,
cause: e.cause?.toString(),
stack: e.stack,
})
}
if (e instanceof NamedError) {
const obj = e.toObject()
if (isRecord(obj.data)) {
for (const [key, value] of Object.entries(obj.data)) {
if (key === "name" || key === "stack" || key === "cause") continue
data[key] = value
}
}
}
if (e instanceof ResolveMessage) {
Object.assign(data, {
name: e.name,
message: e.message,
code: e.code,
specifier: e.specifier,
referrer: e.referrer,
position: e.position,
importKind: e.importKind,
})
}
Log.Default.error("fatal", data)
const formatted = FormatError(e)
if (formatted) UI.error(formatted)
if (formatted === undefined) {
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
UI.error("Unexpected error" + EOL)
process.stderr.write(errorMessage(e) + EOL)
}
process.exitCode = 1
+11 -10
View File
@@ -7,14 +7,10 @@ import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import path from "path"
import { BusEvent } from "@/bus/bus-event"
import * as Log from "@opencode-ai/core/util/log"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import semver from "semver"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { NpmConfig } from "@opencode-ai/core/npm-config"
const log = Log.create({ service: "installation" })
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
export type ReleaseType = "patch" | "minor" | "major"
@@ -317,12 +313,17 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | AppProce
if (!upgradeResult || upgradeResult.code !== 0) {
return yield* new UpgradeFailedError({ stderr: upgradeFailure(m, upgradeResult) })
}
log.info("upgraded", {
method: m,
target,
stdout: upgradeResult.stdout,
stderr: upgradeResult.stderr,
})
yield* Effect.logInfo("upgraded").pipe(
Effect.annotateLogs({
service: "installation",
...{
method: m,
target,
stdout: upgradeResult.stdout,
stderr: upgradeResult.stderr,
},
}),
)
yield* text([process.execPath, "--version"])
}),
}
+1 -48
View File
@@ -4,7 +4,6 @@ import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
import * as Log from "@opencode-ai/core/util/log"
import { Process } from "@/util/process"
import { LANGUAGE_EXTENSIONS } from "./language"
import { Effect, Schema } from "effect"
@@ -26,8 +25,6 @@ const INITIALIZE_TIMEOUT_MS = 45_000
const FILE_CHANGE_CREATED = 1
const FILE_CHANGE_CHANGED = 2
const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
const log = Log.create({ service: "lsp.client" })
const busRuntime = makeRuntime(Bus.Service, Bus.layer)
export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
@@ -145,23 +142,13 @@ export async function create(input: {
directory: string
instance: InstanceContext
}) {
const logger = log.clone().tag("serverID", input.serverID)
logger.info("starting client")
const logAnnotations = { service: "lsp.client", serverID: input.serverID }
const instance = input.instance
const connection = createMessageConnection(
new StreamMessageReader(input.server.process.stdout as any),
new StreamMessageWriter(input.server.process.stdin as any),
)
// Server stderr can contain both real errors and routine informational logs,
// which is normal stderr practice for some tools. Keep the raw stream at
// debug so users can opt in with --print-logs --log-level DEBUG without
// polluting normal logs.
input.server.process.stderr?.on("data", (data: Buffer) => {
const text = data.toString().trim()
if (text) logger.debug("server stderr", { text: text.slice(0, 1000) })
})
// --- Connection state ---
const pushDiagnostics = new Map<string, Diagnostic[]>()
@@ -191,11 +178,6 @@ export async function create(input: {
connection.onNotification("textDocument/publishDiagnostics", (params) => {
const filePath = getFilePath(params.uri)
if (!filePath) return
logger.info("textDocument/publishDiagnostics", {
path: filePath,
count: params.diagnostics.length,
version: params.version,
})
published.set(filePath, {
at: Date.now(),
version: typeof params.version === "number" ? params.version : undefined,
@@ -207,7 +189,6 @@ export async function create(input: {
updatePushDiagnostics(filePath, params.diagnostics)
})
connection.onRequest("window/workDoneProgress/create", (params) => {
logger.info("window/workDoneProgress/create", params)
return null
})
connection.onRequest("workspace/configuration", async (params) => {
@@ -242,10 +223,6 @@ export async function create(input: {
])
connection.onRequest("workspace/diagnostic/refresh", async () => null)
connection.listen()
// --- Initialize handshake ---
logger.info("sending initialize")
const initialized = await withTimeout(
connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", {
rootUri: pathToFileURL(input.root).href,
@@ -289,7 +266,6 @@ export async function create(input: {
}),
INITIALIZE_TIMEOUT_MS,
).catch((err) => {
logger.error("initialize error", { error: err })
throw new InitializeError({ serverID: input.serverID, cause: err })
})
@@ -602,11 +578,6 @@ export async function create(input: {
const document = files[request.path]
if (document !== undefined) {
// Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only
// re-emit diagnostics when the content actually changes, so clearing
// here would lose errors for no-op touchFile calls. Let the server's
// next push/pull overwrite naturally.
logger.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
@@ -618,10 +589,6 @@ export async function create(input: {
const next = document.version + 1
files[request.path] = { version: next, text }
logger.info("textDocument/didChange", {
path: request.path,
version: next,
})
await connection.sendNotification("textDocument/didChange", {
textDocument: {
uri: pathToFileURL(request.path).href,
@@ -642,8 +609,6 @@ export async function create(input: {
})
return next
}
logger.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
@@ -652,8 +617,6 @@ export async function create(input: {
},
],
})
logger.info("textDocument/didOpen", request)
pushDiagnostics.delete(request.path)
pullDiagnostics.delete(request.path)
await connection.sendNotification("textDocument/didOpen", {
@@ -679,11 +642,6 @@ export async function create(input: {
const normalizedPath = Filesystem.normalizePath(
path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
)
logger.info("waiting for diagnostics", {
path: normalizedPath,
mode: request.mode ?? "full",
version: request.version,
})
if (request.mode === "document") {
await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
return
@@ -691,16 +649,11 @@ export async function create(input: {
await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
},
async shutdown() {
logger.info("shutting down")
connection.end()
connection.dispose()
await Process.stop(input.server.process)
logger.info("shutdown")
},
}
logger.info("initialized")
return result
}
+14 -20
View File
@@ -1,6 +1,5 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import * as Log from "@opencode-ai/core/util/log"
import * as LSPClient from "./client"
import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
@@ -13,9 +12,6 @@ import { InstanceState } from "@/effect/instance-state"
import { containsPath } from "@/project/instance-context"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "lsp" })
export const Event = {
Updated: BusEvent.define("lsp.updated", Schema.Struct({})),
}
@@ -101,7 +97,6 @@ const kinds = [
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>, flags: RuntimeFlags.Info) => {
if (flags.experimentalLspTy) {
if (servers["pyright"]) {
log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
delete servers["pyright"]
}
} else {
@@ -152,7 +147,7 @@ export const layer = Layer.effect(
const servers: Record<string, LSPServer.Info> = {}
if (!cfg.lsp) {
log.info("all LSPs are disabled")
yield* Effect.logInfo("all LSPs are disabled").pipe(Effect.annotateLogs({ service: "lsp" }))
} else {
for (const server of Object.values(LSPServer)) {
servers[server.id] = server
@@ -164,7 +159,7 @@ export const layer = Layer.effect(
for (const [name, item] of Object.entries(cfg.lsp)) {
const existing = servers[name]
if (item.disabled) {
log.info(`LSP server ${name} is disabled`)
yield* Effect.logInfo(`LSP server ${name} is disabled`).pipe(Effect.annotateLogs({ service: "lsp" }))
delete servers[name]
continue
}
@@ -184,11 +179,16 @@ export const layer = Layer.effect(
}
}
log.info("enabled LSP servers", {
serverIds: Object.values(servers)
.map((server) => server.id)
.join(", "),
})
yield* Effect.logInfo("enabled LSP servers").pipe(
Effect.annotateLogs({
service: "lsp",
...{
serverIds: Object.values(servers)
.map((server) => server.id)
.join(", "),
},
}),
)
}
const s: State = {
@@ -225,13 +225,10 @@ export const layer = Layer.effect(
})
.catch((err) => {
s.broken.add(key)
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
return undefined
})
if (!handle) return undefined
log.info("spawned lsp server", { serverID: server.id, root })
const client = await LSPClient.create({
serverID: server.id,
server: handle,
@@ -241,7 +238,6 @@ export const layer = Layer.effect(
}).catch(async (err) => {
s.broken.add(key)
await Process.stop(handle.process)
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
return undefined
})
@@ -344,7 +340,7 @@ export const layer = Layer.effect(
})
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") {
log.info("touching file", { file: input })
yield* Effect.logInfo("touching file").pipe(Effect.annotateLogs({ service: "lsp", ...{ file: input } }))
const clients = yield* getClients(input)
yield* Effect.promise(() =>
Promise.all(
@@ -359,9 +355,7 @@ export const layer = Layer.effect(
after,
})
}),
).catch((err) => {
log.error("failed to touch file", { err, file: input })
}),
).catch((err) => {}),
)
})

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