diff --git a/bun.lock b/bun.lock index 8d83d8ab47..3cb3cbea60 100644 --- a/bun.lock +++ b/bun.lock @@ -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", }, @@ -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", diff --git a/packages/app/package.json b/packages/app/package.json index 0f4ae4228b..5a1a4504ea 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.4.9", + "version": "1.4.11", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 63b6a5c414..200a5e30e3 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.4.9", + "version": "1.4.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index e5351c1a87..f233726e69 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -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", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index ef84eae470..1142230bb7 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -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", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 439beb15c8..860150aa28 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -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", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index b698a08896..8142b12ada 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -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", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1d9d24bd32..a23342bdec 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.4.9", + "version": "1.4.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 02f1ad83ca..f565159628 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.4.9", + "version": "1.4.11", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index ffcc975d1b..32039c097a 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -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"] diff --git a/packages/function/package.json b/packages/function/package.json index 2ac49a9228..5d4229f64f 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -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", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 2f56b74775..2acbc4fe84 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -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", diff --git a/packages/opencode/specs/effect/facades.md b/packages/opencode/specs/effect/facades.md index e2d9d3d8a1..8bf7d97bad 100644 --- a/packages/opencode/specs/effect/facades.md +++ b/packages/opencode/specs/effect/facades.md @@ -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 diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 71b50250ed..93ef81a325 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -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/` to `/` -2. add `.all("/", handler)` / `.all("//*", 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("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 +``` + +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 diff --git a/packages/opencode/specs/effect/instance-context.md b/packages/opencode/specs/effect/instance-context.md index 6c160a9477..7d0d7eb13c 100644 --- a/packages/opencode/specs/effect/instance-context.md +++ b/packages/opencode/specs/effect/instance-context.md @@ -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` diff --git a/packages/opencode/specs/effect/loose-ends.md b/packages/opencode/specs/effect/loose-ends.md index a2fed492b3..4e7ada7ff9 100644 --- a/packages/opencode/specs/effect/loose-ends.md +++ b/packages/opencode/specs/effect/loose-ends.md @@ -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 diff --git a/packages/opencode/specs/effect/migration.md b/packages/opencode/specs/effect/migration.md index b8bf4e0494..947eef5a15 100644 --- a/packages/opencode/specs/effect/migration.md +++ b/packages/opencode/specs/effect/migration.md @@ -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 - } - - export class Service extends Context.Service()("@opencode/Foo") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - // For instance-scoped services: - const state = yield* InstanceState.make( - 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 } + +export class Service extends Context.Service()("@opencode/Foo") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const state = yield* InstanceState.make( + 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 diff --git a/packages/opencode/specs/effect/routes.md b/packages/opencode/specs/effect/routes.md index f6a61d2342..3bf7e1b556 100644 --- a/packages/opencode/specs/effect/routes.md +++ b/packages/opencode/specs/effect/routes.md @@ -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. diff --git a/packages/opencode/specs/effect/server-package.md b/packages/opencode/specs/effect/server-package.md index 10be7b9aed..06e89c18de 100644 --- a/packages/opencode/specs/effect/server-package.md +++ b/packages/opencode/specs/effect/server-package.md @@ -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. diff --git a/packages/opencode/specs/effect/tools.md b/packages/opencode/specs/effect/tools.md index e97e0d23e0..7b47831709 100644 --- a/packages/opencode/specs/effect/tools.md +++ b/packages/opencode/specs/effect/tools.md @@ -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` diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts index 66569efea5..ed79e8e524 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-schema.ts @@ -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) diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 179046e026..9d5cd65bfd 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -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), { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 4a7b711a03..6d92752efe 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -3,7 +3,7 @@ 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" @@ -62,6 +62,9 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {session()!.title} + + {props.sessionID} + {" "} diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index f754f009d4..9053b19fc1 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -15,7 +15,7 @@ const log = Log.create({ service: "config" }) export const Info = z .object({ - model: ConfigModelID.optional(), + model: ConfigModelID.zod.optional(), variant: z .string() .optional() diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index 9799250567..3e0adccc30 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -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 +export type Info = Schema.Schema.Type export async function load(dir: string) { const result: Record = {} @@ -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 diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 459f76961a..bfb0c2f1f4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -97,10 +97,10 @@ export const Info = z 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) + .record(z.string(), ConfigCommand.Info.zod) .optional() .describe("Command configuration, see https://opencode.ai/docs/commands"), - skills: ConfigSkills.Info.optional().describe("Additional skill folder paths"), + skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"), watcher: z .object({ ignore: z.array(z.string()).optional(), @@ -113,7 +113,7 @@ export const Info = z "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(), + plugin: ConfigPlugin.Spec.zod.array().optional(), share: z .enum(["manual", "auto", "disabled"]) .optional() @@ -135,10 +135,10 @@ export const Info = 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(), + model: ConfigModelID.zod.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(), + small_model: ConfigModelID.zod + .describe("Small model to use for tasks like title generation in the format of provider/model") + .optional(), default_agent: z .string() .optional() @@ -171,14 +171,14 @@ export const Info = z .optional() .describe("Agent configuration, see https://opencode.ai/docs/agents"), provider: z - .record(z.string(), ConfigProvider.Info) + .record(z.string(), ConfigProvider.Info.zod) .optional() .describe("Custom provider configurations and model overrides"), mcp: z .record( z.string(), z.union([ - ConfigMCP.Info, + ConfigMCP.Info.zod, z .object({ enabled: z.boolean(), @@ -188,8 +188,8 @@ export const Info = z ) .optional() .describe("MCP (Model Context Protocol) server configurations"), - formatter: ConfigFormatter.Info.optional(), - lsp: ConfigLSP.Info.optional(), + formatter: ConfigFormatter.Info.zod.optional(), + lsp: ConfigLSP.Info.zod.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(), @@ -518,7 +518,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, diff --git a/packages/opencode/src/config/console-state.ts b/packages/opencode/src/config/console-state.ts index cf96a4e305..08668afe4e 100644 --- a/packages/opencode/src/config/console-state.ts +++ b/packages/opencode/src/config/console-state.ts @@ -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")({ + 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 - -export const emptyConsoleState: ConsoleState = { +export const emptyConsoleState: ConsoleState = ConsoleState.make({ consoleManagedProviders: [], activeOrgName: undefined, switchableOrgCount: 0, -} +}) diff --git a/packages/opencode/src/config/formatter.ts b/packages/opencode/src/config/formatter.ts index 93b87f0281..8c1f09a247 100644 --- a/packages/opencode/src/config/formatter.ts +++ b/packages/opencode/src/config/formatter.ts @@ -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 +export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe( + withStatics((s) => ({ zod: zod(s) })), +) +export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/lsp.ts b/packages/opencode/src/config/lsp.ts index 5530a5be56..1cf93177e4 100644 --- a/packages/opencode/src/config/lsp.ts +++ b/packages/opencode/src/config/lsp.ts @@ -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> +>((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 +export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index 5036cd6e4f..8b77bc4c28 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -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("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 +export class OAuth extends Schema.Class("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("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 +export const Info = Schema.Union([Local, Remote]) + .annotate({ discriminator: "type" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type export * as ConfigMCP from "./mcp" diff --git a/packages/opencode/src/config/model-id.ts b/packages/opencode/src/config/model-id.ts index 909e9aa929..3ad9e035ce 100644 --- a/packages/opencode/src/config/model-id.ts +++ b/packages/opencode/src/config/model-id.ts @@ -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 diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index af01f6f2a3..7cfbaec01f 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,5 +1,8 @@ export * as ConfigPermission from "./permission" +import { Schema } from "effect" import z from "zod" +import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" const permissionPreprocess = (val: unknown) => { if (typeof val === "object" && val !== null && !Array.isArray(val)) { @@ -8,20 +11,20 @@ const permissionPreprocess = (val: unknown) => { return val } -export const Action = z.enum(["ask", "allow", "deny"]).meta({ - ref: "PermissionActionConfig", -}) -export type Action = z.infer +export const Action = Schema.Literals(["ask", "allow", "deny"]) + .annotate({ identifier: "PermissionActionConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Action = Schema.Schema.Type -export const Object = z.record(z.string(), Action).meta({ - ref: "PermissionObjectConfig", -}) -export type Object = z.infer +export const Object = Schema.Record(Schema.String, Action) + .annotate({ identifier: "PermissionObjectConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Object = Schema.Schema.Type -export const Rule = z.union([Action, Object]).meta({ - ref: "PermissionRuleConfig", -}) -export type Rule = z.infer +export const Rule = Schema.Union([Action, Object]) + .annotate({ identifier: "PermissionRuleConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Rule = Schema.Schema.Type const transform = (x: unknown): Record => { if (typeof x === "string") return { "*": x as Action } @@ -41,25 +44,25 @@ export const Info = z 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(), + read: Rule.zod.optional(), + edit: Rule.zod.optional(), + glob: Rule.zod.optional(), + grep: Rule.zod.optional(), + list: Rule.zod.optional(), + bash: Rule.zod.optional(), + task: Rule.zod.optional(), + external_directory: Rule.zod.optional(), + todowrite: Action.zod.optional(), + question: Action.zod.optional(), + webfetch: Action.zod.optional(), + websearch: Action.zod.optional(), + codesearch: Action.zod.optional(), + lsp: Rule.zod.optional(), + doom_loop: Action.zod.optional(), + skill: Rule.zod.optional(), }) - .catchall(Rule) - .or(Action), + .catchall(Rule.zod) + .or(Action.zod), ) .transform(transform) .meta({ diff --git a/packages/opencode/src/config/plugin.ts b/packages/opencode/src/config/plugin.ts index 7d335bcc53..4277c1cd6d 100644 --- a/packages/opencode/src/config/plugin.ts +++ b/packages/opencode/src/config/plugin.ts @@ -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 +export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Options = Schema.Schema.Type // 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 +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 export type Scope = "global" | "local" diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index 877677519f..b435f43759 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -1,120 +1,118 @@ +import { Schema } from "effect" import z from "zod" +import { zod, ZodOverride } 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(), +// Positive integer preserving exact Zod JSON Schema (type: integer, exclusiveMinimum: 0). +const PositiveInt = Schema.Number.annotate({ + [ZodOverride]: z.number().int().positive(), +}) + +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("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 + }), + }), + [Schema.Record(Schema.String, Schema.Any)], + ), + ), + models: Schema.optional(Schema.Record(Schema.String, Model)), +}) { + static readonly zod = zod(this) +} export * as ConfigProvider from "./provider" diff --git a/packages/opencode/src/config/skills.ts b/packages/opencode/src/config/skills.ts index 38cbf99e7d..f29d854f50 100644 --- a/packages/opencode/src/config/skills.ts +++ b/packages/opencode/src/config/skills.ts @@ -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 +export type Info = Schema.Schema.Type export * as ConfigSkills from "./skills" diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index f242598192..d92099bc3c 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -25,7 +25,12 @@ export interface Interface { readonly add: (pkg: string) => Effect.Effect readonly install: ( dir: string, - input?: { add: string[] }, + input?: { + add: { + name: string + version?: string + }[] + }, ) => Effect.Effect readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect readonly which: (pkg: string) => Effect.Effect> @@ -137,17 +142,18 @@ export const layer = Layer.effect( return resolveEntryPoint(first.name, first.path) }, Effect.scoped) - const install = Effect.fn("Npm.install")(function* (dir: string, input?: { add: string[] }) { + 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: input?.add, dir }) + yield* reify({ add, dir }) return } }).pipe(Effect.withSpan("Npm.checkNodeModules")) @@ -163,7 +169,7 @@ export const layer = Layer.effect( ...Object.keys(pkgAny?.devDependencies || {}), ...Object.keys(pkgAny?.peerDependencies || {}), ...Object.keys(pkgAny?.optionalDependencies || {}), - ...(input?.add || []), + ...(input?.add || []).map((pkg) => pkg.name), ]) const root = lockAny?.packages?.[""] || {} @@ -176,7 +182,7 @@ export const layer = Layer.effect( for (const name of declared) { if (!locked.has(name)) { - yield* reify({ dir, add: input?.add }) + yield* reify({ dir, add }) return } } diff --git a/packages/opencode/src/plugin/github-copilot/copilot.ts b/packages/opencode/src/plugin/github-copilot/copilot.ts index c9b7e3c1c7..c018f72bd5 100644 --- a/packages/opencode/src/plugin/github-copilot/copilot.ts +++ b/packages/opencode/src/plugin/github-copilot/copilot.ts @@ -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" diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 71d21afbe4..0aac0d3f5e 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -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)) { diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index 0245d311e0..e61612561b 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -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 } 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( candidate: Candidate, kind: PluginKind, @@ -116,11 +138,17 @@ export namespace PluginLoader { report: Report | undefined, ): Promise { 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(input: Input): Promise { const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) })) const list: Array> = [] @@ -160,6 +197,9 @@ export namespace PluginLoader { let deps: Promise | 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 diff --git a/packages/opencode/src/server/routes/control/index.ts b/packages/opencode/src/server/routes/control/index.ts index 3fd60636ff..60883274a5 100644 --- a/packages/opencode/src/server/routes/control/index.ts +++ b/packages/opencode/src/server/routes/control/index.ts @@ -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()) } diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index 235f5682e2..7f368cd31c 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -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", diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index f7ecc8255b..9c86494987 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -12,11 +12,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 +49,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 +85,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 +123,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 +150,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 +196,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 @@ -249,11 +237,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 +261,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 +286,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 +315,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 +399,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() + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts index a82e5687d8..bbef679a85 100644 --- a/packages/opencode/src/server/routes/instance/file.ts +++ b/packages/opencode/src/server/routes/instance/file.ts @@ -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() + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index c0339fded7..5cc51d27ab 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -15,7 +15,6 @@ import { Command } from "@/command" import { QuestionRoutes } from "./question" import { PermissionRoutes } from "./permission" import { Flag } from "@/flag/flag" -import { WorkspaceID } from "@/control-plane/schema" import { ExperimentalHttpApiServer } from "./httpapi/server" import { ProjectRoutes } from "./project" import { SessionRoutes } from "./session" @@ -27,11 +26,11 @@ 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, workspaceID?: WorkspaceID): Hono => { - const app = new Hono().use(InstanceMiddleware(workspaceID)) +export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { + const app = new Hono() if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) { const handler = ExperimentalHttpApiServer.webHandler().handler @@ -142,19 +141,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac }, }, }), - 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", @@ -179,16 +173,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac 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", @@ -207,10 +196,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac }, }, }), - 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", @@ -229,10 +219,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac }, }, }), - 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", @@ -251,15 +242,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac }, }, }), - 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", @@ -278,10 +265,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac }, }, }), - 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", @@ -300,8 +288,10 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, workspaceID?: Workspac }, }, }), - 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() + }), ) } diff --git a/packages/opencode/src/server/routes/instance/mcp.ts b/packages/opencode/src/server/routes/instance/mcp.ts index 197185bde0..ce4722933b 100644 --- a/packages/opencode/src/server/routes/instance/mcp.ts +++ b/packages/opencode/src/server/routes/instance/mcp.ts @@ -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 + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/permission.ts b/packages/opencode/src/server/routes/instance/permission.ts index c3f9c82011..c18f4734b4 100644 --- a/packages/opencode/src/server/routes/instance/permission.ts +++ b/packages/opencode/src/server/routes/instance/permission.ts @@ -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() + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/project.ts b/packages/opencode/src/server/routes/instance/project.ts index 060542c4b4..5acef6d788 100644 --- a/packages/opencode/src/server/routes/instance/project.ts +++ b/packages/opencode/src/server/routes/instance/project.ts @@ -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 }) + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/provider.ts b/packages/opencode/src/server/routes/instance/provider.ts index 57aa895e3d..617980e39c 100644 --- a/packages/opencode/src/server/routes/instance/provider.ts +++ b/packages/opencode/src/server/routes/instance/provider.ts @@ -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 = {} - 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 = {} + 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 + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/pty.ts b/packages/opencode/src/server/routes/instance/pty.ts index b3f71c235c..a25b66e9ff 100644 --- a/packages/opencode/src/server/routes/instance/pty.ts +++ b/packages/opencode/src/server/routes/instance/pty.ts @@ -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) diff --git a/packages/opencode/src/server/routes/instance/question.ts b/packages/opencode/src/server/routes/instance/question.ts index 9b8f461e39..51ecb48ccd 100644 --- a/packages/opencode/src/server/routes/instance/question.ts +++ b/packages/opencode/src/server/routes/instance/question.ts @@ -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 + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index ae6185abb8..bf713935b0 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -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 + }), ), ) diff --git a/packages/opencode/src/server/routes/instance/trace.ts b/packages/opencode/src/server/routes/instance/trace.ts index 3e1f72d8b2..4c7119ef3a 100644 --- a/packages/opencode/src/server/routes/instance/trace.ts +++ b/packages/opencode/src/server/routes/instance/trace.ts @@ -4,18 +4,44 @@ import { AppRuntime } from "@/effect/app-runtime" type AppEnv = Parameters[0] extends Effect.Effect ? 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.` 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 + } +} + +// 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 { + const attributes: Record = { + "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(name: string, c: Context, effect: Effect.Effect) { - 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( diff --git a/packages/opencode/src/server/routes/instance/tui.ts b/packages/opencode/src/server/routes/instance/tui.ts index 2f856c3488..d6add67b97 100644 --- a/packages/opencode/src/server/routes/instance/tui.ts +++ b/packages/opencode/src/server/routes/instance/tui.ts @@ -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) }, diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f608c2b732..8b1f1aee10 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -14,6 +14,8 @@ import { ControlPlaneRoutes } from "./routes/control" import { UIRoutes } from "./routes/ui" import { GlobalRoutes } from "./routes/global" import { WorkspaceRouterMiddleware } from "./workspace" +import { InstanceMiddleware } from "./routes/instance/middleware" +import { WorkspaceRoutes } from "./routes/control/workspace" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -45,14 +47,9 @@ function create(opts: { cors?: string[] }) { if (Flag.OPENCODE_WORKSPACE_ID) { return { app: app + .use(InstanceMiddleware(Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined)) .use(FenceMiddleware) - .route( - "/", - InstanceRoutes( - runtime.upgradeWebSocket, - Flag.OPENCODE_WORKSPACE_ID ? WorkspaceID.make(Flag.OPENCODE_WORKSPACE_ID) : undefined, - ), - ), + .route("/", InstanceRoutes(runtime.upgradeWebSocket)), runtime, } } @@ -60,7 +57,13 @@ function create(opts: { cors?: string[] }) { return { app: app .route("/", ControlPlaneRoutes()) - .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)) + .route( + "/", + new Hono() + .use(InstanceMiddleware()) + .route("/experimental/workspace", WorkspaceRoutes()) + .use(WorkspaceRouterMiddleware(runtime.upgradeWebSocket)), + ) .route("/", InstanceRoutes(runtime.upgradeWebSocket)) .route("/", UIRoutes()), runtime, diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts index c141d10956..d30a117d6a 100644 --- a/packages/opencode/src/server/workspace.ts +++ b/packages/opencode/src/server/workspace.ts @@ -10,6 +10,7 @@ import { Instance } from "@/project/instance" import { Session } from "@/session" import { SessionID } from "@/session/schema" import { AppRuntime } from "@/effect/app-runtime" +import { Effect } from "effect" import { Log } from "@/util" import { ServerProxy } from "./proxy" @@ -42,7 +43,9 @@ async function getSessionWorkspace(url: URL) { const id = getSessionID(url) if (!id) return null - const session = await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(id))).catch(() => undefined) + const session = await AppRuntime.runPromise( + Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")), + ).catch(() => undefined) return session?.workspaceID } diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 3484d5da76..2622f4f7f0 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -38,8 +38,9 @@ const ShareSchema = Schema.Struct({ export type Share = typeof ShareSchema.Type type State = { - queue: Map }> + queue: Map> scope: Scope.Closeable + shared: Map } type Data = @@ -118,17 +119,20 @@ export const layer = Layer.effect( function sync(sessionID: SessionID, data: Data[]): Effect.Effect { return Effect.gen(function* () { if (disabled) return + const share = yield* getCached(sessionID) + if (!share) return + const s = yield* InstanceState.get(state) const existing = s.queue.get(sessionID) if (existing) { for (const item of data) { - existing.data.set(key(item), item) + existing.set(key(item), item) } return } const next = new Map(data.map((item) => [key(item), item])) - s.queue.set(sessionID, { data: next }) + s.queue.set(sessionID, next) yield* flush(sessionID).pipe( Effect.delay(1000), Effect.catchCause((cause) => @@ -143,13 +147,14 @@ export const layer = Layer.effect( const state: InstanceState.InstanceState = yield* InstanceState.make( Effect.fn("ShareNext.state")(function* (_ctx) { - const cache: State = { queue: new Map(), scope: yield* Scope.make() } + const cache: State = { queue: new Map(), scope: yield* Scope.make(), shared: new Map() } yield* Effect.addFinalizer(() => Scope.close(cache.scope, Exit.void).pipe( Effect.andThen( Effect.sync(() => { cache.queue.clear() + cache.shared.clear() }), ), ), @@ -227,6 +232,18 @@ export const layer = Layer.effect( return { id: row.id, secret: row.secret, url: row.url } satisfies Share }) + const getCached = Effect.fnUntraced(function* (sessionID: SessionID) { + const s = yield* InstanceState.get(state) + if (s.shared.has(sessionID)) { + const cached = s.shared.get(sessionID) + return cached === null ? undefined : cached + } + + const share = yield* get(sessionID) + s.shared.set(sessionID, share ?? null) + return share + }) + const flush = Effect.fn("ShareNext.flush")(function* (sessionID: SessionID) { if (disabled) return const s = yield* InstanceState.get(state) @@ -235,13 +252,13 @@ export const layer = Layer.effect( s.queue.delete(sessionID) - const share = yield* get(sessionID) + const share = yield* getCached(sessionID) if (!share) return const req = yield* request() const res = yield* HttpClientRequest.post(`${req.baseUrl}${req.api.sync(share.id)}`).pipe( HttpClientRequest.setHeaders(req.headers), - HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.data.values()) }), + HttpClientRequest.bodyJson({ secret: share.secret, data: Array.from(queued.values()) }), Effect.flatMap((r) => http.execute(r)), ) @@ -307,6 +324,7 @@ export const layer = Layer.effect( .run(), ) const s = yield* InstanceState.get(state) + s.shared.set(sessionID, result) yield* full(sessionID).pipe( Effect.catchCause((cause) => Effect.sync(() => { @@ -321,8 +339,13 @@ export const layer = Layer.effect( const remove = Effect.fn("ShareNext.remove")(function* (sessionID: SessionID) { if (disabled) return log.info("removing share", { sessionID }) - const share = yield* get(sessionID) - if (!share) return + const s = yield* InstanceState.get(state) + const share = yield* getCached(sessionID) + if (!share) { + s.shared.delete(sessionID) + s.queue.delete(sessionID) + return + } const req = yield* request() yield* HttpClientRequest.delete(`${req.baseUrl}${req.api.remove(share.id)}`).pipe( @@ -332,6 +355,8 @@ export const layer = Layer.effect( ) yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run()) + s.shared.delete(sessionID) + s.queue.delete(sessionID) }) return Service.of({ init, url, request, create, remove }) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 6e99fd4688..82c661e402 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -1,4 +1,4 @@ -import { Schema, SchemaAST } from "effect" +import { Effect, Option, Schema, SchemaAST } from "effect" import z from "zod" /** @@ -8,19 +8,97 @@ import z from "zod" */ export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") +// AST nodes are immutable and frequently shared across schemas (e.g. a single +// Schema.Class embedded in multiple parents). Memoizing by node identity +// avoids rebuilding equivalent Zod subtrees and keeps derived children stable +// by reference across callers. +const walkCache = new WeakMap() + +// Shared empty ParseOptions for the rare callers that need one — avoids +// allocating a fresh object per parse inside refinements and transforms. +const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions + export function zod(schema: S): z.ZodType> { return walk(schema.ast) as z.ZodType> } function walk(ast: SchemaAST.AST): z.ZodTypeAny { + const cached = walkCache.get(ast) + if (cached) return cached + const result = walkUncached(ast) + walkCache.set(ast, result) + return result +} + +function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined if (override) return override - const out = body(ast) + // Schema.Class wraps its fields in a Declaration AST plus an encoding that + // constructs the class instance. For the Zod derivation we want the plain + // field shape (the decoded/consumer view), not the class instance — so + // Declarations fall through to body(), not encoded(). User-level + // Schema.decodeTo / Schema.transform attach encoding to non-Declaration + // nodes, where we do apply the transform. + const hasTransform = ast.encoding?.length && ast._tag !== "Declaration" + const base = hasTransform ? encoded(ast) : body(ast) + const out = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base const desc = SchemaAST.resolveDescription(ast) const ref = SchemaAST.resolveIdentifier(ast) - const next = desc ? out.describe(desc) : out - return ref ? next.meta({ ref }) : next + const described = desc ? out.describe(desc) : out + return ref ? described.meta({ ref }) : described +} + +// Walk the encoded side and apply each link's decode to produce the decoded +// shape. A node `Target` produced by `from.decodeTo(Target)` carries +// `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls +// nest the encoding via `Link.to` so walking it recursively threads all +// prior transforms — typical encoding.length is 1. +function encoded(ast: SchemaAST.AST): z.ZodTypeAny { + const encoding = ast.encoding! + return encoding.reduce( + (acc, link) => acc.transform((v) => decode(link.transformation, v)), + walk(encoding[0].to), + ) +} + +// Transformations built via pure `SchemaGetter.transform(fn)` (the common +// decodeTo case) resolve synchronously, so running with no services is safe. +// Effectful / middleware-based transforms will surface as Effect defects. +function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown { + const exit = Effect.runSyncExit( + (transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect< + Option.Option + >, + ) + if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`) + return Option.getOrElse(exit.value, () => value) +} + +// Flatten FilterGroups and any nested variants into a linear list of Filters +// so we can run all of them inside a single Zod .superRefine wrapper instead +// of stacking N wrapper layers (one per check). +function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny { + const filters: SchemaAST.Filter[] = [] + const collect = (c: SchemaAST.Check) => { + if (c._tag === "FilterGroup") c.checks.forEach(collect) + else filters.push(c) + } + checks.forEach(collect) + return out.superRefine((value, ctx) => { + for (const filter of filters) { + const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS) + if (!issue) continue + const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed" + ctx.addIssue({ code: "custom", message }) + } + }) +} + +function issueMessage(issue: any): string | undefined { + if (typeof issue?.annotations?.message === "string") return issue.annotations.message + if (typeof issue?.message === "string") return issue.message + return undefined } function body(ast: SchemaAST.AST): z.ZodTypeAny { @@ -86,21 +164,40 @@ function union(ast: SchemaAST.Union): z.ZodTypeAny { } function object(ast: SchemaAST.Objects): z.ZodTypeAny { + // Pure record: { [k: string]: V } if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) { const sig = ast.indexSignatures[0] if (sig.parameter._tag !== "String") return fail(ast) return z.record(z.string(), walk(sig.type)) } - if (ast.indexSignatures.length > 0) return fail(ast) + // Pure object with known fields and no index signatures. + if (ast.indexSignatures.length === 0) { + return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)]))) + } - return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)]))) + // Struct with a catchall (StructWithRest): known fields + index signature. + // Only supports a single string-keyed index signature; multi-signature or + // symbol/number keys fall through to fail. + if (ast.indexSignatures.length !== 1) return fail(ast) + const sig = ast.indexSignatures[0] + if (sig.parameter._tag !== "String") return fail(ast) + return z + .object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)]))) + .catchall(walk(sig.type)) } function array(ast: SchemaAST.Arrays): z.ZodTypeAny { - if (ast.elements.length > 0) return fail(ast) - if (ast.rest.length !== 1) return fail(ast) - return z.array(walk(ast.rest[0])) + // Pure variadic arrays: { elements: [], rest: [item] } + if (ast.elements.length === 0) { + if (ast.rest.length !== 1) return fail(ast) + return z.array(walk(ast.rest[0])) + } + // Fixed-length tuples: { elements: [a, b, ...], rest: [] } + // Tuples with a variadic tail (...rest) are not yet supported. + if (ast.rest.length > 0) return fail(ast) + const items = ast.elements.map(walk) + return z.tuple(items as [z.ZodTypeAny, ...Array]) } function decl(ast: SchemaAST.Declaration): z.ZodTypeAny { diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts index 140fa47d23..08122428ae 100644 --- a/packages/opencode/src/v2/session-entry.ts +++ b/packages/opencode/src/v2/session-entry.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" import { SessionEvent } from "./session-event" -import { produce } from "immer" +import { castDraft, produce } from "immer" export const ID = SessionEvent.ID export type ID = Schema.Schema.Type @@ -70,7 +70,9 @@ export class ToolStateError extends Schema.Class("Session.Entry. metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), }) {} -export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]) +export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe( + Schema.toTaggedUnion("status"), +) export type ToolState = Schema.Schema.Type export class AssistantTool extends Schema.Class("Session.Entry.Assistant.Tool")({ @@ -96,7 +98,9 @@ export class AssistantReasoning extends Schema.Class("Sessio text: Schema.String, }) {} -export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]) +export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe( + Schema.toTaggedUnion("type"), +) export type AssistantContent = Schema.Schema.Type export class Assistant extends Schema.Class("Session.Entry.Assistant")({ @@ -126,7 +130,7 @@ export class Compaction extends Schema.Class("Session.Entry.Compacti ...Base, }) {} -export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]) +export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type")) export type Entry = Schema.Schema.Type @@ -141,19 +145,29 @@ export function step(old: History, event: SessionEvent.Event): History { return produce(old, (draft) => { const lastAssistant = draft.entries.findLast((x) => x.type === "assistant") const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined + type DraftContent = NonNullable["content"][number] + type DraftTool = Extract - switch (event.type) { - case "prompt": { + const latestTool = (callID?: string) => + pendingAssistant?.content.findLast( + (item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID), + ) + const latestText = () => pendingAssistant?.content.findLast((item) => item.type === "text") + const latestReasoning = () => pendingAssistant?.content.findLast((item) => item.type === "reasoning") + + SessionEvent.Event.match(event, { + prompt: (event) => { + const entry = User.fromEvent(event) if (pendingAssistant) { - // @ts-expect-error - draft.pending.push(User.fromEvent(event)) - break + draft.pending.push(castDraft(entry)) + return } - // @ts-expect-error - draft.entries.push(User.fromEvent(event)) - break - } - case "step.started": { + draft.entries.push(castDraft(entry)) + }, + synthetic: (event) => { + draft.entries.push(new Synthetic({ ...event, time: { created: event.timestamp } })) + }, + "step.started": (event) => { if (pendingAssistant) pendingAssistant.time.completed = event.timestamp draft.entries.push({ id: event.id, @@ -163,27 +177,28 @@ export function step(old: History, event: SessionEvent.Event): History { }, content: [], }) - break - } - case "text.started": { - if (!pendingAssistant) break + }, + "step.ended": (event) => { + if (!pendingAssistant) return + pendingAssistant.time.completed = event.timestamp + pendingAssistant.cost = event.cost + pendingAssistant.tokens = event.tokens + }, + "text.started": () => { + if (!pendingAssistant) return pendingAssistant.content.push({ type: "text", text: "", }) - break - } - case "text.delta": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "text") + }, + "text.delta": (event) => { + if (!pendingAssistant) return + const match = latestText() if (match) match.text += event.delta - break - } - case "text.ended": { - break - } - case "tool.input.started": { - if (!pendingAssistant) break + }, + "text.ended": () => {}, + "tool.input.started": (event) => { + if (!pendingAssistant) return pendingAssistant.content.push({ type: "tool", callID: event.callID, @@ -196,21 +211,17 @@ export function step(old: History, event: SessionEvent.Event): History { input: "", }, }) - break - } - case "tool.input.delta": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "tool") + }, + "tool.input.delta": (event) => { + if (!pendingAssistant) return + const match = latestTool(event.callID) // oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string) if (match) match.state.input += event.delta - break - } - case "tool.input.ended": { - break - } - case "tool.called": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "tool") + }, + "tool.input.ended": () => {}, + "tool.called": (event) => { + if (!pendingAssistant) return + const match = latestTool(event.callID) if (match) { match.time.ran = event.timestamp match.state = { @@ -218,11 +229,10 @@ export function step(old: History, event: SessionEvent.Event): History { input: event.input, } } - break - } - case "tool.success": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "tool") + }, + "tool.success": (event) => { + if (!pendingAssistant) return + const match = latestTool(event.callID) if (match && match.state.status === "running") { match.state = { status: "completed", @@ -230,15 +240,13 @@ export function step(old: History, event: SessionEvent.Event): History { output: event.output ?? "", title: event.title, metadata: event.metadata ?? {}, - // @ts-expect-error - attachments: event.attachments ?? [], + attachments: [...(event.attachments ?? [])], } } - break - } - case "tool.error": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "tool") + }, + "tool.error": (event) => { + if (!pendingAssistant) return + const match = latestTool(event.callID) if (match && match.state.status === "running") { match.state = { status: "error", @@ -247,36 +255,29 @@ export function step(old: History, event: SessionEvent.Event): History { metadata: event.metadata ?? {}, } } - break - } - case "reasoning.started": { - if (!pendingAssistant) break + }, + "reasoning.started": () => { + if (!pendingAssistant) return pendingAssistant.content.push({ type: "reasoning", text: "", }) - break - } - case "reasoning.delta": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "reasoning") + }, + "reasoning.delta": (event) => { + if (!pendingAssistant) return + const match = latestReasoning() if (match) match.text += event.delta - break - } - case "reasoning.ended": { - if (!pendingAssistant) break - const match = pendingAssistant.content.findLast((x) => x.type === "reasoning") + }, + "reasoning.ended": (event) => { + if (!pendingAssistant) return + const match = latestReasoning() if (match) match.text = event.text - break - } - case "step.ended": { - if (!pendingAssistant) break - pendingAssistant.time.completed = event.timestamp - pendingAssistant.cost = event.cost - pendingAssistant.tokens = event.tokens - break - } - } + }, + retried: () => {}, + compacted: (event) => { + draft.entries.push(new Compaction({ ...event, type: "compaction", time: { created: event.timestamp } })) + }, + }) }) } diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts index 8ea239033f..11d4a5db2d 100644 --- a/packages/opencode/src/v2/session-event.ts +++ b/packages/opencode/src/v2/session-event.ts @@ -441,7 +441,7 @@ export namespace SessionEvent { { mode: "oneOf", }, - ) + ).pipe(Schema.toTaggedUnion("type")) export type Event = Schema.Schema.Type export type Type = Event["type"] } diff --git a/packages/opencode/test/config/lsp.test.ts b/packages/opencode/test/config/lsp.test.ts new file mode 100644 index 0000000000..1d24fe124d --- /dev/null +++ b/packages/opencode/test/config/lsp.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { ConfigLSP } from "../../src/config/lsp" + +// The LSP config refinement enforces: any custom (non-builtin) LSP server +// entry must declare an `extensions` array so the client knows which files +// the server should attach to. Builtin server IDs and explicitly disabled +// entries are exempt. +// +// Both validation paths must honor this rule: +// - `Schema.decodeUnknownSync(ConfigLSP.Info)` (Effect layer) +// - `ConfigLSP.Info.zod.parse(...)` (derived Zod) +// +// `typescript` is a builtin server id (see src/lsp/server.ts). +describe("ConfigLSP.Info refinement", () => { + const decodeEffect = Schema.decodeUnknownSync(ConfigLSP.Info) + + describe("accepted inputs", () => { + test("true and false pass (top-level toggle)", () => { + expect(decodeEffect(true)).toBe(true) + expect(decodeEffect(false)).toBe(false) + expect(ConfigLSP.Info.zod.parse(true)).toBe(true) + expect(ConfigLSP.Info.zod.parse(false)).toBe(false) + }) + + test("builtin server with no extensions passes", () => { + const input = { typescript: { command: ["typescript-language-server", "--stdio"] } } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + + test("custom server WITH extensions passes", () => { + const input = { + "my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] }, + } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + + test("disabled custom server passes (no extensions needed)", () => { + const input = { "my-lsp": { disabled: true as const } } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + + test("mix of builtin and custom with extensions passes", () => { + const input = { + typescript: { command: ["typescript-language-server", "--stdio"] }, + "my-lsp": { command: ["my-lsp-bin"], extensions: [".ml"] }, + } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + }) + + describe("rejected inputs", () => { + const expectedMessage = "For custom LSP servers, 'extensions' array is required." + + test("custom server WITHOUT extensions fails via Effect decode", () => { + expect(() => decodeEffect({ "my-lsp": { command: ["my-lsp-bin"] } })).toThrow(expectedMessage) + }) + + test("custom server WITHOUT extensions fails via derived Zod", () => { + const result = ConfigLSP.Info.zod.safeParse({ "my-lsp": { command: ["my-lsp-bin"] } }) + expect(result.success).toBe(false) + expect(result.error!.issues.some((i) => i.message === expectedMessage)).toBe(true) + }) + + test("custom server with empty extensions array fails (extensions must be non-empty-truthy)", () => { + // Boolean(['']) is true, so a non-empty array of strings is fine. + // Boolean([]) is also true in JS, so empty arrays are accepted by the + // refinement. This test documents current behavior. + const input = { "my-lsp": { command: ["my-lsp-bin"], extensions: [] } } + expect(decodeEffect(input)).toEqual(input) + expect(ConfigLSP.Info.zod.parse(input)).toEqual(input) + }) + + test("custom server without extensions mixed with a valid builtin still fails", () => { + const input = { + typescript: { command: ["typescript-language-server", "--stdio"] }, + "my-lsp": { command: ["my-lsp-bin"] }, + } + expect(() => decodeEffect(input)).toThrow(expectedMessage) + expect(ConfigLSP.Info.zod.safeParse(input).success).toBe(false) + }) + }) +}) diff --git a/packages/opencode/test/server/trace-attributes.test.ts b/packages/opencode/test/server/trace-attributes.test.ts new file mode 100644 index 0000000000..c6e8005a20 --- /dev/null +++ b/packages/opencode/test/server/trace-attributes.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test" +import { paramToAttributeKey, requestAttributes } from "../../src/server/routes/instance/trace" + +function fakeContext(method: string, url: string, params: Record) { + return { + req: { + method, + url, + param: () => params, + }, + } +} + +describe("paramToAttributeKey", () => { + test("converts fooID to foo.id", () => { + expect(paramToAttributeKey("sessionID")).toBe("session.id") + expect(paramToAttributeKey("messageID")).toBe("message.id") + expect(paramToAttributeKey("partID")).toBe("part.id") + expect(paramToAttributeKey("projectID")).toBe("project.id") + expect(paramToAttributeKey("providerID")).toBe("provider.id") + expect(paramToAttributeKey("ptyID")).toBe("pty.id") + expect(paramToAttributeKey("permissionID")).toBe("permission.id") + expect(paramToAttributeKey("requestID")).toBe("request.id") + expect(paramToAttributeKey("workspaceID")).toBe("workspace.id") + }) + + test("namespaces non-ID params under opencode.", () => { + expect(paramToAttributeKey("name")).toBe("opencode.name") + expect(paramToAttributeKey("slug")).toBe("opencode.slug") + }) +}) + +describe("requestAttributes", () => { + test("includes http method and path", () => { + const attrs = requestAttributes(fakeContext("GET", "http://localhost/session", {})) + expect(attrs["http.method"]).toBe("GET") + expect(attrs["http.path"]).toBe("/session") + }) + + test("strips query string from path", () => { + const attrs = requestAttributes(fakeContext("GET", "http://localhost/file/search?query=foo&limit=10", {})) + expect(attrs["http.path"]).toBe("/file/search") + }) + + test("emits OTel-style .id for ID-shaped route params", () => { + const attrs = requestAttributes( + fakeContext("GET", "http://localhost/session/ses_abc/message/msg_def/part/prt_ghi", { + sessionID: "ses_abc", + messageID: "msg_def", + partID: "prt_ghi", + }), + ) + expect(attrs["session.id"]).toBe("ses_abc") + expect(attrs["message.id"]).toBe("msg_def") + expect(attrs["part.id"]).toBe("prt_ghi") + // No camelCase leftovers: + expect(attrs["opencode.sessionID"]).toBeUndefined() + expect(attrs["opencode.messageID"]).toBeUndefined() + expect(attrs["opencode.partID"]).toBeUndefined() + }) + + test("produces no param attributes when no params are matched", () => { + const attrs = requestAttributes(fakeContext("POST", "http://localhost/config", {})) + expect(Object.keys(attrs).filter((k) => k !== "http.method" && k !== "http.path")).toEqual([]) + }) + + test("namespaces non-ID params under opencode. (e.g. mcp :name)", () => { + const attrs = requestAttributes( + fakeContext("POST", "http://localhost/mcp/exa/connect", { + name: "exa", + }), + ) + expect(attrs["opencode.name"]).toBe("exa") + expect(attrs["name"]).toBeUndefined() + }) +}) diff --git a/packages/opencode/test/session/session-entry.test.ts b/packages/opencode/test/session/session-entry.test.ts index 7eba3900d7..dea8da20a0 100644 --- a/packages/opencode/test/session/session-entry.test.ts +++ b/packages/opencode/test/session/session-entry.test.ts @@ -591,7 +591,64 @@ describe("session-entry step", () => { ) }) - test.failing("records synthetic events", () => { + test("routes tool events by callID when tool streams interleave", () => { + FastCheck.assert( + FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => { + const next = run( + [ + SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }), + SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }), + SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }), + SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }), + SessionEvent.Tool.Called.create({ + callID: "a", + tool: "bash", + input: a, + provider: { executed: true }, + timestamp: time(5), + }), + SessionEvent.Tool.Called.create({ + callID: "b", + tool: "grep", + input: b, + provider: { executed: true }, + timestamp: time(6), + }), + SessionEvent.Tool.Success.create({ + callID: "a", + title: titleA, + output: "done-a", + provider: { executed: true }, + timestamp: time(7), + }), + SessionEvent.Tool.Success.create({ + callID: "b", + title: titleB, + output: "done-b", + provider: { executed: true }, + timestamp: time(8), + }), + ], + active(), + ) + + const first = tool(next, "a") + const second = tool(next, "b") + + expect(first?.state.status).toBe("completed") + expect(second?.state.status).toBe("completed") + if (first?.state.status !== "completed" || second?.state.status !== "completed") return + + expect(first.state.input).toEqual(a) + expect(second.state.input).toEqual(b) + expect(first.state.title).toBe(titleA) + expect(second.state.title).toBe(titleB) + }), + { numRuns: 50 }, + ) + }) + + test("records synthetic events", () => { FastCheck.assert( FastCheck.property(word, (body) => { const next = SessionEntry.step(history(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) })) @@ -604,7 +661,7 @@ describe("session-entry step", () => { ) }) - test.failing("records compaction events", () => { + test("records compaction events", () => { FastCheck.assert( FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => { const next = SessionEntry.step( diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 7f7249514d..3d72984bfc 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Schema } from "effect" +import { Schema, SchemaGetter } from "effect" import z from "zod" import { zod, ZodOverride } from "../../src/util/effect-zod" @@ -61,8 +61,32 @@ describe("util.effect-zod", () => { }) }) - test("throws for unsupported tuple schemas", () => { - expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema") + describe("Tuples", () => { + test("fixed-length tuple parses matching array", () => { + const out = zod(Schema.Tuple([Schema.String, Schema.Number])) + expect(out.parse(["a", 1])).toEqual(["a", 1]) + expect(out.safeParse(["a"]).success).toBe(false) + expect(out.safeParse(["a", "b"]).success).toBe(false) + }) + + test("single-element tuple parses a one-element array", () => { + const out = zod(Schema.Tuple([Schema.Boolean])) + expect(out.parse([true])).toEqual([true]) + expect(out.safeParse([true, false]).success).toBe(false) + }) + + test("tuple inside a union picks the right branch", () => { + const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])])) + expect(out.parse("hello")).toBe("hello") + expect(out.parse(["foo", 42])).toEqual(["foo", 42]) + expect(out.safeParse(["foo"]).success).toBe(false) + }) + + test("plain arrays still work (no element positions)", () => { + const out = zod(Schema.Array(Schema.String)) + expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"]) + expect(out.parse([])).toEqual([]) + }) }) test("string literal unions produce z.enum with enum in JSON Schema", () => { @@ -186,4 +210,272 @@ describe("util.effect-zod", () => { const schema = json(zod(Parent)) as any expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" }) }) + + describe("Schema.check translation", () => { + test("filter returning string triggers refinement with that message", () => { + const isEven = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "expected an even number")) + const schema = zod(Schema.Number.check(isEven)) + + expect(schema.parse(4)).toBe(4) + const result = schema.safeParse(3) + expect(result.success).toBe(false) + expect(result.error!.issues[0].message).toBe("expected an even number") + }) + + test("filter returning false triggers refinement with fallback message", () => { + const nonEmpty = Schema.makeFilter((s: string) => s.length > 0) + const schema = zod(Schema.String.check(nonEmpty)) + + expect(schema.parse("hi")).toBe("hi") + const result = schema.safeParse("") + expect(result.success).toBe(false) + expect(result.error!.issues[0].message).toMatch(/./) + }) + + test("filter returning undefined passes validation", () => { + const alwaysOk = Schema.makeFilter(() => undefined) + const schema = zod(Schema.Number.check(alwaysOk)) + + expect(schema.parse(42)).toBe(42) + }) + + test("annotations.message on the filter is used when filter returns false", () => { + const positive = Schema.makeFilter((n: number) => n > 0, { message: "must be positive" }) + const schema = zod(Schema.Number.check(positive)) + + const result = schema.safeParse(-1) + expect(result.success).toBe(false) + expect(result.error!.issues[0].message).toBe("must be positive") + }) + + test("cross-field check on a record flags missing key", () => { + const hasKey = Schema.makeFilter((data: Record) => + "required" in data ? undefined : "missing 'required' key", + ) + const schema = zod(Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey)) + + expect(schema.parse({ required: { enabled: true } })).toEqual({ + required: { enabled: true }, + }) + + const result = schema.safeParse({ other: { enabled: true } }) + expect(result.success).toBe(false) + expect(result.error!.issues[0].message).toBe("missing 'required' key") + }) + }) + + describe("StructWithRest / catchall", () => { + test("struct with a string-keyed record rest parses known AND extra keys", () => { + const schema = zod( + Schema.StructWithRest( + Schema.Struct({ + apiKey: Schema.optional(Schema.String), + baseURL: Schema.optional(Schema.String), + }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ) + + // Known fields come through as declared + expect(schema.parse({ apiKey: "sk-x" })).toEqual({ apiKey: "sk-x" }) + + // Extra keys are preserved (catchall) + expect( + schema.parse({ + apiKey: "sk-x", + baseURL: "https://api.example.com", + customField: "anything", + nested: { foo: 1 }, + }), + ).toEqual({ + apiKey: "sk-x", + baseURL: "https://api.example.com", + customField: "anything", + nested: { foo: 1 }, + }) + }) + + test("catchall value type constrains the extras", () => { + const schema = zod( + Schema.StructWithRest( + Schema.Struct({ + count: Schema.Number, + }), + [Schema.Record(Schema.String, Schema.Number)], + ), + ) + + // Known field + numeric extras + expect(schema.parse({ count: 10, a: 1, b: 2 })).toEqual({ count: 10, a: 1, b: 2 }) + + // Non-numeric extra is rejected + expect(schema.safeParse({ count: 10, bad: "not a number" }).success).toBe(false) + }) + + test("JSON schema output marks additionalProperties appropriately", () => { + const schema = zod( + Schema.StructWithRest( + Schema.Struct({ + id: Schema.String, + }), + [Schema.Record(Schema.String, Schema.Unknown)], + ), + ) + const shape = json(schema) as { additionalProperties?: unknown } + // Presence of `additionalProperties` (truthy or a schema) signals catchall. + expect(shape.additionalProperties).not.toBe(false) + expect(shape.additionalProperties).toBeDefined() + }) + + test("plain struct without rest still emits additionalProperties unchanged (regression)", () => { + const schema = zod(Schema.Struct({ id: Schema.String })) + expect(schema.parse({ id: "x" })).toEqual({ id: "x" }) + }) + }) + + describe("transforms (Schema.decodeTo)", () => { + test("Number -> pseudo-Duration (seconds) applies the decode function", () => { + // Models the account/account.ts DurationFromSeconds pattern. + const SecondsToMs = Schema.Number.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((n: number) => n * 1000), + encode: SchemaGetter.transform((ms: number) => ms / 1000), + }), + ) + + const schema = zod(SecondsToMs) + expect(schema.parse(3)).toBe(3000) + expect(schema.parse(0)).toBe(0) + }) + + test("String -> Number via parseInt decode", () => { + const ParsedInt = Schema.String.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)), + encode: SchemaGetter.transform((n: number) => String(n)), + }), + ) + + const schema = zod(ParsedInt) + expect(schema.parse("42")).toBe(42) + expect(schema.parse("0")).toBe(0) + }) + + test("transform inside a struct field applies per-field", () => { + const Field = Schema.Number.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((n: number) => n + 1), + encode: SchemaGetter.transform((n: number) => n - 1), + }), + ) + + const schema = zod( + Schema.Struct({ + plain: Schema.Number, + bumped: Field, + }), + ) + + expect(schema.parse({ plain: 5, bumped: 10 })).toEqual({ plain: 5, bumped: 11 }) + }) + + test("chained decodeTo composes transforms in order", () => { + // String -> Number (parseInt) -> Number (doubled). + // Exercises the encoded() reduce, not just a single link. + const Chained = Schema.String.pipe( + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)), + encode: SchemaGetter.transform((n: number) => String(n)), + }), + Schema.decodeTo(Schema.Number, { + decode: SchemaGetter.transform((n: number) => n * 2), + encode: SchemaGetter.transform((n: number) => n / 2), + }), + ) + + const schema = zod(Chained) + expect(schema.parse("21")).toBe(42) + expect(schema.parse("0")).toBe(0) + }) + + test("Schema.Class is unaffected by transform walker (returns plain object, not instance)", () => { + // Schema.Class uses Declaration + encoding under the hood to construct + // class instances. The walker must NOT apply that transform, or zod + // parsing would return class instances instead of plain objects. + class Method extends Schema.Class("TxTestMethod")({ + type: Schema.String, + value: Schema.Number, + }) {} + + const schema = zod(Method) + const parsed = schema.parse({ type: "oauth", value: 1 }) + expect(parsed).toEqual({ type: "oauth", value: 1 }) + // Guardrail: ensure we didn't get back a Method instance. + expect(parsed).not.toBeInstanceOf(Method) + }) + }) + + describe("optimizations", () => { + test("walk() memoizes by AST identity — same AST node returns same Zod", () => { + const shared = Schema.Struct({ id: Schema.String, name: Schema.String }) + const left = zod(shared) + const right = zod(shared) + expect(left).toBe(right) + }) + + test("nested reuse of the same AST reuses the cached Zod child", () => { + // Two different parents embed the same inner schema. The inner zod + // child should be identical by reference inside both parents. + class Inner extends Schema.Class("MemoTestInner")({ + value: Schema.String, + }) {} + + class OuterA extends Schema.Class("MemoTestOuterA")({ + inner: Inner, + }) {} + + class OuterB extends Schema.Class("MemoTestOuterB")({ + inner: Inner, + }) {} + + const shapeA = (zod(OuterA) as any).shape ?? (zod(OuterA) as any)._def?.shape?.() + const shapeB = (zod(OuterB) as any).shape ?? (zod(OuterB) as any)._def?.shape?.() + expect(shapeA.inner).toBe(shapeB.inner) + }) + + test("multiple checks run in a single refinement layer (all fire on one value)", () => { + // Three checks attached to the same schema. All three must run and + // report — asserting that no check silently got dropped when we + // flattened into one superRefine. + const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive")) + const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even")) + const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big")) + + const schema = zod(Schema.Number.check(positive).check(even).check(under100)) + + const neg = schema.safeParse(-3) + expect(neg.success).toBe(false) + expect(neg.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"])) + + const big = schema.safeParse(101) + expect(big.success).toBe(false) + expect(big.error!.issues.map((i) => i.message)).toContain("too big") + + // Passing value satisfies all three + expect(schema.parse(42)).toBe(42) + }) + + test("FilterGroup flattens into the single refinement layer alongside its siblings", () => { + const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive")) + const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even")) + const group = Schema.makeFilterGroup([positive, even]) + const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big")) + + const schema = zod(Schema.Number.check(group).check(under100)) + + const bad = schema.safeParse(-3) + expect(bad.success).toBe(false) + expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"])) + }) + }) }) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a1aa6470dc..15cd2db6e2 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.4.9", + "version": "1.4.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 27d9188151..91d6647449 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.4.9", + "version": "1.4.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5698cba54f..72a383a608 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1807,6 +1807,12 @@ export type Provider = { } } +export type ConsoleState = { + consoleManagedProviders: Array + activeOrgName?: string + switchableOrgCount: number +} + export type ToolIds = Array export type ToolListItem = { @@ -2933,11 +2939,7 @@ export type ExperimentalConsoleGetResponses = { /** * Active Console provider metadata */ - 200: { - consoleManagedProviders: Array - activeOrgName?: string - switchableOrgCount: number - } + 200: ConsoleState } export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses] diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 3b811f2fa9..5a93c4db2a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1607,24 +1607,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "consoleManagedProviders": { - "type": "array", - "items": { - "type": "string" - } - }, - "activeOrgName": { - "type": "string" - }, - "switchableOrgCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - "required": ["consoleManagedProviders", "switchableOrgCount"] + "$ref": "#/components/schemas/ConsoleState" } } } @@ -11197,13 +11180,11 @@ "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "anyOf": [ { - "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 }, { - "description": "Disable timeout for this provider entirely.", "type": "boolean", "const": false } @@ -11264,8 +11245,7 @@ "enum": ["reasoning_content", "reasoning_details"] } }, - "required": ["field"], - "additionalProperties": false + "required": ["field"] } ] }, @@ -11394,8 +11374,7 @@ } } } - }, - "additionalProperties": false + } }, "McpLocalConfig": { "type": "object", @@ -11428,13 +11407,10 @@ }, "timeout": { "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "type": "number" } }, - "required": ["type", "command"], - "additionalProperties": false + "required": ["type", "command"] }, "McpOAuthConfig": { "type": "object", @@ -11455,8 +11431,7 @@ "description": "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", "type": "string" } - }, - "additionalProperties": false + } }, "McpRemoteConfig": { "type": "object", @@ -11498,13 +11473,10 @@ }, "timeout": { "description": "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "type": "number" } }, - "required": ["type", "url"], - "additionalProperties": false + "required": ["type", "url"] }, "LayoutConfig": { "description": "@deprecated Always uses stretch layout.", @@ -12366,6 +12338,24 @@ }, "required": ["id", "name", "source", "env", "options", "models"] }, + "ConsoleState": { + "type": "object", + "properties": { + "consoleManagedProviders": { + "type": "array", + "items": { + "type": "string" + } + }, + "activeOrgName": { + "type": "string" + }, + "switchableOrgCount": { + "type": "number" + } + }, + "required": ["consoleManagedProviders", "switchableOrgCount"] + }, "ToolIDs": { "type": "array", "items": { diff --git a/packages/shared/package.json b/packages/shared/package.json index 383b26d6ed..a8cd62886b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.4.9", + "version": "1.4.11", "name": "@opencode-ai/shared", "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 75974ed39e..8ca990ba58 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.4.9", + "version": "1.4.11", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 525cf20935..98cb928b7b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.4.9", + "version": "1.4.11", "type": "module", "license": "MIT", "exports": { diff --git a/packages/web/package.json b/packages/web/package.json index d3a2a25e4c..194f44ec03 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.4.9", + "version": "1.4.11", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index a3ed76c883..f52135c206 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.4.9", + "version": "1.4.11", "publisher": "sst-dev", "repository": { "type": "git",