mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-20 12:51:13 +08:00
Merge branch 'dev' into tui-favorite-sort-on-query
This commit is contained in:
32
bun.lock
32
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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.11",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.11",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.11",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.11",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.4.9"
|
||||
version = "1.4.11"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.9/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ Many route boundaries still use Zod-first validators. That does not block all ex
|
||||
|
||||
### Mixed handler styles
|
||||
|
||||
Many current `server/instance/*.ts` handlers still call async facades directly. Migrating those to composed `Effect.gen(...)` handlers is the low-risk step to do first.
|
||||
Many current `server/routes/instance/*.ts` handlers still mix composed Effect code with smaller Promise- or ALS-backed seams. Migrating those to consistent `Effect.gen(...)` handlers is the low-risk step to do first.
|
||||
|
||||
### Non-JSON routes
|
||||
|
||||
@@ -90,7 +90,7 @@ The current server composition, middleware, and docs flow are Hono-centered toda
|
||||
|
||||
### 1. Finish the prerequisites first
|
||||
|
||||
- continue route-handler effectification in `server/instance/*.ts`
|
||||
- continue route-handler effectification in `server/routes/instance/*.ts`
|
||||
- continue schema migration toward Effect Schema-first DTOs and errors
|
||||
- keep removing service facades
|
||||
|
||||
@@ -98,9 +98,9 @@ The current server composition, middleware, and docs flow are Hono-centered toda
|
||||
|
||||
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
|
||||
|
||||
- `server/instance/question.ts`
|
||||
- `server/instance/provider.ts`
|
||||
- `server/instance/permission.ts`
|
||||
- `server/routes/instance/question.ts`
|
||||
- `server/routes/instance/provider.ts`
|
||||
- `server/routes/instance/permission.ts`
|
||||
|
||||
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
|
||||
|
||||
@@ -155,9 +155,9 @@ This gives:
|
||||
|
||||
As each route group is ported to `HttpApi`:
|
||||
|
||||
1. change its `root` path from `/experimental/httpapi/<group>` to `/<group>`
|
||||
2. add `.all("/<group>", handler)` / `.all("/<group>/*", handler)` to the flag block in `instance/index.ts`
|
||||
3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
|
||||
1. add `.get(...)` / `.post(...)` bridge entries to the flag block in `server/routes/instance/index.ts`
|
||||
2. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
|
||||
3. keep the legacy Hono route registered behind it for OpenAPI / SDK generation until the spec pipeline changes
|
||||
4. verify SDK output is unchanged
|
||||
|
||||
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
|
||||
@@ -189,10 +189,46 @@ Ordering for a route-group migration:
|
||||
|
||||
SDK shape rule:
|
||||
|
||||
- every schema migration must preserve the generated SDK output byte-for-byte
|
||||
- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema
|
||||
- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
|
||||
- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
|
||||
- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
|
||||
- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
|
||||
|
||||
### Schema.Class vs Schema.Struct
|
||||
|
||||
The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
|
||||
|
||||
**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
|
||||
|
||||
```ts
|
||||
export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
```
|
||||
|
||||
**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
|
||||
|
||||
```ts
|
||||
export const Info = Schema.Struct({ ... }).pipe(
|
||||
withStatics((s) => ({ zod: zod(s) })),
|
||||
)
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
```
|
||||
|
||||
When to use each:
|
||||
|
||||
- Use **Schema.Class** when:
|
||||
- the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
|
||||
- the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
|
||||
- Use **Schema.Struct** when:
|
||||
- the type is only used as a nested field inside another named schema
|
||||
- the original Zod was anonymous and promoting it would bloat SDK types with no import value
|
||||
|
||||
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
|
||||
|
||||
Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
|
||||
|
||||
```ts
|
||||
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
|
||||
```
|
||||
|
||||
Temporary exception:
|
||||
|
||||
@@ -231,7 +267,7 @@ Use the same sequence for each route group.
|
||||
3. Apply the schema migration ordering above so those types are Effect Schema-first.
|
||||
4. Define the `HttpApi` contract separately from the handlers.
|
||||
5. Implement handlers by yielding the existing service from context.
|
||||
6. Mount the new surface in parallel under an experimental prefix.
|
||||
6. Mount the new surface in parallel behind the `OPENCODE_EXPERIMENTAL_HTTPAPI` bridge.
|
||||
7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
|
||||
8. Add one end-to-end test and one OpenAPI-focused test.
|
||||
9. Compare ergonomics before migrating the next endpoint.
|
||||
@@ -250,20 +286,20 @@ Placement rule:
|
||||
- keep `HttpApi` code under `src/server`, not `src/effect`
|
||||
- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
|
||||
- place each `HttpApi` slice next to the HTTP boundary it serves
|
||||
- for instance-scoped routes, prefer `src/server/instance/httpapi/*`
|
||||
- if control-plane routes ever migrate, prefer `src/server/control/httpapi/*`
|
||||
- for instance-scoped routes, prefer `src/server/routes/instance/httpapi/*`
|
||||
- if control-plane routes ever migrate, prefer `src/server/routes/control/httpapi/*`
|
||||
|
||||
Suggested file layout for a repeatable spike:
|
||||
|
||||
- `src/server/instance/httpapi/question.ts` — contract and handler layer for one route group
|
||||
- `src/server/instance/httpapi/server.ts` — standalone Effect HTTP server that composes all groups
|
||||
- `test/server/question-httpapi.test.ts` — end-to-end test against the real service
|
||||
- `src/server/routes/instance/httpapi/question.ts` — contract and handler layer for one route group
|
||||
- `src/server/routes/instance/httpapi/server.ts` — bridged Effect HTTP layer that composes all groups
|
||||
- route or OpenAPI verification should live alongside the existing server tests; there is no dedicated `question-httpapi` test file on this branch
|
||||
|
||||
Suggested responsibilities:
|
||||
|
||||
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
|
||||
- `server.ts` composes all route groups into one `HttpRouter.serve` layer with shared middleware (auth, instance lookup)
|
||||
- tests use `ExperimentalHttpApiServer.layerTest` to run against a real in-process HTTP server
|
||||
- `server.ts` composes all route groups into one `HttpRouter.toWebHandler(...)` bridge with shared middleware (auth, instance lookup)
|
||||
- tests should verify the bridged routes through the normal server surface
|
||||
|
||||
## Example migration shape
|
||||
|
||||
@@ -283,33 +319,33 @@ Each route-group spike should follow the same shape.
|
||||
- keep handler bodies thin
|
||||
- keep transport mapping at the HTTP boundary only
|
||||
|
||||
### 3. Standalone server
|
||||
### 3. Bridged server
|
||||
|
||||
- the Effect HTTP server is self-contained in `httpapi/server.ts`
|
||||
- it is **not** mounted into the Hono app — no bridge, no `toWebHandler`
|
||||
- route paths use the `/experimental/httpapi` prefix so they match the eventual cutover
|
||||
- each route group exposes its own OpenAPI doc endpoint
|
||||
- the Effect HTTP layer is composed in `httpapi/server.ts`
|
||||
- it is mounted into the Hono app via `HttpRouter.toWebHandler(...)`
|
||||
- routes keep their normal instance paths and are gated by the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
|
||||
- the legacy Hono handlers stay registered after the bridge so current OpenAPI / SDK generation still works
|
||||
|
||||
### 4. Verification
|
||||
|
||||
- seed real state through the existing service
|
||||
- call the experimental endpoints
|
||||
- call the bridged endpoints with the flag enabled
|
||||
- assert that the service behavior is unchanged
|
||||
- assert that the generated OpenAPI contains the migrated paths and schemas
|
||||
|
||||
## Boundary composition
|
||||
|
||||
The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server.
|
||||
The Effect `HttpApi` layer owns its own auth and instance middleware, but it is currently mounted inside the existing Hono server.
|
||||
|
||||
### Auth
|
||||
|
||||
- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
|
||||
- the bridged `HttpApi` layer implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
|
||||
- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
|
||||
- this is independent of the Hono `AuthMiddleware` — when the Effect server eventually replaces Hono, this becomes the only auth layer
|
||||
- this is independent of the Hono auth layer; the current bridge keeps the responsibility local to the `HttpApi` slice
|
||||
|
||||
### Instance and workspace lookup
|
||||
|
||||
- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
|
||||
- the bridged `HttpApi` layer resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
|
||||
- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
|
||||
- `HttpApi` handlers yield services from context and assume the correct instance has already been provided
|
||||
|
||||
@@ -324,7 +360,7 @@ The standalone Effect server owns its own middleware stack. It does not share mi
|
||||
|
||||
The first slice is successful if:
|
||||
|
||||
- the standalone Effect server starts and serves the endpoints independently of the Hono server
|
||||
- the bridged endpoints serve correctly through the existing Hono host when the flag is enabled
|
||||
- the handlers reuse the existing Effect service
|
||||
- request decoding and response shapes are schema-defined from canonical Effect schemas
|
||||
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
|
||||
@@ -365,17 +401,16 @@ Current instance route inventory:
|
||||
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
|
||||
- `permission` - `bridged`
|
||||
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
|
||||
- `provider` - `bridged` (partial)
|
||||
bridged endpoint: `GET /provider/auth`
|
||||
not yet ported: `GET /provider`, OAuth mutations
|
||||
- `config` - `next`
|
||||
best next endpoint: `GET /config/providers`
|
||||
- `provider` - `bridged`
|
||||
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
|
||||
- `config` - `bridged` (partial)
|
||||
bridged endpoint: `GET /config/providers`
|
||||
later endpoint: `GET /config`
|
||||
defer `PATCH /config` for now
|
||||
- `project` - `later`
|
||||
best small reads: `GET /project`, `GET /project/current`
|
||||
- `project` - `bridged` (partial)
|
||||
bridged endpoints: `GET /project`, `GET /project/current`
|
||||
defer git-init mutation first
|
||||
- `workspace` - `later`
|
||||
- `workspace` - `next`
|
||||
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
|
||||
defer create/remove mutations first
|
||||
- `file` - `later`
|
||||
@@ -393,12 +428,12 @@ Current instance route inventory:
|
||||
- `tui` - `defer`
|
||||
queue-style UI bridge, weak early `HttpApi` fit
|
||||
|
||||
Recommended near-term sequence after the first spike:
|
||||
Recommended near-term sequence:
|
||||
|
||||
1. `provider` auth read endpoint
|
||||
2. `config` providers read endpoint
|
||||
3. `project` read endpoints
|
||||
4. `workspace` read endpoints
|
||||
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
|
||||
2. `config` full read endpoint (`GET /config`)
|
||||
3. `file` JSON read endpoints
|
||||
4. `mcp` JSON read endpoints
|
||||
|
||||
## Checklist
|
||||
|
||||
@@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike:
|
||||
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
|
||||
- [x] verify OTEL spans and HTTP logs flow to motel
|
||||
- [x] bridge question, permission, and provider auth routes
|
||||
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
|
||||
- [ ] port `config` read endpoints
|
||||
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
|
||||
- [x] port `config` providers read endpoint
|
||||
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
|
||||
- [ ] port `workspace` read endpoints
|
||||
- [ ] port `GET /config` full read endpoint
|
||||
- [ ] port `file` JSON read endpoints
|
||||
- [ ] decide when to remove the flag and make Effect routes the default
|
||||
|
||||
## Rule of thumb
|
||||
|
||||
@@ -157,7 +157,7 @@ Direct legacy usage means any source file that still calls one of:
|
||||
- `Instance.reload(...)`
|
||||
- `Instance.dispose()` / `Instance.disposeAll()`
|
||||
|
||||
Current total: `54` files in `packages/opencode/src`.
|
||||
Current total: `56` files in `packages/opencode/src`.
|
||||
|
||||
### Core bridge and plumbing
|
||||
|
||||
@@ -177,13 +177,13 @@ Migration rule:
|
||||
|
||||
These are the current request-entry seams that still create or consume instance context through the legacy helper.
|
||||
|
||||
- `src/server/instance/middleware.ts`
|
||||
- `src/server/instance/index.ts`
|
||||
- `src/server/instance/project.ts`
|
||||
- `src/server/instance/workspace.ts`
|
||||
- `src/server/instance/file.ts`
|
||||
- `src/server/instance/experimental.ts`
|
||||
- `src/server/instance/global.ts`
|
||||
- `src/server/routes/instance/middleware.ts`
|
||||
- `src/server/routes/instance/index.ts`
|
||||
- `src/server/routes/instance/project.ts`
|
||||
- `src/server/routes/control/workspace.ts`
|
||||
- `src/server/routes/instance/file.ts`
|
||||
- `src/server/routes/instance/experimental.ts`
|
||||
- `src/server/routes/global.ts`
|
||||
|
||||
Migration rule:
|
||||
|
||||
@@ -239,7 +239,7 @@ Migration rule:
|
||||
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
|
||||
|
||||
- `src/agent/agent.ts`
|
||||
- `src/config/tui-migrate.ts`
|
||||
- `src/cli/cmd/tui/config/tui-migrate.ts`
|
||||
- `src/file/index.ts`
|
||||
- `src/file/watcher.ts`
|
||||
- `src/format/formatter.ts`
|
||||
@@ -250,7 +250,7 @@ These modules are already the best near-term migration targets because they are
|
||||
- `src/project/vcs.ts`
|
||||
- `src/provider/provider.ts`
|
||||
- `src/pty/index.ts`
|
||||
- `src/session/index.ts`
|
||||
- `src/session/session.ts`
|
||||
- `src/session/instruction.ts`
|
||||
- `src/session/llm.ts`
|
||||
- `src/session/system.ts`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -19,53 +19,43 @@ See `instance-context.md` for the phased plan to remove the legacy ALS / promise
|
||||
|
||||
## Service shape
|
||||
|
||||
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
|
||||
Every service follows the same pattern: one module, flat top-level exports, traced Effect methods, and a self-reexport at the bottom when the file is the public module.
|
||||
|
||||
```ts
|
||||
export namespace Foo {
|
||||
export interface Interface {
|
||||
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
// For instance-scoped services:
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Foo.get")(function* (id: FooID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
// ...
|
||||
})
|
||||
|
||||
return Service.of({ get })
|
||||
}),
|
||||
)
|
||||
|
||||
// Optional: wire dependencies
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
||||
|
||||
// Per-service runtime (inside the namespace)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
// Async facade functions
|
||||
export async function get(id: FooID) {
|
||||
return runPromise((svc) => svc.get(id))
|
||||
}
|
||||
export interface Interface {
|
||||
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Foo.get")(function* (id: FooID) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
// ...
|
||||
})
|
||||
|
||||
return Service.of({ get })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
||||
|
||||
export * as Foo from "."
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
|
||||
- `runPromise` goes inside the namespace (not exported unless tests need it)
|
||||
- Facade functions are plain `async function` — no `fn()` wrappers
|
||||
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
|
||||
- No `Layer.fresh` — InstanceState handles per-directory isolation
|
||||
- Keep the service surface in one module; prefer flat top-level exports over `export namespace Foo { ... }`
|
||||
- Use `Effect.fn("Foo.method")` for Effect methods
|
||||
- Use a self-reexport (`export * as Foo from "."` or `"./foo"`) for the public namespace projection
|
||||
- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase
|
||||
- No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
|
||||
|
||||
## Schema → Zod interop
|
||||
|
||||
@@ -266,7 +256,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
|
||||
|
||||
## Destroying the facades
|
||||
|
||||
This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`.
|
||||
This phase is no longer broadly open. There are 5 `makeRuntime(...)` call sites under `src/`, and only a small subset are still ordinary facade-removal targets. The live checklist now lives in `facades.md`.
|
||||
|
||||
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
|
||||
|
||||
@@ -297,11 +287,11 @@ For each service, the migration is roughly:
|
||||
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
||||
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
|
||||
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
||||
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
|
||||
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
||||
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/instance/session.ts` converted; facade removed.
|
||||
- `Account` — migrated 2026-04-11. Callers in `server/routes/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
||||
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
|
||||
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
|
||||
- `Question` — migrated 2026-04-11. Callers in `server/routes/instance/question.ts` and test converted; facade removed.
|
||||
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
|
||||
|
||||
## Route handler effectification
|
||||
|
||||
@@ -39,28 +39,26 @@ This eliminates multiple `runPromise` round-trips and lets handlers compose natu
|
||||
|
||||
## Current route files
|
||||
|
||||
Current instance route files live under `src/server/instance`, not `server/routes`.
|
||||
Current instance route files live under `src/server/routes/instance`.
|
||||
|
||||
The main migration targets are:
|
||||
Files that are already mostly on the intended service-yielding shape:
|
||||
|
||||
- [ ] `server/instance/session.ts` — heaviest; still has many direct facade calls for Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, Agent, Bus
|
||||
- [ ] `server/instance/global.ts` — still has direct facade calls for Config and instance lifecycle actions
|
||||
- [ ] `server/instance/provider.ts` — still has direct facade calls for Config and Provider
|
||||
- [ ] `server/instance/question.ts` — partially converted; still worth tracking here until it consistently uses the composed style
|
||||
- [ ] `server/instance/pty.ts` — still calls Pty facades directly
|
||||
- [ ] `server/instance/experimental.ts` — mixed state; some handlers are already composed, others still use facades
|
||||
- [x] `server/routes/instance/question.ts` — handlers yield `Question.Service`
|
||||
- [x] `server/routes/instance/provider.ts` — handlers yield `Provider.Service`, `ProviderAuth.Service`, and `Config.Service`
|
||||
- [x] `server/routes/instance/permission.ts` — handlers yield `Permission.Service`
|
||||
- [x] `server/routes/instance/mcp.ts` — handlers mostly yield `MCP.Service`
|
||||
- [x] `server/routes/instance/pty.ts` — handlers yield `Pty.Service`
|
||||
|
||||
Additional route files that still participate in the migration:
|
||||
Files still worth tracking here:
|
||||
|
||||
- [ ] `server/instance/index.ts` — Vcs, Agent, Skill, LSP, Format
|
||||
- [ ] `server/instance/file.ts` — Ripgrep, File, LSP
|
||||
- [ ] `server/instance/mcp.ts` — MCP facade-heavy
|
||||
- [ ] `server/instance/permission.ts` — Permission
|
||||
- [ ] `server/instance/workspace.ts` — Workspace
|
||||
- [ ] `server/instance/tui.ts` — Bus and Session
|
||||
- [ ] `server/instance/middleware.ts` — Session and Workspace lookups
|
||||
- [ ] `server/routes/instance/session.ts` — still the heaviest mixed file; many handlers are composed, but the file still mixes patterns and has direct `Bus.publish(...)` / `Session.list(...)` usage
|
||||
- [ ] `server/routes/instance/index.ts` — mostly converted, but still has direct `Instance.dispose()` / `Instance.*` reads for `/instance/dispose` and `/path`
|
||||
- [ ] `server/routes/instance/file.ts` — most handlers yield services, but `/find` still passes `Instance.directory` directly into ripgrep and `/find/symbol` is still stubbed
|
||||
- [ ] `server/routes/instance/experimental.ts` — mixed state; many handlers are composed, but some still rely on `runRequest(...)` or direct `Instance.project` reads
|
||||
- [ ] `server/routes/instance/middleware.ts` — still enters the instance via `Instance.provide(...)`
|
||||
- [ ] `server/routes/global.ts` — still uses `Instance.disposeAll()` and remains partly outside the fully-composed style
|
||||
|
||||
## Notes
|
||||
|
||||
- Some handlers already use `AppRuntime.runPromise(Effect.gen(...))` in isolated places. Keep pushing those files toward one consistent style.
|
||||
- Route conversion is closely tied to facade removal. As services lose `makeRuntime`-backed async exports, route handlers should switch to yielding the service directly.
|
||||
- Route conversion is now less about facade removal and more about removing the remaining direct `Instance.*` reads, `Instance.provide(...)` boundaries, and small Promise-style bridges inside route files.
|
||||
- `jsonRequest(...)` / `runRequest(...)` already provide a good intermediate shape for many handlers. The remaining cleanup is mostly consistency work in the heavier files.
|
||||
|
||||
@@ -40,13 +40,13 @@ Everything still lives in `packages/opencode`.
|
||||
Important current facts:
|
||||
|
||||
- there is no `packages/core` or `packages/cli` workspace yet
|
||||
- `packages/server` now exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet
|
||||
- there is no `packages/server` workspace yet on this branch
|
||||
- the main host server is still Hono-based in `src/server/server.ts`
|
||||
- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
|
||||
- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
|
||||
- there is already one experimental Effect `HttpApi` slice at `src/server/instance/httpapi/question.ts`
|
||||
- that experimental slice is mounted under `/experimental/httpapi/question`
|
||||
- that experimental slice already has an end-to-end test at `test/server/question-httpapi.test.ts`
|
||||
- there are already bridged Effect `HttpApi` slices under `src/server/routes/instance/httpapi/*`
|
||||
- those slices are mounted into the Hono server behind `OPENCODE_EXPERIMENTAL_HTTPAPI`
|
||||
- the bridge currently covers `question`, `permission`, `provider`, partial `config`, and partial `project` routes
|
||||
|
||||
This means the package split should start from an extraction path, not from greenfield package ownership.
|
||||
|
||||
@@ -209,17 +209,19 @@ Current host and route composition:
|
||||
|
||||
- `src/server/server.ts`
|
||||
- `src/server/control/index.ts`
|
||||
- `src/server/instance/index.ts`
|
||||
- `src/server/routes/instance/index.ts`
|
||||
- `src/server/middleware.ts`
|
||||
- `src/server/adapter.bun.ts`
|
||||
- `src/server/adapter.node.ts`
|
||||
|
||||
Current experimental `HttpApi` slice:
|
||||
Current bridged `HttpApi` slices:
|
||||
|
||||
- `src/server/instance/httpapi/question.ts`
|
||||
- `src/server/instance/httpapi/index.ts`
|
||||
- `src/server/instance/experimental.ts`
|
||||
- `test/server/question-httpapi.test.ts`
|
||||
- `src/server/routes/instance/httpapi/question.ts`
|
||||
- `src/server/routes/instance/httpapi/permission.ts`
|
||||
- `src/server/routes/instance/httpapi/provider.ts`
|
||||
- `src/server/routes/instance/httpapi/config.ts`
|
||||
- `src/server/routes/instance/httpapi/project.ts`
|
||||
- `src/server/routes/instance/httpapi/server.ts`
|
||||
|
||||
Current OpenAPI flow:
|
||||
|
||||
@@ -245,7 +247,7 @@ Keep in `packages/opencode` for now:
|
||||
|
||||
- `src/server/server.ts`
|
||||
- `src/server/control/index.ts`
|
||||
- `src/server/instance/*.ts`
|
||||
- `src/server/routes/**/*.ts`
|
||||
- `src/server/middleware.ts`
|
||||
- `src/server/adapter.*.ts`
|
||||
- `src/effect/app-runtime.ts`
|
||||
@@ -305,14 +307,13 @@ Bad early migration targets:
|
||||
|
||||
## First vertical slice
|
||||
|
||||
The first slice for the package split is the existing experimental `question` group.
|
||||
The first slice for the package split is still the existing `question` `HttpApi` group.
|
||||
|
||||
Why `question` first:
|
||||
|
||||
- it already exists as an experimental `HttpApi` slice
|
||||
- it already follows the desired contract and implementation split in one file
|
||||
- it is already mounted through the current Hono host
|
||||
- it already has an end-to-end test
|
||||
- it is JSON-only
|
||||
- it has low blast radius
|
||||
|
||||
@@ -357,7 +358,7 @@ Done means:
|
||||
|
||||
Scope:
|
||||
|
||||
- extract the pure `HttpApi` contract from `src/server/instance/httpapi/question.ts`
|
||||
- extract the pure `HttpApi` contract from `src/server/routes/instance/httpapi/question.ts`
|
||||
- place it in `packages/server/src/definition/question.ts`
|
||||
- aggregate it in `packages/server/src/definition/api.ts`
|
||||
- generate OpenAPI in `packages/server/src/openapi.ts`
|
||||
@@ -399,8 +400,9 @@ Scope:
|
||||
|
||||
- replace local experimental question route wiring in `packages/opencode`
|
||||
- keep the same mount path:
|
||||
- `/experimental/httpapi/question`
|
||||
- `/experimental/httpapi/question/doc`
|
||||
- `/question`
|
||||
- `/question/:requestID/reply`
|
||||
- `/question/:requestID/reject`
|
||||
|
||||
Rules:
|
||||
|
||||
@@ -569,7 +571,7 @@ For package-split PRs, validate the smallest useful thing.
|
||||
Typical validation for the first waves:
|
||||
|
||||
- `bun typecheck` in the touched package directory or directories
|
||||
- the relevant route test, especially `test/server/question-httpapi.test.ts`
|
||||
- the relevant server / route coverage for the migrated slice
|
||||
- merged OpenAPI coverage if the PR touches spec generation
|
||||
|
||||
Do not run tests from repo root.
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
{
|
||||
|
||||
@@ -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 }) {
|
||||
<text fg={theme.text}>
|
||||
<b>{session()!.title}</b>
|
||||
</text>
|
||||
<Show when={InstallationChannel !== "latest"}>
|
||||
<text fg={theme.textMuted}>{props.sessionID}</text>
|
||||
</Show>
|
||||
<Show when={session()!.workspaceID}>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}>●</span>{" "}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
export * as ConfigCommand from "./command"
|
||||
|
||||
import { Log } from "../util"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||
import { Bus } from "@/bus"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import { configEntryNameFromPath } from "./entry-name"
|
||||
import { InvalidError } from "./error"
|
||||
import * as ConfigMarkdown from "./markdown"
|
||||
@@ -12,15 +14,15 @@ import { ConfigModelID } from "./model-id"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
export const Info = z.object({
|
||||
template: z.string(),
|
||||
description: z.string().optional(),
|
||||
agent: z.string().optional(),
|
||||
model: ConfigModelID.optional(),
|
||||
subtask: z.boolean().optional(),
|
||||
})
|
||||
export const Info = Schema.Struct({
|
||||
template: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
agent: Schema.optional(Schema.String),
|
||||
model: Schema.optional(ConfigModelID),
|
||||
subtask: Schema.optional(Schema.Boolean),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export async function load(dir: string) {
|
||||
const result: Record<string, Info> = {}
|
||||
@@ -49,7 +51,7 @@ export async function load(dir: string) {
|
||||
...md.data,
|
||||
template: md.content.trim(),
|
||||
}
|
||||
const parsed = Info.safeParse(config)
|
||||
const parsed = Info.zod.safeParse(config)
|
||||
if (parsed.success) {
|
||||
result[config.name] = parsed.data
|
||||
continue
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
|
||||
export const ConsoleState = z.object({
|
||||
consoleManagedProviders: z.array(z.string()),
|
||||
activeOrgName: z.string().optional(),
|
||||
switchableOrgCount: z.number().int().nonnegative(),
|
||||
})
|
||||
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
|
||||
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
|
||||
activeOrgName: Schema.optional(Schema.String),
|
||||
switchableOrgCount: Schema.Number,
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export type ConsoleState = z.infer<typeof ConsoleState>
|
||||
|
||||
export const emptyConsoleState: ConsoleState = {
|
||||
export const emptyConsoleState: ConsoleState = ConsoleState.make({
|
||||
consoleManagedProviders: [],
|
||||
activeOrgName: undefined,
|
||||
switchableOrgCount: 0,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
export * as ConfigFormatter from "./formatter"
|
||||
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
export const Entry = z.object({
|
||||
disabled: z.boolean().optional(),
|
||||
command: z.array(z.string()).optional(),
|
||||
environment: z.record(z.string(), z.string()).optional(),
|
||||
extensions: z.array(z.string()).optional(),
|
||||
})
|
||||
export const Entry = Schema.Struct({
|
||||
disabled: Schema.optional(Schema.Boolean),
|
||||
command: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)])
|
||||
export type Info = z.infer<typeof Info>
|
||||
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)]).pipe(
|
||||
withStatics((s) => ({ zod: zod(s) })),
|
||||
)
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
@@ -1,37 +1,45 @@
|
||||
export * as ConfigLSP from "./lsp"
|
||||
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import * as LSPServer from "../lsp/server"
|
||||
|
||||
export const Disabled = z.object({
|
||||
disabled: z.literal(true),
|
||||
export const Disabled = Schema.Struct({
|
||||
disabled: Schema.Literal(true),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export const Entry = Schema.Union([
|
||||
Disabled,
|
||||
Schema.Struct({
|
||||
command: Schema.mutable(Schema.Array(Schema.String)),
|
||||
extensions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
disabled: Schema.optional(Schema.Boolean),
|
||||
env: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
initialization: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}),
|
||||
]).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
/**
|
||||
* For custom (non-builtin) LSP server entries, `extensions` is required so the
|
||||
* client knows which files the server should attach to. Builtin server IDs and
|
||||
* explicitly disabled entries are exempt.
|
||||
*/
|
||||
export const requiresExtensionsForCustomServers = Schema.makeFilter<
|
||||
boolean | Record<string, Schema.Schema.Type<typeof Entry>>
|
||||
>((data) => {
|
||||
if (typeof data === "boolean") return undefined
|
||||
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
|
||||
const ok = Object.entries(data).every(([id, config]) => {
|
||||
if ("disabled" in config && config.disabled) return true
|
||||
if (serverIds.has(id)) return true
|
||||
return "extensions" in config && Boolean(config.extensions)
|
||||
})
|
||||
return ok ? undefined : "For custom LSP servers, 'extensions' array is required."
|
||||
})
|
||||
|
||||
export const Entry = z.union([
|
||||
Disabled,
|
||||
z.object({
|
||||
command: z.array(z.string()),
|
||||
extensions: z.array(z.string()).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
initialization: z.record(z.string(), z.any()).optional(),
|
||||
}),
|
||||
])
|
||||
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
|
||||
.check(requiresExtensionsForCustomServers)
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export const Info = z.union([z.boolean(), z.record(z.string(), Entry)]).refine(
|
||||
(data) => {
|
||||
if (typeof data === "boolean") return true
|
||||
const serverIds = new Set(Object.values(LSPServer).map((server) => server.id))
|
||||
|
||||
return Object.entries(data).every(([id, config]) => {
|
||||
if (config.disabled) return true
|
||||
if (serverIds.has(id)) return true
|
||||
return Boolean(config.extensions)
|
||||
})
|
||||
},
|
||||
{
|
||||
error: "For custom LSP servers, 'extensions' array is required.",
|
||||
},
|
||||
)
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
@@ -1,68 +1,62 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
export const Local = z
|
||||
.object({
|
||||
type: z.literal("local").describe("Type of MCP server connection"),
|
||||
command: z.string().array().describe("Command and arguments to run the MCP server"),
|
||||
environment: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe("Environment variables to set when running the MCP server"),
|
||||
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
|
||||
timeout: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
ref: "McpLocalConfig",
|
||||
})
|
||||
export class Local extends Schema.Class<Local>("McpLocalConfig")({
|
||||
type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }),
|
||||
command: Schema.mutable(Schema.Array(Schema.String)).annotate({
|
||||
description: "Command and arguments to run the MCP server",
|
||||
}),
|
||||
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
|
||||
description: "Environment variables to set when running the MCP server",
|
||||
}),
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable or disable the MCP server on startup",
|
||||
}),
|
||||
timeout: Schema.optional(Schema.Number).annotate({
|
||||
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||
}),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export const OAuth = z
|
||||
.object({
|
||||
clientId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted."),
|
||||
clientSecret: z.string().optional().describe("OAuth client secret (if required by the authorization server)"),
|
||||
scope: z.string().optional().describe("OAuth scopes to request during authorization"),
|
||||
redirectUri: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback)."),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
ref: "McpOAuthConfig",
|
||||
})
|
||||
export type OAuth = z.infer<typeof OAuth>
|
||||
export class OAuth extends Schema.Class<OAuth>("McpOAuthConfig")({
|
||||
clientId: Schema.optional(Schema.String).annotate({
|
||||
description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.",
|
||||
}),
|
||||
clientSecret: Schema.optional(Schema.String).annotate({
|
||||
description: "OAuth client secret (if required by the authorization server)",
|
||||
}),
|
||||
scope: Schema.optional(Schema.String).annotate({ description: "OAuth scopes to request during authorization" }),
|
||||
redirectUri: Schema.optional(Schema.String).annotate({
|
||||
description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).",
|
||||
}),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export const Remote = z
|
||||
.object({
|
||||
type: z.literal("remote").describe("Type of MCP server connection"),
|
||||
url: z.string().describe("URL of the remote MCP server"),
|
||||
enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"),
|
||||
headers: z.record(z.string(), z.string()).optional().describe("Headers to send with the request"),
|
||||
oauth: z
|
||||
.union([OAuth, z.literal(false)])
|
||||
.optional()
|
||||
.describe("OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection."),
|
||||
timeout: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
ref: "McpRemoteConfig",
|
||||
})
|
||||
export class Remote extends Schema.Class<Remote>("McpRemoteConfig")({
|
||||
type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }),
|
||||
url: Schema.String.annotate({ description: "URL of the remote MCP server" }),
|
||||
enabled: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable or disable the MCP server on startup",
|
||||
}),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)).annotate({
|
||||
description: "Headers to send with the request",
|
||||
}),
|
||||
oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])).annotate({
|
||||
description: "OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection.",
|
||||
}),
|
||||
timeout: Schema.optional(Schema.Number).annotate({
|
||||
description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.",
|
||||
}),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export const Info = z.discriminatedUnion("type", [Local, Remote])
|
||||
export type Info = z.infer<typeof Info>
|
||||
export const Info = Schema.Union([Local, Remote])
|
||||
.annotate({ discriminator: "type" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export * as ConfigMCP from "./mcp"
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
export const ConfigModelID = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||
// The original Zod schema carried an external $ref pointing at the models.dev
|
||||
// JSON schema. That external reference is not a named SDK component — it is a
|
||||
// literal pointer to an outside schema — so the walker cannot re-derive it
|
||||
// from AST metadata. Preserve the exact original Zod via ZodOverride.
|
||||
export const ConfigModelID = Schema.String.annotate({
|
||||
[ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>
|
||||
|
||||
@@ -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<typeof Action>
|
||||
export const Action = Schema.Literals(["ask", "allow", "deny"])
|
||||
.annotate({ identifier: "PermissionActionConfig" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Action = Schema.Schema.Type<typeof Action>
|
||||
|
||||
export const Object = z.record(z.string(), Action).meta({
|
||||
ref: "PermissionObjectConfig",
|
||||
})
|
||||
export type Object = z.infer<typeof Object>
|
||||
export const Object = Schema.Record(Schema.String, Action)
|
||||
.annotate({ identifier: "PermissionObjectConfig" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Object = Schema.Schema.Type<typeof Object>
|
||||
|
||||
export const Rule = z.union([Action, Object]).meta({
|
||||
ref: "PermissionRuleConfig",
|
||||
})
|
||||
export type Rule = z.infer<typeof Rule>
|
||||
export const Rule = Schema.Union([Action, Object])
|
||||
.annotate({ identifier: "PermissionRuleConfig" })
|
||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Rule = Schema.Schema.Type<typeof Rule>
|
||||
|
||||
const transform = (x: unknown): Record<string, Rule> => {
|
||||
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({
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
import path from "path"
|
||||
|
||||
const Options = z.record(z.string(), z.unknown())
|
||||
export type Options = z.infer<typeof Options>
|
||||
export const Options = Schema.Record(Schema.String, Schema.Unknown).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
export type Options = Schema.Schema.Type<typeof Options>
|
||||
|
||||
// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
|
||||
// It answers "what should we load?" but says nothing about where that value came from.
|
||||
export const Spec = z.union([z.string(), z.tuple([z.string(), Options])])
|
||||
export type Spec = z.infer<typeof Spec>
|
||||
export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe(
|
||||
withStatics((s) => ({ zod: zod(s) })),
|
||||
)
|
||||
export type Spec = Schema.Schema.Type<typeof Spec>
|
||||
|
||||
export type Scope = "global" | "local"
|
||||
|
||||
|
||||
@@ -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<Info>("ProviderConfig")({
|
||||
api: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
id: Schema.optional(Schema.String),
|
||||
npm: Schema.optional(Schema.String),
|
||||
whitelist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
blacklist: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||
options: Schema.optional(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
apiKey: Schema.optional(Schema.String),
|
||||
baseURL: Schema.optional(Schema.String),
|
||||
enterpriseUrl: Schema.optional(Schema.String).annotate({
|
||||
description: "GitHub Enterprise URL for copilot authentication",
|
||||
}),
|
||||
setCacheKey: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable promptCacheKey for this provider (default false)",
|
||||
}),
|
||||
timeout: Schema.optional(
|
||||
Schema.Union([PositiveInt, Schema.Literal(false)]).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
|
||||
}),
|
||||
).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.",
|
||||
),
|
||||
chunkTimeout: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe(
|
||||
}),
|
||||
chunkTimeout: Schema.optional(PositiveInt).annotate({
|
||||
description:
|
||||
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
|
||||
),
|
||||
})
|
||||
.catchall(z.any())
|
||||
.optional(),
|
||||
models: z.record(z.string(), Model).optional(),
|
||||
})
|
||||
.partial()
|
||||
.strict()
|
||||
.meta({
|
||||
ref: "ProviderConfig",
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
}),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
),
|
||||
),
|
||||
models: Schema.optional(Schema.Record(Schema.String, Model)),
|
||||
}) {
|
||||
static readonly zod = zod(this)
|
||||
}
|
||||
|
||||
export * as ConfigProvider from "./provider"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { withStatics } from "@/util/schema"
|
||||
|
||||
export const Info = z.object({
|
||||
paths: z.array(z.string()).optional().describe("Additional paths to skill folders"),
|
||||
urls: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"),
|
||||
})
|
||||
export const Info = Schema.Struct({
|
||||
paths: Schema.optional(Schema.Array(Schema.String)).annotate({
|
||||
description: "Additional paths to skill folders",
|
||||
}),
|
||||
urls: Schema.optional(Schema.Array(Schema.String)).annotate({
|
||||
description: "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)",
|
||||
}),
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export * as ConfigSkills from "./skills"
|
||||
|
||||
@@ -25,7 +25,12 @@ export interface Interface {
|
||||
readonly add: (pkg: string) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly install: (
|
||||
dir: string,
|
||||
input?: { add: string[] },
|
||||
input?: {
|
||||
add: {
|
||||
name: string
|
||||
version?: string
|
||||
}[]
|
||||
},
|
||||
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
|
||||
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
|
||||
readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -12,31 +12,41 @@ import { ConfigPlugin } from "@/config/plugin"
|
||||
import { InstallationVersion } from "@/installation/version"
|
||||
|
||||
export namespace PluginLoader {
|
||||
// A normalized plugin declaration derived from config before any filesystem or npm work happens.
|
||||
export type Plan = {
|
||||
spec: string
|
||||
options: ConfigPlugin.Options | undefined
|
||||
deprecated: boolean
|
||||
}
|
||||
|
||||
// A plugin that has been resolved to a concrete target and entrypoint on disk.
|
||||
export type Resolved = Plan & {
|
||||
source: PluginSource
|
||||
target: string
|
||||
entry: string
|
||||
pkg?: PluginPackage
|
||||
}
|
||||
|
||||
// A plugin target we could inspect, but which does not expose the requested kind of entrypoint.
|
||||
export type Missing = Plan & {
|
||||
source: PluginSource
|
||||
target: string
|
||||
pkg?: PluginPackage
|
||||
message: string
|
||||
}
|
||||
|
||||
// A resolved plugin whose module has been imported successfully.
|
||||
export type Loaded = Resolved & {
|
||||
mod: Record<string, unknown>
|
||||
}
|
||||
|
||||
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
|
||||
type Report = {
|
||||
// Called before each attempt so callers can log initial load attempts and retries uniformly.
|
||||
start?: (candidate: Candidate, retry: boolean) => void
|
||||
// Called when the package exists but does not provide the requested entrypoint.
|
||||
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
|
||||
// Called for operational failures such as install, compatibility, or dynamic import errors.
|
||||
error?: (
|
||||
candidate: Candidate,
|
||||
retry: boolean,
|
||||
@@ -46,11 +56,16 @@ export namespace PluginLoader {
|
||||
) => void
|
||||
}
|
||||
|
||||
// Normalize a config item into the loader's internal representation.
|
||||
function plan(item: ConfigPlugin.Spec): Plan {
|
||||
const spec = ConfigPlugin.pluginSpecifier(item)
|
||||
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
|
||||
}
|
||||
|
||||
// Resolve a configured plugin into a concrete entrypoint that can later be imported.
|
||||
//
|
||||
// The stages here intentionally separate install/target resolution, entrypoint detection,
|
||||
// and compatibility checks so callers can report the exact reason a plugin was skipped.
|
||||
export async function resolve(
|
||||
plan: Plan,
|
||||
kind: PluginKind,
|
||||
@@ -59,6 +74,7 @@ export namespace PluginLoader {
|
||||
| { ok: false; stage: "missing"; value: Missing }
|
||||
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
||||
> {
|
||||
// First make sure the plugin exists locally, installing npm plugins on demand.
|
||||
let target = ""
|
||||
try {
|
||||
target = await resolvePluginTarget(plan.spec)
|
||||
@@ -67,6 +83,7 @@ export namespace PluginLoader {
|
||||
}
|
||||
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
|
||||
|
||||
// Then inspect the target for the requested server/tui entrypoint.
|
||||
let base
|
||||
try {
|
||||
base = await createPluginEntry(plan.spec, target, kind)
|
||||
@@ -86,6 +103,8 @@ export namespace PluginLoader {
|
||||
},
|
||||
}
|
||||
|
||||
// npm plugins can declare which opencode versions they support; file plugins are treated
|
||||
// as local development code and skip this compatibility gate.
|
||||
if (base.source === "npm") {
|
||||
try {
|
||||
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
|
||||
@@ -96,6 +115,7 @@ export namespace PluginLoader {
|
||||
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
|
||||
}
|
||||
|
||||
// Import the resolved module only after all earlier validation has succeeded.
|
||||
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
|
||||
let mod
|
||||
try {
|
||||
@@ -107,6 +127,8 @@ export namespace PluginLoader {
|
||||
return { ok: true, value: { ...row, mod } }
|
||||
}
|
||||
|
||||
// Run one candidate through the full pipeline: resolve, optionally surface a missing entry,
|
||||
// import the module, and finally let the caller transform the loaded plugin into any result type.
|
||||
async function attempt<R>(
|
||||
candidate: Candidate,
|
||||
kind: PluginKind,
|
||||
@@ -116,11 +138,17 @@ export namespace PluginLoader {
|
||||
report: Report | undefined,
|
||||
): Promise<R | undefined> {
|
||||
const plan = candidate.plan
|
||||
|
||||
// Deprecated plugin packages are silently ignored because they are now built in.
|
||||
if (plan.deprecated) return
|
||||
|
||||
report?.start?.(candidate, retry)
|
||||
|
||||
const resolved = await resolve(plan, kind)
|
||||
if (!resolved.ok) {
|
||||
if (resolved.stage === "missing") {
|
||||
// Missing entrypoints are handled separately so callers can still inspect package metadata,
|
||||
// for example to load theme files from a tui plugin package that has no code entrypoint.
|
||||
if (missing) {
|
||||
const value = await missing(resolved.value, candidate.origin, retry)
|
||||
if (value !== undefined) return value
|
||||
@@ -131,11 +159,15 @@ export namespace PluginLoader {
|
||||
report?.error?.(candidate, retry, resolved.stage, resolved.error)
|
||||
return
|
||||
}
|
||||
|
||||
const loaded = await load(resolved.value)
|
||||
if (!loaded.ok) {
|
||||
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
|
||||
return
|
||||
}
|
||||
|
||||
// The default behavior is to return the successfully loaded plugin as-is, but callers can
|
||||
// provide a finisher to adapt the result into a more specific runtime shape.
|
||||
if (!finish) return loaded.value as R
|
||||
return finish(loaded.value, candidate.origin, retry)
|
||||
}
|
||||
@@ -149,6 +181,11 @@ export namespace PluginLoader {
|
||||
report?: Report
|
||||
}
|
||||
|
||||
// Resolve and load all configured plugins in parallel.
|
||||
//
|
||||
// If `wait` is provided, file-based plugins that initially failed are retried once after the
|
||||
// caller finishes preparing dependencies. This supports local plugins that depend on an install
|
||||
// step happening elsewhere before their entrypoint becomes loadable.
|
||||
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
|
||||
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
|
||||
const list: Array<Promise<R | undefined>> = []
|
||||
@@ -160,6 +197,9 @@ export namespace PluginLoader {
|
||||
let deps: Promise<void> | undefined
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
if (out[i] !== undefined) continue
|
||||
|
||||
// Only local file plugins are retried. npm plugins already attempted installation during
|
||||
// the first pass, while file plugins may need the caller's dependency preparation to finish.
|
||||
const candidate = candidates[i]
|
||||
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
|
||||
deps ??= input.wait()
|
||||
@@ -167,6 +207,8 @@ export namespace PluginLoader {
|
||||
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop skipped/failed entries while preserving the successful result order.
|
||||
const ready: R[] = []
|
||||
for (const item of out) if (item !== undefined) ready.push(item)
|
||||
return ready
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Hono } from "hono"
|
||||
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { errors } from "../../error"
|
||||
import { WorkspaceRoutes } from "./workspace"
|
||||
|
||||
export function ControlPlaneRoutes(): Hono {
|
||||
const app = new Hono()
|
||||
@@ -158,5 +157,4 @@ export function ControlPlaneRoutes(): Hono {
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.route("/experimental/workspace", WorkspaceRoutes())
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Config } from "@/config"
|
||||
import { Provider } from "@/provider"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const ConfigRoutes = lazy(() =>
|
||||
@@ -52,11 +51,13 @@ export const ConfigRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
validator("json", Config.Info),
|
||||
async (c) => {
|
||||
const config = c.req.valid("json")
|
||||
await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.update(config)))
|
||||
return c.json(config)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ConfigRoutes.update", c, function* () {
|
||||
const config = c.req.valid("json")
|
||||
const cfg = yield* Config.Service
|
||||
yield* cfg.update(config)
|
||||
return config
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/providers",
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import z from "zod"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Permission } from "@/permission"
|
||||
import { PermissionID } from "@/permission/schema"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const PermissionRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -34,20 +34,18 @@ export const PermissionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
Permission.Service.use((svc) =>
|
||||
svc.reply({
|
||||
requestID: params.requestID,
|
||||
reply: json.reply,
|
||||
message: json.message,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("PermissionRoutes.reply", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
const svc = yield* Permission.Service
|
||||
yield* svc.reply({
|
||||
requestID: params.requestID,
|
||||
reply: json.reply,
|
||||
message: json.message,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/",
|
||||
@@ -66,9 +64,10 @@ export const PermissionRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list()))
|
||||
return c.json(permissions)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("PermissionRoutes.list", c, function* () {
|
||||
const svc = yield* Permission.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
export const ProjectRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -75,7 +76,9 @@ export const ProjectRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const dir = Instance.directory
|
||||
const prev = Instance.project
|
||||
const next = await AppRuntime.runPromise(
|
||||
const next = await runRequest(
|
||||
"ProjectRoutes.initGit",
|
||||
c,
|
||||
Project.Service.use((svc) => svc.initGit({ directory: dir, project: prev })),
|
||||
)
|
||||
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
|
||||
@@ -108,11 +111,12 @@ export const ProjectRoutes = lazy(() =>
|
||||
}),
|
||||
validator("param", z.object({ projectID: ProjectID.zod })),
|
||||
validator("json", Project.UpdateInput.omit({ projectID: true })),
|
||||
async (c) => {
|
||||
const projectID = c.req.valid("param").projectID
|
||||
const body = c.req.valid("json")
|
||||
const project = await AppRuntime.runPromise(Project.Service.use((svc) => svc.update({ ...body, projectID })))
|
||||
return c.json(project)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ProjectRoutes.update", c, function* () {
|
||||
const projectID = c.req.valid("param").projectID
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Project.Service
|
||||
return yield* svc.update({ ...body, projectID })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,11 +6,11 @@ import { Provider } from "@/provider"
|
||||
import { ModelsDev } from "@/provider"
|
||||
import { ProviderAuth } from "@/provider"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { mapValues } from "remeda"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Effect } from "effect"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
export const ProviderRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -31,39 +31,31 @@ export const ProviderRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const result = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const cfg = yield* Config.Service
|
||||
const config = yield* cfg.get()
|
||||
const all = yield* Effect.promise(() => ModelsDev.get())
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
const filtered: Record<string, (typeof all)[string]> = {}
|
||||
for (const [key, value] of Object.entries(all)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filtered[key] = value
|
||||
}
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.list", c, function* () {
|
||||
const svc = yield* Provider.Service
|
||||
const cfg = yield* Config.Service
|
||||
const config = yield* cfg.get()
|
||||
const all = yield* Effect.promise(() => ModelsDev.get())
|
||||
const disabled = new Set(config.disabled_providers ?? [])
|
||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
|
||||
const filtered: Record<string, (typeof all)[string]> = {}
|
||||
for (const [key, value] of Object.entries(all)) {
|
||||
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
|
||||
filtered[key] = value
|
||||
}
|
||||
const connected = yield* svc.list()
|
||||
const providers = Object.assign(
|
||||
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
|
||||
connected,
|
||||
)
|
||||
return {
|
||||
all: Object.values(providers),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
connected: Object.keys(connected),
|
||||
}
|
||||
}),
|
||||
)
|
||||
return c.json({
|
||||
all: result.all,
|
||||
default: result.default,
|
||||
connected: result.connected,
|
||||
})
|
||||
},
|
||||
}
|
||||
const connected = yield* svc.list()
|
||||
const providers = Object.assign(
|
||||
mapValues(filtered, (x) => Provider.fromModelsDevProvider(x)),
|
||||
connected,
|
||||
)
|
||||
return {
|
||||
all: Object.values(providers),
|
||||
default: Provider.defaultModelIDs(providers),
|
||||
connected: Object.keys(connected),
|
||||
}
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/auth",
|
||||
@@ -82,9 +74,11 @@ export const ProviderRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(await AppRuntime.runPromise(ProviderAuth.Service.use((svc) => svc.methods())))
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.auth", c, function* () {
|
||||
const svc = yield* ProviderAuth.Service
|
||||
return yield* svc.methods()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:providerID/oauth/authorize",
|
||||
@@ -111,20 +105,17 @@ export const ProviderRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", ProviderAuth.AuthorizeInput.zod),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method, inputs } = c.req.valid("json")
|
||||
const result = await AppRuntime.runPromise(
|
||||
ProviderAuth.Service.use((svc) =>
|
||||
svc.authorize({
|
||||
providerID,
|
||||
method,
|
||||
inputs,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(result)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.oauth.authorize", c, function* () {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method, inputs } = c.req.valid("json")
|
||||
const svc = yield* ProviderAuth.Service
|
||||
return yield* svc.authorize({
|
||||
providerID,
|
||||
method,
|
||||
inputs,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:providerID/oauth/callback",
|
||||
@@ -151,19 +142,17 @@ export const ProviderRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", ProviderAuth.CallbackInput.zod),
|
||||
async (c) => {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method, code } = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
ProviderAuth.Service.use((svc) =>
|
||||
svc.callback({
|
||||
providerID,
|
||||
method,
|
||||
code,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("ProviderRoutes.oauth.callback", c, function* () {
|
||||
const providerID = c.req.valid("param").providerID
|
||||
const { method, code } = c.req.valid("json")
|
||||
const svc = yield* ProviderAuth.Service
|
||||
yield* svc.callback({
|
||||
providerID,
|
||||
method,
|
||||
code,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Pty } from "@/pty"
|
||||
import { PtyID } from "@/pty/schema"
|
||||
import { NotFoundError } from "@/storage"
|
||||
import { errors } from "../../error"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
return new Hono()
|
||||
@@ -28,16 +29,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.list()
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.list", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.list()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/",
|
||||
@@ -58,15 +54,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
},
|
||||
}),
|
||||
validator("json", Pty.CreateInput),
|
||||
async (c) => {
|
||||
const info = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.create(c.req.valid("json"))
|
||||
}),
|
||||
)
|
||||
return c.json(info)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.create", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.create(c.req.valid("json"))
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/:ptyID",
|
||||
@@ -88,7 +80,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
async (c) => {
|
||||
const info = await AppRuntime.runPromise(
|
||||
const info = await runRequest(
|
||||
"PtyRoutes.get",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.get(c.req.valid("param").ptyID)
|
||||
@@ -120,15 +114,11 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
validator("json", Pty.UpdateInput),
|
||||
async (c) => {
|
||||
const info = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
|
||||
}),
|
||||
)
|
||||
return c.json(info)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.update", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.update(c.req.valid("param").ptyID, c.req.valid("json"))
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/:ptyID",
|
||||
@@ -149,15 +139,12 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
},
|
||||
}),
|
||||
validator("param", z.object({ ptyID: PtyID.zod })),
|
||||
async (c) => {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
yield* pty.remove(c.req.valid("param").ptyID)
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("PtyRoutes.remove", c, function* () {
|
||||
const pty = yield* Pty.Service
|
||||
yield* pty.remove(c.req.valid("param").ptyID)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/:ptyID/connect",
|
||||
@@ -194,7 +181,9 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
})()
|
||||
let handler: Handler | undefined
|
||||
if (
|
||||
!(await AppRuntime.runPromise(
|
||||
!(await runRequest(
|
||||
"PtyRoutes.connect",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.get(id)
|
||||
@@ -232,7 +221,7 @@ export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
|
||||
Effect.gen(function* () {
|
||||
const pty = yield* Pty.Service
|
||||
return yield* pty.connect(id, socket, cursor)
|
||||
}),
|
||||
}).pipe(Effect.withSpan("PtyRoutes.connect.open")),
|
||||
)
|
||||
ready = true
|
||||
for (const msg of pending) handler?.onMessage(msg)
|
||||
|
||||
@@ -3,10 +3,10 @@ import { describeRoute, validator } from "hono-openapi"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { QuestionID } from "@/question/schema"
|
||||
import { Question } from "@/question"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import z from "zod"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { jsonRequest } from "./trace"
|
||||
|
||||
const Reply = z.object({
|
||||
answers: Question.Answer.zod
|
||||
@@ -33,10 +33,11 @@ export const QuestionRoutes = lazy(() =>
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||
return c.json(questions)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("QuestionRoutes.list", c, function* () {
|
||||
const svc = yield* Question.Service
|
||||
return yield* svc.list()
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reply",
|
||||
@@ -63,19 +64,17 @@ export const QuestionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", Reply),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
Question.Service.use((svc) =>
|
||||
svc.reply({
|
||||
requestID: params.requestID,
|
||||
answers: json.answers,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("QuestionRoutes.reply", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
const svc = yield* Question.Service
|
||||
yield* svc.reply({
|
||||
requestID: params.requestID,
|
||||
answers: json.answers,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:requestID/reject",
|
||||
@@ -101,10 +100,12 @@ export const QuestionRoutes = lazy(() =>
|
||||
requestID: QuestionID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID)))
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("QuestionRoutes.reject", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const svc = yield* Question.Service
|
||||
yield* svc.reject(params.requestID)
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ import { SessionStatus } from "@/session/status"
|
||||
import { SessionSummary } from "@/session/summary"
|
||||
import { Todo } from "@/session/todo"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Command } from "@/command"
|
||||
@@ -26,7 +25,7 @@ import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Bus } from "@/bus"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { jsonRequest } from "./trace"
|
||||
import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -218,11 +217,12 @@ export const SessionRoutes = lazy(() =>
|
||||
},
|
||||
}),
|
||||
validator("json", Session.CreateInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json") ?? {}
|
||||
const session = await AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(body)))
|
||||
return c.json(session)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.create", c, function* () {
|
||||
const body = c.req.valid("json") ?? {}
|
||||
const svc = yield* SessionShare.Service
|
||||
return yield* svc.create(body)
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/:sessionID",
|
||||
@@ -248,11 +248,13 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: Session.RemoveInput,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(sessionID)))
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.delete", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const svc = yield* Session.Service
|
||||
yield* svc.remove(sessionID)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.patch(
|
||||
"/:sessionID",
|
||||
@@ -290,32 +292,28 @@ export const SessionRoutes = lazy(() =>
|
||||
.optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const updates = c.req.valid("json")
|
||||
const session = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const current = yield* session.get(sessionID)
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.update", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const updates = c.req.valid("json")
|
||||
const session = yield* Session.Service
|
||||
const current = yield* session.get(sessionID)
|
||||
|
||||
if (updates.title !== undefined) {
|
||||
yield* session.setTitle({ sessionID, title: updates.title })
|
||||
}
|
||||
if (updates.permission !== undefined) {
|
||||
yield* session.setPermission({
|
||||
sessionID,
|
||||
permission: Permission.merge(current.permission ?? [], updates.permission),
|
||||
})
|
||||
}
|
||||
if (updates.time?.archived !== undefined) {
|
||||
yield* session.setArchived({ sessionID, time: updates.time.archived })
|
||||
}
|
||||
if (updates.title !== undefined) {
|
||||
yield* session.setTitle({ sessionID, title: updates.title })
|
||||
}
|
||||
if (updates.permission !== undefined) {
|
||||
yield* session.setPermission({
|
||||
sessionID,
|
||||
permission: Permission.merge(current.permission ?? [], updates.permission),
|
||||
})
|
||||
}
|
||||
if (updates.time?.archived !== undefined) {
|
||||
yield* session.setArchived({ sessionID, time: updates.time.archived })
|
||||
}
|
||||
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
return c.json(session)
|
||||
},
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
// TODO(v2): remove this dedicated route and rely on the normal `/init` command flow.
|
||||
.post(
|
||||
@@ -351,22 +349,20 @@ export const SessionRoutes = lazy(() =>
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
SessionPrompt.Service.use((svc) =>
|
||||
svc.command({
|
||||
sessionID,
|
||||
messageID: body.messageID,
|
||||
model: body.providerID + "/" + body.modelID,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.init", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* SessionPrompt.Service
|
||||
yield* svc.command({
|
||||
sessionID,
|
||||
messageID: body.messageID,
|
||||
model: body.providerID + "/" + body.modelID,
|
||||
command: Command.Default.INIT,
|
||||
arguments: "",
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/fork",
|
||||
@@ -392,12 +388,13 @@ export const SessionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", Session.ForkInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const result = await AppRuntime.runPromise(Session.Service.use((svc) => svc.fork({ ...body, sessionID })))
|
||||
return c.json(result)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.fork", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* Session.Service
|
||||
return yield* svc.fork({ ...body, sessionID })
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/abort",
|
||||
@@ -423,10 +420,12 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.cancel(c.req.valid("param").sessionID)))
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.abort", c, function* () {
|
||||
const svc = yield* SessionPrompt.Service
|
||||
yield* svc.cancel(c.req.valid("param").sessionID)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/share",
|
||||
@@ -452,18 +451,14 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const session = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.share(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
return c.json(session)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.share", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.share(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/:sessionID/diff",
|
||||
@@ -494,19 +489,16 @@ export const SessionRoutes = lazy(() =>
|
||||
messageID: SessionSummary.DiffInput.shape.messageID,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const query = c.req.valid("query")
|
||||
const params = c.req.valid("param")
|
||||
const result = await AppRuntime.runPromise(
|
||||
SessionSummary.Service.use((summary) =>
|
||||
summary.diff({
|
||||
sessionID: params.sessionID,
|
||||
messageID: query.messageID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(result)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.diff", c, function* () {
|
||||
const query = c.req.valid("query")
|
||||
const params = c.req.valid("param")
|
||||
const summary = yield* SessionSummary.Service
|
||||
return yield* summary.diff({
|
||||
sessionID: params.sessionID,
|
||||
messageID: query.messageID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/:sessionID/share",
|
||||
@@ -532,18 +524,14 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const session = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.unshare(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
return c.json(session)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.unshare", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const share = yield* SessionShare.Service
|
||||
const session = yield* Session.Service
|
||||
yield* share.unshare(sessionID)
|
||||
return yield* session.get(sessionID)
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/summarize",
|
||||
@@ -577,43 +565,40 @@ export const SessionRoutes = lazy(() =>
|
||||
auto: z.boolean().optional().default(false),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
const compact = yield* SessionCompaction.Service
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const agent = yield* Agent.Service
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.summarize", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const session = yield* Session.Service
|
||||
const revert = yield* SessionRevert.Service
|
||||
const compact = yield* SessionCompaction.Service
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const agent = yield* Agent.Service
|
||||
|
||||
yield* revert.cleanup(yield* session.get(sessionID))
|
||||
const msgs = yield* session.messages({ sessionID })
|
||||
const defaultAgent = yield* agent.defaultAgent()
|
||||
let currentAgent = defaultAgent
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const info = msgs[i].info
|
||||
if (info.role === "user") {
|
||||
currentAgent = info.agent || defaultAgent
|
||||
break
|
||||
}
|
||||
yield* revert.cleanup(yield* session.get(sessionID))
|
||||
const msgs = yield* session.messages({ sessionID })
|
||||
const defaultAgent = yield* agent.defaultAgent()
|
||||
let currentAgent = defaultAgent
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const info = msgs[i].info
|
||||
if (info.role === "user") {
|
||||
currentAgent = info.agent || defaultAgent
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
yield* compact.create({
|
||||
sessionID,
|
||||
agent: currentAgent,
|
||||
model: {
|
||||
providerID: body.providerID,
|
||||
modelID: body.modelID,
|
||||
},
|
||||
auto: body.auto,
|
||||
})
|
||||
yield* prompt.loop({ sessionID })
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
yield* compact.create({
|
||||
sessionID,
|
||||
agent: currentAgent,
|
||||
model: {
|
||||
providerID: body.providerID,
|
||||
modelID: body.modelID,
|
||||
},
|
||||
auto: body.auto,
|
||||
})
|
||||
yield* prompt.loop({ sessionID })
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.get(
|
||||
"/:sessionID/message",
|
||||
@@ -675,7 +660,9 @@ export const SessionRoutes = lazy(() =>
|
||||
const query = c.req.valid("query")
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
if (query.limit === undefined || query.limit === 0) {
|
||||
const messages = await AppRuntime.runPromise(
|
||||
const messages = await runRequest(
|
||||
"SessionRoutes.messages",
|
||||
c,
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
yield* session.get(sessionID)
|
||||
@@ -766,21 +753,18 @@ export const SessionRoutes = lazy(() =>
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* SessionRunState.Service
|
||||
const session = yield* Session.Service
|
||||
yield* state.assertNotBusy(params.sessionID)
|
||||
yield* session.removeMessage({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
})
|
||||
}),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.deleteMessage", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const state = yield* SessionRunState.Service
|
||||
const session = yield* Session.Service
|
||||
yield* state.assertNotBusy(params.sessionID)
|
||||
yield* session.removeMessage({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.delete(
|
||||
"/:sessionID/message/:messageID/part/:partID",
|
||||
@@ -807,19 +791,17 @@ export const SessionRoutes = lazy(() =>
|
||||
partID: PartID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await AppRuntime.runPromise(
|
||||
Session.Service.use((svc) =>
|
||||
svc.removePart({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
partID: params.partID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.deletePart", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const svc = yield* Session.Service
|
||||
yield* svc.removePart({
|
||||
sessionID: params.sessionID,
|
||||
messageID: params.messageID,
|
||||
partID: params.partID,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
)
|
||||
.patch(
|
||||
"/:sessionID/message/:messageID/part/:partID",
|
||||
@@ -855,8 +837,10 @@ export const SessionRoutes = lazy(() =>
|
||||
`Part mismatch: body.id='${body.id}' vs partID='${params.partID}', body.messageID='${body.messageID}' vs messageID='${params.messageID}', body.sessionID='${body.sessionID}' vs sessionID='${params.sessionID}'`,
|
||||
)
|
||||
}
|
||||
const part = await AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(body)))
|
||||
return c.json(part)
|
||||
return jsonRequest("SessionRoutes.updatePart", c, function* () {
|
||||
const svc = yield* Session.Service
|
||||
return yield* svc.updatePart(body)
|
||||
})
|
||||
},
|
||||
)
|
||||
.post(
|
||||
@@ -895,7 +879,9 @@ export const SessionRoutes = lazy(() =>
|
||||
return stream(c, async (stream) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await AppRuntime.runPromise(
|
||||
const msg = await runRequest(
|
||||
"SessionRoutes.prompt",
|
||||
c,
|
||||
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
|
||||
)
|
||||
void stream.write(JSON.stringify(msg))
|
||||
@@ -926,15 +912,17 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
void AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID }))).catch(
|
||||
(err) => {
|
||||
log.error("prompt_async failed", { sessionID, error: err })
|
||||
void Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
|
||||
})
|
||||
},
|
||||
)
|
||||
void runRequest(
|
||||
"SessionRoutes.prompt_async",
|
||||
c,
|
||||
SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })),
|
||||
).catch((err) => {
|
||||
log.error("prompt_async failed", { sessionID, error: err })
|
||||
void Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
|
||||
})
|
||||
})
|
||||
|
||||
return c.body(null, 204)
|
||||
},
|
||||
@@ -969,12 +957,13 @@ export const SessionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", SessionPrompt.CommandInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.command({ ...body, sessionID })))
|
||||
return c.json(msg)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.command", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* SessionPrompt.Service
|
||||
return yield* svc.command({ ...body, sessionID })
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/shell",
|
||||
@@ -1001,12 +990,13 @@ export const SessionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", SessionPrompt.ShellInput.omit({ sessionID: true })),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const msg = await AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.shell({ ...body, sessionID })))
|
||||
return c.json(msg)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.shell", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const body = c.req.valid("json")
|
||||
const svc = yield* SessionPrompt.Service
|
||||
return yield* svc.shell({ ...body, sessionID })
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/revert",
|
||||
@@ -1036,15 +1026,13 @@ export const SessionRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
log.info("revert", c.req.valid("json"))
|
||||
const session = await AppRuntime.runPromise(
|
||||
SessionRevert.Service.use((svc) =>
|
||||
svc.revert({
|
||||
sessionID,
|
||||
...c.req.valid("json"),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(session)
|
||||
return jsonRequest("SessionRoutes.revert", c, function* () {
|
||||
const svc = yield* SessionRevert.Service
|
||||
return yield* svc.revert({
|
||||
sessionID,
|
||||
...c.req.valid("json"),
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
.post(
|
||||
@@ -1071,11 +1059,12 @@ export const SessionRoutes = lazy(() =>
|
||||
sessionID: SessionID.zod,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const session = await AppRuntime.runPromise(SessionRevert.Service.use((svc) => svc.unrevert({ sessionID })))
|
||||
return c.json(session)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.unrevert", c, function* () {
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const svc = yield* SessionRevert.Service
|
||||
return yield* svc.unrevert({ sessionID })
|
||||
}),
|
||||
)
|
||||
.post(
|
||||
"/:sessionID/permissions/:permissionID",
|
||||
@@ -1104,17 +1093,15 @@ export const SessionRoutes = lazy(() =>
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ response: Permission.Reply.zod })),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
await AppRuntime.runPromise(
|
||||
Permission.Service.use((svc) =>
|
||||
svc.reply({
|
||||
requestID: params.permissionID,
|
||||
reply: c.req.valid("json").response,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return c.json(true)
|
||||
},
|
||||
async (c) =>
|
||||
jsonRequest("SessionRoutes.permissionRespond", c, function* () {
|
||||
const params = c.req.valid("param")
|
||||
const svc = yield* Permission.Service
|
||||
yield* svc.reply({
|
||||
requestID: params.permissionID,
|
||||
reply: c.req.valid("json").response,
|
||||
})
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -4,18 +4,44 @@ import { AppRuntime } from "@/effect/app-runtime"
|
||||
|
||||
type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
|
||||
|
||||
// Build the base span attributes for an HTTP handler: method, path, and every
|
||||
// matched route param. Names follow OTel attribute-naming guidance:
|
||||
// domain-first (`session.id`, `message.id`, …) so they match the existing
|
||||
// OTel `session.id` semantic convention and the bare `message.id` we
|
||||
// already emit from Tool.execute. Non-standard route params fall back to
|
||||
// `opencode.<name>` since those are internal implementation details
|
||||
// (per https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/).
|
||||
export interface RequestLike {
|
||||
readonly req: {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
param(): Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a Hono route param key (e.g. `sessionID`, `messageID`, `name`)
|
||||
// to an OTel attribute key. `fooID` → `foo.id` for ID-shaped params; any
|
||||
// other param is namespaced under `opencode.` to avoid colliding with
|
||||
// standard conventions.
|
||||
export function paramToAttributeKey(key: string): string {
|
||||
const m = key.match(/^(.+)ID$/)
|
||||
if (m) return `${m[1].toLowerCase()}.id`
|
||||
return `opencode.${key}`
|
||||
}
|
||||
|
||||
export function requestAttributes(c: RequestLike): Record<string, string> {
|
||||
const attributes: Record<string, string> = {
|
||||
"http.method": c.req.method,
|
||||
"http.path": new URL(c.req.url).pathname,
|
||||
}
|
||||
for (const [key, value] of Object.entries(c.req.param())) {
|
||||
attributes[paramToAttributeKey(key)] = value
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
|
||||
const url = new URL(c.req.url)
|
||||
return AppRuntime.runPromise(
|
||||
effect.pipe(
|
||||
Effect.withSpan(name, {
|
||||
attributes: {
|
||||
"http.method": c.req.method,
|
||||
"http.path": url.pathname,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })))
|
||||
}
|
||||
|
||||
export async function jsonRequest<C extends Context, A, E>(
|
||||
|
||||
@@ -4,10 +4,10 @@ import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { Session } from "@/session"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
import { errors } from "../../error"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { runRequest } from "./trace"
|
||||
|
||||
const TuiRequest = z.object({
|
||||
path: z.string(),
|
||||
@@ -371,7 +371,11 @@ export const TuiRoutes = lazy(() =>
|
||||
validator("json", TuiEvent.SessionSelect.properties),
|
||||
async (c) => {
|
||||
const { sessionID } = c.req.valid("json")
|
||||
await AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
|
||||
await runRequest(
|
||||
"TuiRoutes.sessionSelect",
|
||||
c,
|
||||
Session.Service.use((svc) => svc.get(sessionID)),
|
||||
)
|
||||
await Bus.publish(TuiEvent.SessionSelect, { sessionID })
|
||||
return c.json(true)
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -38,8 +38,9 @@ const ShareSchema = Schema.Struct({
|
||||
export type Share = typeof ShareSchema.Type
|
||||
|
||||
type State = {
|
||||
queue: Map<string, { data: Map<string, Data> }>
|
||||
queue: Map<SessionID, Map<string, Data>>
|
||||
scope: Scope.Closeable
|
||||
shared: Map<SessionID, Share | null>
|
||||
}
|
||||
|
||||
type Data =
|
||||
@@ -118,17 +119,20 @@ export const layer = Layer.effect(
|
||||
function sync(sessionID: SessionID, data: Data[]): Effect.Effect<void> {
|
||||
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<State> = yield* InstanceState.make<State>(
|
||||
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 })
|
||||
|
||||
@@ -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<SchemaAST.AST, z.ZodTypeAny>()
|
||||
|
||||
// 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<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
|
||||
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
|
||||
}
|
||||
|
||||
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<z.ZodTypeAny>(
|
||||
(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<unknown>
|
||||
>,
|
||||
)
|
||||
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<unknown>[] = []
|
||||
const collect = (c: SchemaAST.Check<unknown>) => {
|
||||
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<z.ZodTypeAny>])
|
||||
}
|
||||
|
||||
function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
|
||||
|
||||
@@ -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<typeof ID>
|
||||
@@ -70,7 +70,9 @@ export class ToolStateError extends Schema.Class<ToolStateError>("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<typeof ToolState>
|
||||
|
||||
export class AssistantTool extends Schema.Class<AssistantTool>("Session.Entry.Assistant.Tool")({
|
||||
@@ -96,7 +98,9 @@ export class AssistantReasoning extends Schema.Class<AssistantReasoning>("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<typeof AssistantContent>
|
||||
|
||||
export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant")({
|
||||
@@ -126,7 +130,7 @@ export class Compaction extends Schema.Class<Compaction>("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<typeof Entry>
|
||||
|
||||
@@ -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<typeof pendingAssistant>["content"][number]
|
||||
type DraftTool = Extract<DraftContent, { type: "tool" }>
|
||||
|
||||
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 } }))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -441,7 +441,7 @@ export namespace SessionEvent {
|
||||
{
|
||||
mode: "oneOf",
|
||||
},
|
||||
)
|
||||
).pipe(Schema.toTaggedUnion("type"))
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
export type Type = Event["type"]
|
||||
}
|
||||
|
||||
87
packages/opencode/test/config/lsp.test.ts
Normal file
87
packages/opencode/test/config/lsp.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
76
packages/opencode/test/server/trace-attributes.test.ts
Normal file
76
packages/opencode/test/server/trace-attributes.test.ts
Normal file
@@ -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<string, string>) {
|
||||
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 <domain>.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()
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, { enabled: boolean }>) =>
|
||||
"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<Method>("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<Inner>("MemoTestInner")({
|
||||
value: Schema.String,
|
||||
}) {}
|
||||
|
||||
class OuterA extends Schema.Class<OuterA>("MemoTestOuterA")({
|
||||
inner: Inner,
|
||||
}) {}
|
||||
|
||||
class OuterB extends Schema.Class<OuterB>("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"]))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1807,6 +1807,12 @@ export type Provider = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ConsoleState = {
|
||||
consoleManagedProviders: Array<string>
|
||||
activeOrgName?: string
|
||||
switchableOrgCount: number
|
||||
}
|
||||
|
||||
export type ToolIds = Array<string>
|
||||
|
||||
export type ToolListItem = {
|
||||
@@ -2933,11 +2939,7 @@ export type ExperimentalConsoleGetResponses = {
|
||||
/**
|
||||
* Active Console provider metadata
|
||||
*/
|
||||
200: {
|
||||
consoleManagedProviders: Array<string>
|
||||
activeOrgName?: string
|
||||
switchableOrgCount: number
|
||||
}
|
||||
200: ConsoleState
|
||||
}
|
||||
|
||||
export type ExperimentalConsoleGetResponse = ExperimentalConsoleGetResponses[keyof ExperimentalConsoleGetResponses]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.4.9",
|
||||
"version": "1.4.11",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user