Compare commits

...
Author SHA1 Message Date
Kit Langton 60d099a996 refactor: collapse patch barrel into patch/index.ts 2026-04-16 16:27:13 -04:00
Kit Langton e0d71f124e tooling: add collapse-barrel.ts for single-namespace barrel migration (#22887) 2026-04-16 16:12:46 -04:00
Kit Langton 1c33b866ba fix: remove 10 more unnecessary as any casts in opencode core (#22882) 2026-04-16 20:11:05 +00:00
Kobi HudsonandAiden Cline 5e650fd9e2 fix(opencode): drop max_tokens for OpenAI reasoning models on Cloudflare AI Gateway (#22864)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-16 15:01:21 -05:00
Kit Langton 76275fc3ab refactor: move Pty into pty/index.ts with self-reexport (#22881) 2026-04-16 15:49:21 -04:00
Aiden Cline 6c3b28db64 fix: ensure that double pasting doesnt happen after tui perf commit was merged (#22880) 2026-04-16 14:38:39 -05:00
Kit Langton 2fe9d94470 fix: remove 8 more unnecessary as any casts in opencode core (#22877) 2026-04-16 19:27:53 +00:00
Kit Langton 219b473e66 refactor: unwrap BashArity namespace to flat exports + self-reexport (#22874) 2026-04-16 15:24:24 -04:00
opencode-agent[bot] 7c1b30291c chore: update nix node_modules hashes 2026-04-16 19:19:52 +00:00
Aiden Cline 47e0e2342c tweak: set display 'summarized' by default for opus 4.7 thorugh messages api (#22873) 2026-04-16 14:12:43 -05:00
Kit Langton bf4c107829 fix: remove 7 unnecessary as any casts in opencode core (#22840) 2026-04-16 15:07:02 -04:00
Dax 9afbdc102c fix(test): make plugin loader theme source path separator-safe (#22870) 2026-04-16 14:45:17 -04:00
opencode-agent[bot] 370770122c chore: generate 2026-04-16 18:29:57 +00:00
Aiden Cline 143817d44e chore: bump ai sdk deps for opus 4.7 (#22869) 2026-04-16 13:28:20 -05:00
Thomas Butler c60862fc9e fix: add missing glob dependency (#22851) 2026-04-16 13:21:04 -05:00
Dax Raad bee5f919fc core: reorganize ConfigPaths module export for cleaner dependency management 2026-04-16 13:33:54 -04:00
Dax Raad cefa7f04c6 core: reorganize ConfigPaths module export for cleaner dependency management 2026-04-16 13:32:22 -04:00
Dax Raad 03e20e6ac1 core: modularize config parsing to improve maintainability
Extract error handling, parsing logic, and variable substitution into dedicated
modules. This reduces duplication between tui.json and opencode.json parsing
and makes the config system easier to extend for future config formats.
2026-04-16 13:29:03 -04:00
Aiden Cline c5deeee8c7 fix: ensure azure has store = true by default (#22764) 2026-04-16 12:19:01 -05:00
Dax Raad 8b1f0e2d90 core: add documentation comments to plugin configuration merge logic
Adds explanatory comments to config.ts and plugin.ts clarifying:

- How plugin specs are stored and normalized during config loading

- Why plugin_origins tracks provenance for location-sensitive decisions

- Why path-like specs are resolved early to prevent reinterpretation during merges

- How plugin deduplication works while keeping origin metadata for writes and diagnostics
2026-04-16 12:55:40 -04:00
Dax Raad 9bf2dfea35 core: refactor config schemas into separate modules for better maintainability 2026-04-16 12:47:09 -04:00
Dax Raad 33bb847a1d config: refactor 2026-04-16 12:40:24 -04:00
Dax Raad bfffc3c2c6 tui: ensure TUI plugins load with proper project context when multiple directories are open
Fixes potential plugin resolution issues when switching between projects by wrapping
plugin loading in Instance.provide(). This ensures each plugin resolves dependencies
relative to its correct project directory instead of inheriting context from whatever
instance happened to be active.

Also reorganizes config loading code into focused modules (command.ts, managed.ts,
plugin.ts) to make the codebase easier to maintain and test.
2026-04-16 12:40:24 -04:00
James Long b28956f0db fix(core): better global sync event structure (#22858) 2026-04-16 12:35:37 -04:00
opencode-agent[bot] d82bc3a421 chore: generate 2026-04-16 16:26:12 +00:00
James Long 06afd33291 refactor(tui): improve workspace management (#22691) 2026-04-16 12:24:40 -04:00
James Long 305460b25f fix: add a few more tests for sync and session restore (#22837) 2026-04-16 12:15:44 -04:00
Nacaiopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
8c0205a84a fix: align stale bot message with actual 60-day threshold (#22842)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-04-16 11:01:35 -05:00
Graham CampbellandAiden Cline 378c05f202 feat: Add support for claude opus 4.7 xhigh adaptive reasoning effort (#22833)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-16 10:57:36 -05:00
Jérôme Benoit cc7acd90ab fix(nix): add shared package to bun install filters (#22665) 2026-04-16 10:43:15 -05:00
Frank a200f6fb8b zen: opus 4.7 2026-04-16 11:32:56 -04:00
Dax Raad 2b1696f1d1 Revert "tui: fix path comparison in theme installer to handle different path formats"
This reverts commit 8ab17f5ce0.
2026-04-16 11:28:19 -04:00
Dax Raad 8ab17f5ce0 tui: fix path comparison in theme installer to handle different path formats 2026-04-16 11:18:44 -04:00
Dax Raad 6ce481e95b move useful scripts to script folder 2026-04-16 10:09:14 -04:00
91 changed files with 3871 additions and 3117 deletions
+19 -10
View File
@@ -322,15 +322,15 @@
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.94",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.70",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.97", "@ai-sdk/gateway": "3.0.102",
"@ai-sdk/google": "3.0.63", "@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.109", "@ai-sdk/google-vertex": "4.0.111",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53", "@ai-sdk/openai": "3.0.53",
@@ -516,6 +516,7 @@
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@npmcli/arborist": "catalog:", "@npmcli/arborist": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"glob": "13.0.5",
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.2.5", "minimatch": "10.2.5",
"semver": "catalog:", "semver": "catalog:",
@@ -737,7 +738,7 @@
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="], "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="], "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.94", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XKE7wAjXejsIfNQvn3onvGUByhGHVM6W+xlL+1DAQLmjEb+ue4sOJIRehJ96rEvTXVVHRVyA6bSXx7ayxXfn5A=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
@@ -757,11 +758,11 @@
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="], "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.97", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ERHmVGX30YKTwxObuHQzNqoOf8Nb5WwYMDBn34e3TGGVn0vLEXwMimo7uRVTbhhi4gfu9WtwYTE4x1+csZok1w=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.102", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GrwDpaYJiVafrsA1MTbZtXPcQUI67g5AXiJo7Y1F8b+w+SiYHLk3ZIn1YmpQVoVAh2bjvxjj+Vo0AvfskuGH4g=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="], "@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.109", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/google": "3.0.63", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-QzQ+DgOoSYlkU4mK0H+iaCaW1bl5zOimH9X2E2oylcVyUtAdCuduQ959Uw1ygW3l09J2K/ceEDtK8OUPHyOA7g=="], "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.111", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.70", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5gILpAWWI5idfal/MfoH3tlQeSnOJ9jfL8JB8m2fdc3ue/9xoXkYDpXpDL/nyJImFjMCi6eR0Fpvlo/IKEWDIg=="],
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
@@ -5151,7 +5152,11 @@
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -5165,7 +5170,9 @@
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="],
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
@@ -5683,6 +5690,8 @@
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="], "ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
"ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uz8tIlkDgQJG9Js2Wh9JHzd4kI9+hYJqf9XXJLx60vyN5mRIqhr49iwR5zGP5Gl8odp2PeR3Gh2k+5bh3Z1HHw=="], "ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uz8tIlkDgQJG9Js2Wh9JHzd4kI9+hYJqf9XXJLx60vyN5mRIqhr49iwR5zGP5Gl8odp2PeR3Gh2k+5bh3Z1HHw=="],
@@ -5899,7 +5908,7 @@
"nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="], "nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="], "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.70", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hubTFcfnG3NbrlcDW0tU2fsZhRy/7dF5GCymu4DzBQUYliy2lb7tCeeMhDtFBaYa01qSBHRjkwGnsAdUtDPCwA=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-NJAK+cPjwn+2ojDLyyDmBQyx2pD+rILetp7VCylgjek=", "x86_64-linux": "sha256-tYAb5Mo39UW1VEejYuo0jW0jzH2OyY/HrqgiZL3rmjY=",
"aarch64-linux": "sha256-q8NTtFQJoyM7TTvErGA6RtmUscxoZKD/mj9N6S5YhkA=", "aarch64-linux": "sha256-3zGKV5UwokXpmY0nT1mry3IhNf2EQYLKT7ac+/trmQA=",
"aarch64-darwin": "sha256-/ccoSZNLef6j9j14HzpVqhKCR+czM3mhPKPH51mHO24=", "aarch64-darwin": "sha256-oKXAut7eu/eW5a43OT8+aFuH1F1tuIldTs+7PUXSCv4=",
"x86_64-darwin": "sha256-6Pd10sMHL/5ZoWNvGPwPn4/AIs1TKjt/3gFyrVpBaE0=" "x86_64-darwin": "sha256-Az+9X1scOEhw3aOO8laKJoZjiuz3qlLTIk1bx25P/z4="
} }
} }
+1
View File
@@ -55,6 +55,7 @@ stdenvNoCC.mkDerivation {
--filter './packages/opencode' \ --filter './packages/opencode' \
--filter './packages/desktop' \ --filter './packages/desktop' \
--filter './packages/app' \ --filter './packages/app' \
--filter './packages/shared' \
--frozen-lockfile \ --frozen-lockfile \
--ignore-scripts \ --ignore-scripts \
--no-progress --no-progress
+4 -4
View File
@@ -79,15 +79,15 @@
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1", "@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17", "@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.93", "@ai-sdk/amazon-bedrock": "4.0.94",
"@ai-sdk/anthropic": "3.0.67", "@ai-sdk/anthropic": "3.0.70",
"@ai-sdk/azure": "3.0.49", "@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.97", "@ai-sdk/gateway": "3.0.102",
"@ai-sdk/google": "3.0.63", "@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.109", "@ai-sdk/google-vertex": "4.0.111",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27", "@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53", "@ai-sdk/openai": "3.0.53",
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env bun
/**
* Collapse a single-namespace barrel directory into a dir/index.ts module.
*
* Given a directory `src/foo/` that contains:
*
* - `index.ts` (exactly `export * as Foo from "./foo"`)
* - `foo.ts` (the real implementation)
* - zero or more sibling files
*
* this script:
*
* 1. Deletes the old `index.ts` barrel.
* 2. `git mv`s `foo.ts` → `index.ts` so the implementation IS the directory entry.
* 3. Appends `export * as Foo from "."` to the new `index.ts`.
* 4. Rewrites any same-directory sibling `*.ts` files that imported
* `./foo` (with or without the namespace name) to import `"."` instead.
*
* Consumer files outside the directory keep importing from the directory
* (`"@/foo"` / `"../foo"` / etc.) and continue to work, because
* `dir/index.ts` now provides the `Foo` named export directly.
*
* Usage:
*
* bun script/collapse-barrel.ts src/bus
* bun script/collapse-barrel.ts src/bus --dry-run
*
* Notes:
*
* - Only works on directories whose barrel is a single
* `export * as Name from "./file"` line. Refuses otherwise.
* - Refuses if the implementation file name already conflicts with
* `index.ts`.
* - Safe to run repeatedly: a second run on an already-collapsed dir
* will exit with a clear message.
*/
import fs from "node:fs"
import path from "node:path"
import { spawnSync } from "node:child_process"
const args = process.argv.slice(2)
const dryRun = args.includes("--dry-run")
const targetArg = args.find((a) => !a.startsWith("--"))
if (!targetArg) {
console.error("Usage: bun script/collapse-barrel.ts <dir> [--dry-run]")
process.exit(1)
}
const dir = path.resolve(targetArg)
const indexPath = path.join(dir, "index.ts")
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
console.error(`Not a directory: ${dir}`)
process.exit(1)
}
if (!fs.existsSync(indexPath)) {
console.error(`No index.ts in ${dir}`)
process.exit(1)
}
// Validate barrel shape.
const indexContent = fs.readFileSync(indexPath, "utf-8").trim()
const match = indexContent.match(/^export\s+\*\s+as\s+(\w+)\s+from\s+["']\.\/([^"']+)["']\s*;?\s*$/)
if (!match) {
console.error(`Not a simple single-namespace barrel:\n${indexContent}`)
process.exit(1)
}
const namespaceName = match[1]
const implRel = match[2].replace(/\.ts$/, "")
const implPath = path.join(dir, `${implRel}.ts`)
if (!fs.existsSync(implPath)) {
console.error(`Implementation file not found: ${implPath}`)
process.exit(1)
}
if (implRel === "index") {
console.error(`Nothing to do — impl file is already index.ts`)
process.exit(0)
}
console.log(`Collapsing ${path.relative(process.cwd(), dir)}`)
console.log(` namespace: ${namespaceName}`)
console.log(` impl file: ${implRel}.ts → index.ts`)
// Figure out which sibling files need rewriting.
const siblings = fs
.readdirSync(dir)
.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"))
.filter((f) => f !== "index.ts" && f !== `${implRel}.ts`)
.map((f) => path.join(dir, f))
type SiblingEdit = { file: string; content: string }
const siblingEdits: SiblingEdit[] = []
for (const sibling of siblings) {
const content = fs.readFileSync(sibling, "utf-8")
// Match any import or re-export referring to "./<implRel>" inside this directory.
const siblingRegex = new RegExp(`(from\\s*["'])\\.\\/${implRel.replace(/[-\\^$*+?.()|[\]{}]/g, "\\$&")}(["'])`, "g")
if (!siblingRegex.test(content)) continue
const updated = content.replace(siblingRegex, `$1.$2`)
siblingEdits.push({ file: sibling, content: updated })
}
if (siblingEdits.length > 0) {
console.log(` sibling rewrites: ${siblingEdits.length}`)
for (const edit of siblingEdits) {
console.log(` ${path.relative(process.cwd(), edit.file)}`)
}
} else {
console.log(` sibling rewrites: none`)
}
if (dryRun) {
console.log(`\n(dry run) would:`)
console.log(` - delete ${path.relative(process.cwd(), indexPath)}`)
console.log(` - git mv ${path.relative(process.cwd(), implPath)} ${path.relative(process.cwd(), indexPath)}`)
console.log(` - append \`export * as ${namespaceName} from "."\` to the new index.ts`)
for (const edit of siblingEdits) {
console.log(` - rewrite sibling: ${path.relative(process.cwd(), edit.file)}`)
}
process.exit(0)
}
// Apply: remove the old barrel, git-mv the impl onto it, then rewrite content.
// We can't git-mv on top of an existing tracked file, so we remove the barrel first.
function runGit(...cmd: string[]) {
const res = spawnSync("git", cmd, { stdio: "inherit" })
if (res.status !== 0) {
console.error(`git ${cmd.join(" ")} failed`)
process.exit(res.status ?? 1)
}
}
// Step 1: remove the barrel
runGit("rm", "-f", indexPath)
// Step 2: rename the impl file into index.ts
runGit("mv", implPath, indexPath)
// Step 3: append the self-reexport to the new index.ts
const newContent = fs.readFileSync(indexPath, "utf-8")
const trimmed = newContent.endsWith("\n") ? newContent : newContent + "\n"
fs.writeFileSync(indexPath, `${trimmed}\nexport * as ${namespaceName} from "."\n`)
console.log(` appended: export * as ${namespaceName} from "."`)
// Step 4: rewrite siblings
for (const edit of siblingEdits) {
fs.writeFileSync(edit.file, edit.content)
}
if (siblingEdits.length > 0) {
console.log(` rewrote ${siblingEdits.length} sibling file(s)`)
}
console.log(`\nDone. Verify with:`)
console.log(` cd packages/opencode`)
console.log(` bunx --bun tsgo --noEmit`)
console.log(` bun run --conditions=browser ./src/index.ts generate`)
console.log(` bun run test`)
@@ -1,3 +1,5 @@
#!/usr/bin/env bun
import path from "path" import path from "path"
const toDynamicallyImport = path.join(process.cwd(), process.argv[2]) const toDynamicallyImport = path.join(process.cwd(), process.argv[2])
await import(toDynamicallyImport) await import(toDynamicallyImport)
@@ -1,499 +1,256 @@
# Namespace → flat export migration # Namespace → self-reexport migration
Migrate `export namespace` to the `export * as` / flat-export pattern used by Migrate every `export namespace Foo { ... }` to flat top-level exports plus a
effect-smol. Primary goal: tree-shakeability. Secondary: consistency with Effect single self-reexport line at the bottom of the same file:
conventions, LLM-friendliness for future migrations.
## What changes and what doesn't
The **consumer API stays the same**. You still write `Provider.ModelNotFoundError`,
`Config.JsonError`, `Bus.publish`, etc. The namespace ergonomics are preserved.
What changes is **how** the namespace is constructed — the TypeScript
`export namespace` keyword is replaced by `export * as` in a barrel file. This
is a mechanical change: unwrap the namespace body into flat exports, add a
one-line barrel. Consumers that import `{ Provider }` don't notice.
Import paths actually get **nicer**. Today most consumers import from the
explicit file (`"../provider/provider"`). After the migration, each module has a
barrel `index.ts`, so imports become `"../provider"` or `"@/provider"`:
```ts ```ts
// BEFORE — points at the file directly export * as Foo from "./foo"
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider namespace
import { Provider } from "../provider"
``` ```
## Why this matters right now No barrel `index.ts` files. No cross-directory indirection. Consumers keep the
exact same `import { Foo } from "../foo/foo"` ergonomics.
The CLI binary startup time (TOI) is too slow. Profiling shows we're loading ## Why this pattern
massive dependency graphs that are never actually used at runtime — because
bundlers cannot tree-shake TypeScript `export namespace` bodies.
### The problem in one sentence We tested three options against Bun, esbuild, Rollup (what Vite uses under the
hood), Bun's runtime, and Node's native TypeScript runner.
`cli/error.ts` needs 6 lightweight `.isInstance()` checks on error classes, but
importing `{ Provider }` from `provider.ts` forces the bundler to include **all
20+ `@ai-sdk/*` packages**, `@aws-sdk/credential-providers`,
`google-auth-library`, and every other top-level import in that 1709-line file.
### Why `export namespace` defeats tree-shaking
TypeScript compiles `export namespace Foo { ... }` to an IIFE:
```js
// TypeScript output
export var Provider;
(function (Provider) {
Provider.ModelNotFoundError = NamedError.create(...)
// ... 1600 more lines of assignments ...
})(Provider || (Provider = {}))
```
This is **opaque to static analysis**. The bundler sees one big function call
whose return value populates an object. It cannot determine which properties are
used downstream, so it keeps everything. Every `import` statement at the top of
`provider.ts` executes unconditionally — that's 20+ AI SDK packages loaded into
memory just so the CLI can check `Provider.ModelNotFoundError.isInstance(x)`.
### What `export * as` does differently
`export * as Provider from "./provider"` compiles to a static re-export. The
bundler knows the exact shape of `Provider` at compile time — it's the named
export list of `./provider.ts`. When it sees `Provider.ModelNotFoundError` used
but `Provider.layer` unused, it can trace that `ModelNotFoundError` doesn't
reference `createAnthropic` or any AI SDK import, and drop them. The namespace
object still exists at runtime — same API — but the bundler can see inside it.
### Concrete impact
The worst import chain in the codebase:
``` ```
src/index.ts (entry point) heavy.ts loaded?
A. namespace B. barrel C. self-reexport
Bun bundler YES YES no
esbuild YES YES no
Rollup (Vite) YES YES no
Bun runtime YES YES no
Node --experimental-strip-types SYNTAX ERROR YES no
```
- **`export namespace`** compiles to an IIFE. Bundlers see one opaque function
call and can't analyze what's used. Node's native TS runner rejects the
syntax outright: `SyntaxError: TypeScript namespace declaration is not
supported in strip-only mode`.
- **Barrel `index.ts`** files (`export * as Foo from "./foo"` in a separate
file) force every re-exported sibling to evaluate when you import one name.
Siblings with side effects (top-level imports of SDKs, etc.) always load.
- **Self-reexport** keeps the file as plain ESM. Bundlers see static named
exports. The module is only pulled in when something actually imports from
it. There is no barrel hop, so no sibling contamination and no circular
import hazard.
Bundle overhead for the self-reexport wrapper is roughly 240 bytes per module
(`Object.defineProperty` namespace proxy). At ~100 modules that's ~24KB —
negligible for a CLI binary.
## The pattern
### Before
```ts
// src/permission/arity.ts
export namespace BashArity {
export function prefix(tokens: string[]) { ... }
}
```
### After
```ts
// src/permission/arity.ts
export function prefix(tokens: string[]) { ... }
export * as BashArity from "./arity"
```
Consumers don't change at all:
```ts
import { BashArity } from "@/permission/arity"
BashArity.prefix(...) // still works
```
Editors still auto-import `BashArity` like any named export, because the file
does have a named `BashArity` export at the module top level.
### Odd but harmless
`BashArity.BashArity.BashArity.prefix(...)` compiles and runs because the
namespace contains a re-export of itself. Nobody would write that. Not a
problem.
## Why this is different from what we tried first
An earlier pass used sibling barrel files (`index.ts` with `export * as ...`).
That turned out to be wrong for our constraints:
1. The barrel file always loads all its sibling modules when you import
through it, even if you only need one. For our CLI this is exactly the
cost we're trying to avoid.
2. Barrel + sibling imports made it very easy to accidentally create circular
imports that only surface as `ReferenceError` at runtime, not at
typecheck.
The self-reexport has none of those issues. There is no indirection. The
file and the namespace are the same unit.
## Why this matters for startup
The worst import chain in the codebase looks like:
```
src/index.ts
└── FormatError from src/cli/error.ts └── FormatError from src/cli/error.ts
├── { Provider } from provider/provider.ts (1709 lines) ├── { Provider } from provider/provider.ts (~1700 lines)
│ ├── 20+ @ai-sdk/* packages │ ├── 20+ @ai-sdk/* packages
│ ├── @aws-sdk/credential-providers │ ├── @aws-sdk/credential-providers
│ ├── google-auth-library │ ├── google-auth-library
── gitlab-ai-provider, venice-ai-sdk-provider ── more
│ └── fuzzysort, remeda, etc. ├── { Config } from config/config.ts (~1600 lines)
── { Config } from config/config.ts (1663 lines) ── { MCP } from mcp/mcp.ts (~900 lines)
│ ├── jsonc-parser
│ ├── LSPServer (all server definitions)
│ └── Plugin, Auth, Env, Account, etc.
└── { MCP } from mcp/index.ts (930 lines)
├── @modelcontextprotocol/sdk (3 transports)
└── open (browser launcher)
``` ```
All of this gets pulled in to check `.isInstance()` on 6 error classes — code All of that currently gets pulled in just to do `.isInstance()` on a handful
that needs maybe 200 bytes total. This inflates the binary, increases startup of error classes. The namespace IIFE shape is the main reason bundlers cannot
memory, and slows down initial module evaluation. strip the unused parts. Self-reexport + flat ESM fixes it.
### Why this also hurts memory
Every module-level import is eagerly evaluated. Even with Bun's fast module
loader, evaluating 20+ AI SDK factory functions, the AWS credential chain, and
Google's auth library allocates objects, closures, and prototype chains that
persist for the lifetime of the process. Most CLI commands never use a provider
at all.
## What effect-smol does
effect-smol achieves tree-shakeable namespaced APIs via three structural choices.
### 1. Each module is a separate file with flat named exports
```ts
// Effect.ts — no namespace wrapper, just flat exports
export const gen: { ... } = internal.gen
export const fail: <E>(error: E) => Effect<never, E> = internal.fail
export const succeed: <A>(value: A) => Effect<A> = internal.succeed
// ... 230+ individual named exports
```
### 2. Barrel file uses `export * as` (not `export namespace`)
```ts
// index.ts
export * as Effect from "./Effect.ts"
export * as Schema from "./Schema.ts"
export * as Stream from "./Stream.ts"
// ~134 modules
```
This creates a namespace-like API (`Effect.gen`, `Schema.parse`) but the
bundler knows the **exact shape** at compile time — it's the static export list
of that file. It can trace property accesses (`Effect.gen` → keep `gen`,
drop `timeout` if unused). With `export namespace`, the IIFE is opaque and
nothing can be dropped.
### 3. `sideEffects: []` and deep imports
```jsonc
// package.json
{ "sideEffects": [] }
```
Plus `"./*": "./src/*.ts"` in the exports map, enabling
`import * as Effect from "effect/Effect"` to bypass the barrel entirely.
### 4. Errors as flat exports, not class declarations
```ts
// Cause.ts
export const NoSuchElementErrorTypeId = core.NoSuchElementErrorTypeId
export interface NoSuchElementError extends YieldableError { ... }
export const NoSuchElementError: new(msg?: string) => NoSuchElementError = core.NoSuchElementError
export const isNoSuchElementError: (u: unknown) => u is NoSuchElementError = core.isNoSuchElementError
```
Each error is 4 independent exports: TypeId, interface, constructor (as const),
type guard. All individually shakeable.
## The plan
The core migration is **Phase 1** — convert `export namespace` to
`export * as`. Once that's done, the bundler can tree-shake individual exports
within each module. You do NOT need to break things into subfiles for
tree-shaking to work — the bundler traces which exports you actually access on
the namespace object and drops the rest, including their transitive imports.
Splitting errors/schemas into separate files (Phase 0) is optional — it's a
lower-risk warmup step that can be done before or after the main conversion, and
it provides extra resilience against bundler edge cases. But the big win comes
from Phase 1.
### Phase 0 (optional): Pre-split errors into subfiles
This is a low-risk warmup that provides immediate benefit even before the full
`export * as` conversion. It's optional because Phase 1 alone is sufficient for
tree-shaking. But it's a good starting point if you want incremental progress:
**For each namespace that defines errors** (15 files, ~30 error classes total):
1. Create a sibling `errors.ts` file (e.g. `provider/errors.ts`) with the error
definitions as top-level named exports:
```ts
// provider/errors.ts
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { ProviderID, ModelID } from "./schema"
export const ModelNotFoundError = NamedError.create(
"ProviderModelNotFoundError",
z.object({
providerID: ProviderID.zod,
modelID: ModelID.zod,
suggestions: z.array(z.string()).optional(),
}),
)
export const InitError = NamedError.create("ProviderInitError", z.object({ providerID: ProviderID.zod }))
```
2. In the namespace file, re-export from the errors file to maintain backward
compatibility:
```ts
// provider/provider.ts — inside the namespace
export { ModelNotFoundError, InitError } from "./errors"
```
3. Update `cli/error.ts` (and any other light consumers) to import directly:
```ts
// BEFORE
import { Provider } from "../provider/provider"
Provider.ModelNotFoundError.isInstance(input)
// AFTER
import { ModelNotFoundError as ProviderModelNotFoundError } from "../provider/errors"
ProviderModelNotFoundError.isInstance(input)
```
**Files to split (Phase 0):**
| Current file | New errors file | Errors to extract |
| ----------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `provider/provider.ts` | `provider/errors.ts` | ModelNotFoundError, InitError |
| `provider/auth.ts` | `provider/auth-errors.ts` | OauthMissing, OauthCodeMissing, OauthCallbackFailed, ValidationFailed |
| `config/config.ts` | (already has `config/paths.ts`) | ConfigDirectoryTypoError → move to paths.ts |
| `config/markdown.ts` | `config/markdown-errors.ts` | FrontmatterError |
| `mcp/index.ts` | `mcp/errors.ts` | Failed |
| `session/message-v2.ts` | `session/message-errors.ts` | OutputLengthError, AbortedError, StructuredOutputError, AuthError, APIError, ContextOverflowError |
| `session/message.ts` | (shares with message-v2) | OutputLengthError, AuthError |
| `cli/ui.ts` | `cli/ui-errors.ts` | CancelledError |
| `skill/index.ts` | `skill/errors.ts` | InvalidError, NameMismatchError |
| `worktree/index.ts` | `worktree/errors.ts` | NotGitError, NameGenerationFailedError, CreateFailedError, StartCommandFailedError, RemoveFailedError, ResetFailedError |
| `storage/storage.ts` | `storage/errors.ts` | NotFoundError |
| `npm/index.ts` | `npm/errors.ts` | InstallFailedError |
| `ide/index.ts` | `ide/errors.ts` | AlreadyInstalledError, InstallFailedError |
| `lsp/client.ts` | `lsp/errors.ts` | InitializeError |
### Phase 1: The real migration — `export namespace` → `export * as`
This is the phase that actually fixes tree-shaking. For each module:
1. **Unwrap** the `export namespace Foo { ... }` — remove the namespace wrapper,
keep all the members as top-level `export const` / `export function` / etc.
2. **Rename** the file if it's currently `index.ts` (e.g. `bus/index.ts` →
`bus/bus.ts`), so the barrel can take `index.ts`.
3. **Create the barrel** `index.ts` with one line: `export * as Foo from "./foo"`
The file structure change for a module that's currently a single file:
```
# BEFORE
provider/
provider.ts ← 1709-line file with `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← SAME file, same name, just unwrap the namespace
```
And the code change is purely removing the wrapper:
```ts
// BEFORE: provider/provider.ts
export namespace Provider {
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
}
// AFTER: provider/provider.ts — identical exports, no namespace keyword
export class Service extends Context.Service<...>()("@opencode/Provider") {}
export const layer = Layer.effect(Service, ...)
export const ModelNotFoundError = NamedError.create(...)
export function parseModel(model: string) { ... }
```
```ts
// NEW: provider/index.ts
export * as Provider from "./provider"
```
Consumer code barely changes — import path gets shorter:
```ts
// BEFORE
import { Provider } from "../provider/provider"
// AFTER — resolves to provider/index.ts, same Provider object
import { Provider } from "../provider"
```
All access like `Provider.ModelNotFoundError`, `Provider.Service`,
`Provider.layer` works exactly as before. The difference is invisible to
consumers but lets the bundler see inside the namespace.
**Once this is done, you don't need to break anything into subfiles for
tree-shaking.** The bundler traces that `Provider.ModelNotFoundError` only
depends on `NamedError` + `zod` + the schema file, and drops
`Provider.layer` + all 20 AI SDK imports when they're unused. This works because
`export * as` gives the bundler a static export list it can do inner-graph
analysis on — it knows which exports reference which imports.
**Order of conversion** (by risk / size, do small modules first):
1. Tiny utilities: `Archive`, `Color`, `Token`, `Rpc`, `LocalContext` (~7-66 lines each)
2. Small services: `Auth`, `Env`, `BusEvent`, `SessionStatus`, `SessionRunState`, `Editor`, `Selection` (~25-91 lines)
3. Medium services: `Bus`, `Format`, `FileTime`, `FileWatcher`, `Command`, `Question`, `Permission`, `Vcs`, `Project`
4. Large services: `Config`, `Provider`, `MCP`, `Session`, `SessionProcessor`, `SessionPrompt`, `ACP`
### Phase 2: Build configuration
After the module structure supports tree-shaking:
1. Add `"sideEffects": []` to `packages/opencode/package.json` (or
`"sideEffects": false`) — this is safe because our services use explicit
layer composition, not import-time side effects.
2. Verify Bun's bundler respects the new structure. If Bun's tree-shaking is
insufficient, evaluate whether the compiled binary path needs an esbuild
pre-pass.
3. Consider adding `/*#__PURE__*/` annotations to `NamedError.create(...)` calls
— these are factory functions that return classes, and bundlers may not know
they're side-effect-free without the annotation.
## Automation ## Automation
The transformation is scripted. From `packages/opencode`: From `packages/opencode`:
```bash ```bash
bun script/unwrap-namespace.ts <file> [--dry-run] bun script/unwrap-namespace.ts <file> [--dry-run]
``` ```
The script uses ast-grep for accurate AST-based namespace boundary detection The script:
(no false matches from braces in strings/templates/comments), then:
1. Removes the `export namespace Foo {` line and its closing `}` 1. Uses ast-grep to locate the `export namespace Foo { ... }` block accurately.
2. Dedents the body by one indent level (2 spaces) 2. Removes the `export namespace Foo {` line and the matching closing `}`.
3. If the file is `index.ts`, renames it to `<name>.ts` and creates a new 3. Dedents the body by one indent level (2 spaces).
`index.ts` barrel 4. Rewrites `Foo.Bar` self-references inside the file to just `Bar`.
4. If the file is NOT `index.ts`, rewrites it in place and creates `index.ts` 5. Appends `export * as Foo from "./<basename>"` at the bottom of the file.
5. Prints the exact commands to find and rewrite import paths 6. Never creates a barrel `index.ts`.
### Walkthrough: converting a module ### Typical flow for one file
Using `Provider` as an example:
```bash ```bash
# 1. Preview what will change # 1. Preview
bun script/unwrap-namespace.ts src/provider/provider.ts --dry-run bun script/unwrap-namespace.ts src/permission/arity.ts --dry-run
# 2. Apply the transformation # 2. Apply
bun script/unwrap-namespace.ts src/provider/provider.ts bun script/unwrap-namespace.ts src/permission/arity.ts
# 3. Rewrite import paths (script prints the exact command) # 3. Verify
rg -l 'from.*provider/provider' src/ | xargs sed -i '' 's|provider/provider"|provider"|g' cd packages/opencode
bunx --bun tsgo --noEmit
# 4. Verify bun run --conditions=browser ./src/index.ts generate
bun typecheck bun run test <affected test files>
bun run test
``` ```
**What changes on disk:** ### Consumer imports usually don't need to change
``` Most consumers already import straight from the file, e.g.:
# BEFORE
provider/
provider.ts ← 1709 lines, `export namespace Provider { ... }`
# AFTER
provider/
index.ts ← NEW: `export * as Provider from "./provider"`
provider.ts ← same file, namespace unwrapped to flat exports
```
**What changes in consumer code:**
```ts ```ts
// BEFORE import { BashArity } from "@/permission/arity"
import { Provider } from "../provider/provider" import { Config } from "@/config/config"
// AFTER — shorter path, same Provider object
import { Provider } from "../provider"
``` ```
All property access (`Provider.Service`, `Provider.ModelNotFoundError`, etc.) Because the file itself now does `export * as Foo from "./foo"`, those imports
stays identical. keep working with zero edits.
### Two cases the script handles The only edits needed are when a consumer was importing through a previous
barrel (`"@/config"` or `"../config"` resolving to `config/index.ts`). In
**Case A: file is NOT `index.ts`** (e.g. `provider/provider.ts`) that case, repoint it at the file:
- Rewrites the file in place (unwrap + dedent)
- Creates `provider/index.ts` as the barrel
- Import paths change: `"../provider/provider"` → `"../provider"`
**Case B: file IS `index.ts`** (e.g. `bus/index.ts`)
- Renames `index.ts` → `bus.ts` (kebab-case of namespace name)
- Creates new `index.ts` as the barrel
- **No import rewrites needed** — `"@/bus"` already resolves to `bus/index.ts`
## Do I need to split errors/schemas into subfiles?
**No.** Once you do the `export * as` conversion, the bundler can tree-shake
individual exports within the file. If `cli/error.ts` only accesses
`Provider.ModelNotFoundError`, the bundler traces that `ModelNotFoundError`
doesn't reference `createAnthropic` and drops the AI SDK imports.
Splitting into subfiles (errors.ts, schema.ts) is still a fine idea for **code
organization** — smaller files are easier to read and review. But it's not
required for tree-shaking. The `export * as` conversion alone is sufficient.
The one case where subfile splitting provides extra tree-shake value is if an
imported package has module-level side effects that the bundler can't prove are
unused. In practice this is rare — most npm packages are side-effect-free — and
adding `"sideEffects": []` to package.json handles the common cases.
## Scope
| Metric | Count |
| ----------------------------------------------- | --------------- |
| Files with `export namespace` | 106 |
| Total namespace declarations | 118 (12 nested) |
| Files with `NamedError.create` inside namespace | 15 |
| Total error classes to extract | ~30 |
| Files using `export * as` today | 0 |
Phase 1 (the `export * as` conversion) is the main change. It's mechanical and
LLM-friendly but touches every import site, so it should be done module by
module with type-checking between each step. Each module is an independent PR.
## Rules for new code
Going forward:
- **No new `export namespace`**. Use a file with flat named exports and
`export * as` in the barrel.
- Keep the service, layer, errors, schemas, and runtime wiring together in one
file if you want — that's fine now. The `export * as` barrel makes everything
individually shakeable regardless of file structure.
- If a file grows large enough that it's hard to navigate, split by concern
(errors.ts, schema.ts, etc.) for readability. Not for tree-shaking — the
bundler handles that.
## Circular import rules
Barrel files (`index.ts` with `export * as`) introduce circular import risks.
These cause `ReferenceError: Cannot access 'X' before initialization` at
runtime — not caught by the type checker.
### Rule 1: Sibling files never import through their own barrel
Files in the same directory must import directly from the source file, never
through `"."` or `"@/<own-dir>"`:
```ts ```ts
// BAD — circular: index.ts re-exports both files, so A → index → B → index → A // before
import { Sibling } from "." import { Config } from "@/config"
// GOOD — direct, no cycle // after
import * as Sibling from "./sibling" import { Config } from "@/config/config"
``` ```
### Rule 2: Cross-directory imports must not form cycles through barrels ### Dynamic imports in tests
If `src/lsp/lsp.ts` imports `Config` from `"../config"`, and If a test did `const { Foo } = await import("../../src/x/y")`, the destructure
`src/config/config.ts` imports `LSPServer` from `"../lsp"`, that's a cycle: still works because of the self-reexport. No change required.
``` ## Verification checklist (per PR)
lsp/lsp.ts → config/index.ts → config/config.ts → lsp/index.ts → lsp/lsp.ts 💥
```
Fix by importing the specific file, breaking the cycle: Run all of these locally before pushing:
```ts
// In config/config.ts — import directly, not through the lsp barrel
import * as LSPServer from "../lsp/server"
```
### Why the type checker doesn't catch this
TypeScript resolves types lazily — it doesn't evaluate module-scope
expressions. The `ReferenceError` only happens at runtime when a module-scope
`const` or function call accesses a value from a circular dependency that
hasn't finished initializing. The SDK build step (`bun run --conditions=browser
./src/index.ts generate`) is the reliable way to catch these because it
evaluates all modules eagerly.
### How to verify
After any namespace conversion, run:
```bash ```bash
cd packages/opencode cd packages/opencode
bunx --bun tsgo --noEmit
bun run --conditions=browser ./src/index.ts generate bun run --conditions=browser ./src/index.ts generate
bun run test <affected test files>
``` ```
If this completes without `ReferenceError`, the module graph is safe. Also do a quick grep in `src/`, `test/`, and `script/` to make sure no
consumer is still importing the namespace from an old barrel path that no
longer exports it.
The SDK build step (`bun run --conditions=browser ./src/index.ts generate`)
evaluates every module eagerly and is the most reliable way to catch circular
import regressions at runtime — the typechecker does not catch these.
## Rules for new code
- No new `export namespace`.
- Every module directory has a single canonical file — typically
`dir/index.ts` — with flat top-level exports and a self-reexport at the
bottom:
`export * as Foo from "."`
- Consumers import from the directory:
`import { Foo } from "@/dir"` or `import { Foo } from "../dir"`.
- No sibling barrel files. If a directory has multiple independent
namespaces, they each get their own file (e.g. `config/config.ts`,
`config/plugin.ts`) and their own self-reexport; the `index.ts` in that
directory stays minimal or does not exist.
- If a file needs a sibling, import the sibling file directly:
`import * as Sibling from "./sibling"`, not `from "."`.
### Why `dir/index.ts` + `"."` is fine for us
A single-file module (e.g. `pty/`) can live entirely in `dir/index.ts`
with `export * as Foo from "."` at the bottom. Consumers write the
short form:
```ts
import { Pty } from "@/pty"
```
This works in Bun runtime, Bun build, esbuild, and Rollup. It does NOT
work under Node's `--experimental-strip-types` runner:
```
node --experimental-strip-types entry.ts
ERR_UNSUPPORTED_DIR_IMPORT: Directory import '/.../pty' is not supported
```
Node requires an explicit file or a `package.json#exports` map for ESM.
We don't care about that target right now because the opencode CLI is
built with Bun and the web apps are built with Vite/Rollup. If we ever
want to run raw `.ts` through Node, we'll need to either use explicit
`.ts` extensions everywhere or add per-directory `package.json` exports
maps.
### When NOT to collapse to `index.ts`
Some directories contain multiple independent namespaces where
`dir/index.ts` would be misleading. Examples:
- `config/` has `Config`, `ConfigPaths`, `ConfigMarkdown`, `ConfigPlugin`,
`ConfigKeybinds`. Each lives in its own file with its own self-reexport
(`config/config.ts`, `config/plugin.ts`, etc.). Consumers import the
specific one: `import { ConfigPlugin } from "@/config/plugin"`.
- Same shape for `session/`, `server/`, etc.
Collapsing one of those into `index.ts` would mean picking a single
"canonical" namespace for the directory, which breaks the symmetry and
hides the other files.
## Scope
There are still dozens of `export namespace` files left across the codebase.
Each one is its own small PR. Do them one at a time, verified locally, rather
than batching by directory.
+3 -2
View File
@@ -44,6 +44,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { MessageV2 } from "@/session/message-v2" import { MessageV2 } from "@/session/message-v2"
import { Config } from "@/config" import { Config } from "@/config"
import { ConfigMCP } from "@/config/mcp"
import { Todo } from "@/session/todo" import { Todo } from "@/session/todo"
import { z } from "zod" import { z } from "zod"
import { LoadAPIKeyError } from "ai" import { LoadAPIKeyError } from "ai"
@@ -177,7 +178,7 @@ export namespace ACP {
}) })
for await (const event of events.stream) { for await (const event of events.stream) {
if (this.eventAbort.signal.aborted) return if (this.eventAbort.signal.aborted) return
const payload = (event as any)?.payload const payload = event?.payload
if (!payload) continue if (!payload) continue
await this.handleEvent(payload as Event).catch((error) => { await this.handleEvent(payload as Event).catch((error) => {
log.error("failed to handle event", { error, type: payload.type }) log.error("failed to handle event", { error, type: payload.type })
@@ -1213,7 +1214,7 @@ export namespace ACP {
description: "compact the session", description: "compact the session",
}) })
const mcpServers: Record<string, Config.Mcp> = {} const mcpServers: Record<string, ConfigMCP.Info> = {}
for (const server of params.mcpServers) { for (const server of params.mcpServers) {
if ("type" in server) { if ("type" in server) {
mcpServers[server.name] = { mcpServers[server.name] = {
+5 -4
View File
@@ -8,6 +8,7 @@ import { MCP } from "../../mcp"
import { McpAuth } from "../../mcp/auth" import { McpAuth } from "../../mcp/auth"
import { McpOAuthProvider } from "../../mcp/oauth-provider" import { McpOAuthProvider } from "../../mcp/oauth-provider"
import { Config } from "../../config" import { Config } from "../../config"
import { ConfigMCP } from "../../config/mcp"
import { Instance } from "../../project/instance" import { Instance } from "../../project/instance"
import { Installation } from "../../installation" import { Installation } from "../../installation"
import { InstallationVersion } from "../../installation/version" import { InstallationVersion } from "../../installation/version"
@@ -43,7 +44,7 @@ function getAuthStatusText(status: MCP.AuthStatus): string {
type McpEntry = NonNullable<Config.Info["mcp"]>[string] type McpEntry = NonNullable<Config.Info["mcp"]>[string]
type McpConfigured = Config.Mcp type McpConfigured = ConfigMCP.Info
function isMcpConfigured(config: McpEntry): config is McpConfigured { function isMcpConfigured(config: McpEntry): config is McpConfigured {
return typeof config === "object" && config !== null && "type" in config return typeof config === "object" && config !== null && "type" in config
} }
@@ -426,7 +427,7 @@ async function resolveConfigPath(baseDir: string, global = false) {
return candidates[0] return candidates[0]
} }
async function addMcpToConfig(name: string, mcpConfig: Config.Mcp, configPath: string) { async function addMcpToConfig(name: string, mcpConfig: ConfigMCP.Info, configPath: string) {
let text = "{}" let text = "{}"
if (await Filesystem.exists(configPath)) { if (await Filesystem.exists(configPath)) {
text = await Filesystem.readText(configPath) text = await Filesystem.readText(configPath)
@@ -514,7 +515,7 @@ export const McpAddCommand = cmd({
}) })
if (prompts.isCancel(command)) throw new UI.CancelledError() if (prompts.isCancel(command)) throw new UI.CancelledError()
const mcpConfig: Config.Mcp = { const mcpConfig: ConfigMCP.Info = {
type: "local", type: "local",
command: command.split(" "), command: command.split(" "),
} }
@@ -544,7 +545,7 @@ export const McpAddCommand = cmd({
}) })
if (prompts.isCancel(useOAuth)) throw new UI.CancelledError() if (prompts.isCancel(useOAuth)) throw new UI.CancelledError()
let mcpConfig: Config.Mcp let mcpConfig: ConfigMCP.Info
if (useOAuth) { if (useOAuth) {
const hasClientId = await prompts.confirm({ const hasClientId = await prompts.confirm({
+3 -1
View File
@@ -297,7 +297,9 @@ export const ProvidersLoginCommand = cmd({
prompts.intro("Add credential") prompts.intro("Add credential")
if (args.url) { if (args.url) {
const url = args.url.replace(/\/+$/, "") const url = args.url.replace(/\/+$/, "")
const wellknown = await fetch(`${url}/.well-known/opencode`).then((x) => x.json() as any) const wellknown = (await fetch(`${url}/.well-known/opencode`).then((x) => x.json())) as {
auth: { command: string[]; env: string }
}
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Process.spawn(wellknown.auth.command, { const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe", stdout: "pipe",
@@ -0,0 +1,101 @@
import { TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export function DialogSessionDeleteFailed(props: {
session: string
workspace: string
onDelete?: () => boolean | void | Promise<boolean | void>
onRestore?: () => boolean | void | Promise<boolean | void>
onDone?: () => void
}) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "delete" as "delete" | "restore",
})
const options = [
{
id: "delete" as const,
title: "Delete workspace",
description: "Delete the workspace and all sessions attached to it.",
run: props.onDelete,
},
{
id: "restore" as const,
title: "Restore to new workspace",
description: "Try to restore this session into a new workspace.",
run: props.onRestore,
},
]
async function confirm() {
const result = await options.find((item) => item.id === store.active)?.run?.()
if (result === false) return
props.onDone?.()
if (!props.onDone) dialog.clear()
}
useKeyboard((evt) => {
if (evt.name === "return") {
void confirm()
}
if (evt.name === "left" || evt.name === "up") {
setStore("active", "delete")
}
if (evt.name === "right" || evt.name === "down") {
setStore("active", "restore")
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Failed to Delete Session
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.textMuted} wrapMode="word">
{`The session "${props.session}" could not be deleted because the workspace "${props.workspace}" is not available.`}
</text>
<text fg={theme.textMuted} wrapMode="word">
Choose how you want to recover this broken workspace session.
</text>
<box flexDirection="column" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
flexDirection="column"
paddingLeft={1}
paddingRight={1}
paddingTop={1}
paddingBottom={1}
backgroundColor={item.id === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item.id)
void confirm()
}}
>
<text
attributes={TextAttributes.BOLD}
fg={item.id === store.active ? theme.selectedListItemText : theme.text}
>
{item.title}
</text>
<text fg={item.id === store.active ? theme.selectedListItemText : theme.textMuted} wrapMode="word">
{item.description}
</text>
</box>
)}
</For>
</box>
</box>
)
}
@@ -13,8 +13,10 @@ import { DialogSessionRename } from "./dialog-session-rename"
import { Keybind } from "@/util" import { Keybind } from "@/util"
import { createDebouncedSignal } from "../util/signal" import { createDebouncedSignal } from "../util/signal"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
import { DialogWorkspaceCreate, openWorkspaceSession } from "./dialog-workspace-create" import { DialogWorkspaceCreate, openWorkspaceSession, restoreWorkspaceSession } from "./dialog-workspace-create"
import { Spinner } from "./spinner" import { Spinner } from "./spinner"
import { errorMessage } from "@/util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error" type WorkspaceStatus = "connected" | "connecting" | "disconnected" | "error"
@@ -30,7 +32,7 @@ export function DialogSessionList() {
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
const [searchResults] = createResource(search, async (query) => { const [searchResults, { refetch }] = createResource(search, async (query) => {
if (!query) return undefined if (!query) return undefined
const result = await sdk.client.session.list({ search: query, limit: 30 }) const result = await sdk.client.session.list({ search: query, limit: 30 })
return result.data ?? [] return result.data ?? []
@@ -56,6 +58,57 @@ export function DialogSessionList() {
)) ))
} }
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
const list = () => dialog.replace(() => <DialogSessionList />)
dialog.replace(() => (
<DialogSessionDeleteFailed
session={session.title}
workspace={workspace?.name ?? session.workspaceID!}
onDone={list}
onDelete={async () => {
const current = currentSessionID()
const info = current ? sync.data.session.find((item) => item.id === current) : undefined
const result = await sdk.client.experimental.workspace.remove({ id: session.workspaceID! })
if (result.error) {
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return false
}
await project.workspace.sync()
await sync.session.refresh()
if (search()) await refetch()
if (info?.workspaceID === session.workspaceID) {
route.navigate({ type: "home" })
}
return true
}}
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(workspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID,
sessionID: session.id,
done: list,
})
}
/>
))
return false
}}
/>
))
}
const options = createMemo(() => { const options = createMemo(() => {
const today = new Date().toDateString() const today = new Date().toDateString()
return sessions() return sessions()
@@ -145,9 +198,43 @@ export function DialogSessionList() {
title: "delete", title: "delete",
onTrigger: async (option) => { onTrigger: async (option) => {
if (toDelete() === option.value) { if (toDelete() === option.value) {
void sdk.client.session.delete({ const session = sessions().find((item) => item.id === option.value)
sessionID: option.value, const status = session?.workspaceID ? project.workspace.status(session.workspaceID) : undefined
})
try {
const result = await sdk.client.session.delete({
sessionID: option.value,
})
if (result.error) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(result.error),
})
}
setToDelete(undefined)
return
}
} catch (err) {
if (session?.workspaceID) {
recover(session)
} else {
toast.show({
variant: "error",
title: "Failed to delete session",
message: errorMessage(err),
})
}
setToDelete(undefined)
return
}
if (status && status !== "connected") {
await sync.session.refresh()
}
if (search()) await refetch()
setToDelete(undefined) setToDelete(undefined)
return return
} }
@@ -6,6 +6,8 @@ import { useSync } from "@tui/context/sync"
import { useProject } from "@tui/context/project" import { useProject } from "@tui/context/project"
import { createMemo, createSignal, onMount } from "solid-js" import { createMemo, createSignal, onMount } from "solid-js"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { errorData, errorMessage } from "@/util/error"
import * as Log from "@/util/log"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast" import { useToast } from "../ui/toast"
@@ -15,6 +17,8 @@ type Adaptor = {
description: string description: string
} }
const log = Log.Default.clone().tag("service", "tui-workspace")
function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) { function scoped(sdk: ReturnType<typeof useSDK>, sync: ReturnType<typeof useSync>, workspaceID: string) {
return createOpencodeClient({ return createOpencodeClient({
baseUrl: sdk.url, baseUrl: sdk.url,
@@ -33,8 +37,20 @@ export async function openWorkspaceSession(input: {
workspaceID: string workspaceID: string
}) { }) {
const client = scoped(input.sdk, input.sync, input.workspaceID) const client = scoped(input.sdk, input.sync, input.workspaceID)
log.info("workspace session create requested", {
workspaceID: input.workspaceID,
})
console.log("opening!")
while (true) { while (true) {
const result = await client.session.create({ workspaceID: input.workspaceID }).catch(() => undefined) console.log("creating")
const result = await client.session.create({ workspace: input.workspaceID }).catch((err) => {
log.error("workspace session create request failed", {
workspaceID: input.workspaceID,
error: errorData(err),
})
return undefined
})
if (!result) { if (!result) {
input.toast.show({ input.toast.show({
message: "Failed to create workspace session", message: "Failed to create workspace session",
@@ -42,26 +58,113 @@ export async function openWorkspaceSession(input: {
}) })
return return
} }
if (result.response.status >= 500 && result.response.status < 600) { log.info("workspace session create response", {
workspaceID: input.workspaceID,
status: result.response?.status,
sessionID: result.data?.id,
})
if (result.response?.status && result.response.status >= 500 && result.response.status < 600) {
log.warn("workspace session create retrying after server error", {
workspaceID: input.workspaceID,
status: result.response.status,
})
await sleep(1000) await sleep(1000)
continue continue
} }
if (!result.data) { if (!result.data) {
log.error("workspace session create returned no data", {
workspaceID: input.workspaceID,
status: result.response?.status,
})
input.toast.show({ input.toast.show({
message: "Failed to create workspace session", message: "Failed to create workspace session",
variant: "error", variant: "error",
}) })
return return
} }
input.route.navigate({ input.route.navigate({
type: "session", type: "session",
sessionID: result.data.id, sessionID: result.data.id,
}) })
log.info("workspace session create complete", {
workspaceID: input.workspaceID,
sessionID: result.data.id,
})
input.dialog.clear() input.dialog.clear()
return return
} }
} }
export async function restoreWorkspaceSession(input: {
dialog: ReturnType<typeof useDialog>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
project: ReturnType<typeof useProject>
toast: ReturnType<typeof useToast>
workspaceID: string
sessionID: string
done?: () => void
}) {
log.info("session restore requested", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
})
const result = await input.sdk.client.experimental.workspace
.sessionRestore({ id: input.workspaceID, sessionID: input.sessionID })
.catch((err) => {
log.error("session restore request failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
return undefined
})
if (!result?.data) {
log.error("session restore failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: result?.response?.status,
error: result?.error ? errorData(result.error) : undefined,
})
input.toast.show({
message: `Failed to restore session: ${errorMessage(result?.error ?? "no response")}`,
variant: "error",
})
return
}
log.info("session restore response", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
status: result.response?.status,
total: result.data.total,
})
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
log.error("session restore refresh failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
error: errorData(err),
})
throw err
})
log.info("session restore complete", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
total: result.data.total,
})
input.toast.show({
message: "Session restored into the new workspace",
variant: "success",
})
input.done?.()
if (input.done) return
input.dialog.clear()
}
export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) { export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> | void }) {
const dialog = useDialog() const dialog = useDialog()
const sync = useSync() const sync = useSync()
@@ -123,18 +226,43 @@ export function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) =
const create = async (type: string) => { const create = async (type: string) => {
if (creating()) return if (creating()) return
setCreating(type) setCreating(type)
log.info("workspace create requested", {
type,
})
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
log.error("workspace create request failed", {
type,
error: errorData(err),
})
return undefined
})
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch(() => undefined)
const workspace = result?.data const workspace = result?.data
if (!workspace) { if (!workspace) {
setCreating(undefined) setCreating(undefined)
log.error("workspace create failed", {
type,
status: result?.response.status,
error: result?.error ? errorData(result.error) : undefined,
})
toast.show({ toast.show({
message: "Failed to create workspace", message: `Failed to create workspace: ${errorMessage(result?.error ?? "no response")}`,
variant: "error", variant: "error",
}) })
return return
} }
log.info("workspace create response", {
type,
workspaceID: workspace.id,
status: result.response?.status,
})
await project.workspace.sync() await project.workspace.sync()
log.info("workspace create synced", {
type,
workspaceID: workspace.id,
})
await props.onSelect(workspace.id) await props.onSelect(workspace.id)
setCreating(undefined) setCreating(undefined)
} }
@@ -617,9 +617,7 @@ export function Prompt(props: PromptProps) {
let sessionID = props.sessionID let sessionID = props.sessionID
if (sessionID == null) { if (sessionID == null) {
const res = await sdk.client.session.create({ const res = await sdk.client.session.create({ workspace: props.workspaceID })
workspaceID: props.workspaceID,
})
if (res.error) { if (res.error) {
console.log("Creating a session failed:", res.error) console.log("Creating a session failed:", res.error)
@@ -1033,6 +1031,10 @@ export function Prompt(props: PromptProps) {
return return
} }
// Once we cross an async boundary below, the terminal may perform its
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
const filepath = iife(() => { const filepath = iife(() => {
const raw = pastedContent.replace(/^['"]+|['"]+$/g, "") const raw = pastedContent.replace(/^['"]+|['"]+$/g, "")
if (raw.startsWith("file://")) { if (raw.startsWith("file://")) {
@@ -1050,7 +1052,6 @@ export function Prompt(props: PromptProps) {
const filename = path.basename(filepath) const filename = path.basename(filepath)
// Handle SVG as raw text content, not as base64 image // Handle SVG as raw text content, not as base64 image
if (mime === "image/svg+xml") { if (mime === "image/svg+xml") {
event.preventDefault()
const content = await Filesystem.readText(filepath).catch(() => {}) const content = await Filesystem.readText(filepath).catch(() => {})
if (content) { if (content) {
pasteText(content, `[SVG: ${filename ?? "image"}]`) pasteText(content, `[SVG: ${filename ?? "image"}]`)
@@ -1058,7 +1059,6 @@ export function Prompt(props: PromptProps) {
} }
} }
if (mime.startsWith("image/") || mime === "application/pdf") { if (mime.startsWith("image/") || mime === "application/pdf") {
event.preventDefault()
const content = await Filesystem.readArrayBuffer(filepath) const content = await Filesystem.readArrayBuffer(filepath)
.then((buffer) => Buffer.from(buffer).toString("base64")) .then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {}) .catch(() => {})
@@ -1080,11 +1080,12 @@ export function Prompt(props: PromptProps) {
(lineCount >= 3 || pastedContent.length > 150) && (lineCount >= 3 || pastedContent.length > 150) &&
!sync.data.config.experimental?.disable_paste_summary !sync.data.config.experimental?.disable_paste_summary
) { ) {
event.preventDefault()
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return return
} }
input.insertText(normalizedText)
// Force layout update and render for the pasted content // Force layout update and render for the pasted content
setTimeout(() => { setTimeout(() => {
// setTimeout is a workaround and needs to be addressed properly // setTimeout is a workaround and needs to be addressed properly
+25 -20
View File
@@ -1,6 +1,7 @@
import z from "zod" import z from "zod"
import { mergeDeep, unique } from "remeda" import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect" import { Context, Effect, Fiber, Layer } from "effect"
import { ConfigParse } from "@/config/parse"
import * as ConfigPaths from "@/config/paths" import * as ConfigPaths from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate" import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema" import { TuiInfo } from "./tui-schema"
@@ -68,6 +69,14 @@ export namespace TuiConfig {
} }
} }
async function resolvePlugins(config: Info, configFilepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
}
return config
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) { async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file) const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data) acc.result = mergeDeep(acc.result, data)
@@ -183,26 +192,22 @@ export namespace TuiConfig {
} }
async function load(text: string, configFilepath: string): Promise<Info> { async function load(text: string, configFilepath: string): Promise<Info> {
const raw = await ConfigPaths.parseText(text, configFilepath, "empty") return ConfigParse.load(Info, text, {
if (!isRecord(raw)) return {} type: "path",
path: configFilepath,
missing: "empty",
normalize: (data) => {
if (!isRecord(data)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied. // (mirroring the old opencode.json shape) still get their settings applied.
const normalized = normalize(raw) return normalize(data)
},
const parsed = Info.safeParse(normalized) })
if (!parsed.success) { .then((data) => resolvePlugins(data, configFilepath))
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues }) .catch((error) => {
return {} log.warn("invalid tui config", { path: configFilepath, error })
} return {}
})
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = await ConfigPlugin.resolvePluginSpec(data.plugin[i], configFilepath)
}
}
return data
} }
} }
@@ -474,6 +474,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index] if (match.found) return store.session[match.index]
return undefined return undefined
}, },
async refresh() {
const start = Date.now() - 30 * 24 * 60 * 60 * 1000
const list = await sdk.client.session
.list({ start })
.then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id)))
setStore("session", reconcile(list))
},
status(sessionID: string) { status(sessionID: string) {
const session = result.session.get(sessionID) const session = result.session.get(sessionID)
if (!session) return "idle" if (!session) return "idle"
@@ -485,13 +492,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return last.time.completed ? "idle" : "working" return last.time.completed ? "idle" : "working"
}, },
async sync(sessionID: string) { async sync(sessionID: string) {
console.log("YO", sessionID, fullSyncedSessions.has(sessionID))
if (fullSyncedSessions.has(sessionID)) return if (fullSyncedSessions.has(sessionID)) return
const workspace = project.workspace.current()
const [session, messages, todo, diff] = await Promise.all([ const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID, workspace }, { throwOnError: true }), sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100, workspace }), sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID, workspace }), sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID, workspace }), sdk.client.session.diff({ sessionID }),
]) ])
setStore( setStore(
produce((draft) => { produce((draft) => {
@@ -16,6 +16,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { Log } from "@/util" import { Log } from "@/util"
import { errorData, errorMessage } from "@/util/error" import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance"
import { import {
readPackageThemes, readPackageThemes,
readPluginId, readPluginId,
@@ -789,7 +790,13 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
state.pending.delete(spec) state.pending.delete(spec)
return true return true
} }
const ready = await resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()) const ready = await Instance.provide({
directory: state.directory,
fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
}).catch((error) => {
fail("failed to add tui plugin", { path: next, error })
return [] as PluginLoad[]
})
if (!ready.length) { if (!ready.length) {
return false return false
} }
@@ -980,37 +987,42 @@ export namespace TuiPluginRuntime {
} }
runtime = next runtime = next
try { try {
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? []) await Instance.provide({
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) { directory: cwd,
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length }) fn: async () => {
} 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 INTERNAL_TUI_PLUGINS) { for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id }) log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item) const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
addPluginEntry(next, { addPluginEntry(next, {
id: entry.id, id: entry.id,
load: entry, load: entry,
meta, meta,
themes: {}, themes: {},
plugin: entry.module.tui, plugin: entry.module.tui,
enabled: true, enabled: true,
}) })
} }
const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies()) const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
await addExternalPluginEntries(next, ready) await addExternalPluginEntries(next, ready)
applyInitialPluginEnabledState(next, config) applyInitialPluginEnabledState(next, config)
for (const plugin of next.plugins) { for (const plugin of next.plugins) {
if (!plugin.enabled) continue if (!plugin.enabled) continue
// Keep plugin execution sequential for deterministic side effects: // Keep plugin execution sequential for deterministic side effects:
// command registration order affects keybind/command precedence, // command registration order affects keybind/command precedence,
// route registration is last-wins when ids collide, // route registration is last-wins when ids collide,
// and hook chains rely on stable plugin ordering. // and hook chains rely on stable plugin ordering.
await activatePluginEntry(next, plugin, false) await activatePluginEntry(next, plugin, false)
} }
},
})
} catch (error) { } catch (error) {
fail("failed to load tui plugins", { directory: cwd, error }) fail("failed to load tui plugins", { directory: cwd, error })
} }
@@ -44,6 +44,8 @@ import type { GrepTool } from "@/tool/grep"
import type { EditTool } from "@/tool/edit" import type { EditTool } from "@/tool/edit"
import type { ApplyPatchTool } from "@/tool/apply_patch" import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch" import type { WebFetchTool } from "@/tool/webfetch"
import type { CodeSearchTool } from "@/tool/codesearch"
import type { WebSearchTool } from "@/tool/websearch"
import type { TaskTool } from "@/tool/task" import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question" import type { QuestionTool } from "@/tool/question"
import type { SkillTool } from "@/tool/skill" import type { SkillTool } from "@/tool/skill"
@@ -597,7 +599,7 @@ export function Session() {
{ {
title: conceal() ? "Disable code concealment" : "Enable code concealment", title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal", value: "session.toggle.conceal",
keybind: "messages_toggle_conceal" as any, keybind: "messages_toggle_conceal",
category: "Session", category: "Session",
onSelect: (dialog) => { onSelect: (dialog) => {
setConceal((prev) => !prev) setConceal((prev) => !prev)
@@ -1934,28 +1936,26 @@ function Grep(props: ToolProps<typeof GrepTool>) {
function WebFetch(props: ToolProps<typeof WebFetchTool>) { function WebFetch(props: ToolProps<typeof WebFetchTool>) {
return ( return (
<InlineTool icon="%" pending="Fetching from the web..." complete={(props.input as any).url} part={props.part}> <InlineTool icon="%" pending="Fetching from the web..." complete={props.input.url} part={props.part}>
WebFetch {(props.input as any).url} WebFetch {props.input.url}
</InlineTool> </InlineTool>
) )
} }
function CodeSearch(props: ToolProps<any>) { function CodeSearch(props: ToolProps<typeof CodeSearchTool>) {
const input = props.input as any const metadata = props.metadata as { results?: number }
const metadata = props.metadata as any
return ( return (
<InlineTool icon="◇" pending="Searching code..." complete={input.query} part={props.part}> <InlineTool icon="◇" pending="Searching code..." complete={props.input.query} part={props.part}>
Exa Code Search "{input.query}" <Show when={metadata.results}>({metadata.results} results)</Show> Exa Code Search "{props.input.query}" <Show when={metadata.results}>({metadata.results} results)</Show>
</InlineTool> </InlineTool>
) )
} }
function WebSearch(props: ToolProps<any>) { function WebSearch(props: ToolProps<typeof WebSearchTool>) {
const input = props.input as any const metadata = props.metadata as { numResults?: number }
const metadata = props.metadata as any
return ( return (
<InlineTool icon="◈" pending="Searching web..." complete={input.query} part={props.part}> <InlineTool icon="◈" pending="Searching web..." complete={props.input.query} part={props.part}>
Exa Web Search "{input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show> Exa Web Search "{props.input.query}" <Show when={metadata.numResults}>({metadata.numResults} results)</Show>
</InlineTool> </InlineTool>
) )
} }
@@ -1979,7 +1979,9 @@ function Task(props: ToolProps<typeof TaskTool>) {
) )
}) })
const current = createMemo(() => tools().findLast((x) => (x.state as any).title)) const current = createMemo(() =>
tools().findLast((x) => (x.state.status === "running" || x.state.status === "completed") && x.state.title),
)
const isRunning = createMemo(() => props.part.state.status === "running") const isRunning = createMemo(() => props.part.state.status === "running")
@@ -1996,8 +1998,11 @@ function Task(props: ToolProps<typeof TaskTool>) {
if (isRunning() && tools().length > 0) { if (isRunning() && tools().length > 0) {
// content[0] += ` · ${tools().length} toolcalls` // content[0] += ` · ${tools().length} toolcalls`
if (current()) content.push(`${Locale.titlecase(current()!.tool)} ${(current()!.state as any).title}`) if (current()) {
else content.push(`${tools().length} toolcalls`) const state = current()!.state
const title = state.status === "running" || state.status === "completed" ? state.title : undefined
content.push(`${Locale.titlecase(current()!.tool)} ${title}`)
} else content.push(`${tools().length} toolcalls`)
} }
if (props.part.state.status === "completed") { if (props.part.state.status === "completed") {
+3 -2
View File
@@ -1,4 +1,5 @@
import { dlopen, ptr } from "bun:ffi" import { dlopen, ptr } from "bun:ffi"
import type { ReadStream } from "node:tty"
const STD_INPUT_HANDLE = -10 const STD_INPUT_HANDLE = -10
const ENABLE_PROCESSED_INPUT = 0x0001 const ENABLE_PROCESSED_INPUT = 0x0001
@@ -71,7 +72,7 @@ export function win32InstallCtrlCGuard() {
if (!load()) return if (!load()) return
if (unhook) return unhook if (unhook) return unhook
const stdin = process.stdin as any const stdin = process.stdin as ReadStream
const original = stdin.setRawMode const original = stdin.setRawMode
const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE) const handle = k32!.symbols.GetStdHandle(STD_INPUT_HANDLE)
@@ -93,7 +94,7 @@ export function win32InstallCtrlCGuard() {
setImmediate(enforce) setImmediate(enforce)
} }
let wrapped: ((mode: boolean) => unknown) | undefined let wrapped: ReadStream["setRawMode"] | undefined
if (typeof original === "function") { if (typeof original === "function") {
wrapped = (mode: boolean) => { wrapped = (mode: boolean) => {
+171
View File
@@ -0,0 +1,171 @@
export * as ConfigAgent from "./agent"
import { Log } from "../util"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
import { ConfigModelID } from "./model-id"
import { ConfigPermission } from "./permission"
const log = Log.create({ service: "config" })
export const Info = z
.object({
model: ConfigModelID.optional(),
variant: z
.string()
.optional()
.describe("Default model variant for this agent (applies only when using the agent's configured model)."),
temperature: z.number().optional(),
top_p: z.number().optional(),
prompt: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
disable: z.boolean().optional(),
description: z.string().optional().describe("Description of when to use the agent"),
mode: z.enum(["subagent", "primary", "all"]).optional(),
hidden: z
.boolean()
.optional()
.describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
options: z.record(z.string(), z.any()).optional(),
color: z
.union([
z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"),
z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
.optional()
.describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"),
steps: z
.number()
.int()
.positive()
.optional()
.describe("Maximum number of agentic iterations before forcing text-only response"),
maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
permission: ConfigPermission.Info.optional(),
})
.catchall(z.any())
.transform((agent, _ctx) => {
const knownKeys = new Set([
"name",
"model",
"variant",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"hidden",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])
const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!knownKeys.has(key)) options[key] = value
}
const permission: ConfigPermission.Info = {}
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
const action = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
permission.edit = action
continue
}
permission[tool] = action
}
Object.assign(permission, agent.permission)
const steps = agent.steps ?? agent.maxSteps
return { ...agent, options, permission, steps } as typeof agent & {
options?: Record<string, unknown>
permission?: ConfigPermission.Info
steps?: number
}
})
.meta({
ref: "AgentConfig",
})
export type Info = z.infer<typeof Info>
export async function load(dir: string) {
const result: Record<string, Info> = {}
for (const item of await Glob.scan("{agent,agents}/**/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse agent ${item}`
const { Session } = await import("@/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load agent", { agent: item, err })
return undefined
})
if (!md) continue
const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
const name = configEntryNameFromPath(item, patterns)
const config = {
name,
...md.data,
prompt: md.content.trim(),
}
const parsed = Info.safeParse(config)
if (parsed.success) {
result[config.name] = parsed.data
continue
}
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
}
return result
}
export async function loadMode(dir: string) {
const result: Record<string, Info> = {}
for (const item of await Glob.scan("{mode,modes}/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse mode ${item}`
const { Session } = await import("@/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load mode", { mode: item, err })
return undefined
})
if (!md) continue
const config = {
name: configEntryNameFromPath(item, []),
...md.data,
prompt: md.content.trim(),
}
const parsed = Info.safeParse(config)
if (parsed.success) {
result[config.name] = {
...parsed.data,
mode: "primary" as const,
}
}
}
return result
}
+60
View File
@@ -0,0 +1,60 @@
export * as ConfigCommand from "./command"
import { Log } from "../util"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
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 = z.object({
template: z.string(),
description: z.string().optional(),
agent: z.string().optional(),
model: ConfigModelID.optional(),
subtask: z.boolean().optional(),
})
export type Info = z.infer<typeof Info>
export async function load(dir: string) {
const result: Record<string, Info> = {}
for (const item of await Glob.scan("{command,commands}/**/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse command ${item}`
const { Session } = await import("@/session")
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
log.error("failed to load command", { command: item, err })
return undefined
})
if (!md) continue
const patterns = ["/.opencode/command/", "/.opencode/commands/", "/command/", "/commands/"]
const name = configEntryNameFromPath(item, patterns)
const config = {
name,
...md.data,
template: md.content.trim(),
}
const parsed = Info.safeParse(config)
if (parsed.success) {
result[config.name] = parsed.data
continue
}
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
}
return result
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
import path from "path"
function sliceAfterMatch(filePath: string, searchRoots: string[]) {
const normalizedPath = filePath.replaceAll("\\", "/")
for (const searchRoot of searchRoots) {
const index = normalizedPath.indexOf(searchRoot)
if (index === -1) continue
return normalizedPath.slice(index + searchRoot.length)
}
}
export function configEntryNameFromPath(filePath: string, searchRoots: string[]) {
const candidate = sliceAfterMatch(filePath, searchRoots) ?? path.basename(filePath)
const ext = path.extname(candidate)
return ext.length ? candidate.slice(0, -ext.length) : candidate
}
+21
View File
@@ -0,0 +1,21 @@
export * as ConfigError from "./error"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
+11
View File
@@ -1,3 +1,14 @@
export * as Config from "./config" export * as Config from "./config"
export * as ConfigAgent from "./agent"
export * as ConfigCommand from "./command"
export * as ConfigError from "./error"
export * as ConfigVariable from "./variable"
export { ConfigManaged } from "./managed"
export * as ConfigMarkdown from "./markdown" export * as ConfigMarkdown from "./markdown"
export * as ConfigMCP from "./mcp"
export { ConfigModelID } from "./model-id"
export * as ConfigParse from "./parse"
export * as ConfigPermission from "./permission"
export * as ConfigPaths from "./paths" export * as ConfigPaths from "./paths"
export * as ConfigProvider from "./provider"
export * as ConfigSkills from "./skills"
+154 -162
View File
@@ -1,164 +1,156 @@
export * as ConfigKeybinds from "./keybinds"
import z from "zod" import z from "zod"
export namespace ConfigKeybinds { export const Keybinds = z
export const Keybinds = z .object({
.object({ leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"), app_exit: z.string().optional().default("ctrl+c,ctrl+d,<leader>q").describe("Exit the application"),
app_exit: z.string().optional().default("ctrl+c,ctrl+d,<leader>q").describe("Exit the application"), editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"), theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
theme_list: z.string().optional().default("<leader>t").describe("List available themes"), sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"), scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"), username_toggle: z.string().optional().default("none").describe("Toggle username visibility"),
username_toggle: z.string().optional().default("none").describe("Toggle username visibility"), status_view: z.string().optional().default("<leader>s").describe("View status"),
status_view: z.string().optional().default("<leader>s").describe("View status"), session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
session_export: z.string().optional().default("<leader>x").describe("Export session to editor"), session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
session_new: z.string().optional().default("<leader>n").describe("Create a new session"), session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
session_list: z.string().optional().default("<leader>l").describe("List all sessions"), session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"), session_fork: z.string().optional().default("none").describe("Fork session from message"),
session_fork: z.string().optional().default("none").describe("Fork session from message"), session_rename: z.string().optional().default("ctrl+r").describe("Rename session"),
session_rename: z.string().optional().default("ctrl+r").describe("Rename session"), session_delete: z.string().optional().default("ctrl+d").describe("Delete session"),
session_delete: z.string().optional().default("ctrl+d").describe("Delete session"), stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"), model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"), model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"), session_share: z.string().optional().default("none").describe("Share current session"),
session_share: z.string().optional().default("none").describe("Share current session"), session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"), session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"), messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"),
messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"), messages_page_down: z
messages_page_down: z .string()
.string() .optional()
.optional() .default("pagedown,ctrl+alt+f")
.default("pagedown,ctrl+alt+f") .describe("Scroll messages down by one page"),
.describe("Scroll messages down by one page"), messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"),
messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"), messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"),
messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"), messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), messages_half_page_down: z.string().optional().default("ctrl+alt+d").describe("Scroll messages down by half page"),
messages_half_page_down: z messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"),
.string() messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"),
.optional() messages_next: z.string().optional().default("none").describe("Navigate to next message"),
.default("ctrl+alt+d") messages_previous: z.string().optional().default("none").describe("Navigate to previous message"),
.describe("Scroll messages down by half page"), messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"),
messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"), messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"), messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
messages_next: z.string().optional().default("none").describe("Navigate to next message"), messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
messages_previous: z.string().optional().default("none").describe("Navigate to previous message"), messages_toggle_conceal: z
messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"), .string()
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"), .optional()
messages_undo: z.string().optional().default("<leader>u").describe("Undo message"), .default("<leader>h")
messages_redo: z.string().optional().default("<leader>r").describe("Redo message"), .describe("Toggle code block concealment in messages"),
messages_toggle_conceal: z tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"),
.string() model_list: z.string().optional().default("<leader>m").describe("List available models"),
.optional() model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
.default("<leader>h") model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
.describe("Toggle code block concealment in messages"), model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"), model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
model_list: z.string().optional().default("<leader>m").describe("List available models"), command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"), agent_list: z.string().optional().default("<leader>a").describe("List agents"),
model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"), agent_cycle: z.string().optional().default("tab").describe("Next agent"),
model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"), agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"), variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
command_list: z.string().optional().default("ctrl+p").describe("List available commands"), variant_list: z.string().optional().default("none").describe("List model variants"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"), input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"), input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), input_submit: z.string().optional().default("return").describe("Submit input"),
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), input_newline: z
variant_list: z.string().optional().default("none").describe("List model variants"), .string()
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), .optional()
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), .default("shift+return,ctrl+return,alt+return,ctrl+j")
input_submit: z.string().optional().default("return").describe("Submit input"), .describe("Insert newline in input"),
input_newline: z input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"),
.string() input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"),
.optional() input_move_up: z.string().optional().default("up").describe("Move cursor up in input"),
.default("shift+return,ctrl+return,alt+return,ctrl+j") input_move_down: z.string().optional().default("down").describe("Move cursor down in input"),
.describe("Insert newline in input"), input_select_left: z.string().optional().default("shift+left").describe("Select left in input"),
input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"), input_select_right: z.string().optional().default("shift+right").describe("Select right in input"),
input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"), input_select_up: z.string().optional().default("shift+up").describe("Select up in input"),
input_move_up: z.string().optional().default("up").describe("Move cursor up in input"), input_select_down: z.string().optional().default("shift+down").describe("Select down in input"),
input_move_down: z.string().optional().default("down").describe("Move cursor down in input"), input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"),
input_select_left: z.string().optional().default("shift+left").describe("Select left in input"), input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"),
input_select_right: z.string().optional().default("shift+right").describe("Select right in input"), input_select_line_home: z.string().optional().default("ctrl+shift+a").describe("Select to start of line in input"),
input_select_up: z.string().optional().default("shift+up").describe("Select up in input"), input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"),
input_select_down: z.string().optional().default("shift+down").describe("Select down in input"), input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"),
input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"), input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"),
input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"), input_select_visual_line_home: z
input_select_line_home: z .string()
.string() .optional()
.optional() .default("alt+shift+a")
.default("ctrl+shift+a") .describe("Select to start of visual line in input"),
.describe("Select to start of line in input"), input_select_visual_line_end: z
input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"), .string()
input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"), .optional()
input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"), .default("alt+shift+e")
input_select_visual_line_home: z .describe("Select to end of visual line in input"),
.string() input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"),
.optional() input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"),
.default("alt+shift+a") input_select_buffer_home: z
.describe("Select to start of visual line in input"), .string()
input_select_visual_line_end: z .optional()
.string() .default("shift+home")
.optional() .describe("Select to start of buffer in input"),
.default("alt+shift+e") input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"),
.describe("Select to end of visual line in input"), input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"),
input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"), input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"),
input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"), input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"),
input_select_buffer_home: z input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"),
.string() input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"),
.optional() input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"),
.default("shift+home") input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
.describe("Select to start of buffer in input"), input_word_forward: z
input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"), .string()
input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"), .optional()
input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"), .default("alt+f,alt+right,ctrl+right")
input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"), .describe("Move word forward in input"),
input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"), input_word_backward: z
input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"), .string()
input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"), .optional()
input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"), .default("alt+b,alt+left,ctrl+left")
input_word_forward: z .describe("Move word backward in input"),
.string() input_select_word_forward: z
.optional() .string()
.default("alt+f,alt+right,ctrl+right") .optional()
.describe("Move word forward in input"), .default("alt+shift+f,alt+shift+right")
input_word_backward: z .describe("Select word forward in input"),
.string() input_select_word_backward: z
.optional() .string()
.default("alt+b,alt+left,ctrl+left") .optional()
.describe("Move word backward in input"), .default("alt+shift+b,alt+shift+left")
input_select_word_forward: z .describe("Select word backward in input"),
.string() input_delete_word_forward: z
.optional() .string()
.default("alt+shift+f,alt+shift+right") .optional()
.describe("Select word forward in input"), .default("alt+d,alt+delete,ctrl+delete")
input_select_word_backward: z .describe("Delete word forward in input"),
.string() input_delete_word_backward: z
.optional() .string()
.default("alt+shift+b,alt+shift+left") .optional()
.describe("Select word backward in input"), .default("ctrl+w,ctrl+backspace,alt+backspace")
input_delete_word_forward: z .describe("Delete word backward in input"),
.string() history_previous: z.string().optional().default("up").describe("Previous history item"),
.optional() history_next: z.string().optional().default("down").describe("Next history item"),
.default("alt+d,alt+delete,ctrl+delete") session_child_first: z.string().optional().default("<leader>down").describe("Go to first child session"),
.describe("Delete word forward in input"), session_child_cycle: z.string().optional().default("right").describe("Go to next child session"),
input_delete_word_backward: z session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"),
.string() session_parent: z.string().optional().default("up").describe("Go to parent session"),
.optional() terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
.default("ctrl+w,ctrl+backspace,alt+backspace") terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
.describe("Delete word backward in input"), tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
history_previous: z.string().optional().default("up").describe("Previous history item"), plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),
history_next: z.string().optional().default("down").describe("Next history item"), display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
session_child_first: z.string().optional().default("<leader>down").describe("Go to first child session"), })
session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), .strict()
session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), .meta({
session_parent: z.string().optional().default("up").describe("Go to parent session"), ref: "KeybindsConfig",
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), })
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),
display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
})
.strict()
.meta({
ref: "KeybindsConfig",
})
}
+70
View File
@@ -0,0 +1,70 @@
export * as ConfigManaged from "./managed"
import { existsSync } from "fs"
import os from "os"
import path from "path"
import { Log, Process } from "../util"
import { warn } from "console"
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
const PLIST_META = new Set([
"PayloadDisplayName",
"PayloadIdentifier",
"PayloadType",
"PayloadUUID",
"PayloadVersion",
"_manualProfile",
])
function systemManagedConfigDir(): string {
switch (process.platform) {
case "darwin":
return "/Library/Application Support/opencode"
case "win32":
return path.join(process.env.ProgramData || "C:\\ProgramData", "opencode")
default:
return "/etc/opencode"
}
}
export function managedConfigDir() {
return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
}
export function parseManagedPlist(json: string): string {
const raw = JSON.parse(json)
for (const key of Object.keys(raw)) {
if (PLIST_META.has(key)) delete raw[key]
}
return JSON.stringify(raw)
}
export async function readManagedPreferences() {
if (process.platform !== "darwin") return
const user = os.userInfo().username
const paths = [
path.join("/Library/Managed Preferences", user, `${MANAGED_PLIST_DOMAIN}.plist`),
path.join("/Library/Managed Preferences", `${MANAGED_PLIST_DOMAIN}.plist`),
]
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 {
source: `mobileconfig:${plist}`,
text: parseManagedPlist(result.stdout.toString()),
}
}
return
}
+70
View File
@@ -0,0 +1,70 @@
import z from "zod"
export namespace ConfigMCP {
export const Local = z
.object({
type: z.literal("local").describe("Type of MCP server connection"),
command: z.string().array().describe("Command and arguments to run the MCP server"),
environment: z
.record(z.string(), z.string())
.optional()
.describe("Environment variables to set when running the MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpLocalConfig",
})
export const OAuth = z
.object({
clientId: z
.string()
.optional()
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
redirectUri: z
.string()
.optional()
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
})
.strict()
.meta({
ref: "McpOAuthConfig",
})
export type OAuth = z.infer<typeof OAuth>
export const Remote = z
.object({
type: z.literal("remote").describe("Type of MCP server connection"),
url: z.string().describe("URL of the remote MCP server"),
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
oauth: z
.union([OAuth, z.literal(false)])
.optional()
.describe(
"OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
),
timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
})
.strict()
.meta({
ref: "McpRemoteConfig",
})
export const Info = z.discriminatedUnion("type", [Local, Remote])
export type Info = z.infer<typeof Info>
}
+3
View File
@@ -0,0 +1,3 @@
import z from "zod"
export const ConfigModelID = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
+80
View File
@@ -0,0 +1,80 @@
export * as ConfigParse from "./parse"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import z from "zod"
import { ConfigVariable } from "./variable"
import { InvalidError, JsonError } from "./error"
type Schema<T> = z.ZodType<T>
type VariableMode = "error" | "empty"
export type LoadOptions =
| {
type: "path"
path: string
missing?: VariableMode
normalize?: (data: unknown, source: string) => unknown
}
| {
type: "virtual"
dir: string
source: string
missing?: VariableMode
normalize?: (data: unknown, source: string) => unknown
}
function issues(text: string, errors: JsoncParseError[]) {
const lines = text.split("\n")
return errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
}
export function parse<T>(schema: Schema<T>, text: string, filepath: string): T {
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
throw new JsonError({
path: filepath,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues(text, errors)}\n--- End ---`,
})
}
const parsed = schema.safeParse(data)
if (parsed.success) return parsed.data
throw new InvalidError({
path: filepath,
issues: parsed.error.issues,
})
}
export async function load<T>(schema: Schema<T>, text: string, options: LoadOptions): Promise<T> {
const source = options.type === "path" ? options.path : options.source
const expanded = await ConfigVariable.substitute(
text,
options.type === "path" ? { type: "path", path: options.path } : options,
options.missing,
)
const data = parse(z.unknown(), expanded, source)
const normalized = options.normalize ? options.normalize(data, source) : data
const parsed = schema.safeParse(normalized)
if (!parsed.success) {
throw new InvalidError({
path: source,
issues: parsed.error.issues,
})
}
return parsed.data
}
+6 -124
View File
@@ -1,18 +1,18 @@
export * as ConfigPaths from "./paths"
import path from "path" import path from "path"
import os from "os"
import z from "zod"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Filesystem } from "@/util" import { Filesystem } from "@/util"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Global } from "@/global" import { Global } from "@/global"
import { unique } from "remeda"
import { JsonError } from "./error"
export async function projectFiles(name: string, directory: string, worktree?: string) { export async function projectFiles(name: string, directory: string, worktree?: string) {
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true }) return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
} }
export async function directories(directory: string, worktree?: string) { export async function directories(directory: string, worktree?: string) {
return [ return unique([
Global.Path.config, Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync( ? await Array.fromAsync(
@@ -31,30 +31,13 @@ export async function directories(directory: string, worktree?: string) {
}), }),
)), )),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []), ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
] ])
} }
export function fileInDirectory(dir: string, name: string) { export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)] return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
} }
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */ /** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
export async function readFile(filepath: string) { export async function readFile(filepath: string) {
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => { return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
@@ -62,104 +45,3 @@ export async function readFile(filepath: string) {
throw new JsonError({ path: filepath }, { cause: err }) throw new JsonError({ path: filepath }, { cause: err })
}) })
} }
type ParseSource = string | { source: string; dir: string }
function source(input: ParseSource) {
return typeof input === "string" ? input : input.source
}
function dir(input: ParseSource) {
return typeof input === "string" ? path.dirname(input) : input.dir
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = dir(input)
const configSource = source(input)
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configSource,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
const configSource = source(input)
text = await substitute(text, input, missing)
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: configSource,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
return data
}
@@ -0,0 +1,68 @@
export * as ConfigPermission from "./permission"
import z from "zod"
const permissionPreprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
return { __originalKeys: globalThis.Object.keys(val), ...val }
}
return val
}
export const Action = z.enum(["ask", "allow", "deny"]).meta({
ref: "PermissionActionConfig",
})
export type Action = z.infer<typeof Action>
export const Object = z.record(z.string(), Action).meta({
ref: "PermissionObjectConfig",
})
export type Object = z.infer<typeof Object>
export const Rule = z.union([Action, Object]).meta({
ref: "PermissionRuleConfig",
})
export type Rule = z.infer<typeof Rule>
const transform = (x: unknown): Record<string, Rule> => {
if (typeof x === "string") return { "*": x as Action }
const obj = x as { __originalKeys?: string[] } & Record<string, unknown>
const { __originalKeys, ...rest } = obj
if (!__originalKeys) return rest as Record<string, Rule>
const result: Record<string, Rule> = {}
for (const key of __originalKeys) {
if (key in rest) result[key] = rest[key] as Rule
}
return result
}
export const Info = z
.preprocess(
permissionPreprocess,
z
.object({
__originalKeys: z.string().array().optional(),
read: Rule.optional(),
edit: Rule.optional(),
glob: Rule.optional(),
grep: Rule.optional(),
list: Rule.optional(),
bash: Rule.optional(),
task: Rule.optional(),
external_directory: Rule.optional(),
todowrite: Action.optional(),
question: Action.optional(),
webfetch: Action.optional(),
websearch: Action.optional(),
codesearch: Action.optional(),
lsp: Rule.optional(),
doom_loop: Action.optional(),
skill: Rule.optional(),
})
.catchall(Rule)
.or(Action),
)
.transform(transform)
.meta({
ref: "PermissionConfig",
})
export type Info = z.infer<typeof Info>
+10 -1
View File
@@ -8,11 +8,16 @@ export namespace ConfigPlugin {
const Options = z.record(z.string(), z.unknown()) const Options = z.record(z.string(), z.unknown())
export type Options = z.infer<typeof Options> export type Options = z.infer<typeof Options>
// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
// It answers "what should we load?" but says nothing about where that value came from.
export const Spec = z.union([z.string(), z.tuple([z.string(), Options])]) export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
export type Spec = z.infer<typeof Spec> export type Spec = z.infer<typeof Spec>
export type Scope = "global" | "local" export type Scope = "global" | "local"
// Origin keeps the original config provenance attached to a spec.
// After multiple config files are merged, callers still need to know which file declared the plugin
// and whether it should behave like a global or project-local plugin.
export type Origin = { export type Origin = {
spec: Spec spec: Spec
source: string source: string
@@ -33,7 +38,7 @@ export namespace ConfigPlugin {
return plugins return plugins
} }
export function pluginSpecifier(plugin: ConfigPlugin.Spec): string { export function pluginSpecifier(plugin: Spec): string {
return Array.isArray(plugin) ? plugin[0] : plugin return Array.isArray(plugin) ? plugin[0] : plugin
} }
@@ -41,6 +46,8 @@ export namespace ConfigPlugin {
return Array.isArray(plugin) ? plugin[1] : undefined return Array.isArray(plugin) ? plugin[1] : undefined
} }
// Path-like specs are resolved relative to the config file that declared them so merges later on do not
// accidentally reinterpret `./plugin.ts` relative to some other directory.
export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise<Spec> { export async function resolvePluginSpec(plugin: Spec, configFilepath: string): Promise<Spec> {
const spec = pluginSpecifier(plugin) const spec = pluginSpecifier(plugin)
if (!isPathPluginSpec(spec)) return plugin if (!isPathPluginSpec(spec)) return plugin
@@ -58,6 +65,8 @@ export namespace ConfigPlugin {
return resolved return resolved
} }
// Dedupe on the load identity (package name for npm specs, exact file URL for local specs), but keep the
// full Origin so downstream code still knows which config file won and where follow-up writes should go.
export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] { export function deduplicatePluginOrigins(plugins: Origin[]): Origin[] {
const seen = new Set<string>() const seen = new Set<string>()
const list: Origin[] = [] const list: Origin[] = []
+120
View File
@@ -0,0 +1,120 @@
import z from "zod"
export namespace ConfigProvider {
export const Model = z
.object({
id: z.string(),
name: z.string(),
family: z.string().optional(),
release_date: z.string(),
attachment: z.boolean(),
reasoning: z.boolean(),
temperature: z.boolean(),
tool_call: z.boolean(),
interleaved: z
.union([
z.literal(true),
z
.object({
field: z.enum(["reasoning_content", "reasoning_details"]),
})
.strict(),
])
.optional(),
cost: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
context_over_200k: z
.object({
input: z.number(),
output: z.number(),
cache_read: z.number().optional(),
cache_write: z.number().optional(),
})
.optional(),
})
.optional(),
limit: z.object({
context: z.number(),
input: z.number().optional(),
output: z.number(),
}),
modalities: z
.object({
input: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
variants: z
.record(
z.string(),
z
.object({
disabled: z.boolean().optional().describe("Disable this variant for the model"),
})
.catchall(z.any()),
)
.optional()
.describe("Variant-specific configuration"),
})
.partial()
export const Info = z
.object({
api: z.string().optional(),
name: z.string(),
env: z.array(z.string()),
id: z.string(),
npm: z.string().optional(),
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
options: z
.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"),
setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"),
timeout: z
.union([
z
.number()
.int()
.positive()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
z.literal(false).describe("Disable timeout for this provider entirely."),
])
.optional()
.describe(
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
),
chunkTimeout: z
.number()
.int()
.positive()
.optional()
.describe(
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
),
})
.catchall(z.any())
.optional(),
models: z.record(z.string(), Model).optional(),
})
.partial()
.strict()
.meta({
ref: "ProviderConfig",
})
export type Info = z.infer<typeof Info>
}
+13
View File
@@ -0,0 +1,13 @@
import z from "zod"
export namespace ConfigSkills {
export const Info = z.object({
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
urls: z
.array(z.string())
.optional()
.describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"),
})
export type Info = z.infer<typeof Info>
}
+84
View File
@@ -0,0 +1,84 @@
export * as ConfigVariable from "./variable"
import path from "path"
import os from "os"
import { Filesystem } from "@/util"
import { InvalidError } from "./error"
type ParseSource =
| {
type: "path"
path: string
}
| {
type: "virtual"
source: string
dir: string
}
function source(input: ParseSource) {
return input.type === "path" ? input.path : input.source
}
function dir(input: ParseSource) {
return input.type === "path" ? path.dirname(input.path) : input.dir
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = dir(input)
const configSource = source(input)
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configSource,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}
@@ -2,17 +2,17 @@ import { LocalContext } from "../util"
import type { WorkspaceID } from "../control-plane/schema" import type { WorkspaceID } from "../control-plane/schema"
export interface WorkspaceContext { export interface WorkspaceContext {
workspaceID: string workspaceID: WorkspaceID
} }
const context = LocalContext.create<WorkspaceContext>("instance") const context = LocalContext.create<WorkspaceContext>("instance")
export const WorkspaceContext = { export const WorkspaceContext = {
async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> { async provide<R>(input: { workspaceID: WorkspaceID; fn: () => R }): Promise<R> {
return context.provide({ workspaceID: input.workspaceID as string }, () => input.fn()) return context.provide({ workspaceID: input.workspaceID }, () => input.fn())
}, },
restore<R>(workspaceID: string, fn: () => R): R { restore<R>(workspaceID: WorkspaceID, fn: () => R): R {
return context.provide({ workspaceID }, fn) return context.provide({ workspaceID }, fn)
}, },
@@ -460,8 +460,7 @@ export namespace Workspace {
if (!("payload" in evt)) return if (!("payload" in evt)) return
if (evt.payload.type === "sync") { if (evt.payload.type === "sync") {
// This name -> type is temporary SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent)
SyncEvent.replay({ ...evt.payload, type: evt.payload.name } as SyncEvent.SerializedEvent)
} }
GlobalBus.emit("event", { GlobalBus.emit("event", {
+2 -1
View File
@@ -1,6 +1,7 @@
import { Effect, Fiber } from "effect" import { Effect, Fiber } from "effect"
import { WorkspaceContext } from "@/control-plane/workspace-context" import { WorkspaceContext } from "@/control-plane/workspace-context"
import { Instance, type InstanceContext } from "@/project/instance" import { Instance, type InstanceContext } from "@/project/instance"
import type { WorkspaceID } from "@/control-plane/schema"
import { LocalContext } from "@/util" import { LocalContext } from "@/util"
import { InstanceRef, WorkspaceRef } from "./instance-ref" import { InstanceRef, WorkspaceRef } from "./instance-ref"
import { attachWith } from "./run-service" import { attachWith } from "./run-service"
@@ -10,7 +11,7 @@ export interface Shape {
readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E> readonly fork: <A, E, R>(effect: Effect.Effect<A, E, R>) => Fiber.Fiber<A, E>
} }
function restore<R>(instance: InstanceContext | undefined, workspace: string | undefined, fn: () => R): R { function restore<R>(instance: InstanceContext | undefined, workspace: WorkspaceID | undefined, fn: () => R): R {
if (instance && workspace !== undefined) { if (instance && workspace !== undefined) {
return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn)) return WorkspaceContext.restore(workspace, () => Instance.restore(instance, fn))
} }
+2 -1
View File
@@ -1,10 +1,11 @@
import { Context } from "effect" import { Context } from "effect"
import type { InstanceContext } from "@/project/instance" import type { InstanceContext } from "@/project/instance"
import type { WorkspaceID } from "@/control-plane/schema"
export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", { export const InstanceRef = Context.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
defaultValue: () => undefined, defaultValue: () => undefined,
}) })
export const WorkspaceRef = Context.Reference<string | undefined>("~opencode/WorkspaceRef", { export const WorkspaceRef = Context.Reference<WorkspaceID | undefined>("~opencode/WorkspaceRef", {
defaultValue: () => undefined, defaultValue: () => undefined,
}) })
+3 -3
View File
@@ -465,12 +465,12 @@ export const layer = Layer.effect(
direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls", direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",
) { ) {
const results = yield* run(input.file, async (client) => { const results = yield* run(input.file, async (client) => {
const items = (await client.connection const items = await client.connection
.sendRequest("textDocument/prepareCallHierarchy", { .sendRequest<unknown[] | null>("textDocument/prepareCallHierarchy", {
textDocument: { uri: pathToFileURL(input.file).href }, textDocument: { uri: pathToFileURL(input.file).href },
position: { line: input.line, character: input.character }, position: { line: input.line, character: input.character },
}) })
.catch(() => [])) as any[] .catch(() => [] as unknown[])
if (!items?.length) return [] if (!items?.length) return []
return client.connection.sendRequest(direction, { item: items[0] }).catch(() => []) return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])
}) })
+5 -3
View File
@@ -611,7 +611,9 @@ export const Zls: Info = {
return return
} }
const release = (await releaseResponse.json()) as any const release = (await releaseResponse.json()) as {
assets?: { name?: string; browser_download_url?: string }[]
}
const platform = process.platform const platform = process.platform
const arch = process.arch const arch = process.arch
@@ -646,8 +648,8 @@ export const Zls: Info = {
return return
} }
const asset = release.assets.find((a: any) => a.name === assetName) const asset = release.assets?.find((a) => a.name === assetName)
if (!asset) { if (!asset?.browser_download_url) {
log.error(`Could not find asset ${assetName} in latest zls release`) log.error(`Could not find asset ${assetName} in latest zls release`)
return return
} }
+17 -10
View File
@@ -10,6 +10,7 @@ import {
ToolListChangedNotificationSchema, ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js" } from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config" import { Config } from "../config"
import { ConfigMCP } from "../config/mcp"
import { Log } from "../util" import { Log } from "../util"
import { NamedError } from "@opencode-ai/shared/util/error" import { NamedError } from "@opencode-ai/shared/util/error"
import z from "zod/v4" import z from "zod/v4"
@@ -123,7 +124,7 @@ type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][numbe
type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number] type ResourceInfo = Awaited<ReturnType<MCPClient["listResources"]>>["resources"][number]
type McpEntry = NonNullable<Config.Info["mcp"]>[string] type McpEntry = NonNullable<Config.Info["mcp"]>[string]
function isMcpConfigured(entry: McpEntry): entry is Config.Mcp { function isMcpConfigured(entry: McpEntry): entry is ConfigMCP.Info {
return typeof entry === "object" && entry !== null && "type" in entry return typeof entry === "object" && entry !== null && "type" in entry
} }
@@ -224,7 +225,7 @@ export interface Interface {
readonly tools: () => Effect.Effect<Record<string, Tool>> readonly tools: () => Effect.Effect<Record<string, Tool>>
readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>> readonly prompts: () => Effect.Effect<Record<string, PromptInfo & { client: string }>>
readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>> readonly resources: () => Effect.Effect<Record<string, ResourceInfo & { client: string }>>
readonly add: (name: string, mcp: Config.Mcp) => Effect.Effect<{ status: Record<string, Status> | Status }> readonly add: (name: string, mcp: ConfigMCP.Info) => Effect.Effect<{ status: Record<string, Status> | Status }>
readonly connect: (name: string) => Effect.Effect<void> readonly connect: (name: string) => Effect.Effect<void>
readonly disconnect: (name: string) => Effect.Effect<void> readonly disconnect: (name: string) => Effect.Effect<void>
readonly getPrompt: ( readonly getPrompt: (
@@ -276,7 +277,10 @@ export const layer = Layer.effect(
const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } } const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } }
const connectRemote = Effect.fn("MCP.connectRemote")(function* (key: string, mcp: Config.Mcp & { type: "remote" }) { const connectRemote = Effect.fn("MCP.connectRemote")(function* (
key: string,
mcp: ConfigMCP.Info & { type: "remote" },
) {
const oauthDisabled = mcp.oauth === false const oauthDisabled = mcp.oauth === false
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
let authProvider: McpOAuthProvider | undefined let authProvider: McpOAuthProvider | undefined
@@ -382,7 +386,10 @@ export const layer = Layer.effect(
} }
}) })
const connectLocal = Effect.fn("MCP.connectLocal")(function* (key: string, mcp: Config.Mcp & { type: "local" }) { const connectLocal = Effect.fn("MCP.connectLocal")(function* (
key: string,
mcp: ConfigMCP.Info & { type: "local" },
) {
const [cmd, ...args] = mcp.command const [cmd, ...args] = mcp.command
const cwd = Instance.directory const cwd = Instance.directory
const transport = new StdioClientTransport({ const transport = new StdioClientTransport({
@@ -414,7 +421,7 @@ export const layer = Layer.effect(
) )
}) })
const create = Effect.fn("MCP.create")(function* (key: string, mcp: Config.Mcp) { const create = Effect.fn("MCP.create")(function* (key: string, mcp: ConfigMCP.Info) {
if (mcp.enabled === false) { if (mcp.enabled === false) {
log.info("mcp server disabled", { key }) log.info("mcp server disabled", { key })
return DISABLED_RESULT return DISABLED_RESULT
@@ -424,8 +431,8 @@ export const layer = Layer.effect(
const { client: mcpClient, status } = const { client: mcpClient, status } =
mcp.type === "remote" mcp.type === "remote"
? yield* connectRemote(key, mcp as Config.Mcp & { type: "remote" }) ? yield* connectRemote(key, mcp as ConfigMCP.Info & { type: "remote" })
: yield* connectLocal(key, mcp as Config.Mcp & { type: "local" }) : yield* connectLocal(key, mcp as ConfigMCP.Info & { type: "local" })
if (!mcpClient) { if (!mcpClient) {
return { status } satisfies CreateResult return { status } satisfies CreateResult
@@ -524,7 +531,7 @@ export const layer = Layer.effect(
Object.values(s.clients), Object.values(s.clients),
(client) => (client) =>
Effect.gen(function* () { Effect.gen(function* () {
const pid = (client.transport as any)?.pid const pid = client.transport instanceof StdioClientTransport ? client.transport.pid : null
if (typeof pid === "number") { if (typeof pid === "number") {
const pids = yield* descendants(pid) const pids = yield* descendants(pid)
for (const dpid of pids) { for (const dpid of pids) {
@@ -588,7 +595,7 @@ export const layer = Layer.effect(
return s.clients return s.clients
}) })
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) { const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: ConfigMCP.Info) {
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
const result = yield* create(name, mcp) const result = yield* create(name, mcp)
@@ -602,7 +609,7 @@ export const layer = Layer.effect(
return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout) return yield* storeClient(s, name, result.mcpClient, result.defs!, mcp.timeout)
}) })
const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) { const add = Effect.fn("MCP.add")(function* (name: string, mcp: ConfigMCP.Info) {
yield* createAndStore(name, mcp) yield* createAndStore(name, mcp)
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
return { status: s.status } return { status: s.status }
+680 -1
View File
@@ -1 +1,680 @@
export * as Patch from "./patch" import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { readFileSync } from "fs"
import { Log } from "../util"
const log = Log.create({ service: "patch" })
// Schema definitions
export const PatchSchema = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
export type PatchParams = z.infer<typeof PatchSchema>
// Core types matching the Rust implementation
export interface ApplyPatchArgs {
patch: string
hunks: Hunk[]
workdir?: string
}
export type Hunk =
| { type: "add"; path: string; contents: string }
| { type: "delete"; path: string }
| { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] }
export interface UpdateFileChunk {
old_lines: string[]
new_lines: string[]
change_context?: string
is_end_of_file?: boolean
}
export interface ApplyPatchAction {
changes: Map<string, ApplyPatchFileChange>
patch: string
cwd: string
}
export type ApplyPatchFileChange =
| { type: "add"; content: string }
| { type: "delete"; content: string }
| { type: "update"; unified_diff: string; move_path?: string; new_content: string }
export interface AffectedPaths {
added: string[]
modified: string[]
deleted: string[]
}
export enum ApplyPatchError {
ParseError = "ParseError",
IoError = "IoError",
ComputeReplacements = "ComputeReplacements",
ImplicitInvocation = "ImplicitInvocation",
}
export enum MaybeApplyPatch {
Body = "Body",
ShellParseError = "ShellParseError",
PatchParseError = "PatchParseError",
NotApplyPatch = "NotApplyPatch",
}
export enum MaybeApplyPatchVerified {
Body = "Body",
ShellParseError = "ShellParseError",
CorrectnessError = "CorrectnessError",
NotApplyPatch = "NotApplyPatch",
}
// Parser implementation
function parsePatchHeader(
lines: string[],
startIdx: number,
): { filePath: string; movePath?: string; nextIdx: number } | null {
const line = lines[startIdx]
if (line.startsWith("*** Add File:")) {
const filePath = line.slice("*** Add File:".length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}
if (line.startsWith("*** Delete File:")) {
const filePath = line.slice("*** Delete File:".length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}
if (line.startsWith("*** Update File:")) {
const filePath = line.slice("*** Update File:".length).trim()
let movePath: string | undefined
let nextIdx = startIdx + 1
// Check for move directive
if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
movePath = lines[nextIdx].slice("*** Move to:".length).trim()
nextIdx++
}
return filePath ? { filePath, movePath, nextIdx } : null
}
return null
}
function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } {
const chunks: UpdateFileChunk[] = []
let i = startIdx
while (i < lines.length && !lines[i].startsWith("***")) {
if (lines[i].startsWith("@@")) {
// Parse context line
const contextLine = lines[i].substring(2).trim()
i++
const oldLines: string[] = []
const newLines: string[] = []
let isEndOfFile = false
// Parse change lines
while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
const changeLine = lines[i]
if (changeLine === "*** End of File") {
isEndOfFile = true
i++
break
}
if (changeLine.startsWith(" ")) {
// Keep line - appears in both old and new
const content = changeLine.substring(1)
oldLines.push(content)
newLines.push(content)
} else if (changeLine.startsWith("-")) {
// Remove line - only in old
oldLines.push(changeLine.substring(1))
} else if (changeLine.startsWith("+")) {
// Add line - only in new
newLines.push(changeLine.substring(1))
}
i++
}
chunks.push({
old_lines: oldLines,
new_lines: newLines,
change_context: contextLine || undefined,
is_end_of_file: isEndOfFile || undefined,
})
} else {
i++
}
}
return { chunks, nextIdx: i }
}
function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } {
let content = ""
let i = startIdx
while (i < lines.length && !lines[i].startsWith("***")) {
if (lines[i].startsWith("+")) {
content += lines[i].substring(1) + "\n"
}
i++
}
// Remove trailing newline
if (content.endsWith("\n")) {
content = content.slice(0, -1)
}
return { content, nextIdx: i }
}
function stripHeredoc(input: string): string {
// Match heredoc patterns like: cat <<'EOF'\n...\nEOF or <<EOF\n...\nEOF
const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
if (heredocMatch) {
return heredocMatch[2]
}
return input
}
export function parsePatch(patchText: string): { hunks: Hunk[] } {
const cleaned = stripHeredoc(patchText.trim())
const lines = cleaned.split("\n")
const hunks: Hunk[] = []
let i = 0
// Look for Begin/End patch markers
const beginMarker = "*** Begin Patch"
const endMarker = "*** End Patch"
const beginIdx = lines.findIndex((line) => line.trim() === beginMarker)
const endIdx = lines.findIndex((line) => line.trim() === endMarker)
if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
throw new Error("Invalid patch format: missing Begin/End markers")
}
// Parse content between markers
i = beginIdx + 1
while (i < endIdx) {
const header = parsePatchHeader(lines, i)
if (!header) {
i++
continue
}
if (lines[i].startsWith("*** Add File:")) {
const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx)
hunks.push({
type: "add",
path: header.filePath,
contents: content,
})
i = nextIdx
} else if (lines[i].startsWith("*** Delete File:")) {
hunks.push({
type: "delete",
path: header.filePath,
})
i = header.nextIdx
} else if (lines[i].startsWith("*** Update File:")) {
const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx)
hunks.push({
type: "update",
path: header.filePath,
move_path: header.movePath,
chunks,
})
i = nextIdx
} else {
i++
}
}
return { hunks }
}
// Apply patch functionality
export function maybeParseApplyPatch(
argv: string[],
):
| { type: MaybeApplyPatch.Body; args: ApplyPatchArgs }
| { type: MaybeApplyPatch.PatchParseError; error: Error }
| { type: MaybeApplyPatch.NotApplyPatch } {
const APPLY_PATCH_COMMANDS = ["apply_patch", "applypatch"]
// Direct invocation: apply_patch <patch>
if (argv.length === 2 && APPLY_PATCH_COMMANDS.includes(argv[0])) {
try {
const { hunks } = parsePatch(argv[1])
return {
type: MaybeApplyPatch.Body,
args: {
patch: argv[1],
hunks,
},
}
} catch (error) {
return {
type: MaybeApplyPatch.PatchParseError,
error: error as Error,
}
}
}
// Bash heredoc form: bash -lc 'apply_patch <<"EOF" ...'
if (argv.length === 3 && argv[0] === "bash" && argv[1] === "-lc") {
// Simple extraction - in real implementation would need proper bash parsing
const script = argv[2]
const heredocMatch = script.match(/apply_patch\s*<<['"](\w+)['"]\s*\n([\s\S]*?)\n\1/)
if (heredocMatch) {
const patchContent = heredocMatch[2]
try {
const { hunks } = parsePatch(patchContent)
return {
type: MaybeApplyPatch.Body,
args: {
patch: patchContent,
hunks,
},
}
} catch (error) {
return {
type: MaybeApplyPatch.PatchParseError,
error: error as Error,
}
}
}
}
return { type: MaybeApplyPatch.NotApplyPatch }
}
// File content manipulation
interface ApplyPatchFileUpdate {
unified_diff: string
content: string
}
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
// Read original file content
let originalContent: string
try {
originalContent = readFileSync(filePath, "utf-8")
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
}
let originalLines = originalContent.split("\n")
// Drop trailing empty element for consistent line counting
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
originalLines.pop()
}
const replacements = computeReplacements(originalLines, filePath, chunks)
let newLines = applyReplacements(originalLines, replacements)
// Ensure trailing newline
if (newLines.length === 0 || newLines[newLines.length - 1] !== "") {
newLines.push("")
}
const newContent = newLines.join("\n")
// Generate unified diff
const unifiedDiff = generateUnifiedDiff(originalContent, newContent)
return {
unified_diff: unifiedDiff,
content: newContent,
}
}
function computeReplacements(
originalLines: string[],
filePath: string,
chunks: UpdateFileChunk[],
): Array<[number, number, string[]]> {
const replacements: Array<[number, number, string[]]> = []
let lineIndex = 0
for (const chunk of chunks) {
// Handle context-based seeking
if (chunk.change_context) {
const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex)
if (contextIdx === -1) {
throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`)
}
lineIndex = contextIdx + 1
}
// Handle pure addition (no old lines)
if (chunk.old_lines.length === 0) {
const insertionIdx =
originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
? originalLines.length - 1
: originalLines.length
replacements.push([insertionIdx, 0, chunk.new_lines])
continue
}
// Try to match old lines in the file
let pattern = chunk.old_lines
let newSlice = chunk.new_lines
let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
// Retry without trailing empty line if not found
if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
pattern = pattern.slice(0, -1)
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
newSlice = newSlice.slice(0, -1)
}
found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
}
if (found !== -1) {
replacements.push([found, pattern.length, newSlice])
lineIndex = found + pattern.length
} else {
throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}`)
}
}
// Sort replacements by index to apply in order
replacements.sort((a, b) => a[0] - b[0])
return replacements
}
function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] {
// Apply replacements in reverse order to avoid index shifting
const result = [...lines]
for (let i = replacements.length - 1; i >= 0; i--) {
const [startIdx, oldLen, newSegment] = replacements[i]
// Remove old lines
result.splice(startIdx, oldLen)
// Insert new lines
for (let j = 0; j < newSegment.length; j++) {
result.splice(startIdx + j, 0, newSegment[j])
}
}
return result
}
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
function normalizeUnicode(str: string): string {
return str
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
.replace(/\u2026/g, "...") // ellipsis
.replace(/\u00A0/g, " ") // non-breaking space
}
type Comparator = (a: string, b: string) => boolean
function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number {
// If EOF anchor, try matching from end of file first
if (eof) {
const fromEnd = lines.length - pattern.length
if (fromEnd >= startIndex) {
let matches = true
for (let j = 0; j < pattern.length; j++) {
if (!compare(lines[fromEnd + j], pattern[j])) {
matches = false
break
}
}
if (matches) return fromEnd
}
}
// Forward search from startIndex
for (let i = startIndex; i <= lines.length - pattern.length; i++) {
let matches = true
for (let j = 0; j < pattern.length; j++) {
if (!compare(lines[i + j], pattern[j])) {
matches = false
break
}
}
if (matches) return i
}
return -1
}
function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number {
if (pattern.length === 0) return -1
// Pass 1: exact match
const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof)
if (exact !== -1) return exact
// Pass 2: rstrip (trim trailing whitespace)
const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof)
if (rstrip !== -1) return rstrip
// Pass 3: trim (both ends)
const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof)
if (trim !== -1) return trim
// Pass 4: normalized (Unicode punctuation to ASCII)
const normalized = tryMatch(
lines,
pattern,
startIndex,
(a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
eof,
)
return normalized
}
function generateUnifiedDiff(oldContent: string, newContent: string): string {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
// Simple diff generation - in a real implementation you'd use a proper diff algorithm
let diff = "@@ -1 +1 @@\n"
// Find changes (simplified approach)
const maxLen = Math.max(oldLines.length, newLines.length)
let hasChanges = false
for (let i = 0; i < maxLen; i++) {
const oldLine = oldLines[i] || ""
const newLine = newLines[i] || ""
if (oldLine !== newLine) {
if (oldLine) diff += `-${oldLine}\n`
if (newLine) diff += `+${newLine}\n`
hasChanges = true
} else if (oldLine) {
diff += ` ${oldLine}\n`
}
}
return hasChanges ? diff : ""
}
// Apply hunks to filesystem
export async function applyHunksToFiles(hunks: Hunk[]): Promise<AffectedPaths> {
if (hunks.length === 0) {
throw new Error("No files were modified.")
}
const added: string[] = []
const modified: string[] = []
const deleted: string[] = []
for (const hunk of hunks) {
switch (hunk.type) {
case "add":
// Create parent directories
const addDir = path.dirname(hunk.path)
if (addDir !== "." && addDir !== "/") {
await fs.mkdir(addDir, { recursive: true })
}
await fs.writeFile(hunk.path, hunk.contents, "utf-8")
added.push(hunk.path)
log.info(`Added file: ${hunk.path}`)
break
case "delete":
await fs.unlink(hunk.path)
deleted.push(hunk.path)
log.info(`Deleted file: ${hunk.path}`)
break
case "update":
const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
if (hunk.move_path) {
// Handle file move
const moveDir = path.dirname(hunk.move_path)
if (moveDir !== "." && moveDir !== "/") {
await fs.mkdir(moveDir, { recursive: true })
}
await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8")
await fs.unlink(hunk.path)
modified.push(hunk.move_path)
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
} else {
// Regular update
await fs.writeFile(hunk.path, fileUpdate.content, "utf-8")
modified.push(hunk.path)
log.info(`Updated file: ${hunk.path}`)
}
break
}
}
return { added, modified, deleted }
}
// Main patch application function
export async function applyPatch(patchText: string): Promise<AffectedPaths> {
const { hunks } = parsePatch(patchText)
return applyHunksToFiles(hunks)
}
// Async version of maybeParseApplyPatchVerified
export async function maybeParseApplyPatchVerified(
argv: string[],
cwd: string,
): Promise<
| { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction }
| { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
| { type: MaybeApplyPatchVerified.NotApplyPatch }
> {
// Detect implicit patch invocation (raw patch without apply_patch command)
if (argv.length === 1) {
try {
parsePatch(argv[0])
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: new Error(ApplyPatchError.ImplicitInvocation),
}
} catch {
// Not a patch, continue
}
}
const result = maybeParseApplyPatch(argv)
switch (result.type) {
case MaybeApplyPatch.Body:
const { args } = result
const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
const changes = new Map<string, ApplyPatchFileChange>()
for (const hunk of args.hunks) {
const resolvedPath = path.resolve(
effectiveCwd,
hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path,
)
switch (hunk.type) {
case "add":
changes.set(resolvedPath, {
type: "add",
content: hunk.contents,
})
break
case "delete":
// For delete, we need to read the current content
const deletePath = path.resolve(effectiveCwd, hunk.path)
try {
const content = await fs.readFile(deletePath, "utf-8")
changes.set(resolvedPath, {
type: "delete",
content,
})
} catch {
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: new Error(`Failed to read file for deletion: ${deletePath}`),
}
}
break
case "update":
const updatePath = path.resolve(effectiveCwd, hunk.path)
try {
const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks)
changes.set(resolvedPath, {
type: "update",
unified_diff: fileUpdate.unified_diff,
move_path: hunk.move_path ? path.resolve(effectiveCwd, hunk.move_path) : undefined,
new_content: fileUpdate.content,
})
} catch (error) {
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: error as Error,
}
}
break
}
}
return {
type: MaybeApplyPatchVerified.Body,
action: {
changes,
patch: args.patch,
cwd: effectiveCwd,
},
}
case MaybeApplyPatch.PatchParseError:
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: result.error,
}
case MaybeApplyPatch.NotApplyPatch:
return { type: MaybeApplyPatchVerified.NotApplyPatch }
}
}
export * as Patch from "."
-678
View File
@@ -1,678 +0,0 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { readFileSync } from "fs"
import { Log } from "../util"
const log = Log.create({ service: "patch" })
// Schema definitions
export const PatchSchema = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
})
export type PatchParams = z.infer<typeof PatchSchema>
// Core types matching the Rust implementation
export interface ApplyPatchArgs {
patch: string
hunks: Hunk[]
workdir?: string
}
export type Hunk =
| { type: "add"; path: string; contents: string }
| { type: "delete"; path: string }
| { type: "update"; path: string; move_path?: string; chunks: UpdateFileChunk[] }
export interface UpdateFileChunk {
old_lines: string[]
new_lines: string[]
change_context?: string
is_end_of_file?: boolean
}
export interface ApplyPatchAction {
changes: Map<string, ApplyPatchFileChange>
patch: string
cwd: string
}
export type ApplyPatchFileChange =
| { type: "add"; content: string }
| { type: "delete"; content: string }
| { type: "update"; unified_diff: string; move_path?: string; new_content: string }
export interface AffectedPaths {
added: string[]
modified: string[]
deleted: string[]
}
export enum ApplyPatchError {
ParseError = "ParseError",
IoError = "IoError",
ComputeReplacements = "ComputeReplacements",
ImplicitInvocation = "ImplicitInvocation",
}
export enum MaybeApplyPatch {
Body = "Body",
ShellParseError = "ShellParseError",
PatchParseError = "PatchParseError",
NotApplyPatch = "NotApplyPatch",
}
export enum MaybeApplyPatchVerified {
Body = "Body",
ShellParseError = "ShellParseError",
CorrectnessError = "CorrectnessError",
NotApplyPatch = "NotApplyPatch",
}
// Parser implementation
function parsePatchHeader(
lines: string[],
startIdx: number,
): { filePath: string; movePath?: string; nextIdx: number } | null {
const line = lines[startIdx]
if (line.startsWith("*** Add File:")) {
const filePath = line.slice("*** Add File:".length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}
if (line.startsWith("*** Delete File:")) {
const filePath = line.slice("*** Delete File:".length).trim()
return filePath ? { filePath, nextIdx: startIdx + 1 } : null
}
if (line.startsWith("*** Update File:")) {
const filePath = line.slice("*** Update File:".length).trim()
let movePath: string | undefined
let nextIdx = startIdx + 1
// Check for move directive
if (nextIdx < lines.length && lines[nextIdx].startsWith("*** Move to:")) {
movePath = lines[nextIdx].slice("*** Move to:".length).trim()
nextIdx++
}
return filePath ? { filePath, movePath, nextIdx } : null
}
return null
}
function parseUpdateFileChunks(lines: string[], startIdx: number): { chunks: UpdateFileChunk[]; nextIdx: number } {
const chunks: UpdateFileChunk[] = []
let i = startIdx
while (i < lines.length && !lines[i].startsWith("***")) {
if (lines[i].startsWith("@@")) {
// Parse context line
const contextLine = lines[i].substring(2).trim()
i++
const oldLines: string[] = []
const newLines: string[] = []
let isEndOfFile = false
// Parse change lines
while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("***")) {
const changeLine = lines[i]
if (changeLine === "*** End of File") {
isEndOfFile = true
i++
break
}
if (changeLine.startsWith(" ")) {
// Keep line - appears in both old and new
const content = changeLine.substring(1)
oldLines.push(content)
newLines.push(content)
} else if (changeLine.startsWith("-")) {
// Remove line - only in old
oldLines.push(changeLine.substring(1))
} else if (changeLine.startsWith("+")) {
// Add line - only in new
newLines.push(changeLine.substring(1))
}
i++
}
chunks.push({
old_lines: oldLines,
new_lines: newLines,
change_context: contextLine || undefined,
is_end_of_file: isEndOfFile || undefined,
})
} else {
i++
}
}
return { chunks, nextIdx: i }
}
function parseAddFileContent(lines: string[], startIdx: number): { content: string; nextIdx: number } {
let content = ""
let i = startIdx
while (i < lines.length && !lines[i].startsWith("***")) {
if (lines[i].startsWith("+")) {
content += lines[i].substring(1) + "\n"
}
i++
}
// Remove trailing newline
if (content.endsWith("\n")) {
content = content.slice(0, -1)
}
return { content, nextIdx: i }
}
function stripHeredoc(input: string): string {
// Match heredoc patterns like: cat <<'EOF'\n...\nEOF or <<EOF\n...\nEOF
const heredocMatch = input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)
if (heredocMatch) {
return heredocMatch[2]
}
return input
}
export function parsePatch(patchText: string): { hunks: Hunk[] } {
const cleaned = stripHeredoc(patchText.trim())
const lines = cleaned.split("\n")
const hunks: Hunk[] = []
let i = 0
// Look for Begin/End patch markers
const beginMarker = "*** Begin Patch"
const endMarker = "*** End Patch"
const beginIdx = lines.findIndex((line) => line.trim() === beginMarker)
const endIdx = lines.findIndex((line) => line.trim() === endMarker)
if (beginIdx === -1 || endIdx === -1 || beginIdx >= endIdx) {
throw new Error("Invalid patch format: missing Begin/End markers")
}
// Parse content between markers
i = beginIdx + 1
while (i < endIdx) {
const header = parsePatchHeader(lines, i)
if (!header) {
i++
continue
}
if (lines[i].startsWith("*** Add File:")) {
const { content, nextIdx } = parseAddFileContent(lines, header.nextIdx)
hunks.push({
type: "add",
path: header.filePath,
contents: content,
})
i = nextIdx
} else if (lines[i].startsWith("*** Delete File:")) {
hunks.push({
type: "delete",
path: header.filePath,
})
i = header.nextIdx
} else if (lines[i].startsWith("*** Update File:")) {
const { chunks, nextIdx } = parseUpdateFileChunks(lines, header.nextIdx)
hunks.push({
type: "update",
path: header.filePath,
move_path: header.movePath,
chunks,
})
i = nextIdx
} else {
i++
}
}
return { hunks }
}
// Apply patch functionality
export function maybeParseApplyPatch(
argv: string[],
):
| { type: MaybeApplyPatch.Body; args: ApplyPatchArgs }
| { type: MaybeApplyPatch.PatchParseError; error: Error }
| { type: MaybeApplyPatch.NotApplyPatch } {
const APPLY_PATCH_COMMANDS = ["apply_patch", "applypatch"]
// Direct invocation: apply_patch <patch>
if (argv.length === 2 && APPLY_PATCH_COMMANDS.includes(argv[0])) {
try {
const { hunks } = parsePatch(argv[1])
return {
type: MaybeApplyPatch.Body,
args: {
patch: argv[1],
hunks,
},
}
} catch (error) {
return {
type: MaybeApplyPatch.PatchParseError,
error: error as Error,
}
}
}
// Bash heredoc form: bash -lc 'apply_patch <<"EOF" ...'
if (argv.length === 3 && argv[0] === "bash" && argv[1] === "-lc") {
// Simple extraction - in real implementation would need proper bash parsing
const script = argv[2]
const heredocMatch = script.match(/apply_patch\s*<<['"](\w+)['"]\s*\n([\s\S]*?)\n\1/)
if (heredocMatch) {
const patchContent = heredocMatch[2]
try {
const { hunks } = parsePatch(patchContent)
return {
type: MaybeApplyPatch.Body,
args: {
patch: patchContent,
hunks,
},
}
} catch (error) {
return {
type: MaybeApplyPatch.PatchParseError,
error: error as Error,
}
}
}
}
return { type: MaybeApplyPatch.NotApplyPatch }
}
// File content manipulation
interface ApplyPatchFileUpdate {
unified_diff: string
content: string
}
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
// Read original file content
let originalContent: string
try {
originalContent = readFileSync(filePath, "utf-8")
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
}
let originalLines = originalContent.split("\n")
// Drop trailing empty element for consistent line counting
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
originalLines.pop()
}
const replacements = computeReplacements(originalLines, filePath, chunks)
let newLines = applyReplacements(originalLines, replacements)
// Ensure trailing newline
if (newLines.length === 0 || newLines[newLines.length - 1] !== "") {
newLines.push("")
}
const newContent = newLines.join("\n")
// Generate unified diff
const unifiedDiff = generateUnifiedDiff(originalContent, newContent)
return {
unified_diff: unifiedDiff,
content: newContent,
}
}
function computeReplacements(
originalLines: string[],
filePath: string,
chunks: UpdateFileChunk[],
): Array<[number, number, string[]]> {
const replacements: Array<[number, number, string[]]> = []
let lineIndex = 0
for (const chunk of chunks) {
// Handle context-based seeking
if (chunk.change_context) {
const contextIdx = seekSequence(originalLines, [chunk.change_context], lineIndex)
if (contextIdx === -1) {
throw new Error(`Failed to find context '${chunk.change_context}' in ${filePath}`)
}
lineIndex = contextIdx + 1
}
// Handle pure addition (no old lines)
if (chunk.old_lines.length === 0) {
const insertionIdx =
originalLines.length > 0 && originalLines[originalLines.length - 1] === ""
? originalLines.length - 1
: originalLines.length
replacements.push([insertionIdx, 0, chunk.new_lines])
continue
}
// Try to match old lines in the file
let pattern = chunk.old_lines
let newSlice = chunk.new_lines
let found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
// Retry without trailing empty line if not found
if (found === -1 && pattern.length > 0 && pattern[pattern.length - 1] === "") {
pattern = pattern.slice(0, -1)
if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") {
newSlice = newSlice.slice(0, -1)
}
found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file)
}
if (found !== -1) {
replacements.push([found, pattern.length, newSlice])
lineIndex = found + pattern.length
} else {
throw new Error(`Failed to find expected lines in ${filePath}:\n${chunk.old_lines.join("\n")}`)
}
}
// Sort replacements by index to apply in order
replacements.sort((a, b) => a[0] - b[0])
return replacements
}
function applyReplacements(lines: string[], replacements: Array<[number, number, string[]]>): string[] {
// Apply replacements in reverse order to avoid index shifting
const result = [...lines]
for (let i = replacements.length - 1; i >= 0; i--) {
const [startIdx, oldLen, newSegment] = replacements[i]
// Remove old lines
result.splice(startIdx, oldLen)
// Insert new lines
for (let j = 0; j < newSegment.length; j++) {
result.splice(startIdx + j, 0, newSegment[j])
}
}
return result
}
// Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode)
function normalizeUnicode(str: string): string {
return str
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes
.replace(/\u2026/g, "...") // ellipsis
.replace(/\u00A0/g, " ") // non-breaking space
}
type Comparator = (a: string, b: string) => boolean
function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number {
// If EOF anchor, try matching from end of file first
if (eof) {
const fromEnd = lines.length - pattern.length
if (fromEnd >= startIndex) {
let matches = true
for (let j = 0; j < pattern.length; j++) {
if (!compare(lines[fromEnd + j], pattern[j])) {
matches = false
break
}
}
if (matches) return fromEnd
}
}
// Forward search from startIndex
for (let i = startIndex; i <= lines.length - pattern.length; i++) {
let matches = true
for (let j = 0; j < pattern.length; j++) {
if (!compare(lines[i + j], pattern[j])) {
matches = false
break
}
}
if (matches) return i
}
return -1
}
function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number {
if (pattern.length === 0) return -1
// Pass 1: exact match
const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof)
if (exact !== -1) return exact
// Pass 2: rstrip (trim trailing whitespace)
const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof)
if (rstrip !== -1) return rstrip
// Pass 3: trim (both ends)
const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof)
if (trim !== -1) return trim
// Pass 4: normalized (Unicode punctuation to ASCII)
const normalized = tryMatch(
lines,
pattern,
startIndex,
(a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()),
eof,
)
return normalized
}
function generateUnifiedDiff(oldContent: string, newContent: string): string {
const oldLines = oldContent.split("\n")
const newLines = newContent.split("\n")
// Simple diff generation - in a real implementation you'd use a proper diff algorithm
let diff = "@@ -1 +1 @@\n"
// Find changes (simplified approach)
const maxLen = Math.max(oldLines.length, newLines.length)
let hasChanges = false
for (let i = 0; i < maxLen; i++) {
const oldLine = oldLines[i] || ""
const newLine = newLines[i] || ""
if (oldLine !== newLine) {
if (oldLine) diff += `-${oldLine}\n`
if (newLine) diff += `+${newLine}\n`
hasChanges = true
} else if (oldLine) {
diff += ` ${oldLine}\n`
}
}
return hasChanges ? diff : ""
}
// Apply hunks to filesystem
export async function applyHunksToFiles(hunks: Hunk[]): Promise<AffectedPaths> {
if (hunks.length === 0) {
throw new Error("No files were modified.")
}
const added: string[] = []
const modified: string[] = []
const deleted: string[] = []
for (const hunk of hunks) {
switch (hunk.type) {
case "add":
// Create parent directories
const addDir = path.dirname(hunk.path)
if (addDir !== "." && addDir !== "/") {
await fs.mkdir(addDir, { recursive: true })
}
await fs.writeFile(hunk.path, hunk.contents, "utf-8")
added.push(hunk.path)
log.info(`Added file: ${hunk.path}`)
break
case "delete":
await fs.unlink(hunk.path)
deleted.push(hunk.path)
log.info(`Deleted file: ${hunk.path}`)
break
case "update":
const fileUpdate = deriveNewContentsFromChunks(hunk.path, hunk.chunks)
if (hunk.move_path) {
// Handle file move
const moveDir = path.dirname(hunk.move_path)
if (moveDir !== "." && moveDir !== "/") {
await fs.mkdir(moveDir, { recursive: true })
}
await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8")
await fs.unlink(hunk.path)
modified.push(hunk.move_path)
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
} else {
// Regular update
await fs.writeFile(hunk.path, fileUpdate.content, "utf-8")
modified.push(hunk.path)
log.info(`Updated file: ${hunk.path}`)
}
break
}
}
return { added, modified, deleted }
}
// Main patch application function
export async function applyPatch(patchText: string): Promise<AffectedPaths> {
const { hunks } = parsePatch(patchText)
return applyHunksToFiles(hunks)
}
// Async version of maybeParseApplyPatchVerified
export async function maybeParseApplyPatchVerified(
argv: string[],
cwd: string,
): Promise<
| { type: MaybeApplyPatchVerified.Body; action: ApplyPatchAction }
| { type: MaybeApplyPatchVerified.CorrectnessError; error: Error }
| { type: MaybeApplyPatchVerified.NotApplyPatch }
> {
// Detect implicit patch invocation (raw patch without apply_patch command)
if (argv.length === 1) {
try {
parsePatch(argv[0])
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: new Error(ApplyPatchError.ImplicitInvocation),
}
} catch {
// Not a patch, continue
}
}
const result = maybeParseApplyPatch(argv)
switch (result.type) {
case MaybeApplyPatch.Body:
const { args } = result
const effectiveCwd = args.workdir ? path.resolve(cwd, args.workdir) : cwd
const changes = new Map<string, ApplyPatchFileChange>()
for (const hunk of args.hunks) {
const resolvedPath = path.resolve(
effectiveCwd,
hunk.type === "update" && hunk.move_path ? hunk.move_path : hunk.path,
)
switch (hunk.type) {
case "add":
changes.set(resolvedPath, {
type: "add",
content: hunk.contents,
})
break
case "delete":
// For delete, we need to read the current content
const deletePath = path.resolve(effectiveCwd, hunk.path)
try {
const content = await fs.readFile(deletePath, "utf-8")
changes.set(resolvedPath, {
type: "delete",
content,
})
} catch {
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: new Error(`Failed to read file for deletion: ${deletePath}`),
}
}
break
case "update":
const updatePath = path.resolve(effectiveCwd, hunk.path)
try {
const fileUpdate = deriveNewContentsFromChunks(updatePath, hunk.chunks)
changes.set(resolvedPath, {
type: "update",
unified_diff: fileUpdate.unified_diff,
move_path: hunk.move_path ? path.resolve(effectiveCwd, hunk.move_path) : undefined,
new_content: fileUpdate.content,
})
} catch (error) {
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: error as Error,
}
}
break
}
}
return {
type: MaybeApplyPatchVerified.Body,
action: {
changes,
patch: args.patch,
cwd: effectiveCwd,
},
}
case MaybeApplyPatch.PatchParseError:
return {
type: MaybeApplyPatchVerified.CorrectnessError,
error: result.error,
}
case MaybeApplyPatch.NotApplyPatch:
return { type: MaybeApplyPatchVerified.NotApplyPatch }
}
}
+148 -148
View File
@@ -1,15 +1,14 @@
export namespace BashArity { export function prefix(tokens: string[]) {
export function prefix(tokens: string[]) { for (let len = tokens.length; len > 0; len--) {
for (let len = tokens.length; len > 0; len--) { const prefix = tokens.slice(0, len).join(" ")
const prefix = tokens.slice(0, len).join(" ") const arity = ARITY[prefix]
const arity = ARITY[prefix] if (arity !== undefined) return tokens.slice(0, arity)
if (arity !== undefined) return tokens.slice(0, arity)
}
if (tokens.length === 0) return []
return tokens.slice(0, 1)
} }
if (tokens.length === 0) return []
return tokens.slice(0, 1)
}
/* Generated with following prompt: /* Generated with following prompt:
You are generating a dictionary of command-prefix arities for bash-style commands. You are generating a dictionary of command-prefix arities for bash-style commands.
This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string number**, representing how many **tokens** define the command. This dictionary is used to identify the "human-understandable command" from an input shell command.### **RULES (follow strictly)**1. Each entry maps a **command prefix string number**, representing how many **tokens** define the command.
2. **Flags NEVER count as tokens**. Only subcommands count. 2. **Flags NEVER count as tokens**. Only subcommands count.
@@ -22,142 +21,143 @@ This dictionary is used to identify the "human-understandable command" from an i
* `npm run dev` `npm run dev` (because `npm run` has arity 3) * `npm run dev` `npm run dev` (because `npm run` has arity 3)
* `python script.py` `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.** * `python script.py` `python script.py` (default: whole input, not in dictionary)### **Now generate the dictionary.**
*/ */
const ARITY: Record<string, number> = { const ARITY: Record<string, number> = {
cat: 1, // cat file.txt cat: 1, // cat file.txt
cd: 1, // cd /path/to/dir cd: 1, // cd /path/to/dir
chmod: 1, // chmod 755 script.sh chmod: 1, // chmod 755 script.sh
chown: 1, // chown user:group file.txt chown: 1, // chown user:group file.txt
cp: 1, // cp source.txt dest.txt cp: 1, // cp source.txt dest.txt
echo: 1, // echo "hello world" echo: 1, // echo "hello world"
env: 1, // env env: 1, // env
export: 1, // export PATH=/usr/bin export: 1, // export PATH=/usr/bin
grep: 1, // grep pattern file.txt grep: 1, // grep pattern file.txt
kill: 1, // kill 1234 kill: 1, // kill 1234
killall: 1, // killall process killall: 1, // killall process
ln: 1, // ln -s source target ln: 1, // ln -s source target
ls: 1, // ls -la ls: 1, // ls -la
mkdir: 1, // mkdir new-dir mkdir: 1, // mkdir new-dir
mv: 1, // mv old.txt new.txt mv: 1, // mv old.txt new.txt
ps: 1, // ps aux ps: 1, // ps aux
pwd: 1, // pwd pwd: 1, // pwd
rm: 1, // rm file.txt rm: 1, // rm file.txt
rmdir: 1, // rmdir empty-dir rmdir: 1, // rmdir empty-dir
sleep: 1, // sleep 5 sleep: 1, // sleep 5
source: 1, // source ~/.bashrc source: 1, // source ~/.bashrc
tail: 1, // tail -f log.txt tail: 1, // tail -f log.txt
touch: 1, // touch file.txt touch: 1, // touch file.txt
unset: 1, // unset VAR unset: 1, // unset VAR
which: 1, // which node which: 1, // which node
aws: 3, // aws s3 ls aws: 3, // aws s3 ls
az: 3, // az storage blob list az: 3, // az storage blob list
bazel: 2, // bazel build bazel: 2, // bazel build
brew: 2, // brew install node brew: 2, // brew install node
bun: 2, // bun install bun: 2, // bun install
"bun run": 3, // bun run dev "bun run": 3, // bun run dev
"bun x": 3, // bun x vite "bun x": 3, // bun x vite
cargo: 2, // cargo build cargo: 2, // cargo build
"cargo add": 3, // cargo add tokio "cargo add": 3, // cargo add tokio
"cargo run": 3, // cargo run main "cargo run": 3, // cargo run main
cdk: 2, // cdk deploy cdk: 2, // cdk deploy
cf: 2, // cf push app cf: 2, // cf push app
cmake: 2, // cmake build cmake: 2, // cmake build
composer: 2, // composer require laravel composer: 2, // composer require laravel
consul: 2, // consul members consul: 2, // consul members
"consul kv": 3, // consul kv get config/app "consul kv": 3, // consul kv get config/app
crictl: 2, // crictl ps crictl: 2, // crictl ps
deno: 2, // deno run server.ts deno: 2, // deno run server.ts
"deno task": 3, // deno task dev "deno task": 3, // deno task dev
doctl: 3, // doctl kubernetes cluster list doctl: 3, // doctl kubernetes cluster list
docker: 2, // docker run nginx docker: 2, // docker run nginx
"docker builder": 3, // docker builder prune "docker builder": 3, // docker builder prune
"docker compose": 3, // docker compose up "docker compose": 3, // docker compose up
"docker container": 3, // docker container ls "docker container": 3, // docker container ls
"docker image": 3, // docker image prune "docker image": 3, // docker image prune
"docker network": 3, // docker network inspect "docker network": 3, // docker network inspect
"docker volume": 3, // docker volume ls "docker volume": 3, // docker volume ls
eksctl: 2, // eksctl get clusters eksctl: 2, // eksctl get clusters
"eksctl create": 3, // eksctl create cluster "eksctl create": 3, // eksctl create cluster
firebase: 2, // firebase deploy firebase: 2, // firebase deploy
flyctl: 2, // flyctl deploy flyctl: 2, // flyctl deploy
gcloud: 3, // gcloud compute instances list gcloud: 3, // gcloud compute instances list
gh: 3, // gh pr list gh: 3, // gh pr list
git: 2, // git checkout main git: 2, // git checkout main
"git config": 3, // git config user.name "git config": 3, // git config user.name
"git remote": 3, // git remote add origin "git remote": 3, // git remote add origin
"git stash": 3, // git stash pop "git stash": 3, // git stash pop
go: 2, // go build go: 2, // go build
gradle: 2, // gradle build gradle: 2, // gradle build
helm: 2, // helm install mychart helm: 2, // helm install mychart
heroku: 2, // heroku logs heroku: 2, // heroku logs
hugo: 2, // hugo new site blog hugo: 2, // hugo new site blog
ip: 2, // ip link show ip: 2, // ip link show
"ip addr": 3, // ip addr show "ip addr": 3, // ip addr show
"ip link": 3, // ip link set eth0 up "ip link": 3, // ip link set eth0 up
"ip netns": 3, // ip netns exec foo bash "ip netns": 3, // ip netns exec foo bash
"ip route": 3, // ip route add default via 1.1.1.1 "ip route": 3, // ip route add default via 1.1.1.1
kind: 2, // kind delete cluster kind: 2, // kind delete cluster
"kind create": 3, // kind create cluster "kind create": 3, // kind create cluster
kubectl: 2, // kubectl get pods kubectl: 2, // kubectl get pods
"kubectl kustomize": 3, // kubectl kustomize overlays/dev "kubectl kustomize": 3, // kubectl kustomize overlays/dev
"kubectl rollout": 3, // kubectl rollout restart deploy/api "kubectl rollout": 3, // kubectl rollout restart deploy/api
kustomize: 2, // kustomize build . kustomize: 2, // kustomize build .
make: 2, // make build make: 2, // make build
mc: 2, // mc ls myminio mc: 2, // mc ls myminio
"mc admin": 3, // mc admin info myminio "mc admin": 3, // mc admin info myminio
minikube: 2, // minikube start minikube: 2, // minikube start
mongosh: 2, // mongosh test mongosh: 2, // mongosh test
mysql: 2, // mysql -u root mysql: 2, // mysql -u root
mvn: 2, // mvn compile mvn: 2, // mvn compile
ng: 2, // ng generate component home ng: 2, // ng generate component home
npm: 2, // npm install npm: 2, // npm install
"npm exec": 3, // npm exec vite "npm exec": 3, // npm exec vite
"npm init": 3, // npm init vue "npm init": 3, // npm init vue
"npm run": 3, // npm run dev "npm run": 3, // npm run dev
"npm view": 3, // npm view react version "npm view": 3, // npm view react version
nvm: 2, // nvm use 18 nvm: 2, // nvm use 18
nx: 2, // nx build nx: 2, // nx build
openssl: 2, // openssl genrsa 2048 openssl: 2, // openssl genrsa 2048
"openssl req": 3, // openssl req -new -key key.pem "openssl req": 3, // openssl req -new -key key.pem
"openssl x509": 3, // openssl x509 -in cert.pem "openssl x509": 3, // openssl x509 -in cert.pem
pip: 2, // pip install numpy pip: 2, // pip install numpy
pipenv: 2, // pipenv install flask pipenv: 2, // pipenv install flask
pnpm: 2, // pnpm install pnpm: 2, // pnpm install
"pnpm dlx": 3, // pnpm dlx create-next-app "pnpm dlx": 3, // pnpm dlx create-next-app
"pnpm exec": 3, // pnpm exec vite "pnpm exec": 3, // pnpm exec vite
"pnpm run": 3, // pnpm run dev "pnpm run": 3, // pnpm run dev
poetry: 2, // poetry add requests poetry: 2, // poetry add requests
podman: 2, // podman run alpine podman: 2, // podman run alpine
"podman container": 3, // podman container ls "podman container": 3, // podman container ls
"podman image": 3, // podman image prune "podman image": 3, // podman image prune
psql: 2, // psql -d mydb psql: 2, // psql -d mydb
pulumi: 2, // pulumi up pulumi: 2, // pulumi up
"pulumi stack": 3, // pulumi stack output "pulumi stack": 3, // pulumi stack output
pyenv: 2, // pyenv install 3.11 pyenv: 2, // pyenv install 3.11
python: 2, // python -m venv env python: 2, // python -m venv env
rake: 2, // rake db:migrate rake: 2, // rake db:migrate
rbenv: 2, // rbenv install 3.2.0 rbenv: 2, // rbenv install 3.2.0
"redis-cli": 2, // redis-cli ping "redis-cli": 2, // redis-cli ping
rustup: 2, // rustup update rustup: 2, // rustup update
serverless: 2, // serverless invoke serverless: 2, // serverless invoke
sfdx: 3, // sfdx force:org:list sfdx: 3, // sfdx force:org:list
skaffold: 2, // skaffold dev skaffold: 2, // skaffold dev
sls: 2, // sls deploy sls: 2, // sls deploy
sst: 2, // sst deploy sst: 2, // sst deploy
swift: 2, // swift build swift: 2, // swift build
systemctl: 2, // systemctl restart nginx systemctl: 2, // systemctl restart nginx
terraform: 2, // terraform apply terraform: 2, // terraform apply
"terraform workspace": 3, // terraform workspace select prod "terraform workspace": 3, // terraform workspace select prod
tmux: 2, // tmux new -s dev tmux: 2, // tmux new -s dev
turbo: 2, // turbo run build turbo: 2, // turbo run build
ufw: 2, // ufw allow 22 ufw: 2, // ufw allow 22
vault: 2, // vault login vault: 2, // vault login
"vault auth": 3, // vault auth list "vault auth": 3, // vault auth list
"vault kv": 3, // vault kv get secret/api "vault kv": 3, // vault kv get secret/api
vercel: 2, // vercel deploy vercel: 2, // vercel deploy
volta: 2, // volta install node volta: 2, // volta install node
wp: 2, // wp plugin install wp: 2, // wp plugin install
yarn: 2, // yarn add react yarn: 2, // yarn add react
"yarn dlx": 3, // yarn dlx create-react-app "yarn dlx": 3, // yarn dlx create-react-app
"yarn run": 3, // yarn run dev "yarn run": 3, // yarn run dev
}
} }
export * as BashArity from "./arity"
@@ -1,6 +1,6 @@
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config" import { ConfigPermission } from "@/config/permission"
import { InstanceState } from "@/effect" import { InstanceState } from "@/effect"
import { ProjectID } from "@/project/schema" import { ProjectID } from "@/project/schema"
import { MessageID, SessionID } from "@/session/schema" import { MessageID, SessionID } from "@/session/schema"
@@ -289,7 +289,7 @@ function expand(pattern: string): string {
return pattern return pattern
} }
export function fromConfig(permission: Config.Permission) { export function fromConfig(permission: ConfigPermission.Info) {
const ruleset: Ruleset = [] const ruleset: Ruleset = []
for (const [key, value] of Object.entries(permission)) { for (const [key, value] of Object.entries(permission)) {
if (typeof value === "string") { if (typeof value === "string") {
@@ -61,5 +61,16 @@ export async function CloudflareAIGatewayAuthPlugin(_input: PluginInput): Promis
}, },
], ],
}, },
"chat.params": async (input, output) => {
if (input.model.providerID !== "cloudflare-ai-gateway") return
// The unified gateway routes through @ai-sdk/openai-compatible, which
// always emits max_tokens. OpenAI reasoning models (gpt-5.x, o-series)
// reject that field and require max_completion_tokens instead, and the
// compatible SDK has no way to rename it. Drop the cap so OpenAI falls
// back to the model's default output budget.
if (!input.model.api.id.toLowerCase().startsWith("openai/")) return
if (!input.model.capabilities.reasoning) return
output.maxOutputTokens = undefined
},
} }
} }
@@ -354,7 +354,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
details: "flex processing is only available for o3, o4-mini, and gpt-5 models", details: "flex processing is only available for o3, o4-mini, and gpt-5 models",
}) })
// Remove from args if not supported // Remove from args if not supported
delete (baseArgs as any).service_tier baseArgs.service_tier = undefined
} }
// Validate priority processing support // Validate priority processing support
@@ -366,7 +366,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 {
"priority processing is only available for supported models (gpt-4, gpt-5, gpt-5-mini, o3, o4-mini) and requires Enterprise access. gpt-5-nano is not supported", "priority processing is only available for supported models (gpt-4, gpt-5, gpt-5-mini, o3, o4-mini) and requires Enterprise access. gpt-5-nano is not supported",
}) })
// Remove from args if not supported // Remove from args if not supported
delete (baseArgs as any).service_tier baseArgs.service_tier = undefined
} }
const { const {
+23 -7
View File
@@ -193,7 +193,7 @@ function normalizeMessages(
providerOptions: { providerOptions: {
...msg.providerOptions, ...msg.providerOptions,
openaiCompatible: { openaiCompatible: {
...(msg.providerOptions as any)?.openaiCompatible, ...msg.providerOptions?.openaiCompatible,
[field]: reasoningText, [field]: reasoningText,
}, },
}, },
@@ -389,12 +389,21 @@ export function topK(model: Provider.Model) {
const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]
const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"] const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
function anthropicAdaptiveEfforts(apiId: string): string[] | null {
if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) {
return ["low", "medium", "high", "xhigh", "max"]
}
if (["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => apiId.includes(v))) {
return ["low", "medium", "high", "max"]
}
return null
}
export function variants(model: Provider.Model): Record<string, Record<string, any>> { export function variants(model: Provider.Model): Record<string, Record<string, any>> {
if (!model.capabilities.reasoning) return {} if (!model.capabilities.reasoning) return {}
const id = model.id.toLowerCase() const id = model.id.toLowerCase()
const isAnthropicAdaptive = ["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => model.api.id.includes(v)) const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id)
const adaptiveEfforts = ["low", "medium", "high", "max"]
if ( if (
id.includes("deepseek") || id.includes("deepseek") ||
id.includes("minimax") || id.includes("minimax") ||
@@ -429,7 +438,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
case "@ai-sdk/gateway": case "@ai-sdk/gateway":
if (model.id.includes("anthropic")) { if (model.id.includes("anthropic")) {
if (isAnthropicAdaptive) { if (adaptiveEfforts) {
return Object.fromEntries( return Object.fromEntries(
adaptiveEfforts.map((effort) => [ adaptiveEfforts.map((effort) => [
effort, effort,
@@ -578,13 +587,16 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
case "@ai-sdk/google-vertex/anthropic": case "@ai-sdk/google-vertex/anthropic":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider
if (isAnthropicAdaptive) { if (adaptiveEfforts) {
return Object.fromEntries( return Object.fromEntries(
adaptiveEfforts.map((effort) => [ adaptiveEfforts.map((effort) => [
effort, effort,
{ {
thinking: { thinking: {
type: "adaptive", type: "adaptive",
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
? { display: "summarized" }
: {}),
}, },
effort, effort,
}, },
@@ -609,7 +621,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
case "@ai-sdk/amazon-bedrock": case "@ai-sdk/amazon-bedrock":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock // https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock
if (isAnthropicAdaptive) { if (adaptiveEfforts) {
return Object.fromEntries( return Object.fromEntries(
adaptiveEfforts.map((effort) => [ adaptiveEfforts.map((effort) => [
effort, effort,
@@ -716,7 +728,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
case "@jerome-benoit/sap-ai-provider-v2": case "@jerome-benoit/sap-ai-provider-v2":
if (model.api.id.includes("anthropic")) { if (model.api.id.includes("anthropic")) {
if (isAnthropicAdaptive) { if (adaptiveEfforts) {
return Object.fromEntries( return Object.fromEntries(
adaptiveEfforts.map((effort) => [ adaptiveEfforts.map((effort) => [
effort, effort,
@@ -784,6 +796,10 @@ export function options(input: {
result["store"] = false result["store"] = false
} }
if (input.model.api.npm === "@ai-sdk/azure") {
result["store"] = true
}
if (input.model.api.npm === "@openrouter/ai-sdk-provider") { if (input.model.api.npm === "@openrouter/ai-sdk-provider") {
result["usage"] = { result["usage"] = {
include: true, include: true,
+364 -1
View File
@@ -1 +1,364 @@
export * as Pty from "./service" import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect"
import { Instance } from "@/project/instance"
import type { Proc } from "#pty"
import z from "zod"
import { Log } from "../util"
import { lazy } from "@opencode-ai/shared/util/lazy"
import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
import { PtyID } from "./schema"
import { Effect, Layer, Context } from "effect"
import { EffectBridge } from "@/effect"
const log = Log.create({ service: "pty" })
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
type Socket = {
readyState: number
data?: unknown
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
type Active = {
info: Info
process: Proc
buffer: string
bufferCursor: number
cursor: number
subscribers: Map<unknown, Socket>
}
type State = {
dir: string
sessions: Map<PtyID, Active>
}
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
const bytes = encoder.encode(json)
const out = new Uint8Array(bytes.length + 1)
out[0] = 0
out.set(bytes, 1)
return out
}
const pty = lazy(() => import("#pty"))
export const Info = z
.object({
id: PtyID.zod,
title: z.string(),
command: z.string(),
args: z.array(z.string()),
cwd: z.string(),
status: z.enum(["running", "exited"]),
pid: z.number(),
})
.meta({ ref: "Pty" })
export type Info = z.infer<typeof Info>
export const CreateInput = z.object({
command: z.string().optional(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
title: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
})
export type CreateInput = z.infer<typeof CreateInput>
export const UpdateInput = z.object({
title: z.string().optional(),
size: z
.object({
rows: z.number(),
cols: z.number(),
})
.optional(),
})
export type UpdateInput = z.infer<typeof UpdateInput>
export const Event = {
Created: BusEvent.define("pty.created", z.object({ info: Info })),
Updated: BusEvent.define("pty.updated", z.object({ info: Info })),
Exited: BusEvent.define("pty.exited", z.object({ id: PtyID.zod, exitCode: z.number() })),
Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })),
}
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: PtyID) => Effect.Effect<Info | undefined>
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info | undefined>
readonly remove: (id: PtyID) => Effect.Effect<void>
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void>
readonly write: (id: PtyID, data: string) => Effect.Effect<void>
readonly connect: (
id: PtyID,
ws: Socket,
cursor?: number,
) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const plugin = yield* Plugin.Service
function teardown(session: Active) {
try {
session.process.kill()
} catch {}
for (const [sub, ws] of session.subscribers.entries()) {
try {
if (sock(ws) === sub) ws.close()
} catch {}
}
session.subscribers.clear()
}
const state = yield* InstanceState.make<State>(
Effect.fn("Pty.state")(function* (ctx) {
const state = {
dir: ctx.directory,
sessions: new Map<PtyID, Active>(),
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of state.sessions.values()) {
teardown(session)
}
state.sessions.clear()
}),
)
return state
}),
)
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) return
s.sessions.delete(id)
log.info("removing session", { id })
teardown(session)
yield* bus.publish(Event.Deleted, { id: session.info.id })
})
const list = Effect.fn("Pty.list")(function* () {
const s = yield* InstanceState.get(state)
return Array.from(s.sessions.values()).map((session) => session.info)
})
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
const s = yield* InstanceState.get(state)
return s.sessions.get(id)?.info
})
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const s = yield* InstanceState.get(state)
const bridge = yield* EffectBridge.make()
const id = PtyID.ascending()
const command = input.command || Shell.preferred()
const args = input.args || []
if (Shell.login(command)) {
args.push("-l")
}
const cwd = input.cwd || s.dir
const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} })
const env = {
...process.env,
...input.env,
...shell.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
log.info("creating session", { id, cmd: command, args, cwd })
const { spawn } = yield* Effect.promise(() => pty())
const proc = yield* Effect.sync(() =>
spawn(command, args, {
name: "xterm-256color",
cwd,
env,
}),
)
const info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
command,
args,
cwd,
status: "running",
pid: proc.pid,
} as const
const session: Active = {
info,
process: proc,
buffer: "",
bufferCursor: 0,
cursor: 0,
subscribers: new Map(),
}
s.sessions.set(id, session)
proc.onData(
Instance.bind((chunk) => {
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) {
session.subscribers.delete(key)
continue
}
if (sock(ws) !== key) {
session.subscribers.delete(key)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
}
}
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
}),
)
proc.onExit(
Instance.bind(({ exitCode }) => {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
bridge.fork(bus.publish(Event.Exited, { id, exitCode }))
bridge.fork(remove(id))
}),
)
yield* bus.publish(Event.Created, { info })
return info
})
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) return
if (input.title) {
session.info.title = input.title
}
if (input.size) {
session.process.resize(input.size.cols, input.size.rows)
}
yield* bus.publish(Event.Updated, { info: session.info })
return session.info
})
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (session && session.info.status === "running") {
session.process.resize(cols, rows)
}
})
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (session && session.info.status === "running") {
session.process.write(data)
}
})
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) {
ws.close()
return
}
log.info("client connected to session", { id })
const sub = sock(ws)
session.subscribers.delete(sub)
session.subscribers.set(sub, ws)
const cleanup = () => {
session.subscribers.delete(sub)
}
const start = session.bufferCursor
const end = session.cursor
const from =
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
const data = (() => {
if (!session.buffer) return ""
if (from >= end) return ""
const offset = Math.max(0, from - start)
if (offset >= session.buffer.length) return ""
return session.buffer.slice(offset)
})()
if (data) {
try {
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
ws.send(data.slice(i, i + BUFFER_CHUNK))
}
} catch {
cleanup()
ws.close()
return
}
}
try {
ws.send(meta(end))
} catch {
cleanup()
ws.close()
return
}
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
},
onClose: () => {
log.info("client disconnected from session", { id })
cleanup()
},
}
})
return Service.of({ list, get, create, update, remove, resize, write, connect })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
export * as Pty from "."
-362
View File
@@ -1,362 +0,0 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect"
import { Instance } from "@/project/instance"
import type { Proc } from "#pty"
import z from "zod"
import { Log } from "../util"
import { lazy } from "@opencode-ai/shared/util/lazy"
import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
import { PtyID } from "./schema"
import { Effect, Layer, Context } from "effect"
import { EffectBridge } from "@/effect"
const log = Log.create({ service: "pty" })
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
type Socket = {
readyState: number
data?: unknown
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
}
const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws)
type Active = {
info: Info
process: Proc
buffer: string
bufferCursor: number
cursor: number
subscribers: Map<unknown, Socket>
}
type State = {
dir: string
sessions: Map<PtyID, Active>
}
// WebSocket control frame: 0x00 + UTF-8 JSON.
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
const bytes = encoder.encode(json)
const out = new Uint8Array(bytes.length + 1)
out[0] = 0
out.set(bytes, 1)
return out
}
const pty = lazy(() => import("#pty"))
export const Info = z
.object({
id: PtyID.zod,
title: z.string(),
command: z.string(),
args: z.array(z.string()),
cwd: z.string(),
status: z.enum(["running", "exited"]),
pid: z.number(),
})
.meta({ ref: "Pty" })
export type Info = z.infer<typeof Info>
export const CreateInput = z.object({
command: z.string().optional(),
args: z.array(z.string()).optional(),
cwd: z.string().optional(),
title: z.string().optional(),
env: z.record(z.string(), z.string()).optional(),
})
export type CreateInput = z.infer<typeof CreateInput>
export const UpdateInput = z.object({
title: z.string().optional(),
size: z
.object({
rows: z.number(),
cols: z.number(),
})
.optional(),
})
export type UpdateInput = z.infer<typeof UpdateInput>
export const Event = {
Created: BusEvent.define("pty.created", z.object({ info: Info })),
Updated: BusEvent.define("pty.updated", z.object({ info: Info })),
Exited: BusEvent.define("pty.exited", z.object({ id: PtyID.zod, exitCode: z.number() })),
Deleted: BusEvent.define("pty.deleted", z.object({ id: PtyID.zod })),
}
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: PtyID) => Effect.Effect<Info | undefined>
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info | undefined>
readonly remove: (id: PtyID) => Effect.Effect<void>
readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect<void>
readonly write: (id: PtyID, data: string) => Effect.Effect<void>
readonly connect: (
id: PtyID,
ws: Socket,
cursor?: number,
) => Effect.Effect<{ onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const plugin = yield* Plugin.Service
function teardown(session: Active) {
try {
session.process.kill()
} catch {}
for (const [sub, ws] of session.subscribers.entries()) {
try {
if (sock(ws) === sub) ws.close()
} catch {}
}
session.subscribers.clear()
}
const state = yield* InstanceState.make<State>(
Effect.fn("Pty.state")(function* (ctx) {
const state = {
dir: ctx.directory,
sessions: new Map<PtyID, Active>(),
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
for (const session of state.sessions.values()) {
teardown(session)
}
state.sessions.clear()
}),
)
return state
}),
)
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) return
s.sessions.delete(id)
log.info("removing session", { id })
teardown(session)
yield* bus.publish(Event.Deleted, { id: session.info.id })
})
const list = Effect.fn("Pty.list")(function* () {
const s = yield* InstanceState.get(state)
return Array.from(s.sessions.values()).map((session) => session.info)
})
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
const s = yield* InstanceState.get(state)
return s.sessions.get(id)?.info
})
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const s = yield* InstanceState.get(state)
const bridge = yield* EffectBridge.make()
const id = PtyID.ascending()
const command = input.command || Shell.preferred()
const args = input.args || []
if (Shell.login(command)) {
args.push("-l")
}
const cwd = input.cwd || s.dir
const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} })
const env = {
...process.env,
...input.env,
...shell.env,
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
log.info("creating session", { id, cmd: command, args, cwd })
const { spawn } = yield* Effect.promise(() => pty())
const proc = yield* Effect.sync(() =>
spawn(command, args, {
name: "xterm-256color",
cwd,
env,
}),
)
const info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
command,
args,
cwd,
status: "running",
pid: proc.pid,
} as const
const session: Active = {
info,
process: proc,
buffer: "",
bufferCursor: 0,
cursor: 0,
subscribers: new Map(),
}
s.sessions.set(id, session)
proc.onData(
Instance.bind((chunk) => {
session.cursor += chunk.length
for (const [key, ws] of session.subscribers.entries()) {
if (ws.readyState !== 1) {
session.subscribers.delete(key)
continue
}
if (sock(ws) !== key) {
session.subscribers.delete(key)
continue
}
try {
ws.send(chunk)
} catch {
session.subscribers.delete(key)
}
}
session.buffer += chunk
if (session.buffer.length <= BUFFER_LIMIT) return
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
}),
)
proc.onExit(
Instance.bind(({ exitCode }) => {
if (session.info.status === "exited") return
log.info("session exited", { id, exitCode })
session.info.status = "exited"
bridge.fork(bus.publish(Event.Exited, { id, exitCode }))
bridge.fork(remove(id))
}),
)
yield* bus.publish(Event.Created, { info })
return info
})
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) return
if (input.title) {
session.info.title = input.title
}
if (input.size) {
session.process.resize(input.size.cols, input.size.rows)
}
yield* bus.publish(Event.Updated, { info: session.info })
return session.info
})
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (session && session.info.status === "running") {
session.process.resize(cols, rows)
}
})
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (session && session.info.status === "running") {
session.process.write(data)
}
})
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
const s = yield* InstanceState.get(state)
const session = s.sessions.get(id)
if (!session) {
ws.close()
return
}
log.info("client connected to session", { id })
const sub = sock(ws)
session.subscribers.delete(sub)
session.subscribers.set(sub, ws)
const cleanup = () => {
session.subscribers.delete(sub)
}
const start = session.bufferCursor
const end = session.cursor
const from =
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
const data = (() => {
if (!session.buffer) return ""
if (from >= end) return ""
const offset = Math.max(0, from - start)
if (offset >= session.buffer.length) return ""
return session.buffer.slice(offset)
})()
if (data) {
try {
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
ws.send(data.slice(i, i + BUFFER_CHUNK))
}
} catch {
cleanup()
ws.close()
return
}
}
try {
ws.send(meta(end))
} catch {
cleanup()
ws.close()
return
}
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message))
},
onClose: () => {
log.info("client disconnected from session", { id })
cleanup()
},
}
})
return Service.of({ list, get, create, update, remove, resize, write, connect })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
@@ -12,7 +12,6 @@ import { Config } from "../../config"
import { ConsoleState } from "../../config/console-state" import { ConsoleState } from "../../config/console-state"
import { Account, AccountID, OrgID } from "../../account" import { Account, AccountID, OrgID } from "../../account"
import { AppRuntime } from "../../effect/app-runtime" import { AppRuntime } from "../../effect/app-runtime"
import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { Effect, Option } from "effect" import { Effect, Option } from "effect"
@@ -226,8 +225,7 @@ export const ExperimentalRoutes = lazy(() =>
tools.map((t) => ({ tools.map((t) => ({
id: t.id, id: t.id,
description: t.description, description: t.description,
// Handle both Zod schemas and plain JSON schemas parameters: z.toJSONSchema(t.parameters),
parameters: (t.parameters as any)?._def ? zodToJsonSchema(t.parameters as any) : t.parameters,
})), })),
) )
}, },
+2 -1
View File
@@ -3,6 +3,7 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod" import z from "zod"
import { MCP } from "../../mcp" import { MCP } from "../../mcp"
import { Config } from "../../config" import { Config } from "../../config"
import { ConfigMCP } from "../../config/mcp"
import { AppRuntime } from "../../effect/app-runtime" import { AppRuntime } from "../../effect/app-runtime"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
@@ -53,7 +54,7 @@ export const McpRoutes = lazy(() =>
"json", "json",
z.object({ z.object({
name: z.string(), name: z.string(),
config: Config.Mcp, config: ConfigMCP.Info,
}), }),
), ),
async (c) => { async (c) => {
@@ -53,6 +53,7 @@ export const SyncRoutes = lazy(() =>
const body = c.req.valid("json") const body = c.req.valid("json")
const events = body.events const events = body.events
const source = events[0].aggregateID const source = events[0].aggregateID
log.info("sync replay requested", { log.info("sync replay requested", {
sessionID: source, sessionID: source,
events: events.length, events: events.length,
+3 -4
View File
@@ -10,6 +10,7 @@ import { Agent } from "../agent/agent"
import { Provider } from "../provider" import { Provider } from "../provider"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai" import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import type { JSONSchema7 } from "@ai-sdk/provider"
import { SessionCompaction } from "./compaction" import { SessionCompaction } from "./compaction"
import { Bus } from "../bus" import { Bus } from "../bus"
import { ProviderTransform } from "../provider" import { ProviderTransform } from "../provider"
@@ -407,9 +408,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
})) { })) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({ tools[item.id] = tool({
id: item.id as any,
description: item.description, description: item.description,
inputSchema: jsonSchema(schema as any), inputSchema: jsonSchema(schema),
execute(args, options) { execute(args, options) {
return run.promise( return run.promise(
Effect.gen(function* () { Effect.gen(function* () {
@@ -1827,9 +1827,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const { $schema: _, ...toolSchema } = input.schema const { $schema: _, ...toolSchema } = input.schema
return tool({ return tool({
id: "StructuredOutput" as any,
description: STRUCTURED_OUTPUT_DESCRIPTION, description: STRUCTURED_OUTPUT_DESCRIPTION,
inputSchema: jsonSchema(toolSchema as any), inputSchema: jsonSchema(toolSchema as JSONSchema7),
async execute(args) { async execute(args) {
// AI SDK validates args against inputSchema before calling execute() // AI SDK validates args against inputSchema before calling execute()
input.onSuccess(args) input.onSuccess(args)
+2 -1
View File
@@ -519,12 +519,13 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service> =
workspaceID?: WorkspaceID workspaceID?: WorkspaceID
}) { }) {
const directory = yield* InstanceState.directory const directory = yield* InstanceState.directory
const workspace = yield* InstanceState.workspaceID
return yield* createNext({ return yield* createNext({
parentID: input?.parentID, parentID: input?.parentID,
directory, directory,
title: input?.title, title: input?.title,
permission: input?.permission, permission: input?.permission,
workspaceID: input?.workspaceID, workspaceID: workspace,
}) })
}) })
@@ -95,7 +95,7 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
return items return items
} }
function insert(values: any[], table: any, label: string) { function insert(values: unknown[], table: Parameters<typeof db.insert>[0], label: string) {
if (values.length === 0) return 0 if (values.length === 0) return 0
try { try {
db.insert(table).values(values).onConflictDoNothing().run() db.insert(table).values(values).onConflictDoNothing().run()
@@ -152,7 +152,7 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
// Migrate projects first (no FK deps) // Migrate projects first (no FK deps)
// Derive all IDs from file paths, not JSON content // Derive all IDs from file paths, not JSON content
const projectIds = new Set<string>() const projectIds = new Set<string>()
const projectValues = [] as any[] const projectValues: unknown[] = []
for (let i = 0; i < projectFiles.length; i += batchSize) { for (let i = 0; i < projectFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, projectFiles.length) const end = Math.min(i + batchSize, projectFiles.length)
const batch = await read(projectFiles, i, end) const batch = await read(projectFiles, i, end)
@@ -186,7 +186,7 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
// migrations may have moved sessions to new directories without updating the JSON // migrations may have moved sessions to new directories without updating the JSON
const sessionProjects = sessionFiles.map((file) => path.basename(path.dirname(file))) const sessionProjects = sessionFiles.map((file) => path.basename(path.dirname(file)))
const sessionIds = new Set<string>() const sessionIds = new Set<string>()
const sessionValues = [] as any[] const sessionValues: unknown[] = []
for (let i = 0; i < sessionFiles.length; i += batchSize) { for (let i = 0; i < sessionFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, sessionFiles.length) const end = Math.min(i + batchSize, sessionFiles.length)
const batch = await read(sessionFiles, i, end) const batch = await read(sessionFiles, i, end)
@@ -314,7 +314,7 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
for (let i = 0; i < todoFiles.length; i += batchSize) { for (let i = 0; i < todoFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, todoFiles.length) const end = Math.min(i + batchSize, todoFiles.length)
const batch = await read(todoFiles, i, end) const batch = await read(todoFiles, i, end)
const values = [] as any[] const values: unknown[] = []
for (let j = 0; j < batch.length; j++) { for (let j = 0; j < batch.length; j++) {
const data = batch[j] const data = batch[j]
if (!data) continue if (!data) continue
@@ -351,7 +351,7 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
// Migrate permissions // Migrate permissions
const permProjects = permFiles.map((file) => path.basename(file, ".json")) const permProjects = permFiles.map((file) => path.basename(file, ".json"))
const permValues = [] as any[] const permValues: unknown[] = []
for (let i = 0; i < permFiles.length; i += batchSize) { for (let i = 0; i < permFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, permFiles.length) const end = Math.min(i + batchSize, permFiles.length)
const batch = await read(permFiles, i, end) const batch = await read(permFiles, i, end)
@@ -376,7 +376,7 @@ export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<a
// Migrate session shares // Migrate session shares
const shareSessions = shareFiles.map((file) => path.basename(file, ".json")) const shareSessions = shareFiles.map((file) => path.basename(file, ".json"))
const shareValues = [] as any[] const shareValues: unknown[] = []
for (let i = 0; i < shareFiles.length; i += batchSize) { for (let i = 0; i < shareFiles.length; i += batchSize) {
const end = Math.min(i + batchSize, shareFiles.length) const end = Math.min(i + batchSize, shareFiles.length)
const batch = await read(shareFiles, i, end) const batch = await read(shareFiles, i, end)
+4 -8
View File
@@ -155,8 +155,10 @@ function process<Def extends Definition>(def: Def, event: Event<Def>, options: {
workspace: WorkspaceContext.workspaceID, workspace: WorkspaceContext.workspaceID,
payload: { payload: {
type: "sync", type: "sync",
name: versionedType(def.type, def.version), syncEvent: {
...event, type: versionedType(def.type, def.version),
...event,
},
}, },
}) })
} }
@@ -164,12 +166,6 @@ function process<Def extends Definition>(def: Def, event: Event<Def>, options: {
}) })
} }
// TODO:
//
// * Support applying multiple events at one time. One transaction,
// and it validets all the sequence ids
// * when loading events from db, apply zod validation to ensure shape
export function replay(event: SerializedEvent, options?: { publish: boolean }) { export function replay(event: SerializedEvent, options?: { publish: boolean }) {
const def = registry.get(event.type) const def = registry.get(event.type)
if (!def) { if (!def) {
+2 -4
View File
@@ -1,6 +1,4 @@
export function defer<T extends () => void | Promise<void>>( export function defer(fn: () => void | Promise<void>): AsyncDisposable & Disposable {
fn: T,
): T extends () => Promise<void> ? { [Symbol.asyncDispose]: () => Promise<void> } : { [Symbol.dispose]: () => void } {
return { return {
[Symbol.dispose]() { [Symbol.dispose]() {
void fn() void fn()
@@ -8,5 +6,5 @@ export function defer<T extends () => void | Promise<void>>(
[Symbol.asyncDispose]() { [Symbol.asyncDispose]() {
return Promise.resolve(fn()) return Promise.resolve(fn())
}, },
} as any }
} }
+2 -2
View File
@@ -77,8 +77,8 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny {
if (items.length === 1) return items[0] if (items.length === 1) return items[0]
if (items.length < 2) return fail(ast) if (items.length < 2) return fail(ast)
const discriminator = (ast as any).annotations?.discriminator const discriminator = ast.annotations?.discriminator
if (discriminator) { if (typeof discriminator === "string") {
return z.discriminatedUnion(discriminator, items as [z.ZodObject<any>, z.ZodObject<any>, ...z.ZodObject<any>[]]) return z.discriminatedUnion(discriminator, items as [z.ZodObject<any>, z.ZodObject<any>, ...z.ZodObject<any>[]])
} }
@@ -331,7 +331,7 @@ export default {
const localOpts = { const localOpts = {
fn_marker: tmp.extra.fnMarker, fn_marker: tmp.extra.fnMarker,
marker: tmp.extra.localMarker, marker: tmp.extra.localMarker,
source: tmp.extra.localDest.replace(".opencode/themes/", ""), source: path.join(tmp.path, tmp.extra.localThemeFile),
dest: tmp.extra.localDest, dest: tmp.extra.localDest,
theme_path: `./${tmp.extra.localThemeFile}`, theme_path: `./${tmp.extra.localThemeFile}`,
theme_name: tmp.extra.localThemeName, theme_name: tmp.extra.localThemeName,
@@ -264,27 +264,15 @@ describe("SyncProvider", () => {
log.length = 0 log.length = 0
await sync.session.sync("ses_1") await sync.session.sync("ses_1")
expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1)
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1)
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a")
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" })
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts")
log.length = 0
project.workspace.set("ws_b") project.workspace.set("ws_b")
await waitBoot(log, "ws_b") await waitBoot(log, "ws_b")
expect(project.workspace.current()).toBe("ws_b") expect(project.workspace.current()).toBe("ws_b")
log.length = 0 log.length = 0
await sync.session.sync("ses_1") await sync.session.sync("ses_1")
await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")) expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1)
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1)
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b")
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" })
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts")
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }
+63 -47
View File
@@ -1,7 +1,8 @@
import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test" import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
import { Deferred, Effect, Fiber, Layer, Option } from "effect" import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "../../src/config" import { Config, ConfigManaged } from "../../src/config"
import { ConfigParse } from "../../src/config/parse"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
@@ -10,7 +11,7 @@ import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Env } from "../../src/env" import { Env } from "../../src/env"
import { provideTmpdirInstance } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { tmpdir, tmpdirScoped } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@@ -24,7 +25,6 @@ import { pathToFileURL } from "url"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { ProjectID } from "../../src/project/schema" import { ProjectID } from "../../src/project/schema"
import { Filesystem } from "../../src/util" import { Filesystem } from "../../src/util"
import * as Network from "../../src/util/network"
import { ConfigPlugin } from "@/config/plugin" import { ConfigPlugin } from "@/config/plugin"
import { Npm } from "@opencode-ai/shared/npm" import { Npm } from "@opencode-ai/shared/npm"
@@ -846,6 +846,9 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
}, },
}) })
// TODO: this is a hack to wait for backgruounded gitignore
await new Promise((resolve) => setTimeout(resolve, 1000))
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true) expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json") expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
} finally { } finally {
@@ -1860,14 +1863,14 @@ describe("resolvePluginSpec", () => {
}) })
const file = path.join(tmp.path, "opencode.json") const file = path.join(tmp.path, "opencode.json")
const hit = await Config.resolvePluginSpec("./plugin", file) const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file)
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href) expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
}) })
}) })
describe("deduplicatePluginOrigins", () => { describe("deduplicatePluginOrigins", () => {
const dedupe = (plugins: Config.PluginSpec[]) => const dedupe = (plugins: ConfigPlugin.Spec[]) =>
Config.deduplicatePluginOrigins( ConfigPlugin.deduplicatePluginOrigins(
plugins.map((spec) => ({ plugins.map((spec) => ({
spec, spec,
source: "", source: "",
@@ -1937,8 +1940,8 @@ describe("deduplicatePluginOrigins", () => {
const config = await load() const config = await load()
const plugins = config.plugin ?? [] const plugins = config.plugin ?? []
expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true) expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
expect(plugins.some((p) => Config.pluginSpecifier(p).startsWith("file://"))).toBe(true) expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p).startsWith("file://"))).toBe(true)
}, },
}) })
}) })
@@ -2209,17 +2212,20 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
// parseManagedPlist unit tests — pure function, no OS interaction // parseManagedPlist unit tests — pure function, no OS interaction
test("parseManagedPlist strips MDM metadata keys", async () => { test("parseManagedPlist strips MDM metadata keys", async () => {
const config = await Config.parseManagedPlist( const config = ConfigParse.parse(
JSON.stringify({ Config.Info,
PayloadDisplayName: "OpenCode Managed", await ConfigManaged.parseManagedPlist(
PayloadIdentifier: "ai.opencode.managed.test", JSON.stringify({
PayloadType: "ai.opencode.managed", PayloadDisplayName: "OpenCode Managed",
PayloadUUID: "AAAA-BBBB-CCCC", PayloadIdentifier: "ai.opencode.managed.test",
PayloadVersion: 1, PayloadType: "ai.opencode.managed",
_manualProfile: true, PayloadUUID: "AAAA-BBBB-CCCC",
share: "disabled", PayloadVersion: 1,
model: "mdm/model", _manualProfile: true,
}), share: "disabled",
model: "mdm/model",
}),
),
"test:mobileconfig", "test:mobileconfig",
) )
expect(config.share).toBe("disabled") expect(config.share).toBe("disabled")
@@ -2231,12 +2237,15 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
}) })
test("parseManagedPlist parses server settings", async () => { test("parseManagedPlist parses server settings", async () => {
const config = await Config.parseManagedPlist( const config = ConfigParse.parse(
JSON.stringify({ Config.Info,
$schema: "https://opencode.ai/config.json", await ConfigManaged.parseManagedPlist(
server: { hostname: "127.0.0.1", mdns: false }, JSON.stringify({
autoupdate: true, $schema: "https://opencode.ai/config.json",
}), server: { hostname: "127.0.0.1", mdns: false },
autoupdate: true,
}),
),
"test:mobileconfig", "test:mobileconfig",
) )
expect(config.server?.hostname).toBe("127.0.0.1") expect(config.server?.hostname).toBe("127.0.0.1")
@@ -2245,18 +2254,21 @@ test("parseManagedPlist parses server settings", async () => {
}) })
test("parseManagedPlist parses permission rules", async () => { test("parseManagedPlist parses permission rules", async () => {
const config = await Config.parseManagedPlist( const config = ConfigParse.parse(
JSON.stringify({ Config.Info,
$schema: "https://opencode.ai/config.json", await ConfigManaged.parseManagedPlist(
permission: { JSON.stringify({
"*": "ask", $schema: "https://opencode.ai/config.json",
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" }, permission: {
grep: "allow", "*": "ask",
glob: "allow", bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
webfetch: "ask", grep: "allow",
"~/.ssh/*": "deny", glob: "allow",
}, webfetch: "ask",
}), "~/.ssh/*": "deny",
},
}),
),
"test:mobileconfig", "test:mobileconfig",
) )
expect(config.permission?.["*"]).toBe("ask") expect(config.permission?.["*"]).toBe("ask")
@@ -2269,19 +2281,23 @@ test("parseManagedPlist parses permission rules", async () => {
}) })
test("parseManagedPlist parses enabled_providers", async () => { test("parseManagedPlist parses enabled_providers", async () => {
const config = await Config.parseManagedPlist( const config = ConfigParse.parse(
JSON.stringify({ Config.Info,
$schema: "https://opencode.ai/config.json", await ConfigManaged.parseManagedPlist(
enabled_providers: ["anthropic", "google"], JSON.stringify({
}), $schema: "https://opencode.ai/config.json",
enabled_providers: ["anthropic", "google"],
}),
),
"test:mobileconfig", "test:mobileconfig",
) )
expect(config.enabled_providers).toEqual(["anthropic", "google"]) expect(config.enabled_providers).toEqual(["anthropic", "google"])
}) })
test("parseManagedPlist handles empty config", async () => { test("parseManagedPlist handles empty config", async () => {
const config = await Config.parseManagedPlist( const config = ConfigParse.parse(
JSON.stringify({ $schema: "https://opencode.ai/config.json" }), Config.Info,
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
"test:mobileconfig", "test:mobileconfig",
) )
expect(config.$schema).toBe("https://opencode.ai/config.json") expect(config.$schema).toBe("https://opencode.ai/config.json")
@@ -0,0 +1,68 @@
import { expect, test } from "bun:test"
import { CloudflareAIGatewayAuthPlugin } from "@/plugin/cloudflare"
const pluginInput = {
client: {} as never,
project: {} as never,
directory: "",
worktree: "",
experimental_workspace: {
register() {},
},
serverUrl: new URL("https://example.com"),
$: {} as never,
}
function makeHookInput(overrides: { providerID?: string; apiId?: string; reasoning?: boolean }) {
return {
sessionID: "s",
agent: "a",
provider: {} as never,
message: {} as never,
model: {
providerID: overrides.providerID ?? "cloudflare-ai-gateway",
api: { id: overrides.apiId ?? "openai/gpt-5.2-codex", url: "", npm: "ai-gateway-provider" },
capabilities: {
reasoning: overrides.reasoning ?? true,
temperature: false,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
} as never,
}
}
function makeHookOutput() {
return { temperature: 0, topP: 1, topK: 0, maxOutputTokens: 32_000 as number | undefined, options: {} }
}
test("omits maxOutputTokens for openai reasoning models on cloudflare-ai-gateway", async () => {
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
const out = makeHookOutput()
await hooks["chat.params"]!(makeHookInput({ apiId: "openai/gpt-5.2-codex", reasoning: true }), out)
expect(out.maxOutputTokens).toBeUndefined()
})
test("keeps maxOutputTokens for openai non-reasoning models", async () => {
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
const out = makeHookOutput()
await hooks["chat.params"]!(makeHookInput({ apiId: "openai/gpt-4-turbo", reasoning: false }), out)
expect(out.maxOutputTokens).toBe(32_000)
})
test("keeps maxOutputTokens for non-openai reasoning models on cloudflare-ai-gateway", async () => {
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
const out = makeHookOutput()
await hooks["chat.params"]!(makeHookInput({ apiId: "anthropic/claude-sonnet-4-5", reasoning: true }), out)
expect(out.maxOutputTokens).toBe(32_000)
})
test("ignores non-cloudflare-ai-gateway providers", async () => {
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
const out = makeHookOutput()
await hooks["chat.params"]!(makeHookInput({ providerID: "openai", apiId: "gpt-5.2-codex", reasoning: true }), out)
expect(out.maxOutputTokens).toBe(32_000)
})
@@ -100,6 +100,24 @@ describe("ProviderTransform.options - setCacheKey", () => {
}) })
expect(result.store).toBe(false) expect(result.store).toBe(false)
}) })
test("should set store=true for azure provider by default", () => {
const azureModel = {
...mockModel,
providerID: "azure",
api: {
id: "gpt-4",
url: "https://azure.com",
npm: "@ai-sdk/azure",
},
}
const result = ProviderTransform.options({
model: azureModel,
sessionID,
providerOptions: {},
})
expect(result.store).toBe(true)
})
}) })
describe("ProviderTransform.options - zai/zhipuai thinking", () => { describe("ProviderTransform.options - zai/zhipuai thinking", () => {
@@ -2246,6 +2264,46 @@ describe("ProviderTransform.variants", () => {
}) })
}) })
test("anthropic opus 4.7 models return adaptive thinking options with xhigh", () => {
const model = createMockModel({
id: "anthropic/claude-opus-4-7",
providerID: "gateway",
api: {
id: "anthropic/claude-opus-4-7",
url: "https://gateway.ai",
npm: "@ai-sdk/gateway",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
expect(result.xhigh).toEqual({
thinking: {
type: "adaptive",
},
effort: "xhigh",
})
expect(result.max).toEqual({
thinking: {
type: "adaptive",
},
effort: "max",
})
})
test("anthropic opus 4.7 dot-format models return adaptive thinking options with xhigh", () => {
const model = createMockModel({
id: "anthropic/claude-opus-4-7",
providerID: "gateway",
api: {
id: "anthropic/claude-opus-4.7",
url: "https://gateway.ai",
npm: "@ai-sdk/gateway",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
})
test("anthropic models return anthropic thinking options", () => { test("anthropic models return anthropic thinking options", () => {
const model = createMockModel({ const model = createMockModel({
id: "anthropic/claude-sonnet-4", id: "anthropic/claude-sonnet-4",
@@ -2654,6 +2712,34 @@ describe("ProviderTransform.variants", () => {
}) })
}) })
test("opus 4.7 returns adaptive thinking options with xhigh", () => {
const model = createMockModel({
id: "anthropic/claude-opus-4-7",
providerID: "anthropic",
api: {
id: "claude-opus-4-7",
url: "https://api.anthropic.com",
npm: "@ai-sdk/anthropic",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
expect(result.xhigh).toEqual({
thinking: {
type: "adaptive",
display: "summarized",
},
effort: "xhigh",
})
expect(result.max).toEqual({
thinking: {
type: "adaptive",
display: "summarized",
},
effort: "max",
})
})
test("returns high and max with thinking config", () => { test("returns high and max with thinking config", () => {
const model = createMockModel({ const model = createMockModel({
id: "anthropic/claude-4", id: "anthropic/claude-4",
@@ -2702,6 +2788,32 @@ describe("ProviderTransform.variants", () => {
}) })
}) })
test("anthropic opus 4.7 returns adaptive reasoning options with xhigh", () => {
const model = createMockModel({
id: "bedrock/anthropic-claude-opus-4-7",
providerID: "bedrock",
api: {
id: "anthropic.claude-opus-4-7",
url: "https://bedrock.amazonaws.com",
npm: "@ai-sdk/amazon-bedrock",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
expect(result.xhigh).toEqual({
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: "xhigh",
},
})
expect(result.max).toEqual({
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: "max",
},
})
})
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => { test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => {
const model = createMockModel({ const model = createMockModel({
id: "bedrock/llama-4", id: "bedrock/llama-4",
@@ -157,16 +157,6 @@ describe("structured-output.AssistantMessage", () => {
}) })
describe("structured-output.createStructuredOutputTool", () => { describe("structured-output.createStructuredOutputTool", () => {
test("creates tool with correct id", () => {
const tool = SessionPrompt.createStructuredOutputTool({
schema: { type: "object", properties: { name: { type: "string" } } },
onSuccess: () => {},
})
// AI SDK tool type doesn't expose id, but we set it internally
expect((tool as any).id).toBe("StructuredOutput")
})
test("creates tool with description", () => { test("creates tool with description", () => {
const tool = SessionPrompt.createStructuredOutputTool({ const tool = SessionPrompt.createStructuredOutputTool({
schema: { type: "object" }, schema: { type: "object" },
+48
View File
@@ -187,5 +187,53 @@ describe("SyncEvent", () => {
).toThrow(/Unknown event type/) ).toThrow(/Unknown event type/)
}), }),
) )
test(
"replayAll accepts later chunks after the first batch",
withInstance(() => {
const { Created } = setup()
const id = Identifier.descending("message")
const one = SyncEvent.replayAll([
{
id: "evt_1",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 0,
aggregateID: id,
data: { id, name: "first" },
},
{
id: "evt_2",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 1,
aggregateID: id,
data: { id, name: "second" },
},
])
const two = SyncEvent.replayAll([
{
id: "evt_3",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 2,
aggregateID: id,
data: { id, name: "third" },
},
{
id: "evt_4",
type: SyncEvent.versionedType(Created.type, Created.version),
seq: 3,
aggregateID: id,
data: { id, name: "fourth" },
},
])
expect(one).toBe(id)
expect(two).toBe(id)
const rows = Database.use((db) => db.select().from(EventTable).all())
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
}),
)
}) })
}) })
@@ -0,0 +1,280 @@
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import fs from "node:fs/promises"
import path from "node:path"
import { GlobalBus } from "../../src/bus/global"
import { registerAdaptor } from "../../src/control-plane/adaptors"
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { AppRuntime } from "../../src/effect/app-runtime"
import { Flag } from "../../src/flag/flag"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { Session as SessionNs } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { Database, asc, eq } from "../../src/storage"
import { SyncEvent } from "../../src/sync"
import { EventTable } from "../../src/sync/event.sql"
import { Log } from "../../src/util"
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
beforeEach(() => {
Database.close()
// @ts-expect-error test override
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
})
afterEach(async () => {
mock.restore()
await Instance.disposeAll()
// @ts-expect-error test override
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
await resetDatabase()
})
function create(input?: SessionNs.CreateInput) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
}
function get(id: SessionID) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
}
function updateMessage<T extends MessageV2.Info>(msg: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
}
function updatePart<T extends MessageV2.Part>(part: T) {
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
}
async function user(sessionID: SessionID, text: string) {
const msg = await updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
time: { created: Date.now() },
})
await updatePart({
id: PartID.ascending(),
sessionID,
messageID: msg.id,
type: "text",
text,
})
}
function remote(dir: string, url: string): WorkspaceAdaptor {
return {
name: "remote",
description: "remote",
configure(info) {
return {
...info,
directory: dir,
}
},
async create() {
await fs.mkdir(dir, { recursive: true })
},
async remove() {},
target() {
return {
type: "remote" as const,
url,
}
},
}
}
function local(dir: string): WorkspaceAdaptor {
return {
name: "local",
description: "local",
configure(info) {
return {
...info,
directory: dir,
}
},
async create() {
await fs.mkdir(dir, { recursive: true })
},
async remove() {},
target() {
return {
type: "local" as const,
directory: dir,
}
},
}
}
function eventStreamResponse() {
return new Response(new ReadableStream({ start() {} }), {
status: 200,
headers: {
"content-type": "text/event-stream",
},
})
}
describe("Workspace.sessionRestore", () => {
test("replays session events in batches of 10 and emits progress", async () => {
await using tmp = await tmpdir({ git: true })
const dir = path.join(tmp.path, ".restore")
const seen: any[] = []
const posts: Array<{
path: string
body: { directory: string; events: Array<{ seq: number; aggregateID: string }> }
}> = []
const on = (evt: any) => seen.push(evt)
GlobalBus.on("event", on)
const raw = globalThis.fetch
spyOn(globalThis, "fetch").mockImplementation(
Object.assign(
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url)
if (url.pathname !== "/base/sync/replay") {
return eventStreamResponse()
}
const body = JSON.parse(String(init?.body))
posts.push({
path: url.pathname,
body,
})
return Response.json({ sessionID: body.events[0].aggregateID })
},
{
preconnect: raw.preconnect?.bind(raw),
},
) as typeof globalThis.fetch,
)
try {
const setup = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(Instance.project.id, "worktree", remote(dir, "https://workspace.test/base"))
const space = await Workspace.create({
type: "worktree",
branch: null,
extra: null,
projectID: Instance.project.id,
})
const session = await create({})
for (let i = 0; i < 6; i++) {
await user(session.id, `msg ${i}`)
}
const rows = Database.use((db) =>
db
.select({ seq: EventTable.seq })
.from(EventTable)
.where(eq(EventTable.aggregate_id, session.id))
.orderBy(asc(EventTable.seq))
.all(),
)
const result = await Workspace.sessionRestore({
workspaceID: space.id,
sessionID: session.id,
})
return { space, session, rows, result }
},
})
expect(setup.rows).toHaveLength(13)
expect(setup.result).toEqual({ total: 2 })
expect(posts).toHaveLength(2)
expect(posts[0]?.path).toBe("/base/sync/replay")
expect(posts[1]?.path).toBe("/base/sync/replay")
expect(posts[0]?.body.directory).toBe(dir)
expect(posts[1]?.body.directory).toBe(dir)
expect(posts[0]?.body.events).toHaveLength(10)
expect(posts[1]?.body.events).toHaveLength(4)
expect(posts.flatMap((item) => item.body.events.map((event) => event.seq))).toEqual([
...setup.rows.map((row) => row.seq),
setup.rows.at(-1)!.seq + 1,
])
expect(posts[1]?.body.events.at(-1)).toMatchObject({
aggregateID: setup.session.id,
seq: setup.rows.at(-1)!.seq + 1,
type: SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version),
data: {
sessionID: setup.session.id,
info: {
workspaceID: setup.space.id,
},
},
})
const restore = seen.filter(
(evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type,
)
expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2])
expect(restore.map((evt) => evt.payload.properties.total)).toEqual([2, 2, 2])
expect(restore.map((evt) => evt.payload.properties.sessionID)).toEqual([
setup.session.id,
setup.session.id,
setup.session.id,
])
} finally {
GlobalBus.off("event", on)
}
})
test("replays locally without posting to a server", async () => {
await using tmp = await tmpdir({ git: true })
const dir = path.join(tmp.path, ".restore-local")
const seen: any[] = []
const on = (evt: any) => seen.push(evt)
GlobalBus.on("event", on)
const fetch = spyOn(globalThis, "fetch")
const replayAll = spyOn(SyncEvent, "replayAll")
try {
const setup = await Instance.provide({
directory: tmp.path,
fn: async () => {
registerAdaptor(Instance.project.id, "local-restore", local(dir))
const space = await Workspace.create({
type: "local-restore",
branch: null,
extra: null,
projectID: Instance.project.id,
})
const session = await create({})
for (let i = 0; i < 6; i++) {
await user(session.id, `msg ${i}`)
}
const result = await Workspace.sessionRestore({
workspaceID: space.id,
sessionID: session.id,
})
const updated = await get(session.id)
return { space, session, result, updated }
},
})
expect(setup.result).toEqual({ total: 2 })
expect(fetch).not.toHaveBeenCalled()
expect(replayAll).toHaveBeenCalledTimes(2)
expect(setup.updated.workspaceID).toBe(setup.space.id)
const restore = seen.filter(
(evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type,
)
expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2])
} finally {
GlobalBus.off("event", on)
}
})
})
+3 -2
View File
@@ -17,15 +17,16 @@
}, },
"imports": {}, "imports": {},
"devDependencies": { "devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/semver": "catalog:", "@types/semver": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/npmcli__arborist": "6.3.3", "@types/npmcli__arborist": "6.3.3"
"@tsconfig/bun": "catalog:"
}, },
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@npmcli/arborist": "catalog:", "@npmcli/arborist": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"glob": "13.0.5",
"mime-types": "3.0.2", "mime-types": "3.0.2",
"minimatch": "10.2.5", "minimatch": "10.2.5",
"semver": "catalog:", "semver": "catalog:",
+23 -20
View File
@@ -142,7 +142,7 @@ export namespace Npm {
yield* flock.acquire(`npm-install:${dir}`) yield* flock.acquire(`npm-install:${dir}`)
const reify = Effect.fnUntraced(function* () { const reify = Effect.fn("Npm.reify")(function* () {
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const arb = new Arborist({ const arb = new Arborist({
path: dir, path: dir,
@@ -176,28 +176,31 @@ export namespace Npm {
const pkgAny = pkg as any const pkgAny = pkg as any
const lockAny = lock as any const lockAny = lock as any
const declared = new Set([ yield* Effect.gen(function* () {
...Object.keys(pkgAny?.dependencies || {}), const declared = new Set([
...Object.keys(pkgAny?.devDependencies || {}), ...Object.keys(pkgAny?.dependencies || {}),
...Object.keys(pkgAny?.peerDependencies || {}), ...Object.keys(pkgAny?.devDependencies || {}),
...Object.keys(pkgAny?.optionalDependencies || {}), ...Object.keys(pkgAny?.peerDependencies || {}),
...(input?.add || []), ...Object.keys(pkgAny?.optionalDependencies || {}),
]) ...(input?.add || []),
])
const root = lockAny?.packages?.[""] || {} const root = lockAny?.packages?.[""] || {}
const locked = new Set([ const locked = new Set([
...Object.keys(root?.dependencies || {}), ...Object.keys(root?.dependencies || {}),
...Object.keys(root?.devDependencies || {}), ...Object.keys(root?.devDependencies || {}),
...Object.keys(root?.peerDependencies || {}), ...Object.keys(root?.peerDependencies || {}),
...Object.keys(root?.optionalDependencies || {}), ...Object.keys(root?.optionalDependencies || {}),
]) ])
for (const name of declared) { for (const name of declared) {
if (!locked.has(name)) { if (!locked.has(name)) {
yield* reify() yield* reify()
return return
}
} }
} }).pipe(Effect.withSpan("Npm.checkDirty"))
return
}, Effect.scoped) }, Effect.scoped)
const which = Effect.fn("Npm.which")(function* (pkg: string) { const which = Effect.fn("Npm.which")(function* (pkg: string) {
+2
View File
@@ -74,6 +74,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -128,6 +129,7 @@ https://opencode.ai/zen/v1/models
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -79,6 +79,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa.
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -135,6 +136,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -79,6 +79,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -135,6 +136,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**.
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -70,6 +70,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen.
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -124,6 +125,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -79,6 +79,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -135,6 +136,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -70,6 +70,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -124,6 +125,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -79,6 +79,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -135,6 +136,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -70,6 +70,7 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -124,6 +125,7 @@ https://opencode.ai/zen/v1/models
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -70,6 +70,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다.
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -124,6 +125,7 @@ https://opencode.ai/zen/v1/models
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -79,6 +79,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -135,6 +136,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**.
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -79,6 +79,7 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API.
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -135,6 +136,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów*
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
@@ -70,6 +70,7 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API.
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -124,6 +125,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**.
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -79,6 +79,7 @@ OpenCode Zen работает как любой другой провайдер
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -135,6 +136,7 @@ https://opencode.ai/zen/v1/models
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -72,6 +72,7 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -126,6 +127,7 @@ https://opencode.ai/zen/v1/models
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -70,6 +70,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -124,6 +125,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+2
View File
@@ -79,6 +79,7 @@ You can also access our models through the following API endpoints.
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -130,6 +131,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 | | Qwen3.6 Plus | $0.50 | $3.00 | $0.05 | $0.625 |
| Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 | | Qwen3.5 Plus | $0.20 | $1.20 | $0.02 | $0.25 |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
@@ -70,6 +70,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -124,6 +125,7 @@ https://opencode.ai/zen/v1/models
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
@@ -74,6 +74,7 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
| Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
@@ -129,6 +130,7 @@ https://opencode.ai/zen/v1/models
| GLM 5 | $1.00 | $3.20 | $0.20 | - | | GLM 5 | $1.00 | $3.20 | $0.20 | - |
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - | | Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - | | Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
| Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 | | Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
+1 -2
View File
@@ -2,8 +2,7 @@
const repo = "anomalyco/opencode" const repo = "anomalyco/opencode"
const days = 60 const days = 60
const msg = const msg = `To stay organized issues are automatically closed after ${days} days of no activity. If the issue is still relevant please open a new one.`
"To stay organized issues are automatically closed after 90 days of no activity. If the issue is still relevant please open a new one."
const token = process.env.GITHUB_TOKEN const token = process.env.GITHUB_TOKEN
if (!token) { if (!token) {