Compare commits

..

84 Commits

Author SHA1 Message Date
Kit Langton e2eff19a32 refactor(tool): reuse edit entry schema in multiedit 2026-04-18 13:02:29 -04:00
Kit Langton f2cbcacf00 fix(tool): align multiedit schema with single-file edits 2026-04-18 12:54:29 -04:00
Kit Langton a49b5adfbd refactor(tool): migrate tool framework + all 18 built-in tools to Effect Schema (#23293) 2026-04-18 11:58:38 -04:00
Kit Langton d6abb56f58 test(tool): pin every tool's parameters schema before migration
Pre-migration safety net for the upcoming tool-by-tool zod\u2192Schema
conversion. Every tool's parameters schema now has:

1. A JSON Schema snapshot (`z.toJSONSchema` with `io: "input"`) \u2014 this
   captures exactly what the LLM sees at tool registration time, so any
   drift caused by a future migration fails the snapshot.
2. Parse-accept/parse-reject assertions per tool pinning the
   user-visible behavioural contract (required fields, refinement
   bounds, enum membership, default values).

To make the snapshots possible without standing up each tool's full
Effect runtime, every tool file now exports its parameters schema as
`Parameters` at module scope:

- 9 tools already had a module-level const \u2014 just added `export`, and
  standardised the name to `Parameters` (uppercase) where it was
  previously `parameters`.
- 9 tools had their schema inline inside `Tool.define` \u2014 hoisted to
  module scope under the same `Parameters` name and wired back through.

Zero behaviour change: Tool.define still sees the same schema, runtime
validation path is identical, SDK (types.gen.ts + openapi.json) is
byte-identical, and the full 2054-test suite passes.

18 JSON Schema snapshots and 43 explicit parse/reject assertions for the
18 built-in tools (apply_patch, bash, codesearch, edit, glob, grep,
invalid, lsp, multiedit, plan, question, read, skill, task, todo,
webfetch, websearch, write).
2026-04-18 00:01:50 -04:00
Kit Langton 23f31475e7 refactor(config): migrate config.ts root Info to Effect Schema (#23241) 2026-04-18 03:44:35 +00:00
OpeOginni c0eab9e442 fix(desktop): adjust ui tool diff sticky header offset (#23149) 2026-04-18 11:31:38 +08:00
opencode-agent[bot] 8a1e85d0c8 chore: generate 2026-04-18 03:17:28 +00:00
Kit Langton 2793502db2 refactor(config): migrate agent.ts Info to Effect Schema (#23237) 2026-04-18 03:16:24 +00:00
opencode-agent[bot] 9f7bd0246c chore: generate 2026-04-18 03:05:59 +00:00
Kit Langton a6a4350d10 refactor(config): migrate permission.ts Info to Effect Schema (#23231) 2026-04-18 03:05:06 +00:00
Kit Langton 471b9f4dc4 refactor: use InstanceState context in worktree cleanup paths (#23019) 2026-04-17 23:04:16 -04:00
Kit Langton 24fb9b1296 fix: stop rewriting dev during release publish (#22982) 2026-04-18 02:53:19 +00:00
Kit Langton 3573019916 fix(generate): make openapi output deterministic by formatting in-place (#23228) 2026-04-17 22:31:21 -04:00
Kit Langton fc5b353144 refactor(config): migrate keybinds.ts to Effect Schema (#23227) 2026-04-18 02:28:45 +00:00
Kit Langton 1dd257b76a refactor: use instance state in small services (#23022) 2026-04-18 02:16:15 +00:00
Kit Langton 5fa1673341 refactor: use InstanceState context in File service (#23015) 2026-04-17 22:08:57 -04:00
opencode-agent[bot] daaa1c7e26 chore: generate 2026-04-18 02:03:30 +00:00
Kit Langton 1fae784b81 feat(effect-zod): add ZodPreprocess annotation for pre-parse transforms (#23222) 2026-04-18 02:02:37 +00:00
Aiden Cline 81b7b58a5e fix: gh copilot issue w/ haiku (eager_input_streaming not supported) (#23223) 2026-04-17 20:57:48 -05:00
opencode-agent[bot] 866188a643 chore: generate 2026-04-18 01:48:50 +00:00
Kit Langton e6fd57165e refactor: remove ambient instance reads from lsp (#23023) 2026-04-17 21:47:59 -04:00
Kit Langton a5d99e7a3c refactor: pass formatter instance context explicitly (#23020) 2026-04-18 01:22:36 +00:00
opencode-agent[bot] a92c75e5f4 chore: generate 2026-04-18 01:21:01 +00:00
Kit Langton 826fd3350c refactor(config): migrate Server + Layout to Effect Schema (#23216) 2026-04-18 01:20:06 +00:00
Kit Langton 23a2d01282 fix(observability): standardize session telemetry attrs (#23213) 2026-04-17 21:14:23 -04:00
Kit Langton 5181f9b4e1 refactor(config): drop ZodOverride from PositiveInt in provider.ts (#23215) 2026-04-18 01:04:40 +00:00
opencode-agent[bot] f52ae28432 chore: generate 2026-04-18 00:56:33 +00:00
Kit Langton 36119ff173 feat(effect-zod): translate Schema.withDecodingDefault into zod .default() (#23207) 2026-04-17 20:55:38 -04:00
Kit Langton bb90f3bbf9 feat(effect-zod): translate well-known filters into native Zod methods (#23209) 2026-04-17 20:50:36 -04:00
Kit Langton 05cdb7c107 refactor(v2): tag session unions and exhaustively match events (#23201) 2026-04-18 00:29:26 +00:00
Kit Langton b493dabfe6 docs(effect): refresh migration status specs (#23206) 2026-04-18 00:29:26 +00:00
opencode-agent[bot] c4816f944e chore: generate 2026-04-18 00:29:26 +00:00
Kit Langton 211136e3a8 feat(effect-zod): transform support + walk memoization + flattened checks (#23203) 2026-04-18 00:29:26 +00:00
opencode-agent[bot] cf0a53c501 chore: generate 2026-04-18 00:29:26 +00:00
Kit Langton 2899984819 refactor(config): migrate provider (Model + Info) to Effect Schema (#23197) 2026-04-18 00:29:26 +00:00
Kit Langton eafbe5c57c refactor(server): align route-span attrs with OTel semantic conventions (#23198) 2026-04-18 00:29:26 +00:00
Kit Langton 7b98f544ff feat(effect-zod): add catchall (StructWithRest) support to the walker (#23186) 2026-04-18 00:29:26 +00:00
Kit Langton b5aba5807c feat(tui): show session ID in sidebar on non-prod channels (#23185) 2026-04-18 00:29:26 +00:00
Kit Langton d5c4c26b4b feat(server): auto-tag route spans with route params (session.id, message.id, …) (#23189) 2026-04-18 00:29:26 +00:00
opencode a35b8a95c2 release: v1.4.11 2026-04-18 00:29:16 +00:00
Dax cded68a2e2 refactor(npm): use object-based package spec for install API (#23181) 2026-04-17 17:30:50 -04:00
Aiden Cline 0068ccec35 fix: ensure copilot model list filters out disabled models (#23176) 2026-04-17 16:27:32 -05:00
opencode-agent[bot] 89e8994fd1 chore: generate 2026-04-17 21:08:00 +00:00
Kit Langton 5980b0a5ee feat(effect-zod): add tuple support; migrate config/plugin to Effect Schema (#23178) 2026-04-17 21:06:55 +00:00
opencode-agent[bot] 89029a20ef chore: generate 2026-04-17 21:00:20 +00:00
Kit Langton ce69bd97b9 refactor(config): migrate model-id and command to Effect Schema (#23175) 2026-04-17 20:59:24 +00:00
Kit Langton 999d8651aa feat(server): wrap remaining route handlers in request spans (#23169) 2026-04-17 16:52:40 -04:00
opencode-agent[bot] ed0f022502 chore: generate 2026-04-17 20:50:37 +00:00
Kit Langton b1307d5c2a refactor(config): migrate skills, formatter, console-state to Effect Schema (#23162) 2026-04-17 20:49:36 +00:00
opencode-agent[bot] dc16013b4f chore: generate 2026-04-17 20:47:05 +00:00
Kit Langton e7686dbd64 feat(effect-zod): translate Schema.check filters into zod .superRefine + promote LSP refinement to Effect layer (#23173) 2026-04-17 20:46:05 +00:00
James Long 47f553f9ba fix(core): more explicit routing to fix workspace instance issue (#23171) 2026-04-17 16:39:34 -04:00
Kit Langton d11268ece7 refactor(config): migrate permission Action/Object/Rule leaves to Effect Schema (#23168) 2026-04-17 20:35:42 +00:00
Kit Langton 650a13a690 refactor(config): migrate lsp schemas to Effect Schema (#23167) 2026-04-17 20:34:47 +00:00
opencode-agent[bot] 54435325b6 chore: generate 2026-04-17 20:26:43 +00:00
Kit Langton 11fa257549 refactor(config): migrate mcp schemas to Effect Schema.Class (#23163) 2026-04-17 20:25:49 +00:00
Kit Langton 6af8ab0df2 docs(http-api): refresh bridge inventory and clarify Schema.Class vs Struct (#23164) 2026-04-17 16:20:57 -04:00
Kit Langton 984f5ed6eb fix(opencode): skip share sync for unshared sessions (#23159) 2026-04-17 20:15:24 +00:00
opencode-agent[bot] c2061c6bbf chore: generate 2026-04-17 20:13:34 +00:00
Dax b708e8431e docs(opencode): annotate plugin loader flow (#23160) 2026-04-17 20:13:34 +00:00
opencode 9b6c397171 release: v1.4.10 2026-04-17 20:13:25 +00:00
opencode-agent[bot] 9b0659d4f9 chore: generate 2026-04-17 19:30:28 +00:00
Kit Langton f83cecaaf6 fix(opencode): untrace streaming event hot paths (#23156) 2026-04-17 15:29:32 -04:00
James Long aa05b9abe5 fix(core): pass OTEL config to workspace env (#23154) 2026-04-17 15:25:58 -04:00
Kit Langton 68834cfcc3 fix(opencode): normalize provider metadata and tag otel runs (#23140) 2026-04-17 15:22:08 -04:00
James Long 5621373bc2 fix(core): move instance middleware after control plane routes (#23150) 2026-04-17 15:20:11 -04:00
opencode-agent[bot] 88582566bf chore: update nix node_modules hashes 2026-04-17 19:18:55 +00:00
opencode-agent[bot] d6e1362fee chore: generate 2026-04-17 19:15:07 +00:00
James Long b275b8580d feat(tui): minor UX improvements for workspaces (#23146) 2026-04-17 15:14:05 -04:00
Dax 467be08e67 refactor: consolidate npm exports and trace flock acquisition (#23151) 2026-04-17 18:58:37 +00:00
Aiden Cline bbb422d125 chore: bump ai to 6.0.168 and @ai-sdk/gateway to 3.0.104 (#23145) 2026-04-17 13:47:22 -05:00
Dax Raad b1f076558c test: align plugin loader npm mocks
- switch plugin loader tests to the effect npm module
- return Option.none() for mocked npm entrypoints
- keep test fixtures aligned with the current Npm.add contract
2026-04-17 14:33:02 -04:00
Dax Raad 992435aaf8 do not flock until reify 2026-04-17 14:18:48 -04:00
Dax Raad 2f73e73e9d trace npm fully 2026-04-17 14:08:45 -04:00
James Long 4c30a78cd9 fix: revert sdk generation script change (#23133) 2026-04-17 13:33:11 -04:00
James Long a8c78fc005 fix(core): add historical sync on workspace connect (#23121) 2026-04-17 13:30:09 -04:00
opencode-agent[bot] fcb473ff64 chore: update nix node_modules hashes 2026-04-17 17:25:44 +00:00
James Long 797953c88d when generating sdk only format sdk, much faster (#23122) 2026-04-17 13:01:22 -04:00
opencode-agent[bot] ce0cfb0ea5 chore: generate 2026-04-17 16:46:34 +00:00
Kit Langton 13dfe569ef tui: fix agent cycling and prompt metadata polish (#23115) 2026-04-17 12:45:29 -04:00
Aiden Cline c491161c0c chore: bump @ai-sdk/anthropic to 3.0.71 and dependents (#23120) 2026-04-17 11:40:24 -05:00
rasdani fde3d9133b fix(opencode): pass EXA_API_KEY to websearch tool to avoid rate limits (#16362)
Co-authored-by: Dax Raad <d@ironbay.co>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-17 11:28:23 -05:00
Vladimir Glafirov 0d582f9d3f chore: bump gitlab-ai-provider to 6.6.0 (#23057) 2026-04-17 11:22:43 -05:00
Dax Raad 1a59133168 Improve light mode dark mode copy 2026-04-17 16:19:57 +00:00
167 changed files with 5411 additions and 2795 deletions
+1 -5
View File
@@ -1,10 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"opencode": {
"options": {},
},
},
"provider": {},
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",
+31 -33
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -83,7 +83,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -117,7 +117,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -144,7 +144,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -225,7 +225,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"effect": "catalog:",
"electron-context-menu": "4.1.2",
@@ -268,7 +268,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/shared": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -313,7 +313,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.4.9",
"version": "1.4.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -322,15 +322,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.94",
"@ai-sdk/anthropic": "3.0.70",
"@ai-sdk/amazon-bedrock": "4.0.95",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.102",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.111",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -386,7 +386,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.4.2",
"gitlab-ai-provider": "6.6.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -458,7 +458,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -493,7 +493,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -508,7 +508,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
"version": "1.4.9",
"version": "1.4.11",
"bin": {
"opencode": "./bin/opencode",
},
@@ -532,7 +532,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -567,7 +567,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -616,7 +616,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -692,7 +692,7 @@
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "6.0.158",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"diff": "8.0.2",
"dompurify": "3.3.1",
@@ -740,7 +740,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/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/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.95", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@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-qJKWEy+cNx3bLSJi/XpIVhv0P8KO0JFB1SvEroNWN8gKm820SIglBmXS10DTeXJdM5PPbQX4i/wJj5BHEk2LRQ=="],
"@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=="],
@@ -760,11 +760,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/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/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
"@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.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/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@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-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="],
"@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=="],
@@ -2456,7 +2456,7 @@
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -2516,7 +2516,7 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
"ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="],
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
@@ -3314,7 +3314,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-jUxYnKA4XQaPc3wxACDZ8bPDXO0Mzx7cZaBDxbT2uGgLqtGZmSi+9tVNIg7louSS+s/ioVra3SoUz3iOFVhKPA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -5154,7 +5154,7 @@
"@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.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/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@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=="],
@@ -5172,7 +5172,7 @@
"@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.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/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"@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=="],
@@ -5702,8 +5702,6 @@
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"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=="],
@@ -5922,7 +5920,7 @@
"nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"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/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
"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": {
"x86_64-linux": "sha256-OPbZUo/fQv2Xsf+NEZV08GLBMN/DXovhRvn2JkesFtY=",
"aarch64-linux": "sha256-WK7xlVLuirKDN5LaqjBn7qpv5bYVtYHZw0qRNKX4xXg=",
"aarch64-darwin": "sha256-BAoAdeLQ+lXDD7Klxoxij683OVVug8KXEMRUqIQAjc8=",
"x86_64-darwin": "sha256-ZOBwNR2gZgc5f+y3VIBBT4qZpeZfg7Of6AaGDOfqsG8="
"x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=",
"aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=",
"aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=",
"x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA="
}
}
+1 -1
View File
@@ -53,7 +53,7 @@
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.48",
"ai": "6.0.158",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.4.9",
"version": "1.4.11",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.4.9",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.4.9",
"version": "1.4.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.4.9",
"version": "1.4.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.4.9",
"version": "1.4.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.4.9",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.4.9",
"version": "1.4.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.4.9",
"version": "1.4.11",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.4.9"
version = "1.4.11"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.4.9",
"version": "1.4.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.4.9",
"version": "1.4.11",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -79,15 +79,15 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.16.1",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.94",
"@ai-sdk/anthropic": "3.0.70",
"@ai-sdk/amazon-bedrock": "4.0.95",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.102",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.111",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
@@ -143,7 +143,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.4.2",
"gitlab-ai-provider": "6.6.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
+21 -8
View File
@@ -7,6 +7,20 @@ import { fileURLToPath } from "url"
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
async function published(name: string, version: string) {
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
}
async function publish(dir: string, name: string, version: string) {
if (await published(name, version)) {
console.log(`already published ${name}@${version}`)
return
}
if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
await $`bun pm pack`.cwd(dir)
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
}
const binaries: Record<string, string> = {}
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
const pkg = await Bun.file(`./dist/${filepath}`).json()
@@ -40,14 +54,10 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
)
const tasks = Object.entries(binaries).map(async ([name]) => {
if (process.platform !== "win32") {
await $`chmod -R 755 .`.cwd(`./dist/${name}`)
}
await $`bun pm pack`.cwd(`./dist/${name}`)
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(`./dist/${name}`)
await publish(`./dist/${name}`, name, binaries[name])
})
await Promise.all(tasks)
await $`cd ./dist/${pkg.name} && bun pm pack && npm publish *.tgz --access public --tag ${Script.channel}`
await publish(`./dist/${pkg.name}`, `${pkg.name}-ai`, version)
const image = "ghcr.io/anomalyco/opencode"
const platforms = "linux/amd64,linux/arm64"
@@ -104,6 +114,7 @@ if (!Script.preview) {
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild)
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
if ((await $`cd ./dist/aur-${pkg} && git diff --cached --quiet`.nothrow()).exitCode === 0) break
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/aur-${pkg} && git push`
break
@@ -176,6 +187,8 @@ if (!Script.preview) {
await $`git clone ${tap} ./dist/homebrew-tap`
await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula)
await $`cd ./dist/homebrew-tap && git add opencode.rb`
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/homebrew-tap && git push`
if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode !== 0) {
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/homebrew-tap && git push`
}
}
+32 -49
View File
@@ -1,12 +1,13 @@
# Facade removal checklist
Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`.
Concrete inventory of the remaining `makeRuntime(...)`-backed facades in `packages/opencode`.
As of 2026-04-13, latest `origin/dev`:
Current status on this branch:
- `src/` still has 15 `makeRuntime(...)` call sites.
- 13 of those are still in scope for facade removal.
- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`.
- `src/` has 5 `makeRuntime(...)` call sites total.
- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`.
- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
Recent progress:
@@ -15,8 +16,9 @@ Recent progress:
## Priority hotspots
- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades.
- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion.
- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`.
## Completed Batches
@@ -184,53 +186,34 @@ These were the recurring mistakes and useful corrections from the first two batc
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
## Next batch
## Remaining work
Recommended next five, in order:
Most of the original facade-removal backlog is already done. The practical remaining work is narrower now:
1. `src/permission/index.ts`
2. `src/agent/agent.ts`
3. `src/session/summary.ts`
4. `src/session/revert.ts`
5. `src/mcp/auth.ts`
Why this batch:
- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`.
- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`.
- `McpAuth` is small and closely related to the just-landed `MCP` cleanup.
After that batch, the expected follow-up is the main session cluster:
1. `src/session/index.ts`
2. `src/session/prompt.ts`
3. `src/session/compaction.ts`
1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist
## Checklist
- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts`
- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts`
- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts`
- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts`
- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts`
- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`
- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts`
- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts`
- [x] `src/file/index.ts` (`File`) - facades removed and merged.
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged.
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged.
- [x] `src/config/config.ts` (`Config`) - facades removed and merged.
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged.
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged.
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged.
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged.
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged.
- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts`
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged.
- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts`
- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts`
- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts`
- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts`
- [ ] `src/npm/index.ts` (`Npm`) - still exports runtime-backed async facade helpers on top of `Npm.Service`
- [ ] `src/cli/cmd/tui/config/tui.ts` (`TuiConfig`) - still exports runtime-backed async facade helpers on top of `TuiConfig.Service`
- [x] `src/session/session.ts` / `src/session/prompt.ts` / `src/session/revert.ts` / `src/session/summary.ts` - service-local facades removed
- [x] `src/agent/agent.ts` (`Agent`) - service-local facades removed
- [x] `src/permission/index.ts` (`Permission`) - service-local facades removed
- [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed
- [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed
- [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed
- [x] `src/file/index.ts` (`File`) - facades removed and merged
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged
- [x] `src/config/config.ts` (`Config`) - facades removed and merged
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged
## Excluded `makeRuntime(...)` sites
+85 -46
View File
@@ -76,7 +76,7 @@ Many route boundaries still use Zod-first validators. That does not block all ex
### Mixed handler styles
Many current `server/instance/*.ts` handlers still call async facades directly. Migrating those to composed `Effect.gen(...)` handlers is the low-risk step to do first.
Many current `server/routes/instance/*.ts` handlers still mix composed Effect code with smaller Promise- or ALS-backed seams. Migrating those to consistent `Effect.gen(...)` handlers is the low-risk step to do first.
### Non-JSON routes
@@ -90,7 +90,7 @@ The current server composition, middleware, and docs flow are Hono-centered toda
### 1. Finish the prerequisites first
- continue route-handler effectification in `server/instance/*.ts`
- continue route-handler effectification in `server/routes/instance/*.ts`
- continue schema migration toward Effect Schema-first DTOs and errors
- keep removing service facades
@@ -98,9 +98,9 @@ The current server composition, middleware, and docs flow are Hono-centered toda
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
- `server/instance/question.ts`
- `server/instance/provider.ts`
- `server/instance/permission.ts`
- `server/routes/instance/question.ts`
- `server/routes/instance/provider.ts`
- `server/routes/instance/permission.ts`
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
@@ -155,9 +155,9 @@ This gives:
As each route group is ported to `HttpApi`:
1. change its `root` path from `/experimental/httpapi/<group>` to `/<group>`
2. add `.all("/<group>", handler)` / `.all("/<group>/*", handler)` to the flag block in `instance/index.ts`
3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
1. add `.get(...)` / `.post(...)` bridge entries to the flag block in `server/routes/instance/index.ts`
2. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
3. keep the legacy Hono route registered behind it for OpenAPI / SDK generation until the spec pipeline changes
4. verify SDK output is unchanged
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
@@ -189,10 +189,46 @@ Ordering for a route-group migration:
SDK shape rule:
- every schema migration must preserve the generated SDK output byte-for-byte
- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema
- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
### Schema.Class vs Schema.Struct
The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
```ts
export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
static readonly zod = zod(this)
}
```
**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
```ts
export const Info = Schema.Struct({ ... }).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>
```
When to use each:
- Use **Schema.Class** when:
- the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
- the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
- Use **Schema.Struct** when:
- the type is only used as a nested field inside another named schema
- the original Zod was anonymous and promoting it would bloat SDK types with no import value
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
```ts
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
```
Temporary exception:
@@ -231,7 +267,7 @@ Use the same sequence for each route group.
3. Apply the schema migration ordering above so those types are Effect Schema-first.
4. Define the `HttpApi` contract separately from the handlers.
5. Implement handlers by yielding the existing service from context.
6. Mount the new surface in parallel under an experimental prefix.
6. Mount the new surface in parallel behind the `OPENCODE_EXPERIMENTAL_HTTPAPI` bridge.
7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
8. Add one end-to-end test and one OpenAPI-focused test.
9. Compare ergonomics before migrating the next endpoint.
@@ -250,20 +286,20 @@ Placement rule:
- keep `HttpApi` code under `src/server`, not `src/effect`
- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
- place each `HttpApi` slice next to the HTTP boundary it serves
- for instance-scoped routes, prefer `src/server/instance/httpapi/*`
- if control-plane routes ever migrate, prefer `src/server/control/httpapi/*`
- for instance-scoped routes, prefer `src/server/routes/instance/httpapi/*`
- if control-plane routes ever migrate, prefer `src/server/routes/control/httpapi/*`
Suggested file layout for a repeatable spike:
- `src/server/instance/httpapi/question.ts` — contract and handler layer for one route group
- `src/server/instance/httpapi/server.ts`standalone Effect HTTP server that composes all groups
- `test/server/question-httpapi.test.ts` — end-to-end test against the real service
- `src/server/routes/instance/httpapi/question.ts` — contract and handler layer for one route group
- `src/server/routes/instance/httpapi/server.ts`bridged Effect HTTP layer that composes all groups
- route or OpenAPI verification should live alongside the existing server tests; there is no dedicated `question-httpapi` test file on this branch
Suggested responsibilities:
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
- `server.ts` composes all route groups into one `HttpRouter.serve` layer with shared middleware (auth, instance lookup)
- tests use `ExperimentalHttpApiServer.layerTest` to run against a real in-process HTTP server
- `server.ts` composes all route groups into one `HttpRouter.toWebHandler(...)` bridge with shared middleware (auth, instance lookup)
- tests should verify the bridged routes through the normal server surface
## Example migration shape
@@ -283,33 +319,33 @@ Each route-group spike should follow the same shape.
- keep handler bodies thin
- keep transport mapping at the HTTP boundary only
### 3. Standalone server
### 3. Bridged server
- the Effect HTTP server is self-contained in `httpapi/server.ts`
- it is **not** mounted into the Hono app — no bridge, no `toWebHandler`
- route paths use the `/experimental/httpapi` prefix so they match the eventual cutover
- each route group exposes its own OpenAPI doc endpoint
- the Effect HTTP layer is composed in `httpapi/server.ts`
- it is mounted into the Hono app via `HttpRouter.toWebHandler(...)`
- routes keep their normal instance paths and are gated by the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- the legacy Hono handlers stay registered after the bridge so current OpenAPI / SDK generation still works
### 4. Verification
- seed real state through the existing service
- call the experimental endpoints
- call the bridged endpoints with the flag enabled
- assert that the service behavior is unchanged
- assert that the generated OpenAPI contains the migrated paths and schemas
## Boundary composition
The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server.
The Effect `HttpApi` layer owns its own auth and instance middleware, but it is currently mounted inside the existing Hono server.
### Auth
- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
- the bridged `HttpApi` layer implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
- this is independent of the Hono `AuthMiddleware` — when the Effect server eventually replaces Hono, this becomes the only auth layer
- this is independent of the Hono auth layer; the current bridge keeps the responsibility local to the `HttpApi` slice
### Instance and workspace lookup
- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
- the bridged `HttpApi` layer resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
- `HttpApi` handlers yield services from context and assume the correct instance has already been provided
@@ -324,7 +360,7 @@ The standalone Effect server owns its own middleware stack. It does not share mi
The first slice is successful if:
- the standalone Effect server starts and serves the endpoints independently of the Hono server
- the bridged endpoints serve correctly through the existing Hono host when the flag is enabled
- the handlers reuse the existing Effect service
- request decoding and response shapes are schema-defined from canonical Effect schemas
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
@@ -365,17 +401,16 @@ Current instance route inventory:
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
- `permission` - `bridged`
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `bridged` (partial)
bridged endpoint: `GET /provider/auth`
not yet ported: `GET /provider`, OAuth mutations
- `config` - `next`
best next endpoint: `GET /config/providers`
- `provider` - `bridged`
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
- `config` - `bridged` (partial)
bridged endpoint: `GET /config/providers`
later endpoint: `GET /config`
defer `PATCH /config` for now
- `project` - `later`
best small reads: `GET /project`, `GET /project/current`
- `project` - `bridged` (partial)
bridged endpoints: `GET /project`, `GET /project/current`
defer git-init mutation first
- `workspace` - `later`
- `workspace` - `next`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first
- `file` - `later`
@@ -393,12 +428,12 @@ Current instance route inventory:
- `tui` - `defer`
queue-style UI bridge, weak early `HttpApi` fit
Recommended near-term sequence after the first spike:
Recommended near-term sequence:
1. `provider` auth read endpoint
2. `config` providers read endpoint
3. `project` read endpoints
4. `workspace` read endpoints
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
2. `config` full read endpoint (`GET /config`)
3. `file` JSON read endpoints
4. `mcp` JSON read endpoints
## Checklist
@@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike:
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
- [x] verify OTEL spans and HTTP logs flow to motel
- [x] bridge question, permission, and provider auth routes
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [ ] port `config` read endpoints
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [x] port `config` providers read endpoint
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
- [ ] port `workspace` read endpoints
- [ ] port `GET /config` full read endpoint
- [ ] port `file` JSON read endpoints
- [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb
@@ -157,7 +157,7 @@ Direct legacy usage means any source file that still calls one of:
- `Instance.reload(...)`
- `Instance.dispose()` / `Instance.disposeAll()`
Current total: `54` files in `packages/opencode/src`.
Current total: `56` files in `packages/opencode/src`.
### Core bridge and plumbing
@@ -177,13 +177,13 @@ Migration rule:
These are the current request-entry seams that still create or consume instance context through the legacy helper.
- `src/server/instance/middleware.ts`
- `src/server/instance/index.ts`
- `src/server/instance/project.ts`
- `src/server/instance/workspace.ts`
- `src/server/instance/file.ts`
- `src/server/instance/experimental.ts`
- `src/server/instance/global.ts`
- `src/server/routes/instance/middleware.ts`
- `src/server/routes/instance/index.ts`
- `src/server/routes/instance/project.ts`
- `src/server/routes/control/workspace.ts`
- `src/server/routes/instance/file.ts`
- `src/server/routes/instance/experimental.ts`
- `src/server/routes/global.ts`
Migration rule:
@@ -239,7 +239,7 @@ Migration rule:
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
- `src/agent/agent.ts`
- `src/config/tui-migrate.ts`
- `src/cli/cmd/tui/config/tui-migrate.ts`
- `src/file/index.ts`
- `src/file/watcher.ts`
- `src/format/formatter.ts`
@@ -250,7 +250,7 @@ These modules are already the best near-term migration targets because they are
- `src/project/vcs.ts`
- `src/provider/provider.ts`
- `src/pty/index.ts`
- `src/session/index.ts`
- `src/session/session.ts`
- `src/session/instruction.ts`
- `src/session/llm.ts`
- `src/session/system.ts`
+6 -8
View File
@@ -4,11 +4,11 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
## Config / TUI
- [ ] `config/tui.ts` - finish the internal Effect migration after the `Instance.state(...)` removal.
- [ ] `cli/cmd/tui/config/tui.ts` - finish the internal Effect migration.
Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
- [ ] `config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
- [ ] `cli/cmd/tui/config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
- [ ] `env/index.ts` - move the last production `Instance.state(...)` usage onto `InstanceState` (or its replacement) so `Instance.state` can be deleted.
- [x] `env/index.ts` - already uses `InstanceState.make(...)`.
## ConfigPaths
@@ -21,14 +21,12 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
- `readFile(...)`
- `parseText(...)`
- [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
- [ ] `config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
- [ ] `config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
## Instance cleanup
- [ ] `project/instance.ts` - remove `Instance.state(...)` once `env/index.ts` is migrated.
- [ ] `project/state.ts` - delete the bespoke per-instance state helper after the last production caller is gone.
- [ ] `test/project/state.test.ts` - replace or delete the old `Instance.state(...)` tests after the removal.
- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over.
## Notes
+34 -44
View File
@@ -19,53 +19,43 @@ See `instance-context.md` for the phased plan to remove the legacy ALS / promise
## Service shape
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
Every service follows the same pattern: one module, flat top-level exports, traced Effect methods, and a self-reexport at the bottom when the file is the public module.
```ts
export namespace Foo {
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
// For instance-scoped services:
const state = yield* InstanceState.make<State>(
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
)
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
})
return Service.of({ get })
}),
)
// Optional: wire dependencies
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
// Per-service runtime (inside the namespace)
const { runPromise } = makeRuntime(Service, defaultLayer)
// Async facade functions
export async function get(id: FooID) {
return runPromise((svc) => svc.get(id))
}
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
)
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
})
return Service.of({ get })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
export * as Foo from "."
```
Rules:
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
- `runPromise` goes inside the namespace (not exported unless tests need it)
- Facade functions are plain `async function` — no `fn()` wrappers
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
- No `Layer.fresh` — InstanceState handles per-directory isolation
- Keep the service surface in one module; prefer flat top-level exports over `export namespace Foo { ... }`
- Use `Effect.fn("Foo.method")` for Effect methods
- Use a self-reexport (`export * as Foo from "."` or `"./foo"`) for the public namespace projection
- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase
- No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
## Schema → Zod interop
@@ -266,7 +256,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
## Destroying the facades
This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`.
This phase is no longer broadly open. There are 5 `makeRuntime(...)` call sites under `src/`, and only a small subset are still ordinary facade-removal targets. The live checklist now lives in `facades.md`.
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
@@ -297,11 +287,11 @@ For each service, the migration is roughly:
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/routes/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/routes/instance/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
## Route handler effectification
+16 -18
View File
@@ -39,28 +39,26 @@ This eliminates multiple `runPromise` round-trips and lets handlers compose natu
## Current route files
Current instance route files live under `src/server/instance`, not `server/routes`.
Current instance route files live under `src/server/routes/instance`.
The main migration targets are:
Files that are already mostly on the intended service-yielding shape:
- [ ] `server/instance/session.ts` — heaviest; still has many direct facade calls for Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, Agent, Bus
- [ ] `server/instance/global.ts` — still has direct facade calls for Config and instance lifecycle actions
- [ ] `server/instance/provider.ts` — still has direct facade calls for Config and Provider
- [ ] `server/instance/question.ts` — partially converted; still worth tracking here until it consistently uses the composed style
- [ ] `server/instance/pty.ts`still calls Pty facades directly
- [ ] `server/instance/experimental.ts` — mixed state; some handlers are already composed, others still use facades
- [x] `server/routes/instance/question.ts` — handlers yield `Question.Service`
- [x] `server/routes/instance/provider.ts` — handlers yield `Provider.Service`, `ProviderAuth.Service`, and `Config.Service`
- [x] `server/routes/instance/permission.ts` — handlers yield `Permission.Service`
- [x] `server/routes/instance/mcp.ts` — handlers mostly yield `MCP.Service`
- [x] `server/routes/instance/pty.ts`handlers yield `Pty.Service`
Additional route files that still participate in the migration:
Files still worth tracking here:
- [ ] `server/instance/index.ts`Vcs, Agent, Skill, LSP, Format
- [ ] `server/instance/file.ts`Ripgrep, File, LSP
- [ ] `server/instance/mcp.ts`MCP facade-heavy
- [ ] `server/instance/permission.ts`Permission
- [ ] `server/instance/workspace.ts` — Workspace
- [ ] `server/instance/tui.ts` — Bus and Session
- [ ] `server/instance/middleware.ts` — Session and Workspace lookups
- [ ] `server/routes/instance/session.ts`still the heaviest mixed file; many handlers are composed, but the file still mixes patterns and has direct `Bus.publish(...)` / `Session.list(...)` usage
- [ ] `server/routes/instance/index.ts`mostly converted, but still has direct `Instance.dispose()` / `Instance.*` reads for `/instance/dispose` and `/path`
- [ ] `server/routes/instance/file.ts`most handlers yield services, but `/find` still passes `Instance.directory` directly into ripgrep and `/find/symbol` is still stubbed
- [ ] `server/routes/instance/experimental.ts`mixed state; many handlers are composed, but some still rely on `runRequest(...)` or direct `Instance.project` reads
- [ ] `server/routes/instance/middleware.ts` — still enters the instance via `Instance.provide(...)`
- [ ] `server/routes/global.ts` — still uses `Instance.disposeAll()` and remains partly outside the fully-composed style
## Notes
- Some handlers already use `AppRuntime.runPromise(Effect.gen(...))` in isolated places. Keep pushing those files toward one consistent style.
- Route conversion is closely tied to facade removal. As services lose `makeRuntime`-backed async exports, route handlers should switch to yielding the service directly.
- Route conversion is now less about facade removal and more about removing the remaining direct `Instance.*` reads, `Instance.provide(...)` boundaries, and small Promise-style bridges inside route files.
- `jsonRequest(...)` / `runRequest(...)` already provide a good intermediate shape for many handlers. The remaining cleanup is mostly consistency work in the heavier files.
@@ -40,13 +40,13 @@ Everything still lives in `packages/opencode`.
Important current facts:
- there is no `packages/core` or `packages/cli` workspace yet
- `packages/server` now exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet
- there is no `packages/server` workspace yet on this branch
- the main host server is still Hono-based in `src/server/server.ts`
- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
- there is already one experimental Effect `HttpApi` slice at `src/server/instance/httpapi/question.ts`
- that experimental slice is mounted under `/experimental/httpapi/question`
- that experimental slice already has an end-to-end test at `test/server/question-httpapi.test.ts`
- there are already bridged Effect `HttpApi` slices under `src/server/routes/instance/httpapi/*`
- those slices are mounted into the Hono server behind `OPENCODE_EXPERIMENTAL_HTTPAPI`
- the bridge currently covers `question`, `permission`, `provider`, partial `config`, and partial `project` routes
This means the package split should start from an extraction path, not from greenfield package ownership.
@@ -209,17 +209,19 @@ Current host and route composition:
- `src/server/server.ts`
- `src/server/control/index.ts`
- `src/server/instance/index.ts`
- `src/server/routes/instance/index.ts`
- `src/server/middleware.ts`
- `src/server/adapter.bun.ts`
- `src/server/adapter.node.ts`
Current experimental `HttpApi` slice:
Current bridged `HttpApi` slices:
- `src/server/instance/httpapi/question.ts`
- `src/server/instance/httpapi/index.ts`
- `src/server/instance/experimental.ts`
- `test/server/question-httpapi.test.ts`
- `src/server/routes/instance/httpapi/question.ts`
- `src/server/routes/instance/httpapi/permission.ts`
- `src/server/routes/instance/httpapi/provider.ts`
- `src/server/routes/instance/httpapi/config.ts`
- `src/server/routes/instance/httpapi/project.ts`
- `src/server/routes/instance/httpapi/server.ts`
Current OpenAPI flow:
@@ -245,7 +247,7 @@ Keep in `packages/opencode` for now:
- `src/server/server.ts`
- `src/server/control/index.ts`
- `src/server/instance/*.ts`
- `src/server/routes/**/*.ts`
- `src/server/middleware.ts`
- `src/server/adapter.*.ts`
- `src/effect/app-runtime.ts`
@@ -305,14 +307,13 @@ Bad early migration targets:
## First vertical slice
The first slice for the package split is the existing experimental `question` group.
The first slice for the package split is still the existing `question` `HttpApi` group.
Why `question` first:
- it already exists as an experimental `HttpApi` slice
- it already follows the desired contract and implementation split in one file
- it is already mounted through the current Hono host
- it already has an end-to-end test
- it is JSON-only
- it has low blast radius
@@ -357,7 +358,7 @@ Done means:
Scope:
- extract the pure `HttpApi` contract from `src/server/instance/httpapi/question.ts`
- extract the pure `HttpApi` contract from `src/server/routes/instance/httpapi/question.ts`
- place it in `packages/server/src/definition/question.ts`
- aggregate it in `packages/server/src/definition/api.ts`
- generate OpenAPI in `packages/server/src/openapi.ts`
@@ -399,8 +400,9 @@ Scope:
- replace local experimental question route wiring in `packages/opencode`
- keep the same mount path:
- `/experimental/httpapi/question`
- `/experimental/httpapi/question/doc`
- `/question`
- `/question/:requestID/reply`
- `/question/:requestID/reject`
Rules:
@@ -569,7 +571,7 @@ For package-split PRs, validate the smallest useful thing.
Typical validation for the first waves:
- `bun typecheck` in the touched package directory or directories
- the relevant route test, especially `test/server/question-httpapi.test.ts`
- the relevant server / route coverage for the migrated slice
- merged OpenAPI coverage if the PR touches spec generation
Do not run tests from repo root.
+3 -5
View File
@@ -36,7 +36,7 @@ This keeps tool tests aligned with the production service graph and makes follow
## Exported tools
These exported tool definitions already exist in `src/tool` and are on the current Effect-native `Tool.define(...)` path:
These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
- [x] `apply_patch.ts`
- [x] `bash.ts`
@@ -45,7 +45,6 @@ These exported tool definitions already exist in `src/tool` and are on the curre
- [x] `glob.ts`
- [x] `grep.ts`
- [x] `invalid.ts`
- [x] `ls.ts`
- [x] `lsp.ts`
- [x] `multiedit.ts`
- [x] `plan.ts`
@@ -60,7 +59,7 @@ These exported tool definitions already exist in `src/tool` and are on the curre
Notes:
- `batch.ts` is no longer a current tool file and should not be tracked here.
- There is no current `ls.ts` tool file on this branch.
- `truncate.ts` is an Effect service used by tools, not a tool definition itself.
- `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
@@ -73,7 +72,7 @@ Current spot cleanups worth tracking:
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and `ls.ts`
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
@@ -83,7 +82,6 @@ Notable items that are already effectively on the target path and do not need se
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
- `ls.ts`
- `multiedit.ts`
- `edit.ts`
+13 -1
View File
@@ -25,7 +25,19 @@ export const GenerateCommand = {
]
}
}
const json = JSON.stringify(specs, null, 2)
const raw = JSON.stringify(specs, null, 2)
// Format through prettier so output is byte-identical to committed file
// regardless of whether ./script/format.ts runs afterward.
const prettier = await import("prettier")
const babel = await import("prettier/plugins/babel")
const estree = await import("prettier/plugins/estree")
const format = prettier.format ?? prettier.default?.format
const json = await format(raw, {
parser: "json",
plugins: [babel.default ?? babel, estree.default ?? estree],
printWidth: 120,
})
// Wait for stdout to finish writing before process.exit() is called
await new Promise<void>((resolve, reject) => {
+1 -1
View File
@@ -606,7 +606,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
category: "System",
},
{
title: "Toggle theme mode",
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
value: "theme.switch_mode",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
@@ -63,6 +63,7 @@ function init() {
useKeyboard((evt) => {
if (suspended()) return
if (dialog.stack.length > 0) return
if (evt.defaultPrevented) return
for (const option of entries()) {
if (!isEnabled(option)) continue
if (option.keybind && keybind.match(option.keybind, evt)) {
@@ -139,15 +139,10 @@ export function DialogSessionList() {
{desc}{" "}
<span
style={{
fg:
workspaceStatus === "error"
? theme.error
: workspaceStatus === "disconnected"
? theme.textMuted
: theme.success,
fg: workspaceStatus === "connected" ? theme.success : theme.error,
}}
>
</span>
</>
)
@@ -139,7 +139,13 @@ export async function restoreWorkspaceSession(input: {
total: result.data.total,
})
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
input.project.workspace.set(input.workspaceID)
try {
await input.sync.bootstrap({ fatal: false })
} catch (e) {}
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
log.error("session restore refresh failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
@@ -0,0 +1,81 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "restore" as "cancel" | "restore",
})
const options = ["cancel", "restore"] as const
async function confirm() {
if (store.active === "cancel") {
dialog.clear()
return
}
const result = await props.onRestore?.()
if (result === false) return
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
void confirm()
return
}
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "cancel")
return
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "restore")
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Workspace Unavailable
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.textMuted} wrapMode="word">
This session is attached to a workspace that is no longer available.
</text>
<text fg={theme.textMuted} wrapMode="word">
Would you like to restore this session into a new workspace?
</text>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item)
void confirm()
}}
>
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
</box>
)}
</For>
</box>
</box>
)
}
@@ -5,10 +5,11 @@ import path from "path"
import { fileURLToPath } from "url"
import { Filesystem } from "@/util"
import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme"
import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema"
@@ -38,6 +39,8 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
export type PromptProps = {
@@ -92,6 +95,7 @@ export function Prompt(props: PromptProps) {
const args = useArgs()
const sdk = useSDK()
const route = useRoute()
const project = useProject()
const sync = useSync()
const dialog = useDialog()
const toast = useToast()
@@ -241,9 +245,11 @@ export function Prompt(props: PromptProps) {
keybind: "input_submit",
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
onSelect: async (dialog) => {
if (!input.focused) return
void submit()
const handled = await submit()
if (!handled) return
dialog.clear()
},
},
@@ -463,19 +469,25 @@ export function Prompt(props: PromptProps) {
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || dialog.stack.length > 0) {
input.blur()
if (input.focused) input.blur()
return
}
// Slot/plugin updates can remount the background prompt while a dialog is open.
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
input.focus()
if (!input.focused) input.focus()
})
createEffect(() => {
if (!input || input.isDestroyed) return
const capture =
store.mode === "normal"
? auto()?.visible
? (["escape", "navigate", "submit", "tab"] as const)
: (["tab"] as const)
: undefined
input.traits = {
capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined,
capture,
suspend: !!props.disabled || store.mode === "shell",
status: store.mode === "shell" ? "SHELL" : undefined,
}
@@ -622,20 +634,48 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "input", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return
if (autocomplete?.visible) return
if (!store.prompt.input) return
if (props.disabled) return false
if (autocomplete?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
if (!agent) return
if (!agent) return false
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
return
return true
}
const selectedModel = local.model.current()
if (!selectedModel) {
void promptModelWarning()
return
return false
}
const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
const workspaceID = workspaceSession?.workspaceID
const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
dialog.replace(() => (
<DialogWorkspaceUnavailable
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(nextWorkspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: nextWorkspaceID,
sessionID: props.sessionID!,
})
}
/>
))
}}
/>
))
return false
}
let sessionID = props.sessionID
@@ -650,7 +690,7 @@ export function Prompt(props: PromptProps) {
variant: "error",
})
return
return true
}
sessionID = res.data.id
@@ -764,6 +804,7 @@ export function Prompt(props: PromptProps) {
})
}, 50)
input.clear()
return true
}
const exit = useExit()
@@ -870,6 +911,7 @@ export function Prompt(props: PromptProps) {
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
animationsEnabled,
)
const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha()))
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
@@ -931,7 +973,7 @@ export function Prompt(props: PromptProps) {
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box
border={["left"]}
borderColor={highlight()}
borderColor={borderHighlight()}
customBorderChars={{
...SplitBorder.customBorderChars,
bottomLeft: "╹",
@@ -1147,10 +1189,11 @@ export function Prompt(props: PromptProps) {
{(agent) => (
<>
<text fg={fadeColor(highlight(), agentMetaAlpha())}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}{" "}
{store.mode === "shell" ? "Shell" : Locale.titlecase(agent().name)}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
@@ -1183,7 +1226,7 @@ export function Prompt(props: PromptProps) {
<box
height={1}
border={["left"]}
borderColor={highlight()}
borderColor={borderHighlight()}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
@@ -31,7 +31,7 @@ export const TuiInfo = z
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
plugin: ConfigPlugin.Spec.array().optional(),
plugin: ConfigPlugin.Spec.zod.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
})
.extend(TuiOptions.shape)
@@ -11,14 +11,14 @@ import { Flag } from "@/flag/flag"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Npm } from "@opencode-ai/shared/npm"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { makeRuntime } from "@/cli/effect/runtime"
import { makeRuntime } from "@/effect/runtime"
import { Filesystem, Log } from "@/util"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@/npm"
const log = Log.create({ service: "tui.config" })
@@ -158,7 +158,12 @@ export const layer = Layer.effect(
(dir) =>
npm
.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(Effect.forkScoped),
{
@@ -75,7 +75,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
move(direction: 1 | -1) {
batch(() => {
let next = agents().findIndex((x) => x.name === agentStore.current) + direction
const current = this.current()
if (!current) return
let next = agents().findIndex((x) => x.name === current.name) + direction
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]
@@ -2,6 +2,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import type { GlobalEvent } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { Flag } from "@/flag/flag"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
@@ -39,6 +40,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
let queue: GlobalEvent[] = []
let timer: Timer | undefined
let last = 0
const retryDelay = 1000
const maxRetryDelay = 30000
const flush = () => {
if (queue.length === 0) return
@@ -73,9 +76,20 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const ctrl = new AbortController()
sse = ctrl
;(async () => {
let attempt = 0
while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.global.event({ signal: ctrl.signal })
const events = await sdk.global.event({
signal: ctrl.signal,
sseMaxRetryAttempts: 0,
})
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})
}
for await (const event of events.stream) {
if (ctrl.signal.aborted) break
@@ -84,6 +98,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (timer) clearTimeout(timer)
if (queue.length > 0) flush()
attempt += 1
if (abort.signal.aborted || ctrl.signal.aborted) break
// Exponential backoff
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
await new Promise((resolve) => setTimeout(resolve, backoff))
}
})().catch(() => {})
}
@@ -92,6 +112,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (props.events) {
const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub)
if (Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})
}
} else {
startSSE()
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { Layer } from "effect"
import { TuiConfig } from "./config/tui"
import { Npm } from "@opencode-ai/shared/npm"
import { Npm } from "@/npm"
import { Observability } from "@/effect/observability"
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
@@ -1,17 +1,31 @@
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config"
import { InstallationVersion } from "@/installation/version"
import { InstallationChannel, InstallationVersion } from "@/installation/version"
import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const project = useProject()
const sync = useSync()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
const workspaceStatus = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "error"
return project.workspace.status(workspaceID) ?? "error"
}
const workspaceLabel = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "unknown"
const info = project.workspace.get(workspaceID)
if (!info) return "unknown"
return `${info.type}: ${info.name}`
}
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return (
@@ -48,6 +62,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.text}>
<b>{session()!.title}</b>
</text>
<Show when={InstallationChannel !== "latest"}>
<text fg={theme.textMuted}>{props.sessionID}</text>
</Show>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}></span>{" "}
{workspaceLabel()}
</text>
</Show>
<Show when={session()!.share?.url}>
<text fg={theme.textMuted}>{session()!.share!.url}</text>
</Show>
+6 -3
View File
@@ -15,6 +15,7 @@ import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { writeHeapSnapshot } from "v8"
import { TuiConfig } from "./config/tui"
import { OPENCODE_PROCESS_ROLE, OPENCODE_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -129,11 +130,13 @@ export const TuiThreadCommand = cmd({
return
}
const cwd = Filesystem.resolve(process.cwd())
const env = sanitizedProcessEnv({
[OPENCODE_PROCESS_ROLE]: "worker",
[OPENCODE_RUN_ID]: ensureRunID(),
})
const worker = new Worker(file, {
env: Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
env,
})
worker.onerror = (e) => {
Log.Default.error("thread error", {
@@ -8,20 +8,23 @@ export function createDebouncedSignal<T>(value: T, ms: number): [Accessor<T>, Sc
export function createFadeIn(show: Accessor<boolean>, enabled: Accessor<boolean>) {
const [alpha, setAlpha] = createSignal(show() ? 1 : 0)
let revealed = show()
createEffect(
on([show, enabled], ([visible, animate], previous) => {
on([show, enabled], ([visible, animate]) => {
if (!visible) {
setAlpha(0)
return
}
if (!animate || !previous) {
if (!animate || revealed) {
revealed = true
setAlpha(1)
return
}
const start = performance.now()
revealed = true
setAlpha(0)
const timer = setInterval(() => {
@@ -11,6 +11,9 @@ import { Flag } from "@/flag/flag"
import { writeHeapSnapshot } from "node:v8"
import { Heap } from "@/cli/heap"
import { AppRuntime } from "@/effect/app-runtime"
import { ensureProcessMetadata } from "@/util/opencode-process"
ensureProcessMetadata("worker")
await Log.init({
print: process.argv.includes("--print-logs"),
+96 -81
View File
@@ -1,10 +1,12 @@
export * as ConfigAgent from "./agent"
import { Log } from "../util"
import { Schema } from "effect"
import z from "zod"
import { Bus } from "@/bus"
import { zod, ZodOverride } from "@/util/effect-zod"
import { Log } from "../util"
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"
@@ -13,89 +15,102 @@ 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 PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!knownKeys.has(key)) options[key] = value
const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
// ConfigPermission.Info is a zod schema (its `.preprocess(...).transform(...)`
// shape lives outside the Effect Schema type system), so the walker reaches it
// via ZodOverride rather than a pure Schema reference. This preserves the
// `$ref: PermissionConfig` emitted in openapi.json.
const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
const AgentSchema = Schema.StructWithRest(
Schema.Struct({
model: Schema.optional(ConfigModelID),
variant: Schema.optional(Schema.String).annotate({
description: "Default model variant for this agent (applies only when using the agent's configured model).",
}),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
prompt: Schema.optional(Schema.String),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
description: "@deprecated Use 'permission' field instead",
}),
disable: Schema.optional(Schema.Boolean),
description: Schema.optional(Schema.String).annotate({ description: "Description of when to use the agent" }),
mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])),
hidden: Schema.optional(Schema.Boolean).annotate({
description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)",
}),
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
color: Schema.optional(Color).annotate({
description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
}),
steps: Schema.optional(PositiveInt).annotate({
description: "Maximum number of agentic iterations before forcing text-only response",
}),
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
permission: Schema.optional(PermissionRef),
}),
[Schema.Record(Schema.String, Schema.Any)],
)
const KNOWN_KEYS = new Set([
"name",
"model",
"variant",
"prompt",
"description",
"temperature",
"top_p",
"mode",
"hidden",
"color",
"steps",
"maxSteps",
"options",
"permission",
"disable",
"tools",
])
// Post-parse normalisation:
// - Promote any unknown-but-present keys into `options` so they survive the
// round-trip in a well-known field.
// - Translate the deprecated `tools: { name: boolean }` map into the new
// `permission` shape (write-adjacent tools collapse into `permission.edit`).
// - Coalesce `steps ?? maxSteps` so downstream can ignore the deprecated alias.
const normalize = (agent: z.infer<typeof Info>) => {
const options: Record<string, unknown> = { ...agent.options }
for (const [key, value] of Object.entries(agent)) {
if (!KNOWN_KEYS.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
}
globalThis.Object.assign(permission, agent.permission)
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)
return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps }
}
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 const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType<
Omit<z.infer<ReturnType<typeof zod<typeof AgentSchema>>>, "options" | "permission" | "steps"> & {
options?: Record<string, unknown>
permission?: ConfigPermission.Info
steps?: number
}
>
export type Info = z.infer<typeof Info>
export async function load(dir: string) {
+12 -10
View File
@@ -1,10 +1,12 @@
export * as ConfigCommand from "./command"
import { Log } from "../util"
import z from "zod"
import { Schema } from "effect"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
@@ -12,15 +14,15 @@ 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 const Info = Schema.Struct({
template: Schema.String,
description: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(ConfigModelID),
subtask: Schema.optional(Schema.Boolean),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = z.infer<typeof Info>
export type Info = Schema.Schema.Type<typeof Info>
export async function load(dir: string) {
const result: Record<string, Info> = {}
@@ -49,7 +51,7 @@ export async function load(dir: string) {
...md.data,
template: md.content.trim(),
}
const parsed = Info.safeParse(config)
const parsed = Info.zod.safeParse(config)
if (parsed.success) {
result[config.name] = parsed.data
continue
+193 -169
View File
@@ -21,24 +21,27 @@ import { isRecord } from "@/util/record"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { InstanceState } from "@/effect"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
import { Npm } from "@opencode-ai/shared/npm"
import { zod, ZodOverride } from "@/util/effect-zod"
import { ConfigAgent } from "./agent"
import { ConfigCommand } from "./command"
import { ConfigFormatter } from "./formatter"
import { ConfigLayout } from "./layout"
import { ConfigLSP } from "./lsp"
import { ConfigManaged } from "./managed"
import { ConfigMCP } from "./mcp"
import { ConfigModelID } from "./model-id"
import { ConfigPlugin } from "./plugin"
import { ConfigManaged } from "./managed"
import { ConfigCommand } from "./command"
import { ConfigParse } from "./parse"
import { ConfigPermission } from "./permission"
import { ConfigProvider } from "./provider"
import { ConfigSkills } from "./skills"
import { ConfigPaths } from "./paths"
import { ConfigFormatter } from "./formatter"
import { ConfigLSP } from "./lsp"
import { ConfigPermission } from "./permission"
import { ConfigPlugin } from "./plugin"
import { ConfigProvider } from "./provider"
import { ConfigServer } from "./server"
import { ConfigSkills } from "./skills"
import { ConfigVariable } from "./variable"
import { Npm } from "@/npm"
const log = Log.create({ service: "config" })
@@ -73,170 +76,186 @@ async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(
return config
}
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
hostname: z.string().optional().describe("Hostname to listen on"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"),
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
})
.strict()
.meta({
ref: "ServerConfig",
})
export const Server = ConfigServer.Server.zod
export const Layout = ConfigLayout.Layout.zod
export type Layout = ConfigLayout.Layout
export const Layout = z.enum(["auto", "stretch"]).meta({
ref: "LayoutConfig",
})
export type Layout = z.infer<typeof Layout>
// Schemas that still live at the zod layer (have .transform / .preprocess /
// .meta not expressible in current Effect Schema) get referenced via a
// ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the
// exact zod directly, preserving component $refs.
const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
logLevel: Log.Level.optional().describe("Log level"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z
.record(z.string(), ConfigCommand.Info)
.optional()
.describe("Command configuration, see https://opencode.ai/docs/commands"),
skills: ConfigSkills.Info.optional().describe("Additional skill folder paths"),
watcher: z
.object({
ignore: z.array(z.string()).optional(),
})
.optional(),
snapshot: z
.boolean()
.optional()
.describe(
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
),
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
plugin: ConfigPlugin.Spec.array().optional(),
share: z
.enum(["manual", "auto", "disabled"])
.optional()
.describe(
"Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
),
autoshare: z
.boolean()
.optional()
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
autoupdate: z
.union([z.boolean(), z.literal("notify")])
.optional()
.describe(
"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
),
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
enabled_providers: z
.array(z.string())
.optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
model: ConfigModelID.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
small_model: ConfigModelID.describe(
"Small model to use for tasks like title generation in the format of provider/model",
).optional(),
default_agent: z
.string()
.optional()
.describe(
"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
),
username: z.string().optional().describe("Custom username to display in conversations instead of system username"),
mode: z
.object({
build: ConfigAgent.Info.optional(),
plan: ConfigAgent.Info.optional(),
})
.catchall(ConfigAgent.Info)
.optional()
.describe("@deprecated Use `agent` field instead."),
agent: z
.object({
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
const InfoSchema = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
server: Schema.optional(ConfigServer.Server).annotate({
description: "Server configuration for opencode serve and web commands",
}),
command: Schema.optional(Schema.Record(Schema.String, ConfigCommand.Info)).annotate({
description: "Command configuration, see https://opencode.ai/docs/commands",
}),
skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }),
watcher: Schema.optional(
Schema.Struct({
ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
}),
),
snapshot: Schema.optional(Schema.Boolean).annotate({
description:
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
}),
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
plugin: Schema.optional(Schema.mutable(Schema.Array(ConfigPlugin.Spec))),
share: Schema.optional(Schema.Literals(["manual", "auto", "disabled"])).annotate({
description:
"Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
}),
autoshare: Schema.optional(Schema.Boolean).annotate({
description: "@deprecated Use 'share' field instead. Share newly created sessions automatically",
}),
autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({
description:
"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
}),
disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Disable providers that are loaded automatically",
}),
enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "When set, ONLY these providers will be enabled. All other providers will be ignored",
}),
model: Schema.optional(ConfigModelID).annotate({
description: "Model to use in the format of provider/model, eg anthropic/claude-2",
}),
small_model: Schema.optional(ConfigModelID).annotate({
description: "Small model to use for tasks like title generation in the format of provider/model",
}),
default_agent: Schema.optional(Schema.String).annotate({
description:
"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
}),
username: Schema.optional(Schema.String).annotate({
description: "Custom username to display in conversations instead of system username",
}),
mode: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
build: Schema.optional(AgentRef),
plan: Schema.optional(AgentRef),
}),
[Schema.Record(Schema.String, AgentRef)],
),
).annotate({ description: "@deprecated Use `agent` field instead." }),
agent: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
// primary
plan: ConfigAgent.Info.optional(),
build: ConfigAgent.Info.optional(),
plan: Schema.optional(AgentRef),
build: Schema.optional(AgentRef),
// subagent
general: ConfigAgent.Info.optional(),
explore: ConfigAgent.Info.optional(),
general: Schema.optional(AgentRef),
explore: Schema.optional(AgentRef),
// specialized
title: ConfigAgent.Info.optional(),
summary: ConfigAgent.Info.optional(),
compaction: ConfigAgent.Info.optional(),
})
.catchall(ConfigAgent.Info)
.optional()
.describe("Agent configuration, see https://opencode.ai/docs/agents"),
provider: z
.record(z.string(), ConfigProvider.Info)
.optional()
.describe("Custom provider configurations and model overrides"),
mcp: z
.record(
z.string(),
z.union([
ConfigMCP.Info,
z
.object({
enabled: z.boolean(),
})
.strict(),
]),
)
.optional()
.describe("MCP (Model Context Protocol) server configurations"),
formatter: ConfigFormatter.Info.optional(),
lsp: ConfigLSP.Info.optional(),
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
permission: ConfigPermission.Info.optional(),
tools: z.record(z.string(), z.boolean()).optional(),
enterprise: z
.object({
url: z.string().optional().describe("Enterprise URL"),
})
.optional(),
compaction: z
.object({
auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
reserved: z
.number()
.int()
.min(0)
.optional()
.describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."),
})
.optional(),
experimental: z
.object({
disable_paste_summary: z.boolean().optional(),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
openTelemetry: z
.boolean()
.optional()
.describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"),
primary_tools: z
.array(z.string())
.optional()
.describe("Tools that should only be available to primary agents."),
continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"),
mcp_timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
})
.optional(),
})
title: Schema.optional(AgentRef),
summary: Schema.optional(AgentRef),
compaction: Schema.optional(AgentRef),
}),
[Schema.Record(Schema.String, AgentRef)],
),
).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }),
provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({
description: "Custom provider configurations and model overrides",
}),
mcp: Schema.optional(
Schema.Record(
Schema.String,
Schema.Union([
ConfigMCP.Info,
// Matches the legacy `{ enabled: false }` form used to disable a server.
Schema.Any.annotate({ [ZodOverride]: z.object({ enabled: z.boolean() }).strict() }),
]),
),
).annotate({ description: "MCP (Model Context Protocol) server configurations" }),
formatter: Schema.optional(ConfigFormatter.Info),
lsp: Schema.optional(ConfigLSP.Info),
instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Additional instruction files or patterns to include",
}),
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
permission: Schema.optional(PermissionRef),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
enterprise: Schema.optional(
Schema.Struct({
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
}),
),
compaction: Schema.optional(
Schema.Struct({
auto: Schema.optional(Schema.Boolean).annotate({
description: "Enable automatic compaction when context is full (default: true)",
}),
prune: Schema.optional(Schema.Boolean).annotate({
description: "Enable pruning of old tool outputs (default: true)",
}),
reserved: Schema.optional(NonNegativeInt).annotate({
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
}),
}),
),
experimental: Schema.optional(
Schema.Struct({
disable_paste_summary: Schema.optional(Schema.Boolean),
batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }),
openTelemetry: Schema.optional(Schema.Boolean).annotate({
description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)",
}),
primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Tools that should only be available to primary agents.",
}),
continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({
description: "Continue the agent loop when a tool call is denied",
}),
mcp_timeout: Schema.optional(PositiveInt).annotate({
description: "Timeout in milliseconds for model context protocol (MCP) requests",
}),
}),
),
})
// Schema.Struct produces readonly types by default, but the service code
// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
// readonly recursively so callers get the same mutable shape zod inferred.
//
// `Types.DeepMutable` from effect-smol would be a drop-in, but its fallback
// branch `{ -readonly [K in keyof T]: ... }` collapses `unknown` to `{}`
// (since `keyof unknown = never`), which widens `Record<string, unknown>`
// fields like `ConfigPlugin.Options`. The local version gates on
// `extends object` so `unknown` passes through.
//
// Tuple branch preserves `ConfigPlugin.Spec`'s `readonly [string, Options]`
// shape (otherwise the general array branch widens it to an array).
type DeepMutable<T> = T extends readonly [unknown, ...unknown[]]
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T extends readonly (infer U)[]
? DeepMutable<U>[]
: T extends object
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T
// The walker emits `z.object({...})` which is non-strict by default. Config
// historically uses `.strict()` (additionalProperties: false in openapi.json),
// so layer that on after derivation. Re-apply the Config ref afterward
// since `.strict()` strips the walker's meta annotation.
export const Info = (zod(InfoSchema) as unknown as z.ZodObject<any>)
.strict()
.meta({
ref: "Config",
})
.meta({ ref: "Config" }) as unknown as z.ZodType<DeepMutable<Schema.Schema.Type<typeof InfoSchema>>>
export type Info = z.output<typeof Info> & {
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
@@ -518,7 +537,12 @@ export const layer = Layer.effect(
const dep = yield* npmSvc
.install(dir, {
add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
add: [
{
name: "@opencode-ai/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(
Effect.exit,
+11 -10
View File
@@ -1,15 +1,16 @@
import z from "zod"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
export const ConsoleState = z.object({
consoleManagedProviders: z.array(z.string()),
activeOrgName: z.string().optional(),
switchableOrgCount: z.number().int().nonnegative(),
})
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optional(Schema.String),
switchableOrgCount: Schema.Number,
}) {
static readonly zod = zod(this)
}
export type ConsoleState = z.infer<typeof ConsoleState>
export const emptyConsoleState: ConsoleState = {
export const emptyConsoleState: ConsoleState = ConsoleState.make({
consoleManagedProviders: [],
activeOrgName: undefined,
switchableOrgCount: 0,
}
})
+13 -9
View File
@@ -1,13 +1,17 @@
export * as ConfigFormatter from "./formatter"
import z from "zod"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Entry = z.object({
disabled: z.boolean().optional(),
command: z.array(z.string()).optional(),
environment: z.record(z.string(), z.string()).optional(),
extensions: z.array(z.string()).optional(),
})
export const Entry = Schema.Struct({
disabled: Schema.optional(Schema.Boolean),
command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)),
extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)])
export type Info = z.infer<typeof Info>
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>
+124 -163
View File
@@ -1,166 +1,127 @@
export * as ConfigKeybinds from "./keybinds"
import z from "zod"
import { Effect, Schema } from "effect"
import type z from "zod"
import { zod } from "@/util/effect-zod"
export const Keybinds = z
.object({
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"),
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
username_toggle: z.string().optional().default("none").describe("Toggle username visibility"),
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_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_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
session_fork: z.string().optional().default("none").describe("Fork session from message"),
session_rename: z.string().optional().default("ctrl+r").describe("Rename session"),
session_delete: z.string().optional().default("ctrl+d").describe("Delete session"),
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_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_unshare: z.string().optional().default("none").describe("Unshare 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"),
messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"),
messages_page_down: z
.string()
.optional()
.default("pagedown,ctrl+alt+f")
.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_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_down: z.string().optional().default("ctrl+alt+d").describe("Scroll messages down by half page"),
messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"),
messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"),
messages_next: z.string().optional().default("none").describe("Navigate to next message"),
messages_previous: z.string().optional().default("none").describe("Navigate to previous message"),
messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"),
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
messages_toggle_conceal: z
.string()
.optional()
.default("<leader>h")
.describe("Toggle code block concealment in messages"),
tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"),
model_list: z.string().optional().default("<leader>m").describe("List available models"),
model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
variant_list: z.string().optional().default("none").describe("List model variants"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
input_submit: z.string().optional().default("return").describe("Submit input"),
input_newline: z
.string()
.optional()
.default("shift+return,ctrl+return,alt+return,ctrl+j")
.describe("Insert newline in input"),
input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"),
input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"),
input_move_up: z.string().optional().default("up").describe("Move cursor up in input"),
input_move_down: z.string().optional().default("down").describe("Move cursor down in input"),
input_select_left: z.string().optional().default("shift+left").describe("Select left in input"),
input_select_right: z.string().optional().default("shift+right").describe("Select right in input"),
input_select_up: z.string().optional().default("shift+up").describe("Select up in input"),
input_select_down: z.string().optional().default("shift+down").describe("Select down in input"),
input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"),
input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"),
input_select_line_home: z.string().optional().default("ctrl+shift+a").describe("Select to start of line in input"),
input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"),
input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"),
input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"),
input_select_visual_line_home: z
.string()
.optional()
.default("alt+shift+a")
.describe("Select to start of visual line in input"),
input_select_visual_line_end: z
.string()
.optional()
.default("alt+shift+e")
.describe("Select to end of visual line in input"),
input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"),
input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"),
input_select_buffer_home: z
.string()
.optional()
.default("shift+home")
.describe("Select to start of buffer in input"),
input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"),
input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"),
input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"),
input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"),
input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"),
input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"),
input_undo: z
.string()
.optional()
// On Windows prepend ctrl+z since terminal_suspend releases the binding.
.default(process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")
.describe("Undo in input"),
input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
input_word_forward: z
.string()
.optional()
.default("alt+f,alt+right,ctrl+right")
.describe("Move word forward in input"),
input_word_backward: z
.string()
.optional()
.default("alt+b,alt+left,ctrl+left")
.describe("Move word backward in input"),
input_select_word_forward: z
.string()
.optional()
.default("alt+shift+f,alt+shift+right")
.describe("Select word forward in input"),
input_select_word_backward: z
.string()
.optional()
.default("alt+shift+b,alt+shift+left")
.describe("Select word backward in input"),
input_delete_word_forward: z
.string()
.optional()
.default("alt+d,alt+delete,ctrl+delete")
.describe("Delete word forward in input"),
input_delete_word_backward: z
.string()
.optional()
.default("ctrl+w,ctrl+backspace,alt+backspace")
.describe("Delete word backward in input"),
history_previous: z.string().optional().default("up").describe("Previous history item"),
history_next: z.string().optional().default("down").describe("Next history item"),
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"),
session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"),
session_parent: z.string().optional().default("up").describe("Go to parent session"),
terminal_suspend: z
.string()
.optional()
.default("ctrl+z")
.transform((v) => (process.platform === "win32" ? "none" : v))
.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",
})
// Every keybind field has the same shape: an optional string with a default
// binding and a human description. `keybind()` keeps the declaration list
// below dense and readable.
const keybind = (value: string, description: string) =>
Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(value))).annotate({ description })
// Windows prepends ctrl+z to the undo binding because `terminal_suspend`
// cannot consume ctrl+z on native Windows terminals (no POSIX suspend).
const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z"
const KeybindsSchema = Schema.Struct({
leader: keybind("ctrl+x", "Leader key for keybind combinations"),
app_exit: keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
editor_open: keybind("<leader>e", "Open external editor"),
theme_list: keybind("<leader>t", "List available themes"),
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
username_toggle: keybind("none", "Toggle username visibility"),
status_view: keybind("<leader>s", "View status"),
session_export: keybind("<leader>x", "Export session to editor"),
session_new: keybind("<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
session_timeline: keybind("<leader>g", "Show session timeline"),
session_fork: keybind("none", "Fork session from message"),
session_rename: keybind("ctrl+r", "Rename session"),
session_delete: keybind("ctrl+d", "Delete session"),
stash_delete: keybind("ctrl+d", "Delete stash entry"),
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"),
session_share: keybind("none", "Share current session"),
session_unshare: keybind("none", "Unshare current session"),
session_interrupt: keybind("escape", "Interrupt current session"),
session_compact: keybind("<leader>c", "Compact the session"),
messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"),
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
messages_first: keybind("ctrl+g,home", "Navigate to first message"),
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
messages_next: keybind("none", "Navigate to next message"),
messages_previous: keybind("none", "Navigate to previous message"),
messages_last_user: keybind("none", "Navigate to last user message"),
messages_copy: keybind("<leader>y", "Copy message"),
messages_undo: keybind("<leader>u", "Undo message"),
messages_redo: keybind("<leader>r", "Redo message"),
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
tool_details: keybind("none", "Toggle tool details visibility"),
model_list: keybind("<leader>m", "List available models"),
model_cycle_recent: keybind("f2", "Next recently used model"),
model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"),
model_cycle_favorite: keybind("none", "Next favorite model"),
model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
command_list: keybind("ctrl+p", "List available commands"),
agent_list: keybind("<leader>a", "List agents"),
agent_cycle: keybind("tab", "Next agent"),
agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
variant_cycle: keybind("ctrl+t", "Cycle model variants"),
variant_list: keybind("none", "List model variants"),
input_clear: keybind("ctrl+c", "Clear input field"),
input_paste: keybind("ctrl+v", "Paste from clipboard"),
input_submit: keybind("return", "Submit input"),
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
input_move_up: keybind("up", "Move cursor up in input"),
input_move_down: keybind("down", "Move cursor down in input"),
input_select_left: keybind("shift+left", "Select left in input"),
input_select_right: keybind("shift+right", "Select right in input"),
input_select_up: keybind("shift+up", "Select up in input"),
input_select_down: keybind("shift+down", "Select down in input"),
input_line_home: keybind("ctrl+a", "Move to start of line in input"),
input_line_end: keybind("ctrl+e", "Move to end of line in input"),
input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"),
input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"),
input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"),
input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"),
input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"),
input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"),
input_buffer_home: keybind("home", "Move to start of buffer in input"),
input_buffer_end: keybind("end", "Move to end of buffer in input"),
input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"),
input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"),
input_delete_line: keybind("ctrl+shift+d", "Delete line in input"),
input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"),
input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"),
input_backspace: keybind("backspace,shift+backspace", "Backspace in input"),
input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
input_undo: keybind(inputUndoDefault, "Undo in input"),
input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"),
input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"),
input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"),
input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
history_previous: keybind("up", "Previous history item"),
history_next: keybind("down", "Next history item"),
session_child_first: keybind("<leader>down", "Go to first child session"),
session_child_cycle: keybind("right", "Go to next child session"),
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
session_parent: keybind("up", "Go to parent session"),
// `terminal_suspend` was formerly `.default("ctrl+z").transform((v) => win32 ? "none" : v)`,
// but `tui.ts` already forces the binding to "none" on win32 before calling
// `Keybinds.parse(...)`, so the schema-level transform was redundant.
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
terminal_title_toggle: keybind("none", "Toggle terminal title"),
tips_toggle: keybind("<leader>h", "Toggle tips on home screen"),
plugin_manager: keybind("none", "Open plugin manager dialog"),
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
}).annotate({ identifier: "KeybindsConfig" })
export type Keybinds = Schema.Schema.Type<typeof KeybindsSchema>
// Consumers access `Keybinds.shape` and `Keybinds.shape.X.parse(undefined)`,
// which requires the runtime type to be a ZodObject, not just ZodType. Every
// field is `string().optional().default(...)` at runtime, so widen to that.
export const Keybinds = zod(KeybindsSchema) as unknown as z.ZodObject<
Record<keyof Keybinds, z.ZodDefault<z.ZodOptional<z.ZodString>>>
>
+10
View File
@@ -0,0 +1,10 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Layout = Schema.Literals(["auto", "stretch"])
.annotate({ identifier: "LayoutConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Layout = Schema.Schema.Type<typeof Layout>
export * as ConfigLayout from "./layout"
+38 -30
View File
@@ -1,37 +1,45 @@
export * as ConfigLSP from "./lsp"
import z from "zod"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import * as LSPServer from "../lsp/server"
export const Disabled = z.object({
disabled: z.literal(true),
export const Disabled = Schema.Struct({
disabled: Schema.Literal(true),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export const Entry = Schema.Union([
Disabled,
Schema.Struct({
command: Schema.mutable(Schema.Array(Schema.String)),
extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
disabled: Schema.optional(Schema.Boolean),
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
]).pipe(withStatics((s) => ({ zod: zod(s) })))
/**
* For custom (non-builtin) LSP server entries, `extensions` is required so the
* client knows which files the server should attach to. Builtin server IDs and
* explicitly disabled entries are exempt.
*/
export const requiresExtensionsForCustomServers = Schema.makeFilter<
boolean | Record<string, Schema.Schema.Type<typeof Entry>>
>((data) => {
if (typeof data === "boolean") return undefined
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
const ok = Object.entries(data).every(([id, config]) => {
if ("disabled" in config && config.disabled) return true
if (serverIds.has(id)) return true
return "extensions" in config && Boolean(config.extensions)
})
return ok ? undefined : "For custom LSP servers, 'extensions' array is required."
})
export const Entry = z.union([
Disabled,
z.object({
command: z.array(z.string()),
extensions: z.array(z.string()).optional(),
disabled: z.boolean().optional(),
env: z.record(z.string(), z.string()).optional(),
initialization: z.record(z.string(), z.any()).optional(),
}),
])
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
.check(requiresExtensionsForCustomServers)
.pipe(withStatics((s) => ({ zod: zod(s) })))
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]).refine(
(data) => {
if (typeof data === "boolean") return true
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
return Object.entries(data).every(([id, config]) => {
if (config.disabled) return true
if (serverIds.has(id)) return true
return Boolean(config.extensions)
})
},
{
error: "For custom LSP servers, 'extensions' array is required.",
},
)
export type Info = z.infer<typeof Info>
export type Info = Schema.Schema.Type<typeof Info>
+56 -62
View File
@@ -1,68 +1,62 @@
import z from "zod"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
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 class Local extends Schema.Class<Local>("McpLocalConfig")({
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
command: Schema.mutable(Schema.Array(Schema.String)).annotate({
description: "Command and arguments to run the MCP server",
}),
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
description: "Environment variables to set when running the MCP server",
}),
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Enable or disable the MCP server on startup",
}),
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
}) {
static readonly zod = zod(this)
}
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 class OAuth extends Schema.Class<OAuth>("McpOAuthConfig")({
clientId: Schema.optional(Schema.String).annotate({
description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.",
}),
clientSecret: Schema.optional(Schema.String).annotate({
description: "OAuth client secret (if required by the authorization server)",
}),
scope: Schema.optional(Schema.String).annotate({ description: "OAuth scopes to request during authorization" }),
redirectUri: Schema.optional(Schema.String).annotate({
description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
}),
}) {
static readonly zod = zod(this)
}
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 class Remote extends Schema.Class<Remote>("McpRemoteConfig")({
type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }),
url: Schema.String.annotate({ description: "URL of the remote MCP server" }),
enabled: Schema.optional(Schema.Boolean).annotate({
description: "Enable or disable the MCP server on startup",
}),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
description: "Headers to send with the request",
}),
oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
}),
timeout: Schema.optional(Schema.Number).annotate({
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
}),
}) {
static readonly zod = zod(this)
}
export const Info = z.discriminatedUnion("type", [Local, Remote])
export type Info = z.infer<typeof Info>
export const Info = Schema.Union([Local, Remote])
.annotate({ discriminator: "type" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = Schema.Schema.Type<typeof Info>
export * as ConfigMCP from "./mcp"
+12 -1
View File
@@ -1,3 +1,14 @@
import { Schema } from "effect"
import z from "zod"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const ConfigModelID = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
// The original Zod schema carried an external $ref pointing at the models.dev
// JSON schema. That external reference is not a named SDK component — it is a
// literal pointer to an outside schema — so the walker cannot re-derive it
// from AST metadata. Preserve the exact original Zod via ZodOverride.
export const ConfigModelID = Schema.String.annotate({
[ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>
+52 -44
View File
@@ -1,6 +1,28 @@
export * as ConfigPermission from "./permission"
import z from "zod"
import { Schema } from "effect"
import { zod, ZodPreprocess } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
export const Action = Schema.Literals(["ask", "allow", "deny"])
.annotate({ identifier: "PermissionActionConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Action = Schema.Schema.Type<typeof Action>
export const Object = Schema.Record(Schema.String, Action)
.annotate({ identifier: "PermissionObjectConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Object = Schema.Schema.Type<typeof Object>
export const Rule = Schema.Union([Action, Object])
.annotate({ identifier: "PermissionRuleConfig" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Rule = Schema.Schema.Type<typeof Rule>
// Captures the user's original property insertion order before Schema.Struct
// canonicalises the object. See the `ZodPreprocess` comment in
// `util/effect-zod.ts` for the full rationale — in short: rule precedence is
// encoded in JSON key order (`evaluate.ts` uses `findLast`, so later keys win)
// and `Schema.StructWithRest` would otherwise drop that order.
const permissionPreprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
return { __originalKeys: globalThis.Object.keys(val), ...val }
@@ -8,21 +30,36 @@ const permissionPreprocess = (val: unknown) => {
return val
}
export const Action = z.enum(["ask", "allow", "deny"]).meta({
ref: "PermissionActionConfig",
})
export type Action = z.infer<typeof Action>
const ObjectShape = Schema.StructWithRest(
Schema.Struct({
__originalKeys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
read: Schema.optional(Rule),
edit: Schema.optional(Rule),
glob: Schema.optional(Rule),
grep: Schema.optional(Rule),
list: Schema.optional(Rule),
bash: Schema.optional(Rule),
task: Schema.optional(Rule),
external_directory: Schema.optional(Rule),
todowrite: Schema.optional(Action),
question: Schema.optional(Action),
webfetch: Schema.optional(Action),
websearch: Schema.optional(Action),
codesearch: Schema.optional(Action),
lsp: Schema.optional(Rule),
doom_loop: Schema.optional(Action),
skill: Schema.optional(Rule),
}),
[Schema.Record(Schema.String, Rule)],
)
export const Object = z.record(z.string(), Action).meta({
ref: "PermissionObjectConfig",
const InnerSchema = Schema.Union([ObjectShape, Action]).annotate({
[ZodPreprocess]: permissionPreprocess,
})
export type Object = z.infer<typeof Object>
export const Rule = z.union([Action, Object]).meta({
ref: "PermissionRuleConfig",
})
export type Rule = z.infer<typeof Rule>
// Post-parse: drop the __originalKeys metadata and rebuild the rule map in the
// user's original insertion order. A plain string input (the Action branch of
// the union) becomes `{ "*": action }`.
const transform = (x: unknown): Record<string, Rule> => {
if (typeof x === "string") return { "*": x as Action }
const obj = x as { __originalKeys?: string[] } & Record<string, unknown>
@@ -35,34 +72,5 @@ const transform = (x: unknown): Record<string, 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>
export const Info = zod(InnerSchema).transform(transform).meta({ ref: "PermissionConfig" })
export type Info = Record<string, Rule>
+9 -5
View File
@@ -1,16 +1,20 @@
import { Glob } from "@opencode-ai/shared/util/glob"
import z from "zod"
import { Schema } from "effect"
import { pathToFileURL } from "url"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import path from "path"
const Options = z.record(z.string(), z.unknown())
export type Options = z.infer<typeof Options>
export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Options = Schema.Schema.Type<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 type Spec = z.infer<typeof Spec>
export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Spec = Schema.Schema.Type<typeof Spec>
export type Scope = "global" | "local"
+107 -113
View File
@@ -1,120 +1,114 @@
import z from "zod"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
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(),
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
export const Model = Schema.Struct({
id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
family: Schema.optional(Schema.String),
release_date: Schema.optional(Schema.String),
attachment: Schema.optional(Schema.Boolean),
reasoning: Schema.optional(Schema.Boolean),
temperature: Schema.optional(Schema.Boolean),
tool_call: Schema.optional(Schema.Boolean),
interleaved: Schema.optional(
Schema.Union([
Schema.Literal(true),
Schema.Struct({
field: Schema.Literals(["reasoning_content", "reasoning_details"]),
}),
]),
),
cost: Schema.optional(
Schema.Struct({
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.Number),
context_over_200k: Schema.optional(
Schema.Struct({
input: Schema.Number,
output: Schema.Number,
cache_read: Schema.optional(Schema.Number),
cache_write: Schema.optional(Schema.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()
),
limit: Schema.optional(
Schema.Struct({
context: Schema.Number,
input: Schema.optional(Schema.Number),
output: Schema.Number,
}),
),
modalities: Schema.optional(
Schema.Struct({
input: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
output: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
}),
),
experimental: Schema.optional(Schema.Boolean),
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
variants: Schema.optional(
Schema.Record(
Schema.String,
Schema.StructWithRest(
Schema.Struct({
disabled: Schema.optional(Schema.Boolean).annotate({ description: "Disable this variant for the model" }),
}),
[Schema.Record(Schema.String, Schema.Any)],
),
).annotate({ description: "Variant-specific configuration" }),
),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
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(
export class Info extends Schema.Class<Info>("ProviderConfig")({
api: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
id: Schema.optional(Schema.String),
npm: Schema.optional(Schema.String),
whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
options: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
apiKey: Schema.optional(Schema.String),
baseURL: Schema.optional(Schema.String),
enterpriseUrl: Schema.optional(Schema.String).annotate({
description: "GitHub Enterprise URL for copilot authentication",
}),
setCacheKey: Schema.optional(Schema.Boolean).annotate({
description: "Enable promptCacheKey for this provider (default false)",
}),
timeout: Schema.optional(
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
description:
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
}),
).annotate({
description:
"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(
}),
chunkTimeout: Schema.optional(PositiveInt).annotate({
description:
"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>
}),
}),
[Schema.Record(Schema.String, Schema.Any)],
),
),
models: Schema.optional(Schema.Record(Schema.String, Model)),
}) {
static readonly zod = zod(this)
}
export * as ConfigProvider from "./provider"
+20
View File
@@ -0,0 +1,20 @@
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
export class Server extends Schema.Class<Server>("ServerConfig")({
port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({
description: "Port to listen on",
}),
hostname: Schema.optional(Schema.String).annotate({ description: "Hostname to listen on" }),
mdns: Schema.optional(Schema.Boolean).annotate({ description: "Enable mDNS service discovery" }),
mdnsDomain: Schema.optional(Schema.String).annotate({
description: "Custom domain name for mDNS service (default: opencode.local)",
}),
cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
description: "Additional domains to allow for CORS",
}),
}) {
static readonly zod = zod(this)
}
export * as ConfigServer from "./server"
+12 -9
View File
@@ -1,13 +1,16 @@
import z from "zod"
import { Schema } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
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 const Info = Schema.Struct({
paths: Schema.optional(Schema.Array(Schema.String)).annotate({
description: "Additional paths to skill folders",
}),
urls: Schema.optional(Schema.Array(Schema.String)).annotate({
description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)",
}),
}).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Info = z.infer<typeof Info>
export type Info = Schema.Schema.Type<typeof Info>
export * as ConfigSkills from "./skills"
+1 -1
View File
@@ -28,7 +28,7 @@ export type WorkspaceAdaptor = {
name: string
description: string
configure(info: WorkspaceInfo): WorkspaceInfo | Promise<WorkspaceInfo>
create(info: WorkspaceInfo, env: Record<string, string>, from?: WorkspaceInfo): Promise<void>
create(info: WorkspaceInfo, env: Record<string, string | undefined>, from?: WorkspaceInfo): Promise<void>
remove(info: WorkspaceInfo): Promise<void>
target(info: WorkspaceInfo): Target | Promise<Target>
}
@@ -7,7 +7,7 @@ import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
import { Auth } from "@/auth"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { EventSequenceTable, EventTable } from "@/sync/event.sql"
import { Flag } from "@/flag/flag"
import { Log } from "@/util"
import { Filesystem } from "@/util"
@@ -23,8 +23,8 @@ import { SessionTable } from "@/session/session.sql"
import { SessionID } from "@/session/schema"
import { errorData } from "@/util/error"
import { AppRuntime } from "@/effect/app-runtime"
import { EventSequenceTable } from "@/sync/event.sql"
import { waitEvent } from "./util"
import { WorkspaceContext } from "./workspace-context"
export const Info = WorkspaceInfo.meta({
ref: "Workspace",
@@ -115,6 +115,8 @@ export const create = fn(CreateInput, async (input) => {
OPENCODE_AUTH_CONTENT: JSON.stringify(await AppRuntime.runPromise(Auth.Service.use((auth) => auth.all()))),
OPENCODE_WORKSPACE_ID: config.id,
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
}
await adaptor.create(config, env)
@@ -297,22 +299,13 @@ export function list(project: Project.Info) {
db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
)
const spaces = rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
for (const space of spaces) startSync(space)
return spaces
}
function lookup(id: WorkspaceID) {
export const get = fn(WorkspaceID.zod, async (id) => {
const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
if (!row) return
return fromRow(row)
}
export const get = fn(WorkspaceID.zod, async (id) => {
const space = lookup(id)
if (!space) return
startSync(space)
return space
})
export const remove = fn(WorkspaceID.zod, async (id) => {
@@ -437,6 +430,70 @@ async function connectSSE(url: URL | string, headers: HeadersInit | undefined, s
return res.body
}
async function syncHistory(space: Info, url: URL | string, headers: HeadersInit | undefined, signal: AbortSignal) {
const sessionIDs = Database.use((db) =>
db
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.workspace_id, space.id))
.all()
.map((row) => row.id),
)
const state = sessionIDs.length
? Object.fromEntries(
Database.use((db) =>
db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(),
).map((row) => [row.aggregate_id, row.seq]),
)
: {}
log.info("syncing workspace history", {
workspaceID: space.id,
sessions: sessionIDs.length,
known: Object.keys(state).length,
})
const requestHeaders = new Headers(headers)
requestHeaders.set("content-type", "application/json")
const res = await fetch(route(url, "/sync/history"), {
method: "POST",
headers: requestHeaders,
body: JSON.stringify(state),
signal,
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Workspace history HTTP failure: ${res.status} ${body}`)
}
const events = await res.json()
return WorkspaceContext.provide({
workspaceID: space.id,
fn: () => {
for (const event of events) {
SyncEvent.replay(
{
id: event.id,
aggregateID: event.aggregate_id,
seq: event.seq,
type: event.type,
data: event.data,
},
{ publish: true },
)
}
},
})
log.info("workspace history synced", {
workspaceID: space.id,
events: events.length,
})
}
async function syncWorkspaceLoop(space: Info, signal: AbortSignal) {
const adaptor = await getAdaptor(space.projectID, space.type)
const target = await adaptor.target(space)
@@ -452,7 +509,9 @@ async function syncWorkspaceLoop(space: Info, signal: AbortSignal) {
let stream
try {
stream = await connectSSE(target.url, target.headers, signal)
await syncHistory(space, target.url, target.headers, signal)
} catch (err) {
stream = null
setStatus(space.id, "error")
log.info("failed to connect to global sync", {
workspace: space.name,
@@ -469,6 +528,7 @@ async function syncWorkspaceLoop(space: Info, signal: AbortSignal) {
await parseSSE(stream, signal, (evt: any) => {
try {
if (!("payload" in evt)) return
if (evt.payload.type === "server.heartbeat") return
if (evt.payload.type === "sync") {
SyncEvent.replay(evt.payload.syncEvent as SyncEvent.SerializedEvent)
@@ -536,4 +596,19 @@ function stopSync(id: WorkspaceID) {
connections.delete(id)
}
export function startWorkspaceSyncing(projectID: ProjectID) {
const spaces = Database.use((db) =>
db
.select({ workspace: WorkspaceTable })
.from(WorkspaceTable)
.innerJoin(SessionTable, eq(SessionTable.workspace_id, WorkspaceTable.id))
.where(eq(WorkspaceTable.project_id, projectID))
.all(),
)
for (const row of new Map(spaces.map((row) => [row.workspace.id, row.workspace])).values()) {
void startSync(fromRow(row))
}
}
export * as Workspace from "./workspace"
+3 -2
View File
@@ -1,5 +1,5 @@
import { Layer, ManagedRuntime } from "effect"
import { attach, memoMap } from "./run-service"
import { attach } from "./run-service"
import * as Observability from "./observability"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@@ -46,7 +46,8 @@ import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { ShareNext } from "@/share"
import { SessionShare } from "@/share"
import { Npm } from "@opencode-ai/shared/npm"
import { Npm } from "@/npm"
import { memoMap } from "./memo-map"
export const AppLayer = Layer.mergeAll(
Npm.defaultLayer,
@@ -1,5 +1,4 @@
import { Layer, ManagedRuntime } from "effect"
import { memoMap } from "./run-service"
import { Plugin } from "@/plugin"
import { LSP } from "@/lsp"
@@ -12,6 +11,7 @@ import { Snapshot } from "@/snapshot"
import { Bus } from "@/bus"
import { Config } from "@/config"
import * as Observability from "./observability"
import { memoMap } from "./memo-map"
export const BootstrapLayer = Layer.mergeAll(
Config.defaultLayer,
+7 -1
View File
@@ -3,6 +3,8 @@ import { Log } from "@/util"
type Fields = Record<string, unknown>
const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key)
export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
@@ -12,7 +14,11 @@ export interface Handle {
}
const clean = (input?: Fields): Fields =>
Object.fromEntries(Object.entries(input ?? {}).filter((entry) => entry[1] !== undefined && entry[1] !== null))
Object.fromEntries(
Object.entries(input ?? {})
.filter((entry) => entry[1] !== undefined && entry[1] !== null)
.map(([key, value]) => [normalizeKey(key), value]),
)
const text = (input: unknown): string => {
// oxlint-disable-next-line no-base-to-string
+3
View File
@@ -0,0 +1,3 @@
import { Layer } from "effect"
export const memoMap = Layer.makeMemoMapUnsafe()
+30 -20
View File
@@ -4,9 +4,11 @@ import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import * as EffectLogger from "./logger"
import { Flag } from "@/flag/flag"
import { InstallationChannel, InstallationVersion } from "@/installation/version"
import { ensureProcessMetadata } from "@/util/opencode-process"
const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT
export const enabled = !!base
const processID = crypto.randomUUID()
const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce(
@@ -19,26 +21,34 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
)
: undefined
const resource = {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
},
function resource() {
const processMetadata = ensureProcessMetadata("main")
return {
serviceName: "opencode",
serviceVersion: InstallationVersion,
attributes: {
"deployment.environment.name": InstallationChannel,
"opencode.client": Flag.OPENCODE_CLIENT,
"opencode.process_role": processMetadata.processRole,
"opencode.run_id": processMetadata.runID,
"service.instance.id": processID,
},
}
}
const logs = Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource,
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
function logs() {
return Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource: resource(),
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
}
const traces = async () => {
const NodeSdk = await import("@effect/opentelemetry/NodeSdk")
@@ -58,7 +68,7 @@ const traces = async () => {
context.setGlobalContextManager(mgr)
return NodeSdk.layer(() => ({
resource,
resource: resource(),
spanProcessor: new SdkBase.BatchSpanProcessor(
new OTLP.OTLPTraceExporter({
url: `${base}/v1/traces`,
@@ -73,7 +83,7 @@ export const layer = !base
: Layer.unwrap(
Effect.gen(function* () {
const trace = yield* Effect.promise(traces)
return Layer.mergeAll(trace, logs)
return Layer.mergeAll(trace, logs())
}),
)
+1 -2
View File
@@ -6,8 +6,7 @@ import { InstanceRef, WorkspaceRef } from "./instance-ref"
import * as Observability from "./observability"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import type { InstanceContext } from "@/project/instance"
export const memoMap = Layer.makeMemoMapUnsafe()
import { memoMap } from "./memo-map"
type Refs = {
instance?: InstanceContext
@@ -1,7 +1,6 @@
import { Observability } from "@/effect/observability"
import { Observability } from "./observability"
import { Layer, type Context, ManagedRuntime, type Effect } from "effect"
export const memoMap = Layer.makeMemoMapUnsafe()
import { memoMap } from "./memo-map"
export function makeRuntime<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
+28 -20
View File
@@ -356,8 +356,9 @@ export const layer = Layer.effect(
)
const scan = Effect.fn("File.scan")(function* () {
if (Instance.directory === path.parse(Instance.directory).root) return
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
const ctx = yield* InstanceState.context
if (ctx.directory === path.parse(ctx.directory).root) return
const isGlobalHome = ctx.directory === Global.Path.home && ctx.project.id === "global"
const next: Entry = { files: [], dirs: [] }
if (isGlobalHome) {
@@ -366,14 +367,14 @@ export const layer = Layer.effect(
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => []))
const top = yield* appFs.readDirectoryEntries(ctx.directory).pipe(Effect.orElseSucceed(() => []))
for (const entry of top) {
if (entry.type !== "directory") continue
if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/")
const base = path.join(Instance.directory, entry.name)
const base = path.join(ctx.directory, entry.name)
const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
for (const child of children) {
if (child.type !== "directory") continue
@@ -384,7 +385,7 @@ export const layer = Layer.effect(
next.dirs = Array.from(dirs).toSorted()
} else {
const files = yield* rg.files({ cwd: Instance.directory }).pipe(
const files = yield* rg.files({ cwd: ctx.directory }).pipe(
Stream.runCollect,
Effect.map((chunk) => [...chunk]),
)
@@ -416,7 +417,7 @@ export const layer = Layer.effect(
})
const gitText = Effect.fnUntraced(function* (args: string[]) {
return (yield* git.run(args, { cwd: Instance.directory })).text()
return (yield* git.run(args, { cwd: (yield* InstanceState.context).directory })).text()
})
const init = Effect.fn("File.init")(function* () {
@@ -424,7 +425,8 @@ export const layer = Layer.effect(
})
const status = Effect.fn("File.status")(function* () {
if (Instance.project.vcs !== "git") return []
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return []
const diffOutput = yield* gitText([
"-c",
@@ -463,7 +465,7 @@ export const layer = Layer.effect(
if (untrackedOutput.trim()) {
for (const file of untrackedOutput.trim().split("\n")) {
const content = yield* appFs
.readFileString(path.join(Instance.directory, file))
.readFileString(path.join(ctx.directory, file))
.pipe(Effect.catch(() => Effect.succeed<string | undefined>(undefined)))
if (content === undefined) continue
changed.push({
@@ -498,19 +500,22 @@ export const layer = Layer.effect(
}
return changed.map((item) => {
const full = path.isAbsolute(item.path) ? item.path : path.join(Instance.directory, item.path)
const full = path.isAbsolute(item.path) ? item.path : path.join(ctx.directory, item.path)
return {
...item,
path: path.relative(Instance.directory, full),
path: path.relative(ctx.directory, full),
}
})
})
const read: Interface["read"] = Effect.fn("File.read")(function* (file: string) {
using _ = log.time("read", { file })
const full = path.join(Instance.directory, file)
const ctx = yield* InstanceState.context
const full = path.join(ctx.directory, file)
if (!Instance.containsPath(full)) throw new Error("Access denied: path escapes project directory")
if (!Instance.containsPath(full, ctx)) {
throw new Error("Access denied: path escapes project directory")
}
if (isImageByExtension(file)) {
const exists = yield* appFs.existsSafe(full)
@@ -553,13 +558,13 @@ export const layer = Layer.effect(
Effect.catch(() => Effect.succeed("")),
)
if (Instance.project.vcs === "git") {
if (ctx.project.vcs === "git") {
let diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--", file])
if (!diff.trim()) {
diff = yield* gitText(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file])
}
if (diff.trim()) {
const original = yield* git.show(Instance.directory, "HEAD", file)
const original = yield* git.show(ctx.directory, "HEAD", file)
const patch = structuredPatch(file, file, original, content, "old", "new", {
context: Infinity,
ignoreWhitespace: true,
@@ -573,21 +578,24 @@ export const layer = Layer.effect(
})
const list = Effect.fn("File.list")(function* (dir?: string) {
const ctx = yield* InstanceState.context
const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false
if (Instance.project.vcs === "git") {
if (ctx.project.vcs === "git") {
const ig = ignore()
const gitignore = path.join(Instance.project.worktree, ".gitignore")
const gitignore = path.join(ctx.worktree, ".gitignore")
const gitignoreText = yield* appFs.readFileString(gitignore).pipe(Effect.catch(() => Effect.succeed("")))
if (gitignoreText) ig.add(gitignoreText)
const ignoreFile = path.join(Instance.project.worktree, ".ignore")
const ignoreFile = path.join(ctx.worktree, ".ignore")
const ignoreText = yield* appFs.readFileString(ignoreFile).pipe(Effect.catch(() => Effect.succeed("")))
if (ignoreText) ig.add(ignoreText)
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
if (!Instance.containsPath(resolved)) throw new Error("Access denied: path escapes project directory")
const resolved = dir ? path.join(ctx.directory, dir) : ctx.directory
if (!Instance.containsPath(resolved, ctx)) {
throw new Error("Access denied: path escapes project directory")
}
const entries = yield* appFs.readDirectoryEntries(resolved).pipe(Effect.orElseSucceed(() => []))
@@ -595,7 +603,7 @@ export const layer = Layer.effect(
for (const entry of entries) {
if (exclude.includes(entry.name)) continue
const absolute = path.join(resolved, entry.name)
const file = path.relative(Instance.directory, absolute)
const file = path.relative(ctx.directory, absolute)
const type = entry.type === "directory" ? "directory" : "file"
nodes.push({
name: entry.name,
+2 -3
View File
@@ -7,6 +7,7 @@ import { ripgrep } from "ripgrep"
import { Filesystem } from "@/util"
import { Log } from "@/util"
import { sanitizedProcessEnv } from "@/util/opencode-process"
const log = Log.create({ service: "ripgrep" })
@@ -157,9 +158,7 @@ type WorkerError = {
}
function env() {
const env = Object.fromEntries(
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
)
const env = sanitizedProcessEnv()
delete env.RIPGREP_CONFIG_PATH
return env
}
+2 -3
View File
@@ -1,9 +1,8 @@
import { ripgrep } from "ripgrep"
import { sanitizedProcessEnv } from "@/util/opencode-process"
function env() {
const env = Object.fromEntries(
Object.entries(process.env).filter((item): item is [string, string] => item[1] !== undefined),
)
const env = sanitizedProcessEnv()
delete env.RIPGREP_CONFIG_PATH
return env
}
+21 -19
View File
@@ -1,15 +1,17 @@
import { Npm } from "../npm"
import { Instance } from "../project/instance"
import type { InstanceContext } from "../project/instance"
import { Filesystem } from "../util"
import { Process } from "../util"
import { which } from "../util/which"
import { Flag } from "@/flag/flag"
export interface Context extends Pick<InstanceContext, "directory" | "worktree"> {}
export interface Info {
name: string
environment?: Record<string, string>
extensions: string[]
enabled(): Promise<string[] | false>
enabled(context: Context): Promise<string[] | false>
}
export const gofmt: Info = {
@@ -65,8 +67,8 @@ export const prettier: Info = {
".graphql",
".gql",
],
async enabled() {
const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
async enabled(context) {
const items = await Filesystem.findUp("package.json", context.directory, context.worktree)
for (const item of items) {
const json = await Filesystem.readJson<{
dependencies?: Record<string, string>
@@ -87,9 +89,9 @@ export const oxfmt: Info = {
BUN_BE_BUN: "1",
},
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
async enabled() {
async enabled(context) {
if (!Flag.OPENCODE_EXPERIMENTAL_OXFMT) return false
const items = await Filesystem.findUp("package.json", Instance.directory, Instance.worktree)
const items = await Filesystem.findUp("package.json", context.directory, context.worktree)
for (const item of items) {
const json = await Filesystem.readJson<{
dependencies?: Record<string, string>
@@ -137,10 +139,10 @@ export const biome: Info = {
".graphql",
".gql",
],
async enabled() {
async enabled(context) {
const configs = ["biome.json", "biome.jsonc"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
const found = await Filesystem.findUp(config, context.directory, context.worktree)
if (found.length > 0) {
const bin = await Npm.which("@biomejs/biome")
if (bin) return [bin, "format", "--write", "$FILE"]
@@ -163,8 +165,8 @@ export const zig: Info = {
export const clang: Info = {
name: "clang-format",
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
async enabled() {
const items = await Filesystem.findUp(".clang-format", Instance.directory, Instance.worktree)
async enabled(context) {
const items = await Filesystem.findUp(".clang-format", context.directory, context.worktree)
if (items.length > 0) {
const match = which("clang-format")
if (match) return [match, "-i", "$FILE"]
@@ -186,11 +188,11 @@ export const ktlint: Info = {
export const ruff: Info = {
name: "ruff",
extensions: [".py", ".pyi"],
async enabled() {
async enabled(context) {
if (!which("ruff")) return false
const configs = ["pyproject.toml", "ruff.toml", ".ruff.toml"]
for (const config of configs) {
const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
const found = await Filesystem.findUp(config, context.directory, context.worktree)
if (found.length > 0) {
if (config === "pyproject.toml") {
const content = await Filesystem.readText(found[0])
@@ -202,7 +204,7 @@ export const ruff: Info = {
}
const deps = ["requirements.txt", "pyproject.toml", "Pipfile"]
for (const dep of deps) {
const found = await Filesystem.findUp(dep, Instance.directory, Instance.worktree)
const found = await Filesystem.findUp(dep, context.directory, context.worktree)
if (found.length > 0) {
const content = await Filesystem.readText(found[0])
if (content.includes("ruff")) return ["ruff", "format", "$FILE"]
@@ -233,8 +235,8 @@ export const rlang: Info = {
export const uvformat: Info = {
name: "uv",
extensions: [".py", ".pyi"],
async enabled() {
if (await ruff.enabled()) return false
async enabled(context) {
if (await ruff.enabled(context)) return false
const uv = which("uv")
if (uv == null) return false
const output = await Process.run([uv, "format", "--help"], { nothrow: true })
@@ -286,9 +288,9 @@ export const dart: Info = {
export const ocamlformat: Info = {
name: "ocamlformat",
extensions: [".ml", ".mli"],
async enabled() {
async enabled(context) {
if (!which("ocamlformat")) return false
const items = await Filesystem.findUp(".ocamlformat", Instance.directory, Instance.worktree)
const items = await Filesystem.findUp(".ocamlformat", context.directory, context.worktree)
if (items.length > 0) return ["ocamlformat", "-i", "$FILE"]
return false
},
@@ -357,8 +359,8 @@ export const rustfmt: Info = {
export const pint: Info = {
name: "pint",
extensions: [".php"],
async enabled() {
const items = await Filesystem.findUp("composer.json", Instance.directory, Instance.worktree)
async enabled(context) {
const items = await Filesystem.findUp("composer.json", context.directory, context.worktree)
for (const item of items) {
const json = await Filesystem.readJson<{
require?: Record<string, string>
+3 -3
View File
@@ -37,14 +37,14 @@ export const layer = Layer.effect(
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const state = yield* InstanceState.make(
Effect.fn("Format.state")(function* (_ctx) {
Effect.fn("Format.state")(function* (ctx) {
const commands: Record<string, string[] | false> = {}
const formatters: Record<string, Formatter.Info> = {}
async function getCommand(item: Formatter.Info) {
let cmd = commands[item.name]
if (cmd === false || cmd === undefined) {
cmd = await item.enabled()
cmd = await item.enabled(ctx)
commands[item.name] = cmd
}
return cmd
@@ -153,7 +153,7 @@ export const layer = Layer.effect(
...info,
name,
extensions: info.extensions ?? [],
enabled: builtIn && !info.command ? builtIn.enabled : async () => info.command ?? false,
enabled: builtIn && !info.command ? builtIn.enabled : async (_context) => info.command ?? false,
}
}
}
+5
View File
@@ -38,6 +38,9 @@ import { errorMessage } from "./util/error"
import { PluginCommand } from "./cli/cmd/plug"
import { Heap } from "./cli/heap"
import { drizzle } from "drizzle-orm/bun-sqlite"
import { ensureProcessMetadata } from "./util/opencode-process"
const processMetadata = ensureProcessMetadata("main")
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
@@ -108,6 +111,8 @@ const cli = yargs(args)
Log.Default.info("opencode", {
version: InstallationVersion,
args: process.argv.slice(2),
process_role: processMetadata.processRole,
run_id: processMetadata.runID,
})
const marker = path.join(Global.Path.data, "opencode.db")
+19 -20
View File
@@ -11,7 +11,6 @@ import z from "zod"
import type * as LSPServer from "./server"
import { NamedError } from "@opencode-ai/shared/util/error"
import { withTimeout } from "../util/timeout"
import { Instance } from "../project/instance"
import { Filesystem } from "../util"
const DIAGNOSTICS_DEBOUNCE_MS = 150
@@ -39,7 +38,7 @@ export const Event = {
),
}
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) {
const l = log.clone().tag("serverID", input.serverID)
l.info("starting client")
@@ -145,33 +144,33 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
return connection
},
notify: {
async open(input: { path: string }) {
input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
const text = await Filesystem.readText(input.path)
const extension = path.extname(input.path)
async open(request: { path: string }) {
request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path)
const text = await Filesystem.readText(request.path)
const extension = path.extname(request.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
const version = files[input.path]
const version = files[request.path]
if (version !== undefined) {
log.info("workspace/didChangeWatchedFiles", input)
log.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(input.path).href,
uri: pathToFileURL(request.path).href,
type: 2, // Changed
},
],
})
const next = version + 1
files[input.path] = next
files[request.path] = next
log.info("textDocument/didChange", {
path: input.path,
path: request.path,
version: next,
})
await connection.sendNotification("textDocument/didChange", {
textDocument: {
uri: pathToFileURL(input.path).href,
uri: pathToFileURL(request.path).href,
version: next,
},
contentChanges: [{ text }],
@@ -179,36 +178,36 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
return
}
log.info("workspace/didChangeWatchedFiles", input)
log.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(input.path).href,
uri: pathToFileURL(request.path).href,
type: 1, // Created
},
],
})
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
log.info("textDocument/didOpen", request)
diagnostics.delete(request.path)
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: pathToFileURL(input.path).href,
uri: pathToFileURL(request.path).href,
languageId,
version: 0,
text,
},
})
files[input.path] = 0
files[request.path] = 0
return
},
},
get diagnostics() {
return diagnostics
},
async waitForDiagnostics(input: { path: string }) {
async waitForDiagnostics(request: { path: string }) {
const normalizedPath = Filesystem.normalizePath(
path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
)
log.info("waiting for diagnostics", { path: normalizedPath })
let unsub: () => void
+17 -8
View File
@@ -7,12 +7,12 @@ import { pathToFileURL, fileURLToPath } from "url"
import * as LSPServer from "./server"
import z from "zod"
import { Config } from "../config"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Process } from "../util"
import { spawn as lspspawn } from "./launch"
import { Effect, Layer, Context } from "effect"
import { InstanceState } from "@/effect"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
const log = Log.create({ service: "lsp" })
@@ -162,7 +162,7 @@ export const layer = Layer.effect(
const config = yield* Config.Service
const state = yield* InstanceState.make<State>(
Effect.fn("LSP.state")(function* () {
Effect.fn("LSP.state")(function* (ctx) {
const cfg = yield* config.get()
const servers: Record<string, LSPServer.Info> = {}
@@ -187,7 +187,7 @@ export const layer = Layer.effect(
servers[name] = {
...existing,
id: name,
root: existing?.root ?? (async () => Instance.directory),
root: existing?.root ?? (async (_file, ctx) => ctx.directory),
extensions: item.extensions ?? existing?.extensions ?? [],
spawn: async (root) => ({
process: lspspawn(item.command[0], item.command.slice(1), {
@@ -225,7 +225,13 @@ export const layer = Layer.effect(
)
const getClients = Effect.fnUntraced(function* (file: string) {
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
const ctx = yield* InstanceState.context
if (
!AppFileSystem.contains(ctx.directory, file) &&
(ctx.worktree === "/" || !AppFileSystem.contains(ctx.worktree, file))
) {
return [] as LSPClient.Info[]
}
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
@@ -233,7 +239,7 @@ export const layer = Layer.effect(
async function schedule(server: LSPServer.Info, root: string, key: string) {
const handle = await server
.spawn(root)
.spawn(root, ctx)
.then((value) => {
if (!value) s.broken.add(key)
return value
@@ -251,6 +257,7 @@ export const layer = Layer.effect(
serverID: server.id,
server: handle,
root,
directory: ctx.directory,
}).catch(async (err) => {
s.broken.add(key)
await Process.stop(handle.process)
@@ -273,7 +280,7 @@ export const layer = Layer.effect(
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file)
const root = await server.root(file, ctx)
if (!root) continue
if (s.broken.has(root + server.id)) continue
@@ -326,13 +333,14 @@ export const layer = Layer.effect(
})
const status = Effect.fn("LSP.status")(function* () {
const ctx = yield* InstanceState.context
const s = yield* InstanceState.get(state)
const result: Status[] = []
for (const client of s.clients) {
result.push({
id: client.serverID,
name: s.servers[client.serverID].id,
root: path.relative(Instance.directory, client.root),
root: path.relative(ctx.directory, client.root),
status: "connected",
})
}
@@ -340,12 +348,13 @@ export const layer = Layer.effect(
})
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
const ctx = yield* InstanceState.context
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const extension = path.parse(file).ext || file
for (const server of Object.values(s.servers)) {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file)
const root = await server.root(file, ctx)
if (!root) continue
if (s.broken.has(root + server.id)) continue
return true
+42 -42
View File
@@ -6,7 +6,7 @@ import { Log } from "../util"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util"
import { Instance } from "../project/instance"
import type { InstanceContext } from "../project/instance"
import { Flag } from "../flag/flag"
import { Archive } from "../util"
import { Process } from "../util"
@@ -29,15 +29,15 @@ export interface Handle {
initialization?: Record<string, any>
}
type RootFunction = (file: string) => Promise<string | undefined>
type RootFunction = (file: string, ctx: InstanceContext) => Promise<string | undefined>
const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): RootFunction => {
return async (file) => {
return async (file, ctx) => {
if (excludePatterns) {
const excludedFiles = Filesystem.up({
targets: excludePatterns,
start: path.dirname(file),
stop: Instance.directory,
stop: ctx.directory,
})
const excluded = await excludedFiles.next()
await excludedFiles.return()
@@ -46,11 +46,11 @@ const NearestRoot = (includePatterns: string[], excludePatterns?: string[]): Roo
const files = Filesystem.up({
targets: includePatterns,
start: path.dirname(file),
stop: Instance.directory,
stop: ctx.directory,
})
const first = await files.next()
await files.return()
if (!first.value) return Instance.directory
if (!first.value) return ctx.directory
return path.dirname(first.value)
}
}
@@ -60,16 +60,16 @@ export interface Info {
extensions: string[]
global?: boolean
root: RootFunction
spawn(root: string): Promise<Handle | undefined>
spawn(root: string, ctx: InstanceContext): Promise<Handle | undefined>
}
export const Deno: Info = {
id: "deno",
root: async (file) => {
root: async (file, ctx) => {
const files = Filesystem.up({
targets: ["deno.json", "deno.jsonc"],
start: path.dirname(file),
stop: Instance.directory,
stop: ctx.directory,
})
const first = await files.next()
await files.return()
@@ -98,8 +98,8 @@ export const Typescript: Info = {
["deno.json", "deno.jsonc"],
),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
async spawn(root) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
async spawn(root, ctx) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
log.info("typescript server", { tsserver })
if (!tsserver) return
const bin = await Npm.which("typescript-language-server")
@@ -154,8 +154,8 @@ export const ESLint: Info = {
id: "eslint",
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue"],
async spawn(root) {
const eslint = Module.resolve("eslint", Instance.directory)
async spawn(root, ctx) {
const eslint = Module.resolve("eslint", ctx.directory)
if (!eslint) return
log.info("spawning eslint server")
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
@@ -219,7 +219,7 @@ export const Oxlint: Info = {
"package.json",
]),
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".astro", ".svelte"],
async spawn(root) {
async spawn(root, ctx) {
const ext = process.platform === "win32" ? ".cmd" : ""
const serverTarget = path.join("node_modules", ".bin", "oxc_language_server" + ext)
@@ -232,7 +232,7 @@ export const Oxlint: Info = {
const candidates = Filesystem.up({
targets: [target],
start: root,
stop: Instance.worktree,
stop: ctx.worktree,
})
const first = await candidates.next()
await candidates.return()
@@ -344,10 +344,10 @@ export const Biome: Info = {
export const Gopls: Info = {
id: "gopls",
root: async (file) => {
const work = await NearestRoot(["go.work"])(file)
root: async (file, ctx) => {
const work = await NearestRoot(["go.work"])(file, ctx)
if (work) return work
return NearestRoot(["go.mod", "go.sum"])(file)
return NearestRoot(["go.mod", "go.sum"])(file, ctx)
},
extensions: [".go"],
async spawn(root) {
@@ -810,8 +810,8 @@ export const SourceKit: Info = {
export const RustAnalyzer: Info = {
id: "rust",
root: async (root) => {
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(root)
root: async (file, ctx) => {
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(file, ctx)
if (crateRoot === undefined) {
return undefined
}
@@ -834,7 +834,7 @@ export const RustAnalyzer: Info = {
currentDir = parentDir
// Stop if we've gone above the app root
if (!currentDir.startsWith(Instance.worktree)) break
if (!currentDir.startsWith(ctx.worktree)) break
}
return crateRoot
@@ -1031,8 +1031,8 @@ export const Astro: Info = {
id: "astro",
extensions: [".astro"],
root: NearestRoot(["package-lock.json", "bun.lockb", "bun.lock", "pnpm-lock.yaml", "yarn.lock"]),
async spawn(root) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
async spawn(root, ctx) {
const tsserver = Module.resolve("typescript/lib/tsserver.js", ctx.directory)
if (!tsserver) {
log.info("typescript not found, required for Astro language server")
return
@@ -1067,7 +1067,7 @@ export const Astro: Info = {
export const JDTLS: Info = {
id: "jdtls",
root: async (file) => {
root: async (file, ctx) => {
// Without exclusions, NearestRoot defaults to instance directory so we can't
// distinguish between a) no project found and b) project found at instance dir.
// So we can't choose the root from (potential) monorepo markers first.
@@ -1077,12 +1077,12 @@ export const JDTLS: Info = {
const exclusionsForMonorepos = gradleMarkers.concat(settingsMarkers)
const [projectRoot, wrapperRoot, settingsRoot] = await Promise.all([
NearestRoot(
["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"],
exclusionsForMonorepos,
)(file),
NearestRoot(gradleMarkers, settingsMarkers)(file),
NearestRoot(settingsMarkers)(file),
NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], exclusionsForMonorepos)(
file,
ctx,
),
NearestRoot(gradleMarkers, settingsMarkers)(file, ctx),
NearestRoot(settingsMarkers)(file, ctx),
])
// If projectRoot is undefined we know we are in a monorepo or no project at all.
@@ -1189,18 +1189,18 @@ export const JDTLS: Info = {
export const KotlinLS: Info = {
id: "kotlin-ls",
extensions: [".kt", ".kts"],
root: async (file) => {
root: async (file, ctx) => {
// 1) Nearest Gradle root (multi-project or included build)
const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file)
const settingsRoot = await NearestRoot(["settings.gradle.kts", "settings.gradle"])(file, ctx)
if (settingsRoot) return settingsRoot
// 2) Gradle wrapper (strong root signal)
const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file)
const wrapperRoot = await NearestRoot(["gradlew", "gradlew.bat"])(file, ctx)
if (wrapperRoot) return wrapperRoot
// 3) Single-project or module-level build
const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file)
const buildRoot = await NearestRoot(["build.gradle.kts", "build.gradle"])(file, ctx)
if (buildRoot) return buildRoot
// 4) Maven fallback
return NearestRoot(["pom.xml"])(file)
return NearestRoot(["pom.xml"])(file, ctx)
},
async spawn(root) {
const distPath = path.join(Global.Path.bin, "kotlin-ls")
@@ -1539,7 +1539,7 @@ export const Ocaml: Info = {
export const BashLS: Info = {
id: "bash",
extensions: [".sh", ".bash", ".zsh", ".ksh"],
root: async () => Instance.directory,
root: async (_file, ctx) => ctx.directory,
async spawn(root) {
let binary = which("bash-language-server")
const args: string[] = []
@@ -1734,7 +1734,7 @@ export const TexLab: Info = {
export const DockerfileLS: Info = {
id: "dockerfile",
extensions: [".dockerfile", "Dockerfile"],
root: async () => Instance.directory,
root: async (_file, ctx) => ctx.directory,
async spawn(root) {
let binary = which("docker-langserver")
const args: string[] = []
@@ -1799,16 +1799,16 @@ export const Clojure: Info = {
export const Nixd: Info = {
id: "nixd",
extensions: [".nix"],
root: async (file) => {
root: async (file, ctx) => {
// First, look for flake.nix - the most reliable Nix project root indicator
const flakeRoot = await NearestRoot(["flake.nix"])(file)
if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot
const flakeRoot = await NearestRoot(["flake.nix"])(file, ctx)
if (flakeRoot && flakeRoot !== ctx.directory) return flakeRoot
// If no flake.nix, fall back to git repository root
if (Instance.worktree && Instance.worktree !== Instance.directory) return Instance.worktree
if (ctx.worktree && ctx.worktree !== ctx.directory) return ctx.worktree
// Finally, use the instance directory as fallback
return Instance.directory
return ctx.directory
},
async spawn(root) {
const nixd = which("nixd")
+1 -2
View File
@@ -14,7 +14,6 @@ import { ConfigMCP } from "../config/mcp"
import { Log } from "../util"
import { NamedError } from "@opencode-ai/shared/util/error"
import z from "zod/v4"
import { Instance } from "../project/instance"
import { Installation } from "../installation"
import { InstallationVersion } from "../installation/version"
import { withTimeout } from "@/util/timeout"
@@ -391,7 +390,7 @@ export const layer = Layer.effect(
mcp: ConfigMCP.Info & { type: "local" },
) {
const [cmd, ...args] = mcp.command
const cwd = Instance.directory
const cwd = yield* InstanceState.directory
const transport = new StdioClientTransport({
stderr: "pipe",
command: cmd,
+245 -166
View File
@@ -1,198 +1,277 @@
import semver from "semver"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Global } from "../global"
import { Log } from "../util"
export * as Npm from "."
import path from "path"
import { readdir, rm } from "fs/promises"
import { Filesystem } from "@/util"
import { Flock } from "@opencode-ai/shared/util/flock"
import semver from "semver"
import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Global } from "@opencode-ai/shared/global"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { makeRuntime } from "../effect/runtime"
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
add: Schema.Array(Schema.String).pipe(Schema.optional),
dir: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
export interface EntryPoint {
readonly directory: string
readonly entrypoint: Option.Option<string>
}
export interface Interface {
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
readonly install: (
dir: string,
input?: {
add: {
name: string
version?: string
}[]
},
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
const log = Log.create({ service: "npm" })
const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined
export const InstallFailedError = NamedError.create(
"NpmInstallFailedError",
z.object({
pkg: z.string(),
}),
)
export function sanitize(pkg: string) {
if (!illegal) return pkg
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
}
function directory(pkg: string) {
return path.join(Global.Path.cache, "packages", sanitize(pkg))
}
function resolveEntryPoint(name: string, dir: string) {
let entrypoint: string | undefined
const resolveEntryPoint = (name: string, dir: string): EntryPoint => {
let entrypoint: Option.Option<string>
try {
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
} catch {}
const result = {
const resolved = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
entrypoint = Option.some(resolved)
} catch {
entrypoint = Option.none()
}
return {
directory: dir,
entrypoint,
}
return result
}
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
if (!response.ok) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
interface ArboristNode {
name: string
path: string
}
export async function add(pkg: string) {
const { Arborist } = await import("@npmcli/arborist")
const dir = directory(pkg)
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
log.info("installing package", {
pkg,
})
interface ArboristTree {
edgesOut: Map<string, { to?: ArboristNode }>
}
const arborist = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
const tree = await arborist.loadVirtual().catch(() => {})
if (tree) {
const first = tree.edgesOut.values().next().value?.to
if (first) {
return resolveEntryPoint(first.name, first.path)
}
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const afs = yield* AppFileSystem.Service
const global = yield* Global.Service
const fs = yield* FileSystem.FileSystem
const flock = yield* EffectFlock.Service
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
const reify = (input: { dir: string; add?: string[] }) =>
Effect.gen(function* () {
yield* flock.acquire(`npm-install:${input.dir}`)
const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist"))
const arborist = new Arborist({
path: input.dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
})
return yield* Effect.tryPromise({
try: () =>
arborist.reify({
add: input?.add || [],
save: true,
saveType: "prod",
}),
catch: (cause) =>
new InstallFailedError({
cause,
add: input?.add,
dir: input.dir,
}),
}) as Effect.Effect<ArboristTree, InstallFailedError>
}).pipe(
Effect.withSpan("Npm.reify", {
attributes: input,
}),
)
const result = await arborist
.reify({
add: [pkg],
save: true,
saveType: "prod",
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
const response = yield* Effect.tryPromise({
try: () => fetch(`https://registry.npmjs.org/${pkg}`),
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined))
if (!response || !response.ok) {
return false
}
const data = yield* Effect.tryPromise({
try: () => response.json() as Promise<{ "dist-tags"?: { latest?: string } }>,
catch: () => undefined,
}).pipe(Effect.orElseSucceed(() => undefined))
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
})
.catch((cause) => {
throw new InstallFailedError(
{ pkg },
{
cause,
},
const add = Effect.fn("Npm.add")(function* (pkg: string) {
const dir = directory(pkg)
const tree = yield* reify({ dir, add: [pkg] })
const first = tree.edgesOut.values().next().value?.to
if (!first) return yield* new InstallFailedError({ add: [pkg], dir })
return resolveEntryPoint(first.name, first.path)
}, Effect.scoped)
const install: Interface["install"] = Effect.fn("Npm.install")(function* (dir, input) {
const canWrite = yield* afs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.orElseSucceed(() => false),
)
if (!canWrite) return
const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
yield* Effect.gen(function* () {
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
if (!nodeModulesExists) {
yield* reify({ add, dir })
return
}
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
yield* Effect.gen(function* () {
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
const lock = yield* afs.readJson(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => ({})))
const pkgAny = pkg as any
const lockAny = lock as any
const declared = new Set([
...Object.keys(pkgAny?.dependencies || {}),
...Object.keys(pkgAny?.devDependencies || {}),
...Object.keys(pkgAny?.peerDependencies || {}),
...Object.keys(pkgAny?.optionalDependencies || {}),
...(input?.add || []).map((pkg) => pkg.name),
])
const root = lockAny?.packages?.[""] || {}
const locked = new Set([
...Object.keys(root?.dependencies || {}),
...Object.keys(root?.devDependencies || {}),
...Object.keys(root?.peerDependencies || {}),
...Object.keys(root?.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
yield* reify({ dir, add })
return
}
}
}).pipe(Effect.withSpan("Npm.checkDirty"))
return
}, Effect.scoped)
const which = Effect.fn("Npm.which")(function* (pkg: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = Effect.fnUntraced(function* () {
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
if (files.length === 0) return Option.none<string>()
if (files.length === 1) return Option.some(files[0])
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
if (Option.isSome(pkgJson)) {
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
if (parsed?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const bin = parsed.bin
if (typeof bin === "string") return Option.some(unscoped)
const keys = Object.keys(bin)
if (keys.length === 1) return Option.some(keys[0])
return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
}
}
return Option.some(files[0])
})
return yield* Effect.gen(function* () {
const bin = yield* pick()
if (Option.isSome(bin)) {
return Option.some(path.join(binDir, bin.value))
}
yield* fs.remove(path.join(dir, "package-lock.json")).pipe(Effect.orElseSucceed(() => {}))
yield* add(pkg)
const resolved = yield* pick()
if (Option.isNone(resolved)) return Option.none<string>()
return Option.some(path.join(binDir, resolved.value))
}).pipe(
Effect.scoped,
Effect.orElseSucceed(() => Option.none<string>()),
)
})
const first = result.edgesOut.values().next().value?.to
if (!first) throw new InstallFailedError({ pkg })
return resolveEntryPoint(first.name, first.path)
}
export async function install(dir: string) {
await using _ = await Flock.acquire(`npm-install:${dir}`)
log.info("checking dependencies", { dir })
const reify = async () => {
const { Arborist } = await import("@npmcli/arborist")
const arb = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
ignoreScripts: true,
return Service.of({
add,
install,
outdated,
which,
})
await arb.reify().catch(() => {})
}
}),
)
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
log.info("node_modules missing, reifying")
await reify()
return
}
export const defaultLayer = layer.pipe(
Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
)
type PackageDeps = Record<string, string>
type PackageJson = {
dependencies?: PackageDeps
devDependencies?: PackageDeps
peerDependencies?: PackageDeps
optionalDependencies?: PackageDeps
}
const pkg: PackageJson = await Filesystem.readJson<PackageJson>(path.join(dir, "package.json")).catch(() => ({}))
const lock: { packages?: Record<string, PackageJson> } = await Filesystem.readJson<{
packages?: Record<string, PackageJson>
}>(path.join(dir, "package-lock.json")).catch(() => ({}))
const { runPromise } = makeRuntime(Service, defaultLayer)
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
])
const root = lock.packages?.[""] || {}
const locked = new Set([
...Object.keys(root.dependencies || {}),
...Object.keys(root.devDependencies || {}),
...Object.keys(root.peerDependencies || {}),
...Object.keys(root.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
log.info("dependency not in lock file, reifying", { name })
await reify()
return
}
}
log.info("dependencies in sync")
export async function install(...args: Parameters<Interface["install"]>) {
return runPromise((svc) => svc.install(...args))
}
export async function which(pkg: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = async () => {
const files = await readdir(binDir).catch(() => [])
if (files.length === 0) return undefined
if (files.length === 1) return files[0]
// Multiple binaries — resolve from package.json bin field like npx does
const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
path.join(dir, "node_modules", pkg, "package.json"),
).catch(() => undefined)
if (pkgJson?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const bin = pkgJson.bin
if (typeof bin === "string") return unscoped
const keys = Object.keys(bin)
if (keys.length === 1) return keys[0]
return bin[unscoped] ? unscoped : keys[0]
}
return files[0]
export async function add(...args: Parameters<Interface["add"]>) {
const entry = await runPromise((svc) => svc.add(...args))
return {
directory: entry.directory,
entrypoint: Option.getOrUndefined(entry.entrypoint),
}
const bin = await pick()
if (bin) return path.join(binDir, bin)
await rm(path.join(dir, "package-lock.json"), { force: true })
await add(pkg)
const resolved = await pick()
if (!resolved) return
return path.join(binDir, resolved)
}
export * as Npm from "."
export async function outdated(...args: Parameters<Interface["outdated"]>) {
return runPromise((svc) => svc.outdated(...args))
}
export async function which(...args: Parameters<Interface["which"]>) {
const resolved = await runPromise((svc) => svc.which(...args))
return Option.getOrUndefined(resolved)
}
@@ -1,6 +1,5 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2"
import { Installation } from "@/installation"
import { InstallationVersion } from "@/installation/version"
import { iife } from "@/util/iife"
import { Log } from "../../util"
@@ -335,6 +334,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
if (incoming.model.api.id.includes("gpt")) {
output.maxOutputTokens = undefined
}
// GitHub Copilot's /v1/messages shim rejects the GA `eager_input_streaming`
// field on tool definitions ("Extra inputs are not permitted"). Opt out of
// the @ai-sdk/anthropic default so it stops injecting the field.
if (incoming.model.api.npm === "@ai-sdk/anthropic") {
output.options.toolStreaming = false
}
},
"chat.headers": async (incoming, output) => {
if (!incoming.model.providerID.includes("github-copilot")) return
@@ -10,6 +10,11 @@ export const schema = z.object({
// every version looks like: `{model.id}-YYYY-MM-DD`
version: z.string(),
supported_endpoints: z.array(z.string()).optional(),
policy: z
.object({
state: z.string().optional(),
})
.optional(),
capabilities: z.object({
family: z.string(),
limits: z.object({
@@ -122,7 +127,9 @@ export async function get(
})
const result = { ...existing }
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
const remote = new Map(
data.data.filter((m) => m.model_picker_enabled && m.policy?.state !== "disabled").map((m) => [m.id, m] as const),
)
// prune existing models whose api.id isn't in the endpoint response
for (const [key, model] of Object.entries(result)) {
+42
View File
@@ -12,31 +12,41 @@ import { ConfigPlugin } from "@/config/plugin"
import { InstallationVersion } from "@/installation/version"
export namespace PluginLoader {
// A normalized plugin declaration derived from config before any filesystem or npm work happens.
export type Plan = {
spec: string
options: ConfigPlugin.Options | undefined
deprecated: boolean
}
// A plugin that has been resolved to a concrete target and entrypoint on disk.
export type Resolved = Plan & {
source: PluginSource
target: string
entry: string
pkg?: PluginPackage
}
// A plugin target we could inspect, but which does not expose the requested kind of entrypoint.
export type Missing = Plan & {
source: PluginSource
target: string
pkg?: PluginPackage
message: string
}
// A resolved plugin whose module has been imported successfully.
export type Loaded = Resolved & {
mod: Record<string, unknown>
}
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
type Report = {
// Called before each attempt so callers can log initial load attempts and retries uniformly.
start?: (candidate: Candidate, retry: boolean) => void
// Called when the package exists but does not provide the requested entrypoint.
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
// Called for operational failures such as install, compatibility, or dynamic import errors.
error?: (
candidate: Candidate,
retry: boolean,
@@ -46,11 +56,16 @@ export namespace PluginLoader {
) => void
}
// Normalize a config item into the loader's internal representation.
function plan(item: ConfigPlugin.Spec): Plan {
const spec = ConfigPlugin.pluginSpecifier(item)
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
}
// Resolve a configured plugin into a concrete entrypoint that can later be imported.
//
// The stages here intentionally separate install/target resolution, entrypoint detection,
// and compatibility checks so callers can report the exact reason a plugin was skipped.
export async function resolve(
plan: Plan,
kind: PluginKind,
@@ -59,6 +74,7 @@ export namespace PluginLoader {
| { ok: false; stage: "missing"; value: Missing }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> {
// First make sure the plugin exists locally, installing npm plugins on demand.
let target = ""
try {
target = await resolvePluginTarget(plan.spec)
@@ -67,6 +83,7 @@ export namespace PluginLoader {
}
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
// Then inspect the target for the requested server/tui entrypoint.
let base
try {
base = await createPluginEntry(plan.spec, target, kind)
@@ -86,6 +103,8 @@ export namespace PluginLoader {
},
}
// npm plugins can declare which opencode versions they support; file plugins are treated
// as local development code and skip this compatibility gate.
if (base.source === "npm") {
try {
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
@@ -96,6 +115,7 @@ export namespace PluginLoader {
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
}
// Import the resolved module only after all earlier validation has succeeded.
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
let mod
try {
@@ -107,6 +127,8 @@ export namespace PluginLoader {
return { ok: true, value: { ...row, mod } }
}
// Run one candidate through the full pipeline: resolve, optionally surface a missing entry,
// import the module, and finally let the caller transform the loaded plugin into any result type.
async function attempt<R>(
candidate: Candidate,
kind: PluginKind,
@@ -116,11 +138,17 @@ export namespace PluginLoader {
report: Report | undefined,
): Promise<R | undefined> {
const plan = candidate.plan
// Deprecated plugin packages are silently ignored because they are now built in.
if (plan.deprecated) return
report?.start?.(candidate, retry)
const resolved = await resolve(plan, kind)
if (!resolved.ok) {
if (resolved.stage === "missing") {
// Missing entrypoints are handled separately so callers can still inspect package metadata,
// for example to load theme files from a tui plugin package that has no code entrypoint.
if (missing) {
const value = await missing(resolved.value, candidate.origin, retry)
if (value !== undefined) return value
@@ -131,11 +159,15 @@ export namespace PluginLoader {
report?.error?.(candidate, retry, resolved.stage, resolved.error)
return
}
const loaded = await load(resolved.value)
if (!loaded.ok) {
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
return
}
// The default behavior is to return the successfully loaded plugin as-is, but callers can
// provide a finisher to adapt the result into a more specific runtime shape.
if (!finish) return loaded.value as R
return finish(loaded.value, candidate.origin, retry)
}
@@ -149,6 +181,11 @@ export namespace PluginLoader {
report?: Report
}
// Resolve and load all configured plugins in parallel.
//
// If `wait` is provided, file-based plugins that initially failed are retried once after the
// caller finishes preparing dependencies. This supports local plugins that depend on an install
// step happening elsewhere before their entrypoint becomes loadable.
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
const list: Array<Promise<R | undefined>> = []
@@ -160,6 +197,9 @@ export namespace PluginLoader {
let deps: Promise<void> | undefined
for (let i = 0; i < candidates.length; i++) {
if (out[i] !== undefined) continue
// Only local file plugins are retried. npm plugins already attempted installation during
// the first pass, while file plugins may need the caller's dependency preparation to finish.
const candidate = candidates[i]
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
deps ??= input.wait()
@@ -167,6 +207,8 @@ export namespace PluginLoader {
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
}
}
// Drop skipped/failed entries while preserving the successful result order.
const ready: R[] = []
for (const item of out) if (item !== undefined) ready.push(item)
return ready
+1 -1
View File
@@ -2,9 +2,9 @@ import path from "path"
import { fileURLToPath, pathToFileURL } from "url"
import npa from "npm-package-arg"
import semver from "semver"
import { Npm } from "../npm"
import { Filesystem } from "@/util"
import { isRecord } from "@/util/record"
import { Npm } from "@/npm"
// Old npm package names for plugins that are now built-in
export const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
+1 -1
View File
@@ -96,7 +96,7 @@ export const Instance = {
if (AppFileSystem.contains(instance.directory, filepath)) return true
// Non-git projects set worktree to "/" which would match ANY absolute path.
// Skip worktree check in this case to preserve external_directory permissions.
if (Instance.worktree === "/") return false
if (instance.worktree === "/") return false
return AppFileSystem.contains(instance.worktree, filepath)
},
/**
+5 -10
View File
@@ -8,7 +8,6 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import { Log } from "@/util"
import { Instance } from "./instance"
import z from "zod"
const log = Log.create({ service: "vcs" })
@@ -205,21 +204,17 @@ export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Serv
}),
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
const value = yield* InstanceState.get(state)
if (Instance.project.vcs !== "git") return []
const ctx = yield* InstanceState.context
if (ctx.project.vcs !== "git") return []
if (mode === "git") {
return yield* track(
fs,
git,
Instance.directory,
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
)
return yield* track(fs, git, ctx.directory, (yield* git.hasHead(ctx.directory)) ? "HEAD" : undefined)
}
if (!value.root) return []
if (value.current && value.current === value.root.name) return []
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
const ref = yield* git.mergeBase(ctx.directory, value.root.ref)
if (!ref) return []
return yield* compare(fs, git, Instance.directory, ref)
return yield* compare(fs, git, ctx.directory, ref)
}),
})
}),
+9 -12
View File
@@ -13,7 +13,6 @@ import { type LanguageModelV3 } from "@ai-sdk/provider"
import * as ModelsDev from "./models"
import { Auth } from "../auth"
import { Env } from "../env"
import { Instance } from "../project/instance"
import { InstallationVersion } from "../installation/version"
import { Flag } from "../flag/flag"
import { zod } from "@/util/effect-zod"
@@ -537,6 +536,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN"))
const providerConfig = (yield* dep.config()).provider?.["gitlab"]
const directory = yield* InstanceState.directory
const aiGatewayHeaders = {
"User-Agent": `opencode/${InstallationVersion} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
@@ -591,10 +591,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
auth?.type === "api" ? { "PRIVATE-TOKEN": token } : { Authorization: `Bearer ${token}` }
log.info("gitlab model discovery starting", { instanceUrl })
const result = await discoverWorkflowModels(
{ instanceUrl, getHeaders },
{ workingDirectory: Instance.directory },
)
const result = await discoverWorkflowModels({ instanceUrl, getHeaders }, { workingDirectory: directory })
if (!result.models.length) {
log.info("gitlab model discovery skipped: no models found", {
@@ -968,7 +965,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
family: model.family,
api: {
id: model.id,
url: model.provider?.api ?? provider.api!,
url: model.provider?.api ?? provider.api ?? "",
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
},
status: model.status ?? "active",
@@ -981,10 +978,10 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
output: model.limit.output,
},
capabilities: {
temperature: model.temperature,
reasoning: model.reasoning,
attachment: model.attachment,
toolcall: model.tool_call,
temperature: model.temperature ?? false,
reasoning: model.reasoning ?? false,
attachment: model.attachment ?? false,
toolcall: model.tool_call ?? true,
input: {
text: model.modalities?.input?.includes("text") ?? false,
audio: model.modalities?.input?.includes("audio") ?? false,
@@ -1001,7 +998,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
},
interleaved: model.interleaved ?? false,
},
release_date: model.release_date,
release_date: model.release_date ?? "",
variants: {},
}
@@ -1143,7 +1140,7 @@ const layer: Layer.Layer<
existingModel?.api.npm ??
modelsDev[providerID]?.npm ??
"@ai-sdk/openai-compatible",
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "",
},
status: model.status ?? existingModel?.status ?? "active",
name,
-7
View File
@@ -130,13 +130,6 @@ export async function http(url: string | URL, extra: HeadersInit | undefined, re
const done = sync ? Fence.wait(workspaceID, sync, req.signal) : Promise.resolve()
return done.then(async () => {
console.log("proxy http response", {
method: req.method,
request: req.url,
url: String(url),
status: res.status,
statusText: res.statusText,
})
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
@@ -7,7 +7,6 @@ import { Hono } from "hono"
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
import z from "zod"
import { errors } from "../../error"
import { WorkspaceRoutes } from "./workspace"
export function ControlPlaneRoutes(): Hono {
const app = new Hono()
@@ -158,5 +157,4 @@ export function ControlPlaneRoutes(): Hono {
return c.json(true)
},
)
.route("/experimental/workspace", WorkspaceRoutes())
}
@@ -5,7 +5,6 @@ import { Config } from "@/config"
import { Provider } from "@/provider"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { AppRuntime } from "@/effect/app-runtime"
import { jsonRequest } from "./trace"
export const ConfigRoutes = lazy(() =>
@@ -52,11 +51,13 @@ export const ConfigRoutes = lazy(() =>
},
}),
validator("json", Config.Info),
async (c) => {
const config = c.req.valid("json")
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
return c.json(config)
},
async (c) =>
jsonRequest("ConfigRoutes.update", c, function* () {
const config = c.req.valid("json")
const cfg = yield* Config.Service
yield* cfg.update(config)
return config
}),
)
.get(
"/providers",
@@ -1,6 +1,7 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import * as EffectZod from "@/util/effect-zod"
import { ProviderID, ModelID } from "@/provider/schema"
import { ToolRegistry } from "@/tool"
import { Worktree } from "@/worktree"
@@ -12,11 +13,11 @@ import { Config } from "@/config"
import { ConsoleState } from "@/config/console-state"
import { Account } from "@/account/account"
import { AccountID, OrgID } from "@/account/schema"
import { AppRuntime } from "@/effect/app-runtime"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { Effect, Option } from "effect"
import { Agent } from "@/agent/agent"
import { jsonRequest, runRequest } from "./trace"
const ConsoleOrgOption = z.object({
accountID: z.string(),
@@ -49,28 +50,24 @@ export const ExperimentalRoutes = lazy(() =>
description: "Active Console provider metadata",
content: {
"application/json": {
schema: resolver(ConsoleState),
schema: resolver(ConsoleState.zod),
},
},
},
},
}),
async (c) => {
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const config = yield* Config.Service
const account = yield* Account.Service
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
concurrency: "unbounded",
})
return {
...state,
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
}
}),
)
return c.json(result)
},
async (c) =>
jsonRequest("ExperimentalRoutes.console.get", c, function* () {
const config = yield* Config.Service
const account = yield* Account.Service
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
concurrency: "unbounded",
})
return {
...state,
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
}
}),
)
.get(
"/console/orgs",
@@ -89,28 +86,25 @@ export const ExperimentalRoutes = lazy(() =>
},
},
}),
async (c) => {
const orgs = await AppRuntime.runPromise(
Effect.gen(function* () {
const account = yield* Account.Service
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
concurrency: "unbounded",
})
const info = Option.getOrUndefined(active)
return groups.flatMap((group) =>
group.orgs.map((org) => ({
accountID: group.account.id,
accountEmail: group.account.email,
accountUrl: group.account.url,
orgID: org.id,
orgName: org.name,
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
})),
)
}),
)
return c.json({ orgs })
},
async (c) =>
jsonRequest("ExperimentalRoutes.console.listOrgs", c, function* () {
const account = yield* Account.Service
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
concurrency: "unbounded",
})
const info = Option.getOrUndefined(active)
const orgs = groups.flatMap((group) =>
group.orgs.map((org) => ({
accountID: group.account.id,
accountEmail: group.account.email,
accountUrl: group.account.url,
orgID: org.id,
orgName: org.name,
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
})),
)
return { orgs }
}),
)
.post(
"/console/switch",
@@ -130,16 +124,13 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
validator("json", ConsoleSwitchBody),
async (c) => {
const body = c.req.valid("json")
await AppRuntime.runPromise(
Effect.gen(function* () {
const account = yield* Account.Service
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
}),
)
return c.json(true)
},
async (c) =>
jsonRequest("ExperimentalRoutes.console.switchOrg", c, function* () {
const body = c.req.valid("json")
const account = yield* Account.Service
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
return true
}),
)
.get(
"/tool/ids",
@@ -160,15 +151,11 @@ export const ExperimentalRoutes = lazy(() =>
...errors(400),
},
}),
async (c) => {
const ids = await AppRuntime.runPromise(
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
return yield* registry.ids()
}),
)
return c.json(ids)
},
async (c) =>
jsonRequest("ExperimentalRoutes.tool.ids", c, function* () {
const registry = yield* ToolRegistry.Service
return yield* registry.ids()
}),
)
.get(
"/tool",
@@ -210,7 +197,9 @@ export const ExperimentalRoutes = lazy(() =>
),
async (c) => {
const { provider, model } = c.req.valid("query")
const tools = await AppRuntime.runPromise(
const tools = await runRequest(
"ExperimentalRoutes.tool.list",
c,
Effect.gen(function* () {
const agents = yield* Agent.Service
const registry = yield* ToolRegistry.Service
@@ -225,7 +214,7 @@ export const ExperimentalRoutes = lazy(() =>
tools.map((t) => ({
id: t.id,
description: t.description,
parameters: z.toJSONSchema(t.parameters),
parameters: EffectZod.toJsonSchema(t.parameters),
})),
)
},
@@ -249,11 +238,12 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
validator("json", Worktree.CreateInput.optional()),
async (c) => {
const body = c.req.valid("json")
const worktree = await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.create(body)))
return c.json(worktree)
},
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.create", c, function* () {
const body = c.req.valid("json")
const svc = yield* Worktree.Service
return yield* svc.create(body)
}),
)
.get(
"/worktree",
@@ -272,10 +262,11 @@ export const ExperimentalRoutes = lazy(() =>
},
},
}),
async (c) => {
const sandboxes = await AppRuntime.runPromise(Project.Service.use((svc) => svc.sandboxes(Instance.project.id)))
return c.json(sandboxes)
},
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.list", c, function* () {
const svc = yield* Project.Service
return yield* svc.sandboxes(Instance.project.id)
}),
)
.delete(
"/worktree",
@@ -296,14 +287,15 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
validator("json", Worktree.RemoveInput),
async (c) => {
const body = c.req.valid("json")
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.remove(body)))
await AppRuntime.runPromise(
Project.Service.use((svc) => svc.removeSandbox(Instance.project.id, body.directory)),
)
return c.json(true)
},
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.remove", c, function* () {
const body = c.req.valid("json")
const worktree = yield* Worktree.Service
const project = yield* Project.Service
yield* worktree.remove(body)
yield* project.removeSandbox(Instance.project.id, body.directory)
return true
}),
)
.post(
"/worktree/reset",
@@ -324,11 +316,13 @@ export const ExperimentalRoutes = lazy(() =>
},
}),
validator("json", Worktree.ResetInput),
async (c) => {
const body = c.req.valid("json")
await AppRuntime.runPromise(Worktree.Service.use((svc) => svc.reset(body)))
return c.json(true)
},
async (c) =>
jsonRequest("ExperimentalRoutes.worktree.reset", c, function* () {
const body = c.req.valid("json")
const svc = yield* Worktree.Service
yield* svc.reset(body)
return true
}),
)
.get(
"/session",
@@ -406,15 +400,10 @@ export const ExperimentalRoutes = lazy(() =>
},
},
}),
async (c) => {
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const mcp = yield* MCP.Service
return yield* mcp.resources()
}),
),
)
},
async (c) =>
jsonRequest("ExperimentalRoutes.resource.list", c, function* () {
const mcp = yield* MCP.Service
return yield* mcp.resources()
}),
),
)
@@ -1,13 +1,12 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import { Effect } from "effect"
import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import { LSP } from "@/lsp"
import { Instance } from "@/project/instance"
import { lazy } from "@/util/lazy"
import { jsonRequest } from "./trace"
export const FileRoutes = lazy(() =>
new Hono()
@@ -34,13 +33,13 @@ export const FileRoutes = lazy(() =>
pattern: z.string(),
}),
),
async (c) => {
const pattern = c.req.valid("query").pattern
const result = await AppRuntime.runPromise(
Ripgrep.Service.use((svc) => svc.search({ cwd: Instance.directory, pattern, limit: 10 })),
)
return c.json(result.items)
},
async (c) =>
jsonRequest("FileRoutes.findText", c, function* () {
const pattern = c.req.valid("query").pattern
const svc = yield* Ripgrep.Service
const result = yield* svc.search({ cwd: Instance.directory, pattern, limit: 10 })
return result.items
}),
)
.get(
"/find/file",
@@ -68,25 +67,17 @@ export const FileRoutes = lazy(() =>
limit: z.coerce.number().int().min(1).max(200).optional(),
}),
),
async (c) => {
const query = c.req.valid("query").query
const dirs = c.req.valid("query").dirs
const type = c.req.valid("query").type
const limit = c.req.valid("query").limit
const results = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) =>
svc.search({
query,
limit: limit ?? 10,
dirs: dirs !== "false",
type,
}),
)
}),
)
return c.json(results)
},
async (c) =>
jsonRequest("FileRoutes.findFile", c, function* () {
const query = c.req.valid("query")
const svc = yield* File.Service
return yield* svc.search({
query: query.query,
limit: query.limit ?? 10,
dirs: query.dirs !== "false",
type: query.type,
})
}),
)
.get(
"/find/symbol",
@@ -138,15 +129,11 @@ export const FileRoutes = lazy(() =>
path: z.string(),
}),
),
async (c) => {
const path = c.req.valid("query").path
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.list(path))
}),
)
return c.json(content)
},
async (c) =>
jsonRequest("FileRoutes.list", c, function* () {
const svc = yield* File.Service
return yield* svc.list(c.req.valid("query").path)
}),
)
.get(
"/file/content",
@@ -171,15 +158,11 @@ export const FileRoutes = lazy(() =>
path: z.string(),
}),
),
async (c) => {
const path = c.req.valid("query").path
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.read(path))
}),
)
return c.json(content)
},
async (c) =>
jsonRequest("FileRoutes.read", c, function* () {
const svc = yield* File.Service
return yield* svc.read(c.req.valid("query").path)
}),
)
.get(
"/file/status",
@@ -198,13 +181,10 @@ export const FileRoutes = lazy(() =>
},
},
}),
async (c) => {
const content = await AppRuntime.runPromise(
Effect.gen(function* () {
return yield* File.Service.use((svc) => svc.status())
}),
)
return c.json(content)
},
async (c) =>
jsonRequest("FileRoutes.status", c, function* () {
const svc = yield* File.Service
return yield* svc.status()
}),
),
)
@@ -4,7 +4,6 @@ import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { Observability } from "@/effect"
import { memoMap } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
@@ -15,6 +14,7 @@ import { PermissionApi, permissionHandlers } from "./permission"
import { ProjectApi, projectHandlers } from "./project"
import { ProviderApi, providerHandlers } from "./provider"
import { QuestionApi, questionHandlers } from "./question"
import { memoMap } from "@/effect/memo-map"
const Query = Schema.Struct({
directory: Schema.optional(Schema.String),
@@ -26,7 +26,8 @@ import { ExperimentalRoutes } from "./experimental"
import { ProviderRoutes } from "./provider"
import { EventRoutes } from "./event"
import { SyncRoutes } from "./sync"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceMiddleware } from "./middleware"
import { jsonRequest } from "./trace"
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
const app = new Hono()
@@ -140,19 +141,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
async (c) => {
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
concurrency: 2,
})
return { branch, default_branch }
}),
),
)
},
async (c) =>
jsonRequest("InstanceRoutes.vcs.get", c, function* () {
const vcs = yield* Vcs.Service
const [branch, default_branch] = yield* Effect.all([vcs.branch(), vcs.defaultBranch()], {
concurrency: 2,
})
return { branch, default_branch }
}),
)
.get(
"/vcs/diff",
@@ -177,16 +173,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
mode: Vcs.Mode,
}),
),
async (c) => {
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff(c.req.valid("query").mode)
}),
),
)
},
async (c) =>
jsonRequest("InstanceRoutes.vcs.diff", c, function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff(c.req.valid("query").mode)
}),
)
.get(
"/command",
@@ -205,10 +196,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
async (c) => {
const commands = await AppRuntime.runPromise(Command.Service.use((svc) => svc.list()))
return c.json(commands)
},
async (c) =>
jsonRequest("InstanceRoutes.command.list", c, function* () {
const svc = yield* Command.Service
return yield* svc.list()
}),
)
.get(
"/agent",
@@ -227,10 +219,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
async (c) => {
const modes = await AppRuntime.runPromise(Agent.Service.use((svc) => svc.list()))
return c.json(modes)
},
async (c) =>
jsonRequest("InstanceRoutes.agent.list", c, function* () {
const svc = yield* Agent.Service
return yield* svc.list()
}),
)
.get(
"/skill",
@@ -249,15 +242,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
async (c) => {
const skills = await AppRuntime.runPromise(
Effect.gen(function* () {
const skill = yield* Skill.Service
return yield* skill.all()
}),
)
return c.json(skills)
},
async (c) =>
jsonRequest("InstanceRoutes.skill.list", c, function* () {
const skill = yield* Skill.Service
return yield* skill.all()
}),
)
.get(
"/lsp",
@@ -276,10 +265,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
async (c) => {
const items = await AppRuntime.runPromise(LSP.Service.use((lsp) => lsp.status()))
return c.json(items)
},
async (c) =>
jsonRequest("InstanceRoutes.lsp.status", c, function* () {
const lsp = yield* LSP.Service
return yield* lsp.status()
}),
)
.get(
"/formatter",
@@ -298,8 +288,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
},
},
}),
async (c) => {
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
},
async (c) =>
jsonRequest("InstanceRoutes.formatter.status", c, function* () {
const svc = yield* Format.Service
return yield* svc.status()
}),
)
}
@@ -2,12 +2,11 @@ import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { MCP } from "@/mcp"
import { Config } from "@/config"
import { ConfigMCP } from "@/config/mcp"
import { AppRuntime } from "@/effect/app-runtime"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { Effect } from "effect"
import { jsonRequest, runRequest } from "./trace"
export const McpRoutes = lazy(() =>
new Hono()
@@ -28,9 +27,11 @@ export const McpRoutes = lazy(() =>
},
},
}),
async (c) => {
return c.json(await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.status())))
},
async (c) =>
jsonRequest("McpRoutes.status", c, function* () {
const mcp = yield* MCP.Service
return yield* mcp.status()
}),
)
.post(
"/",
@@ -54,14 +55,16 @@ export const McpRoutes = lazy(() =>
"json",
z.object({
name: z.string(),
config: ConfigMCP.Info,
config: ConfigMCP.Info.zod,
}),
),
async (c) => {
const { name, config } = c.req.valid("json")
const result = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.add(name, config)))
return c.json(result.status)
},
async (c) =>
jsonRequest("McpRoutes.add", c, function* () {
const { name, config } = c.req.valid("json")
const mcp = yield* MCP.Service
const result = yield* mcp.add(name, config)
return result.status
}),
)
.post(
"/:name/auth",
@@ -87,7 +90,9 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
const result = await AppRuntime.runPromise(
const result = await runRequest(
"McpRoutes.auth.start",
c,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
@@ -129,12 +134,13 @@ export const McpRoutes = lazy(() =>
code: z.string().describe("Authorization code from OAuth callback"),
}),
),
async (c) => {
const name = c.req.param("name")
const { code } = c.req.valid("json")
const status = await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.finishAuth(name, code)))
return c.json(status)
},
async (c) =>
jsonRequest("McpRoutes.auth.callback", c, function* () {
const name = c.req.param("name")
const { code } = c.req.valid("json")
const mcp = yield* MCP.Service
return yield* mcp.finishAuth(name, code)
}),
)
.post(
"/:name/auth/authenticate",
@@ -156,7 +162,9 @@ export const McpRoutes = lazy(() =>
}),
async (c) => {
const name = c.req.param("name")
const result = await AppRuntime.runPromise(
const result = await runRequest(
"McpRoutes.auth.authenticate",
c,
Effect.gen(function* () {
const mcp = yield* MCP.Service
const supports = yield* mcp.supportsOAuth(name)
@@ -191,11 +199,13 @@ export const McpRoutes = lazy(() =>
...errors(404),
},
}),
async (c) => {
const name = c.req.param("name")
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.removeAuth(name)))
return c.json({ success: true as const })
},
async (c) =>
jsonRequest("McpRoutes.auth.remove", c, function* () {
const name = c.req.param("name")
const mcp = yield* MCP.Service
yield* mcp.removeAuth(name)
return { success: true as const }
}),
)
.post(
"/:name/connect",
@@ -214,11 +224,13 @@ export const McpRoutes = lazy(() =>
},
}),
validator("param", z.object({ name: z.string() })),
async (c) => {
const { name } = c.req.valid("param")
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.connect(name)))
return c.json(true)
},
async (c) =>
jsonRequest("McpRoutes.connect", c, function* () {
const { name } = c.req.valid("param")
const mcp = yield* MCP.Service
yield* mcp.connect(name)
return true
}),
)
.post(
"/:name/disconnect",
@@ -237,10 +249,12 @@ export const McpRoutes = lazy(() =>
},
}),
validator("param", z.object({ name: z.string() })),
async (c) => {
const { name } = c.req.valid("param")
await AppRuntime.runPromise(MCP.Service.use((mcp) => mcp.disconnect(name)))
return c.json(true)
},
async (c) =>
jsonRequest("McpRoutes.disconnect", c, function* () {
const { name } = c.req.valid("param")
const mcp = yield* MCP.Service
yield* mcp.disconnect(name)
return true
}),
),
)
@@ -0,0 +1,35 @@
import type { MiddlewareHandler } from "hono"
import { Instance } from "@/project/instance"
import { InstanceBootstrap } from "@/project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { WorkspaceContext } from "@/control-plane/workspace-context"
import { WorkspaceID } from "@/control-plane/schema"
export function InstanceMiddleware(workspaceID?: WorkspaceID): MiddlewareHandler {
return async (c, next) => {
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
const directory = AppFileSystem.resolve(
(() => {
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})(),
)
return WorkspaceContext.provide({
workspaceID,
async fn() {
return Instance.provide({
directory,
init: () => AppRuntime.runPromise(InstanceBootstrap),
async fn() {
return next()
},
})
},
})
}
}
@@ -1,11 +1,11 @@
import { Hono } from "hono"
import { describeRoute, validator, resolver } from "hono-openapi"
import z from "zod"
import { AppRuntime } from "@/effect/app-runtime"
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { jsonRequest } from "./trace"
export const PermissionRoutes = lazy(() =>
new Hono()
@@ -34,20 +34,18 @@ export const PermissionRoutes = lazy(() =>
}),
),
validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.reply({
requestID: params.requestID,
reply: json.reply,
message: json.message,
}),
),
)
return c.json(true)
},
async (c) =>
jsonRequest("PermissionRoutes.reply", c, function* () {
const params = c.req.valid("param")
const json = c.req.valid("json")
const svc = yield* Permission.Service
yield* svc.reply({
requestID: params.requestID,
reply: json.reply,
message: json.message,
})
return true
}),
)
.get(
"/",
@@ -66,9 +64,10 @@ export const PermissionRoutes = lazy(() =>
},
},
}),
async (c) => {
const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))
return c.json(permissions)
},
async (c) =>
jsonRequest("PermissionRoutes.list", c, function* () {
const svc = yield* Permission.Service
return yield* svc.list()
}),
),
)
@@ -9,6 +9,7 @@ import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { InstanceBootstrap } from "@/project/bootstrap"
import { AppRuntime } from "@/effect/app-runtime"
import { jsonRequest, runRequest } from "./trace"
export const ProjectRoutes = lazy(() =>
new Hono()
@@ -75,7 +76,9 @@ export const ProjectRoutes = lazy(() =>
async (c) => {
const dir = Instance.directory
const prev = Instance.project
const next = await AppRuntime.runPromise(
const next = await runRequest(
"ProjectRoutes.initGit",
c,
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
)
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
@@ -108,11 +111,12 @@ export const ProjectRoutes = lazy(() =>
}),
validator("param", z.object({ projectID: ProjectID.zod })),
validator("json", Project.UpdateInput.omit({ projectID: true })),
async (c) => {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")
const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID })))
return c.json(project)
},
async (c) =>
jsonRequest("ProjectRoutes.update", c, function* () {
const projectID = c.req.valid("param").projectID
const body = c.req.valid("json")
const svc = yield* Project.Service
return yield* svc.update({ ...body, projectID })
}),
),
)
@@ -6,11 +6,11 @@ import { Provider } from "@/provider"
import { ModelsDev } from "@/provider"
import { ProviderAuth } from "@/provider"
import { ProviderID } from "@/provider/schema"
import { AppRuntime } from "@/effect/app-runtime"
import { mapValues } from "remeda"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { Effect } from "effect"
import { jsonRequest } from "./trace"
export const ProviderRoutes = lazy(() =>
new Hono()
@@ -31,39 +31,31 @@ export const ProviderRoutes = lazy(() =>
},
},
}),
async (c) => {
const result = await AppRuntime.runPromise(
Effect.gen(function* () {
const svc = yield* Provider.Service
const cfg = yield* Config.Service
const config = yield* cfg.get()
const all = yield* Effect.promise(() => ModelsDev.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
for (const [key, value] of Object.entries(all)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
async (c) =>
jsonRequest("ProviderRoutes.list", c, function* () {
const svc = yield* Provider.Service
const cfg = yield* Config.Service
const config = yield* cfg.get()
const all = yield* Effect.promise(() => ModelsDev.get())
const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
const filtered: Record<string, (typeof all)[string]> = {}
for (const [key, value] of Object.entries(all)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
const connected = yield* svc.list()
const providers = Object.assign(
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
connected,
)
return {
all: Object.values(providers),
default: Provider.defaultModelIDs(providers),
connected: Object.keys(connected),
}
}),
)
return c.json({
all: result.all,
default: result.default,
connected: result.connected,
})
},
}
const connected = yield* svc.list()
const providers = Object.assign(
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
connected,
)
return {
all: Object.values(providers),
default: Provider.defaultModelIDs(providers),
connected: Object.keys(connected),
}
}),
)
.get(
"/auth",
@@ -82,9 +74,11 @@ export const ProviderRoutes = lazy(() =>
},
},
}),
async (c) => {
return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods())))
},
async (c) =>
jsonRequest("ProviderRoutes.auth", c, function* () {
const svc = yield* ProviderAuth.Service
return yield* svc.methods()
}),
)
.post(
"/:providerID/oauth/authorize",
@@ -111,20 +105,17 @@ export const ProviderRoutes = lazy(() =>
}),
),
validator("json", ProviderAuth.AuthorizeInput.zod),
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, inputs } = c.req.valid("json")
const result = await AppRuntime.runPromise(
ProviderAuth.Service.use((svc) =>
svc.authorize({
providerID,
method,
inputs,
}),
),
)
return c.json(result)
},
async (c) =>
jsonRequest("ProviderRoutes.oauth.authorize", c, function* () {
const providerID = c.req.valid("param").providerID
const { method, inputs } = c.req.valid("json")
const svc = yield* ProviderAuth.Service
return yield* svc.authorize({
providerID,
method,
inputs,
})
}),
)
.post(
"/:providerID/oauth/callback",
@@ -151,19 +142,17 @@ export const ProviderRoutes = lazy(() =>
}),
),
validator("json", ProviderAuth.CallbackInput.zod),
async (c) => {
const providerID = c.req.valid("param").providerID
const { method, code } = c.req.valid("json")
await AppRuntime.runPromise(
ProviderAuth.Service.use((svc) =>
svc.callback({
providerID,
method,
code,
}),
),
)
return c.json(true)
},
async (c) =>
jsonRequest("ProviderRoutes.oauth.callback", c, function* () {
const providerID = c.req.valid("param").providerID
const { method, code } = c.req.valid("json")
const svc = yield* ProviderAuth.Service
yield* svc.callback({
providerID,
method,
code,
})
return true
}),
),
)
@@ -8,6 +8,7 @@ import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { NotFoundError } from "@/storage"
import { errors } from "../../error"
import { jsonRequest, runRequest } from "./trace"
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
return new Hono()
@@ -28,16 +29,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
},
}),
async (c) => {
return c.json(
await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}),
),
)
},
async (c) =>
jsonRequest("PtyRoutes.list", c, function* () {
const pty = yield* Pty.Service
return yield* pty.list()
}),
)
.post(
"/",
@@ -58,15 +54,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
}),
validator("json", Pty.CreateInput),
async (c) => {
const info = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.create(c.req.valid("json"))
}),
)
return c.json(info)
},
async (c) =>
jsonRequest("PtyRoutes.create", c, function* () {
const pty = yield* Pty.Service
return yield* pty.create(c.req.valid("json"))
}),
)
.get(
"/:ptyID",
@@ -88,7 +80,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => {
const info = await AppRuntime.runPromise(
const info = await runRequest(
"PtyRoutes.get",
c,
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(c.req.valid("param").ptyID)
@@ -120,15 +114,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
}),
validator("param", z.object({ ptyID: PtyID.zod })),
validator("json", Pty.UpdateInput),
async (c) => {
const info = await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
}),
)
return c.json(info)
},
async (c) =>
jsonRequest("PtyRoutes.update", c, function* () {
const pty = yield* Pty.Service
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
}),
)
.delete(
"/:ptyID",
@@ -149,15 +139,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
},
}),
validator("param", z.object({ ptyID: PtyID.zod })),
async (c) => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const pty = yield* Pty.Service
yield* pty.remove(c.req.valid("param").ptyID)
}),
)
return c.json(true)
},
async (c) =>
jsonRequest("PtyRoutes.remove", c, function* () {
const pty = yield* Pty.Service
yield* pty.remove(c.req.valid("param").ptyID)
return true
}),
)
.get(
"/:ptyID/connect",
@@ -194,7 +181,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
})()
let handler: Handler | undefined
if (
!(await AppRuntime.runPromise(
!(await runRequest(
"PtyRoutes.connect",
c,
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.get(id)
@@ -232,7 +221,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
Effect.gen(function* () {
const pty = yield* Pty.Service
return yield* pty.connect(id, socket, cursor)
}),
}).pipe(Effect.withSpan("PtyRoutes.connect.open")),
)
ready = true
for (const msg of pending) handler?.onMessage(msg)
@@ -3,10 +3,10 @@ import { describeRoute, validator } from "hono-openapi"
import { resolver } from "hono-openapi"
import { QuestionID } from "@/question/schema"
import { Question } from "@/question"
import { AppRuntime } from "@/effect/app-runtime"
import z from "zod"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { jsonRequest } from "./trace"
const Reply = z.object({
answers: Question.Answer.zod
@@ -33,10 +33,11 @@ export const QuestionRoutes = lazy(() =>
},
},
}),
async (c) => {
const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
return c.json(questions)
},
async (c) =>
jsonRequest("QuestionRoutes.list", c, function* () {
const svc = yield* Question.Service
return yield* svc.list()
}),
)
.post(
"/:requestID/reply",
@@ -63,19 +64,17 @@ export const QuestionRoutes = lazy(() =>
}),
),
validator("json", Reply),
async (c) => {
const params = c.req.valid("param")
const json = c.req.valid("json")
await AppRuntime.runPromise(
Question.Service.use((svc) =>
svc.reply({
requestID: params.requestID,
answers: json.answers,
}),
),
)
return c.json(true)
},
async (c) =>
jsonRequest("QuestionRoutes.reply", c, function* () {
const params = c.req.valid("param")
const json = c.req.valid("json")
const svc = yield* Question.Service
yield* svc.reply({
requestID: params.requestID,
answers: json.answers,
})
return true
}),
)
.post(
"/:requestID/reject",
@@ -101,10 +100,12 @@ export const QuestionRoutes = lazy(() =>
requestID: QuestionID.zod,
}),
),
async (c) => {
const params = c.req.valid("param")
await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID)))
return c.json(true)
},
async (c) =>
jsonRequest("QuestionRoutes.reject", c, function* () {
const params = c.req.valid("param")
const svc = yield* Question.Service
yield* svc.reject(params.requestID)
return true
}),
),
)
@@ -14,7 +14,6 @@ import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { Effect } from "effect"
import { AppRuntime } from "@/effect/app-runtime"
import { Agent } from "@/agent/agent"
import { Snapshot } from "@/snapshot"
import { Command } from "@/command"
@@ -26,7 +25,7 @@ import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { Bus } from "@/bus"
import { NamedError } from "@opencode-ai/shared/util/error"
import { jsonRequest } from "./trace"
import { jsonRequest, runRequest } from "./trace"
const log = Log.create({ service: "server" })
@@ -218,11 +217,12 @@ export const SessionRoutes = lazy(() =>
},
}),
validator("json", Session.CreateInput),
async (c) => {
const body = c.req.valid("json") ?? {}
const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body)))
return c.json(session)
},
async (c) =>
jsonRequest("SessionRoutes.create", c, function* () {
const body = c.req.valid("json") ?? {}
const svc = yield* SessionShare.Service
return yield* svc.create(body)
}),
)
.delete(
"/:sessionID",
@@ -248,11 +248,13 @@ export const SessionRoutes = lazy(() =>
sessionID: Session.RemoveInput,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
return c.json(true)
},
async (c) =>
jsonRequest("SessionRoutes.delete", c, function* () {
const sessionID = c.req.valid("param").sessionID
const svc = yield* Session.Service
yield* svc.remove(sessionID)
return true
}),
)
.patch(
"/:sessionID",
@@ -290,32 +292,28 @@ export const SessionRoutes = lazy(() =>
.optional(),
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json")
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const current = yield* session.get(sessionID)
async (c) =>
jsonRequest("SessionRoutes.update", c, function* () {
const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json")
const session = yield* Session.Service
const current = yield* session.get(sessionID)
if (updates.title !== undefined) {
yield* session.setTitle({ sessionID, title: updates.title })
}
if (updates.permission !== undefined) {
yield* session.setPermission({
sessionID,
permission: Permission.merge(current.permission ?? [], updates.permission),
})
}
if (updates.time?.archived !== undefined) {
yield* session.setArchived({ sessionID, time: updates.time.archived })
}
if (updates.title !== undefined) {
yield* session.setTitle({ sessionID, title: updates.title })
}
if (updates.permission !== undefined) {
yield* session.setPermission({
sessionID,
permission: Permission.merge(current.permission ?? [], updates.permission),
})
}
if (updates.time?.archived !== undefined) {
yield* session.setArchived({ sessionID, time: updates.time.archived })
}
return yield* session.get(sessionID)
}),
)
return c.json(session)
},
return yield* session.get(sessionID)
}),
)
// TODO(v2): remove this dedicated route and rely on the normal `/init` command flow.
.post(
@@ -351,22 +349,20 @@ export const SessionRoutes = lazy(() =>
messageID: MessageID.zod,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
await AppRuntime.runPromise(
SessionPrompt.Service.use((svc) =>
svc.command({
sessionID,
messageID: body.messageID,
model: body.providerID + "/" + body.modelID,
command: Command.Default.INIT,
arguments: "",
}),
),
)
return c.json(true)
},
async (c) =>
jsonRequest("SessionRoutes.init", c, function* () {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const svc = yield* SessionPrompt.Service
yield* svc.command({
sessionID,
messageID: body.messageID,
model: body.providerID + "/" + body.modelID,
command: Command.Default.INIT,
arguments: "",
})
return true
}),
)
.post(
"/:sessionID/fork",
@@ -392,12 +388,13 @@ export const SessionRoutes = lazy(() =>
}),
),
validator("json", Session.ForkInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID })))
return c.json(result)
},
async (c) =>
jsonRequest("SessionRoutes.fork", c, function* () {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const svc = yield* Session.Service
return yield* svc.fork({ ...body, sessionID })
}),
)
.post(
"/:sessionID/abort",
@@ -423,10 +420,12 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
async (c) => {
await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID)))
return c.json(true)
},
async (c) =>
jsonRequest("SessionRoutes.abort", c, function* () {
const svc = yield* SessionPrompt.Service
yield* svc.cancel(c.req.valid("param").sessionID)
return true
}),
)
.post(
"/:sessionID/share",
@@ -452,18 +451,14 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.share(sessionID)
return yield* session.get(sessionID)
}),
)
return c.json(session)
},
async (c) =>
jsonRequest("SessionRoutes.share", c, function* () {
const sessionID = c.req.valid("param").sessionID
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.share(sessionID)
return yield* session.get(sessionID)
}),
)
.get(
"/:sessionID/diff",
@@ -494,19 +489,16 @@ export const SessionRoutes = lazy(() =>
messageID: SessionSummary.DiffInput.shape.messageID,
}),
),
async (c) => {
const query = c.req.valid("query")
const params = c.req.valid("param")
const result = await AppRuntime.runPromise(
SessionSummary.Service.use((summary) =>
summary.diff({
sessionID: params.sessionID,
messageID: query.messageID,
}),
),
)
return c.json(result)
},
async (c) =>
jsonRequest("SessionRoutes.diff", c, function* () {
const query = c.req.valid("query")
const params = c.req.valid("param")
const summary = yield* SessionSummary.Service
return yield* summary.diff({
sessionID: params.sessionID,
messageID: query.messageID,
})
}),
)
.delete(
"/:sessionID/share",
@@ -532,18 +524,14 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await AppRuntime.runPromise(
Effect.gen(function* () {
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.unshare(sessionID)
return yield* session.get(sessionID)
}),
)
return c.json(session)
},
async (c) =>
jsonRequest("SessionRoutes.unshare", c, function* () {
const sessionID = c.req.valid("param").sessionID
const share = yield* SessionShare.Service
const session = yield* Session.Service
yield* share.unshare(sessionID)
return yield* session.get(sessionID)
}),
)
.post(
"/:sessionID/summarize",
@@ -577,43 +565,40 @@ export const SessionRoutes = lazy(() =>
auto: z.boolean().optional().default(false),
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
await AppRuntime.runPromise(
Effect.gen(function* () {
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const compact = yield* SessionCompaction.Service
const prompt = yield* SessionPrompt.Service
const agent = yield* Agent.Service
async (c) =>
jsonRequest("SessionRoutes.summarize", c, function* () {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const session = yield* Session.Service
const revert = yield* SessionRevert.Service
const compact = yield* SessionCompaction.Service
const prompt = yield* SessionPrompt.Service
const agent = yield* Agent.Service
yield* revert.cleanup(yield* session.get(sessionID))
const msgs = yield* session.messages({ sessionID })
const defaultAgent = yield* agent.defaultAgent()
let currentAgent = defaultAgent
for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info
if (info.role === "user") {
currentAgent = info.agent || defaultAgent
break
}
yield* revert.cleanup(yield* session.get(sessionID))
const msgs = yield* session.messages({ sessionID })
const defaultAgent = yield* agent.defaultAgent()
let currentAgent = defaultAgent
for (let i = msgs.length - 1; i >= 0; i--) {
const info = msgs[i].info
if (info.role === "user") {
currentAgent = info.agent || defaultAgent
break
}
}
yield* compact.create({
sessionID,
agent: currentAgent,
model: {
providerID: body.providerID,
modelID: body.modelID,
},
auto: body.auto,
})
yield* prompt.loop({ sessionID })
}),
)
return c.json(true)
},
yield* compact.create({
sessionID,
agent: currentAgent,
model: {
providerID: body.providerID,
modelID: body.modelID,
},
auto: body.auto,
})
yield* prompt.loop({ sessionID })
return true
}),
)
.get(
"/:sessionID/message",
@@ -675,7 +660,9 @@ export const SessionRoutes = lazy(() =>
const query = c.req.valid("query")
const sessionID = c.req.valid("param").sessionID
if (query.limit === undefined || query.limit === 0) {
const messages = await AppRuntime.runPromise(
const messages = await runRequest(
"SessionRoutes.messages",
c,
Effect.gen(function* () {
const session = yield* Session.Service
yield* session.get(sessionID)
@@ -766,21 +753,18 @@ export const SessionRoutes = lazy(() =>
messageID: MessageID.zod,
}),
),
async (c) => {
const params = c.req.valid("param")
await AppRuntime.runPromise(
Effect.gen(function* () {
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(params.sessionID)
yield* session.removeMessage({
sessionID: params.sessionID,
messageID: params.messageID,
})
}),
)
return c.json(true)
},
async (c) =>
jsonRequest("SessionRoutes.deleteMessage", c, function* () {
const params = c.req.valid("param")
const state = yield* SessionRunState.Service
const session = yield* Session.Service
yield* state.assertNotBusy(params.sessionID)
yield* session.removeMessage({
sessionID: params.sessionID,
messageID: params.messageID,
})
return true
}),
)
.delete(
"/:sessionID/message/:messageID/part/:partID",
@@ -807,19 +791,17 @@ export const SessionRoutes = lazy(() =>
partID: PartID.zod,
}),
),
async (c) => {
const params = c.req.valid("param")
await AppRuntime.runPromise(
Session.Service.use((svc) =>
svc.removePart({
sessionID: params.sessionID,
messageID: params.messageID,
partID: params.partID,
}),
),
)
return c.json(true)
},
async (c) =>
jsonRequest("SessionRoutes.deletePart", c, function* () {
const params = c.req.valid("param")
const svc = yield* Session.Service
yield* svc.removePart({
sessionID: params.sessionID,
messageID: params.messageID,
partID: params.partID,
})
return true
}),
)
.patch(
"/:sessionID/message/:messageID/part/:partID",
@@ -855,8 +837,10 @@ export const SessionRoutes = lazy(() =>
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
)
}
const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body)))
return c.json(part)
return jsonRequest("SessionRoutes.updatePart", c, function* () {
const svc = yield* Session.Service
return yield* svc.updatePart(body)
})
},
)
.post(
@@ -895,7 +879,9 @@ export const SessionRoutes = lazy(() =>
return stream(c, async (stream) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await AppRuntime.runPromise(
const msg = await runRequest(
"SessionRoutes.prompt",
c,
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
)
void stream.write(JSON.stringify(msg))
@@ -926,15 +912,17 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
void AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch(
(err) => {
log.error("prompt_async failed", { sessionID, error: err })
void Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
})
},
)
void runRequest(
"SessionRoutes.prompt_async",
c,
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err })
void Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
})
})
return c.body(null, 204)
},
@@ -969,12 +957,13 @@ export const SessionRoutes = lazy(() =>
}),
),
validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID })))
return c.json(msg)
},
async (c) =>
jsonRequest("SessionRoutes.command", c, function* () {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const svc = yield* SessionPrompt.Service
return yield* svc.command({ ...body, sessionID })
}),
)
.post(
"/:sessionID/shell",
@@ -1001,12 +990,13 @@ export const SessionRoutes = lazy(() =>
}),
),
validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID })))
return c.json(msg)
},
async (c) =>
jsonRequest("SessionRoutes.shell", c, function* () {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const svc = yield* SessionPrompt.Service
return yield* svc.shell({ ...body, sessionID })
}),
)
.post(
"/:sessionID/revert",
@@ -1036,15 +1026,13 @@ export const SessionRoutes = lazy(() =>
async (c) => {
const sessionID = c.req.valid("param").sessionID
log.info("revert", c.req.valid("json"))
const session = await AppRuntime.runPromise(
SessionRevert.Service.use((svc) =>
svc.revert({
sessionID,
...c.req.valid("json"),
}),
),
)
return c.json(session)
return jsonRequest("SessionRoutes.revert", c, function* () {
const svc = yield* SessionRevert.Service
return yield* svc.revert({
sessionID,
...c.req.valid("json"),
})
})
},
)
.post(
@@ -1071,11 +1059,12 @@ export const SessionRoutes = lazy(() =>
sessionID: SessionID.zod,
}),
),
async (c) => {
const sessionID = c.req.valid("param").sessionID
const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID })))
return c.json(session)
},
async (c) =>
jsonRequest("SessionRoutes.unrevert", c, function* () {
const sessionID = c.req.valid("param").sessionID
const svc = yield* SessionRevert.Service
return yield* svc.unrevert({ sessionID })
}),
)
.post(
"/:sessionID/permissions/:permissionID",
@@ -1104,17 +1093,15 @@ export const SessionRoutes = lazy(() =>
}),
),
validator("json", z.object({ response: Permission.Reply.zod })),
async (c) => {
const params = c.req.valid("param")
await AppRuntime.runPromise(
Permission.Service.use((svc) =>
svc.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
}),
),
)
return c.json(true)
},
async (c) =>
jsonRequest("SessionRoutes.permissionRespond", c, function* () {
const params = c.req.valid("param")
const svc = yield* Permission.Service
yield* svc.reply({
requestID: params.permissionID,
reply: c.req.valid("json").response,
})
return true
}),
),
)
@@ -6,6 +6,8 @@ import { Database, asc, and, not, or, lte, eq } from "@/storage"
import { EventTable } from "@/sync/event.sql"
import { lazy } from "@/util/lazy"
import { Log } from "@/util"
import { startWorkspaceSyncing } from "@/control-plane/workspace"
import { Instance } from "@/project/instance"
import { errors } from "../../error"
const ReplayEvent = z.object({
@@ -20,6 +22,28 @@ const log = Log.create({ service: "server.sync" })
export const SyncRoutes = lazy(() =>
new Hono()
.post(
"/start",
describeRoute({
summary: "Start workspace sync",
description: "Start sync loops for workspaces in the current project that have active sessions.",
operationId: "sync.start",
responses: {
200: {
description: "Workspace sync started",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) => {
startWorkspaceSyncing(Instance.project.id)
return c.json(true)
},
)
.post(
"/replay",
describeRoute({
@@ -75,7 +99,7 @@ export const SyncRoutes = lazy(() =>
})
},
)
.get(
.post(
"/history",
describeRoute({
summary: "List sync events",
@@ -4,18 +4,44 @@ import { AppRuntime } from "@/effect/app-runtime"
type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
// Build the base span attributes for an HTTP handler: method, path, and every
// matched route param. Names follow OTel attribute-naming guidance:
// domain-first (`session.id`, `message.id`, …) so they match the existing
// OTel `session.id` semantic convention and the bare `message.id` we
// already emit from Tool.execute. Non-standard route params fall back to
// `opencode.<name>` since those are internal implementation details
// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/).
export interface RequestLike {
readonly req: {
readonly method: string
readonly url: string
param(): Record<string, string>
}
}
// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`)
// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any
// other param is namespaced under `opencode.` to avoid colliding with
// standard conventions.
export function paramToAttributeKey(key: string): string {
const m = key.match(/^(.+)ID$/)
if (m) return `${m[1].toLowerCase()}.id`
return `opencode.${key}`
}
export function requestAttributes(c: RequestLike): Record<string, string> {
const attributes: Record<string, string> = {
"http.method": c.req.method,
"http.path": new URL(c.req.url).pathname,
}
for (const [key, value] of Object.entries(c.req.param())) {
attributes[paramToAttributeKey(key)] = value
}
return attributes
}
export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
const url = new URL(c.req.url)
return AppRuntime.runPromise(
effect.pipe(
Effect.withSpan(name, {
attributes: {
"http.method": c.req.method,
"http.path": url.pathname,
},
}),
),
)
return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })))
}
export async function jsonRequest<C extends Context, A, E>(
@@ -4,10 +4,10 @@ import z from "zod"
import { Bus } from "@/bus"
import { Session } from "@/session"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { AppRuntime } from "@/effect/app-runtime"
import { AsyncQueue } from "@/util/queue"
import { errors } from "../../error"
import { lazy } from "@/util/lazy"
import { runRequest } from "./trace"
const TuiRequest = z.object({
path: z.string(),
@@ -371,7 +371,11 @@ export const TuiRoutes = lazy(() =>
validator("json", TuiEvent.SessionSelect.properties),
async (c) => {
const { sessionID } = c.req.valid("json")
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
await runRequest(
"TuiRoutes.sessionSelect",
c,
Session.Service.use((svc) => svc.get(sessionID)),
)
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
return c.json(true)
},

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