Compare commits

...
144 changed files with 3911 additions and 1588 deletions
+261
View File
@@ -0,0 +1,261 @@
# V2 HTTP API audit checklist
**Source:** `packages/protocol/openapi.json`
**Current endpoint count:** 143
**Last regenerated:** 2026-09-13
## How to use this checklist
Review endpoints in document order. For each endpoint, select one disposition and capture rationale or follow-up work in Notes. Mark **Reviewed** only after the disposition is agreed.
### Review criteria
- Resource and operation naming
- HTTP method and idempotency
- Request parameters and location scope
- Response shape and error taxonomy
- Authentication and authorization
- Current production consumers
- Stability level: public, experimental, or internal
- Whether the generated client API is intuitive
### Disposition legend
- **Keep:** ship unchanged as a supported V2 API
- **Change:** retain after a defined contract change
- **Remove:** exclude from the official V2 API
- **Experimental-only:** retain outside the stable API commitment
## Progress
- [ ] Group 1: Foundation and placement (7)
- [ ] Group 2: Configuration and capability catalogs (17)
- [ ] Group 3: Credentials, integrations, MCP, and web search (22)
- [ ] Group 4: Session lifecycle (12)
- [ ] Group 5: Session execution and inputs (11)
- [ ] Group 6: Session history and recovery (13)
- [ ] Group 7: Inbox, permissions, and forms (19)
- [ ] Group 8: Filesystem, worktrees, and VCS (12)
- [ ] Group 9: PTYs, persistent terminals, and shells (24)
- [ ] Group 10: Events, RPC, and experimental operations (6)
## Resolved during audit
### [x] `GET /api/project/current`
- **Decision:** Remove
- **Replacement:** `GET /api/location`, using `project` from the response.
- **Notes:** The endpoint duplicated `Location.Info.project`; production callers were migrated.
## Group 1: Foundation and placement
**Endpoints:** 7
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 001 | `GET` | `/api/health` | `health.get` | Change | Keep path; rename operation to `health.get`; remove redundant `healthy` response field. |
| [ ] 002 | `GET` | `/api/server` | `server.get` | | |
| [ ] 003 | `GET` | `/api/location` | `location.get` | | |
| [ ] 004 | `GET` | `/api/project` | `project.list` | | |
| [ ] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | | |
| [ ] 006 | `POST` | `/api/workspace` | `workspace.create` | Proposed remove | Awaiting feedback in `#core`. |
| [ ] 007 | `DELETE` | `/api/workspace/{workspaceID}` | `workspace.destroy` | Proposed remove | Awaiting feedback in `#core`. |
## Group 2: Configuration and capability catalogs
**Endpoints:** 17
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 008 | `GET` | `/api/agent` | `agent.list` | | |
| [ ] 009 | `GET` | `/api/agent/{agentID}` | `agent.get` | | |
| [ ] 010 | `GET` | `/api/plugin` | `plugin.list` | | |
| [ ] 011 | `POST` | `/api/plugin/await-activation` | `plugin.awaitActivation` | | |
| [ ] 012 | `POST` | `/api/plugin/check` | `plugin.check` | | |
| [ ] 013 | `POST` | `/api/plugin/update` | `plugin.update` | | |
| [ ] 014 | `GET` | `/api/model` | `model.list` | | |
| [ ] 015 | `GET` | `/api/model/default` | `model.default` | | |
| [ ] 016 | `GET` | `/api/provider` | `provider.list` | | |
| [ ] 017 | `GET` | `/api/provider/{providerID}` | `provider.get` | | |
| [ ] 018 | `GET` | `/api/command` | `command.list` | | |
| [ ] 019 | `GET` | `/api/skill` | `skill.list` | | |
| [ ] 020 | `GET` | `/api/reference` | `reference.list` | | |
| [ ] 021 | `GET` | `/api/config` | `config.get` | | |
| [ ] 022 | `GET` | `/api/config/preferences` | `config.preferences` | | |
| [ ] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | | |
| [ ] 024 | `GET` | `/api/config/shell` | `config.shells` | | |
## Group 3: Credentials, integrations, MCP, and web search
**Endpoints:** 22
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 025 | `GET` | `/api/integration` | `integration.list` | | |
| [ ] 026 | `GET` | `/api/integration/{integrationID}` | `integration.get` | | |
| [ ] 027 | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | | |
| [ ] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | | |
| [ ] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | | |
| [ ] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | | |
| [ ] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | | |
| [ ] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | | |
| [ ] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | | |
| [ ] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | | |
| [ ] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | | |
| [ ] 036 | `GET` | `/api/mcp` | `mcp.list` | | |
| [ ] 037 | `PUT` | `/api/mcp/{server}` | `mcp.add` | | |
| [ ] 038 | `DELETE` | `/api/mcp/{server}` | `mcp.remove` | | |
| [ ] 039 | `POST` | `/api/mcp/{server}/connect` | `mcp.connect` | | |
| [ ] 040 | `POST` | `/api/mcp/{server}/disconnect` | `mcp.disconnect` | | |
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | |
| [ ] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | | |
| [ ] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | | |
| [ ] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | | |
| [ ] 045 | `GET` | `/api/websearch/provider` | `websearch.providers` | | |
| [ ] 046 | `POST` | `/api/websearch` | `websearch.query` | | |
## Group 4: Session lifecycle
**Endpoints:** 12
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 047 | `GET` | `/api/session` | `session.list` | | |
| [ ] 048 | `POST` | `/api/session` | `session.create` | | |
| [ ] 049 | `GET` | `/api/session/stats` | `session.stats` | | |
| [ ] 050 | `GET` | `/api/session/active` | `session.active` | | |
| [ ] 051 | `GET` | `/api/session/{sessionID}` | `session.get` | | |
| [ ] 052 | `DELETE` | `/api/session/{sessionID}` | `session.remove` | | |
| [ ] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | | |
| [ ] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | | |
| [ ] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | | |
| [ ] 056 | `POST` | `/api/session/{sessionID}/rename` | `session.rename` | | |
| [ ] 057 | `POST` | `/api/session/{sessionID}/move` | `session.move` | | |
| [ ] 058 | `POST` | `/api/session/{sessionID}/background` | `session.background` | | |
## Group 5: Session execution and inputs
**Endpoints:** 11
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 059 | `POST` | `/api/session/{sessionID}/prompt` | `session.prompt` | | |
| [ ] 060 | `POST` | `/api/session/{sessionID}/command` | `session.command` | | |
| [ ] 061 | `POST` | `/api/session/{sessionID}/skill` | `session.skill` | | |
| [ ] 062 | `POST` | `/api/session/{sessionID}/synthetic` | `session.synthetic` | | |
| [ ] 063 | `POST` | `/api/session/{sessionID}/shell` | `session.shell` | | |
| [ ] 064 | `POST` | `/api/session/{sessionID}/compact` | `session.compact` | | |
| [ ] 065 | `POST` | `/api/session/{sessionID}/wait` | `session.wait` | | |
| [ ] 066 | `POST` | `/api/session/{sessionID}/generate` | `session.generate` | | |
| [ ] 067 | `POST` | `/api/session/{sessionID}/interrupt` | `session.interrupt` | | |
| [ ] 068 | `PUT` | `/api/session/{sessionID}/environment` | `session.environment` | | |
| [ ] 069 | `POST` | `/api/session/{sessionID}/view` | `session.view` | | |
## Group 6: Session history and recovery
**Endpoints:** 13
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 070 | `POST` | `/api/session/import` | `session.import` | | |
| [ ] 071 | `GET` | `/api/session/{sessionID}/export` | `session.export` | | |
| [ ] 072 | `POST` | `/api/session/{sessionID}/revert/stage` | `session.revert.stage` | | |
| [ ] 073 | `POST` | `/api/session/{sessionID}/revert/clear` | `session.revert.clear` | | |
| [ ] 074 | `POST` | `/api/session/{sessionID}/revert/commit` | `session.revert.commit` | | |
| [ ] 075 | `GET` | `/api/session/{sessionID}/context` | `session.context` | | |
| [ ] 076 | `GET` | `/api/session/{sessionID}/diff` | `session.diff` | | |
| [ ] 077 | `GET` | `/api/session/{sessionID}/instructions/entries` | `session.instructions.entry.list` | | |
| [ ] 078 | `PUT` | `/api/session/{sessionID}/instructions/entries/{key}` | `session.instructions.entry.put` | | |
| [ ] 079 | `DELETE` | `/api/session/{sessionID}/instructions/entries/{key}` | `session.instructions.entry.remove` | | |
| [ ] 080 | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | | |
| [ ] 081 | `GET` | `/api/session/{sessionID}/message/{messageID}` | `session.message` | | |
| [ ] 082 | `GET` | `/api/session/{sessionID}/message` | `message.list` | | |
## Group 7: Inbox, permissions, and forms
**Endpoints:** 19
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 083 | `GET` | `/api/session/{sessionID}/inbox` | `session.inbox.list` | | |
| [ ] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | | |
| [ ] 085 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/steer` | `session.inbox.steer` | | |
| [ ] 086 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/queue` | `session.inbox.queue` | | |
| [ ] 087 | `GET` | `/api/form/request` | `form.request.list` | | |
| [ ] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | | |
| [ ] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | | |
| [ ] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | | |
| [ ] 091 | `GET` | `/api/session/{sessionID}/form/{formID}/state` | `session.form.state` | | |
| [ ] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | | |
| [ ] 093 | `POST` | `/api/session/{sessionID}/form/{formID}/cancel` | `session.form.cancel` | | |
| [ ] 094 | `GET` | `/api/permission/request` | `permission.request.list` | | |
| [ ] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | | |
| [ ] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | | |
| [ ] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | | |
| [ ] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | | |
| [ ] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | | |
| [ ] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | | |
| [ ] 101 | `PUT` | `/api/session/{sessionID}/permission/rules` | `session.permission.rules` | | |
## Group 8: Filesystem, worktrees, and VCS
**Endpoints:** 12
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 102 | `GET` | `/api/fs/read/*` | `fs.read` | | |
| [ ] 103 | `GET` | `/api/fs/list` | `fs.list` | | |
| [ ] 104 | `GET` | `/api/fs/find` | `fs.find` | | |
| [ ] 105 | `GET` | `/api/worktree` | `worktree.list` | | |
| [ ] 106 | `POST` | `/api/worktree` | `worktree.create` | | |
| [ ] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | | |
| [ ] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | | |
| [ ] 109 | `GET` | `/api/vcs` | `vcs.get` | | |
| [ ] 110 | `GET` | `/api/vcs/base` | `vcs.base` | | |
| [ ] 111 | `GET` | `/api/vcs/status` | `vcs.status` | | |
| [ ] 112 | `GET` | `/api/vcs/branches` | `vcs.branches` | | |
| [ ] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | | |
## Group 9: PTYs, persistent terminals, and shells
**Endpoints:** 24
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 114 | `GET` | `/api/pty` | `pty.list` | | |
| [ ] 115 | `POST` | `/api/pty` | `pty.create` | | |
| [ ] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | | |
| [ ] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | | |
| [ ] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | | |
| [ ] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | | |
| [ ] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | | |
| [ ] 121 | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | | |
| [ ] 122 | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | | |
| [ ] 123 | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | | |
| [ ] 124 | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | | |
| [ ] 125 | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | | |
| [ ] 126 | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | | |
| [ ] 127 | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | | |
| [ ] 128 | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | | |
| [ ] 129 | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | | |
| [ ] 130 | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | | |
| [ ] 131 | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | | |
| [ ] 132 | `GET` | `/api/shell` | `shell.list` | | |
| [ ] 133 | `POST` | `/api/shell` | `shell.create` | | |
| [ ] 134 | `GET` | `/api/shell/{id}` | `shell.get` | | |
| [ ] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | | |
| [ ] 136 | `PATCH` | `/api/shell/{id}/timeout` | `shell.timeout` | | |
| [ ] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | | |
## Group 10: Events, RPC, and experimental operations
**Endpoints:** 6
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 138 | `POST` | `/api/generate` | `generate.text` | | |
| [ ] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | | |
| [ ] 140 | `GET` | `/api/event` | `event.subscribe` | | |
| [ ] 141 | `GET` | `/api/debug/location` | `debug.location.list` | | |
| [ ] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | | |
| [ ] 143 | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | | |
+35 -35
View File
@@ -31,7 +31,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -53,7 +53,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.2",
"version": "2.0.3",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -176,7 +176,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -202,7 +202,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -216,7 +216,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -252,7 +252,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -279,7 +279,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -296,7 +296,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -320,7 +320,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -340,7 +340,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.82",
@@ -412,7 +412,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -464,7 +464,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -501,7 +501,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -517,7 +517,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -536,7 +536,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -549,7 +549,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -563,7 +563,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -578,7 +578,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -617,7 +617,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -647,7 +647,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -662,7 +662,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -686,7 +686,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -707,7 +707,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -729,7 +729,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -764,7 +764,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -784,7 +784,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -818,7 +818,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -837,7 +837,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -883,7 +883,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -897,7 +897,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -932,7 +932,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -967,7 +967,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -1000,7 +1000,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1041,7 +1041,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.2",
"version": "2.0.3",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+32 -17
View File
@@ -25,6 +25,7 @@ import { BedrockAuth } from "./utils/bedrock-auth.js"
import { BedrockCache } from "./utils/bedrock-cache.js"
import { BedrockMedia } from "./utils/bedrock-media.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { MistralToolID } from "./utils/mistral-tool-id.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
@@ -279,15 +280,19 @@ const removeEmptyToolInputKeys = (input: unknown): unknown => {
)
}
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string): BedrockToolUseBlock => ({
toolUse: {
toolUseId: part.id,
name: part.name,
toolUseId: normalizeID(part.id),
// Models can emit names that Converse rejects when replayed in history.
name: part.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "_",
input: removeEmptyToolInputKeys(part.input),
},
})
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (part: ToolResultPart) {
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (
part: ToolResultPart,
documentNames: Set<string>,
) {
if (part.result.type === "text" || part.result.type === "error")
return [{ text: ProviderShared.toolResultText(part) }]
if (part.result.type === "json") return [{ json: part.result.value }]
@@ -298,22 +303,29 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
content.push({ text: item.text })
continue
}
const media = yield* BedrockMedia.lower({
type: "media",
mediaType: item.mime,
data: item.uri,
filename: item.name,
})
content.push(media)
const media = yield* BedrockMedia.lower(
{
type: "media",
mediaType: item.mime,
data: item.uri,
filename: item.name,
},
documentNames,
)
content.push(...media)
}
return content
})
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (part: ToolResultPart) {
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
part: ToolResultPart,
documentNames: Set<string>,
normalizeID: (id: string) => string,
) {
return {
toolResult: {
toolUseId: part.id,
content: yield* lowerToolResultContent(part),
toolUseId: normalizeID(part.id),
content: yield* lowerToolResultContent(part, documentNames),
status: part.result.type === "error" ? "error" : "success",
},
} satisfies BedrockToolResultBlock
@@ -324,6 +336,9 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
breakpoints: BedrockCache.Breakpoints,
) {
const messages: BedrockMessage[] = []
const documentNames = new Set<string>()
// Mistral can reject replay IDs even when they satisfy Converse's broader ID syntax.
const normalizeID = request.model.id.includes("mistral.") ? MistralToolID.normalizer(request) : (id: string) => id
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
for (const message of request.messages) {
@@ -347,7 +362,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
if (part.type === "media") {
content.push(yield* BedrockMedia.lower(part))
content.push(...(yield* BedrockMedia.lower(part, documentNames)))
continue
}
}
@@ -388,7 +403,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
if (part.type === "tool-call") {
content.push(lowerToolCall(part))
content.push(lowerToolCall(part, normalizeID))
continue
}
}
@@ -400,7 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
content.push(yield* lowerToolResult(part))
content.push(yield* lowerToolResult(part, documentNames, normalizeID))
const cachePoint = BedrockCache.block(breakpoints, part.cache)
if (cachePoint) content.push(cachePoint)
}
+5 -33
View File
@@ -21,11 +21,11 @@ import {
import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { MistralToolID } from "./utils/mistral-tool-id.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "mistral-chat"
const DONE = "[DONE]" as const
const TOOL_ID = /^[A-Za-z0-9]{9}$/
export const DEFAULT_BASE_URL = "https://api.mistral.ai/v1"
export const PATH = "/chat/completions"
@@ -223,34 +223,6 @@ const MistralEvent = Schema.StructWithRest(
type MistralEvent = Schema.Schema.Type<typeof MistralEvent>
const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)])
const hashID = (value: string) => {
const hash = (seed: number) => {
let result = seed
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
return (result >>> 0).toString(36)
}
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
}
const toolIDNormalizer = (request: LLMRequest) => {
const ids = request.messages.flatMap((message) =>
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
)
const used = new Set(ids.filter((id) => TOOL_ID.test(id)))
const normalized = new Map<string, string>()
return (id: string) => {
if (TOOL_ID.test(id)) return id
const previous = normalized.get(id)
if (previous) return previous
let attempt = 0
let candidate = hashID(id)
while (used.has(candidate)) candidate = hashID(`${id}:${++attempt}`)
used.add(candidate)
normalized.set(id, candidate)
return candidate
}
}
const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
const url = typeof part.data === "string" && /^(?:https?:|data:)/.test(part.data) ? part.data : media.dataUrl
@@ -359,7 +331,7 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
})
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
const normalizeID = toolIDNormalizer(request)
const normalizeID = MistralToolID.normalizer(request)
const messages: MistralMessage[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
for (const message of request.messages) {
@@ -582,13 +554,13 @@ const appendContent = (
}
const normalizeStreamToolID = (state: ParserState, source: string) => {
if (TOOL_ID.test(source))
if (MistralToolID.valid.test(source))
return { id: source, state: { ...state, usedToolIDs: new Set([...state.usedToolIDs, source]) } }
const previous = state.toolIDs.get(source)
if (previous) return { id: previous, state }
let attempt = 0
let id = hashID(source)
while (state.usedToolIDs.has(id)) id = hashID(`${source}:${++attempt}`)
let id = MistralToolID.hash(source)
while (state.usedToolIDs.has(id)) id = MistralToolID.hash(`${source}:${++attempt}`)
return {
id,
state: {
@@ -57,6 +57,25 @@ const documentBlock = (name: string, format: DocumentFormat, bytes: string): Doc
},
})
function documentName(filename: string | undefined, names: Set<string>) {
const base =
(filename ?? "")
.replace(/\.[^.]*$/, "")
.replace(/[^a-zA-Z0-9 ()[\]-]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 200)
.trim() || "document"
let name = base
// Converse requires labels to be unique across the entire request, including tool results.
for (let index = 2; names.has(name); index++) {
const suffix = ` ${index}`
name = `${base.slice(0, 200 - suffix.length).trimEnd()}${suffix}`
}
names.add(name)
return name
}
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
const media = ProviderShared.normalizeMedia(part)
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
@@ -72,19 +91,26 @@ const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: Media
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
// get an image-specific error so the caller knows it's a format-support issue,
// not a kind-detection issue.
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart) {
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart, documentNames: Set<string>) {
const mime = part.mediaType.toLowerCase()
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
if (imageFormat) {
return { image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock
return [{ image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock]
}
if (mime.startsWith("image/"))
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
if (documentFormat) {
if (!part.filename)
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
const name = documentName(part.filename, documentNames)
const block = documentBlock(name, documentFormat, yield* mediaBase64(part))
return part.filename !== undefined && part.filename !== name
? [
{
text: `Attached file ${ProviderShared.encodeJson(part.filename)} has document label ${ProviderShared.encodeJson(name)}.`,
},
block,
]
: [block]
}
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
})
@@ -0,0 +1,34 @@
import type { LLMRequest } from "../../schema/index.js"
export const valid = /^[A-Za-z0-9]{9}$/
export const hash = (value: string) => {
const hash = (seed: number) => {
let result = seed
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
return (result >>> 0).toString(36)
}
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
}
export const normalizer = (request: LLMRequest) => {
const ids = request.messages.flatMap((message) =>
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
)
// Reserve valid IDs before projecting any history, including IDs encountered later.
const used = new Set(ids.filter((id) => valid.test(id)))
const normalized = new Map<string, string>()
return (id: string) => {
if (valid.test(id)) return id
const previous = normalized.get(id)
if (previous) return previous
let attempt = 0
let candidate = hash(id)
while (used.has(candidate)) candidate = hash(`${id}:${++attempt}`)
used.add(candidate)
normalized.set(id, candidate)
return candidate
}
}
export * as MistralToolID from "./mistral-tool-id.js"
@@ -1,7 +1,7 @@
import { EventStreamCodec } from "@smithy/eventstream-codec"
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
import { describe, expect } from "bun:test"
import { Effect, Encoding, Ref, Stream } from "effect"
import { Effect, Encoding, Ref, Schema, Stream } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import {
CacheHint,
@@ -11,9 +11,11 @@ import {
LLMEvent,
LLMRequest,
Message,
Tool,
ToolCallPart,
ToolChoice,
ToolDefinition,
ToolRuntime,
} from "../../src/index.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
@@ -394,6 +396,45 @@ describe("Bedrock Converse route", () => {
})
}),
)
;[
{ name: "browser.tabs.open", expected: "browser_tabs_open" },
{ name: "$lookup", expected: "_lookup" },
{ name: "", expected: "_" },
{ name: " ", expected: "___" },
{ name: "a".repeat(65), expected: "a".repeat(64) },
{ name: "lookup_123-ABC", expected: "lookup_123-ABC" },
{ name: "a".repeat(64), expected: "a".repeat(64) },
].forEach((item) => {
it.effect(`replays historical tool name ${JSON.stringify(item.name)} within Bedrock constraints`, () =>
Effect.gen(function* () {
const call = ToolCallPart.make({ id: "call_unknown", name: item.name, input: { query: "weather" } })
const error = `No tool named "${item.name}" is currently available. Please use a tool from the available tool list.`
const request = LLM.request({
model,
cache: "none",
tools: [ToolDefinition.make({ name: "execute", description: "Run code", inputSchema: { type: "object" } })],
messages: [
Message.user("Check the weather"),
Message.assistant([call]),
Message.tool({ id: call.id, name: call.name, result: error, resultType: "error" }),
Message.user("Say OK"),
],
})
const prepared = yield* compileRequest(request)
expect(prepared.body.messages[1].content).toEqual([
{ toolUse: { toolUseId: call.id, name: item.expected, input: call.input } },
])
expect(prepared.body.messages[2].content).toEqual([
{ toolResult: { toolUseId: call.id, content: [{ text: error }], status: "error" } },
{ text: "Say OK" },
])
expect(prepared.body.toolConfig.tools.map((tool) => tool.toolSpec.name)).toEqual(["execute"])
expect(request.messages[1].content[0]).toEqual(call)
expect(call.name).toBe(item.name)
}),
)
})
it.effect("removes empty keys recursively from outbound tool inputs without mutating history", () =>
Effect.gen(function* () {
@@ -797,6 +838,57 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("rejects a provider-emitted dotted name before normalizing its replay", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(baseRequest).pipe(
Effect.provide(
fixedBytes(
eventStreamBody(
[
"contentBlockStart",
{ contentBlockIndex: 0, start: { toolUse: { toolUseId: "call_unknown", name: "browser.tabs.open" } } },
],
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "tool_use" }],
),
),
),
)
const call = response.toolCalls[0]
if (!call) throw new Error("Expected a tool call")
expect(call.name).toBe("browser.tabs.open")
const dispatched = yield* ToolRuntime.dispatch(
{
browser_tabs_open: Tool.make({
description: "Open a tab",
parameters: Schema.Struct({}),
success: Schema.String,
execute: () => Effect.die("A normalized replay name must not select an executor"),
}),
},
call,
)
expect(dispatched.result).toEqual({
type: "error",
value:
'No tool named "browser.tabs.open" is currently available. Please use a tool from the available tool list.',
})
const prepared = yield* compileRequest(
LLM.request({
model,
cache: "none",
messages: [response.message, Message.tool({ id: call.id, name: call.name, result: dispatched.result })],
}),
)
expect(prepared.body.messages[0].content).toEqual([
{ toolUse: { toolUseId: call.id, name: "browser_tabs_open", input: {} } },
])
expect(response.toolCalls[0]?.name).toBe("browser.tabs.open")
}),
)
it.effect("ignores tool deltas without an open tool block", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -1673,29 +1765,141 @@ describe("Bedrock Converse route", () => {
role: "user",
content: [
{ text: "Summarize these documents." },
{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
{ document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } },
{ text: 'Attached file "report.pdf" has document label "report".' },
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
{ text: 'Attached file "data.csv" has document label "data".' },
{ document: { format: "csv", name: "data", source: { bytes: "Q1NWREFUQQ==" } } },
],
},
],
})
}),
)
;[
{
label: "filename punctuation",
filename: "report_v1.2?.pdf",
expected: "report v1 2",
duplicate: "report v1 2 2",
},
{
label: "repeated whitespace",
filename: " Quarterly\t \n report.txt",
expected: "Quarterly report",
duplicate: "Quarterly report 2",
},
{
label: "allowed characters",
filename: "Report - Final (v2) [2026]",
expected: "Report - Final (v2) [2026]",
duplicate: "Report - Final (v2) [2026] 2",
},
{ label: "accented filename", filename: "résumé.pdf", expected: "r sum", duplicate: "r sum 2" },
{ label: "non-Latin filename", filename: "報告書.pdf", expected: "document", duplicate: "document 2" },
{ label: "missing filename", filename: undefined, expected: "document", duplicate: "document 2" },
{ label: "empty filename", filename: "", expected: "document", duplicate: "document 2" },
{ label: "blank filename", filename: " \t\n", expected: "document", duplicate: "document 2" },
{ label: "extension-only filename", filename: ".pdf", expected: "document", duplicate: "document 2" },
{ label: "symbols-only filename", filename: "@@@.pdf", expected: "document", duplicate: "document 2" },
{
label: "overlong filename",
filename: `${"a".repeat(201)}.txt`,
expected: "a".repeat(200),
duplicate: `${"a".repeat(198)} 2`,
},
{
label: "maximum-length label",
filename: "a".repeat(200),
expected: "a".repeat(200),
duplicate: `${"a".repeat(198)} 2`,
},
{
label: "whitespace at truncation",
filename: `${"a".repeat(199)} b.txt`,
expected: "a".repeat(199),
duplicate: `${"a".repeat(198)} 2`,
},
].forEach((item) => {
it.effect(`normalizes ${item.label} in user and tool-result documents`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
cache: "none",
messages: [
Message.user([
{ type: "text", text: "Read this document" },
{ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: item.filename },
]),
Message.assistant([ToolCallPart.make({ id: "call_read", name: "read", input: {} })]),
Message.tool({
id: "call_read",
name: "read",
result: {
type: "content",
value: [
{ type: "text", text: "Read successfully" },
{
type: "file",
uri: "data:application/pdf;base64,UERGREFUQQ==",
mime: "application/pdf",
name: item.filename,
},
],
},
}),
],
})
const original = JSON.stringify(request.messages)
const first = yield* compileRequest(request)
const second = yield* compileRequest(request)
const expected = { format: "pdf", name: item.expected, source: { bytes: "UERGREFUQQ==" } }
it.effect("requires names for document media", () =>
expect(first.body.messages[0].content.find((part) => "document" in part)?.document).toEqual(expected)
expect(
first.body.messages[2].content[0].toolResult.content.find((part) => "document" in part)?.document,
).toEqual({
...expected,
name: item.duplicate,
})
expect(second.body).toEqual(first.body)
expect(JSON.stringify(request.messages)).toBe(original)
}),
)
})
it.effect("keeps colliding document labels distinct within a request", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
cache: "none",
messages: [
Message.user([
{ type: "text", text: "Read these documents" },
...["report_v1.txt", "report#v1.txt", "report v1 2.txt", "report v1.txt"].map((filename) => ({
type: "media" as const,
mediaType: "text/plain",
data: "SGVsbG8=",
filename,
})),
]),
],
}),
).pipe(Effect.flip)
expect(error.message).toContain("document media requires a filename")
)
expect(prepared.body.messages[0].content.slice(1)).toEqual([
{ text: 'Attached file "report_v1.txt" has document label "report v1".' },
{ document: { format: "txt", name: "report v1", source: { bytes: "SGVsbG8=" } } },
{ text: 'Attached file "report#v1.txt" has document label "report v1 2".' },
{ document: { format: "txt", name: "report v1 2", source: { bytes: "SGVsbG8=" } } },
{ text: 'Attached file "report v1 2.txt" has document label "report v1 2 2".' },
{ document: { format: "txt", name: "report v1 2 2", source: { bytes: "SGVsbG8=" } } },
{ text: 'Attached file "report v1.txt" has document label "report v1 3".' },
{ document: { format: "txt", name: "report v1 3", source: { bytes: "SGVsbG8=" } } },
])
}),
)
it.effect("passes named document-only messages through for provider validation", () =>
it.effect("annotates renamed document-only messages with their original filename", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -1715,12 +1919,44 @@ describe("Bedrock Converse route", () => {
expect(prepared.body.messages).toEqual([
{
role: "user",
content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }],
content: [
{ text: 'Attached file "report.pdf" has document label "report".' },
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
],
},
])
}),
)
it.effect("quotes original filenames in annotations and omits redundant mappings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
cache: "none",
messages: [
Message.user([
{ type: "text", text: "Read these documents" },
...["report", undefined, 'report "final"\n.pdf'].map((filename) => ({
type: "media" as const,
mediaType: "text/plain",
data: "SGVsbG8=",
filename,
})),
]),
],
}),
)
expect(prepared.body.messages[0].content).toEqual([
{ text: "Read these documents" },
{ document: { format: "txt", name: "report", source: { bytes: "SGVsbG8=" } } },
{ document: { format: "txt", name: "document", source: { bytes: "SGVsbG8=" } } },
{ text: 'Attached file "report \\"final\\"\\n.pdf" has document label "report final".' },
{ document: { format: "txt", name: "report final", source: { bytes: "SGVsbG8=" } } },
])
}),
)
it.effect("lowers document media in tool results", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1740,7 +1976,7 @@ describe("Bedrock Converse route", () => {
type: "file",
uri: "data:application/pdf;base64,UERGREFUQQ==",
mime: "application/pdf",
name: "report",
name: "report.pdf",
},
],
},
@@ -1763,6 +1999,7 @@ describe("Bedrock Converse route", () => {
status: "success",
content: [
{ text: "Read successfully" },
{ text: 'Attached file "report.pdf" has document label "report".' },
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
],
},
@@ -0,0 +1,86 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, Message, ToolCallPart } from "../../src/index.js"
import { AmazonBedrock } from "../../src/providers.js"
import { BedrockConverse } from "../../src/protocols/bedrock-converse.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
const bedrock = AmazonBedrock.configure({ baseURL: "https://bedrock.test", apiKey: "test-key" })
const history = (ids: string[]) => [
Message.user("Read every label"),
Message.assistant(ids.map((id, index) => ToolCallPart.make({ id, name: "lookup", input: { label: index } }))),
...ids.map((id, index) => Message.tool({ id, name: "lookup", result: `value-${index}` })).toReversed(),
]
for (const model of [
"mistral.mistral-large-2407-v1:0",
"us.mistral.pixtral-large-2502-v1:0",
"arn:aws:bedrock:us-east-1::foundation-model/mistral.pixtral-large-2502-v1:0",
]) {
it.effect(`preserves distinct call/result pairs and history for ${model}`, () =>
Effect.gen(function* () {
const request = LLM.request({ model: bedrock.model(model), messages: history(["a"]), cache: "none" })
const first = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)(
(yield* compileRequest(request)).body,
)
const reserved = first.messages.flatMap((message) =>
message.content.flatMap((part) => ("toolUse" in part ? [part.toolUse.toolUseId] : [])),
)[0]
if (!reserved) throw new Error("Expected projected tool call")
const ids = [
"a",
"b",
"tooluse_GQn7COr2AN8bw2oVKYyYzZ",
"tooluse_abcdefghi111111111",
"tooluse_abcdefghi222222222",
"call_other-provider-id",
"Ab12Cd34E",
reserved,
]
const messages = history(ids)
const before = structuredClone(messages)
const input = LLM.request({ model: bedrock.model(model), messages, cache: "none" })
const prepared = yield* compileRequest(input)
const body = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)(prepared.body)
const calls = body.messages.flatMap((message) =>
message.content.flatMap((part) => ("toolUse" in part ? [part.toolUse.toolUseId] : [])),
)
const results = body.messages.flatMap((message) =>
message.content.flatMap((part) => ("toolResult" in part ? [part.toolResult.toolUseId] : [])),
)
expect(calls).toHaveLength(ids.length)
expect(new Set(calls).size).toBe(ids.length)
calls.forEach((id) => expect(id).toMatch(/^[A-Za-z0-9]{9}$/))
expect(results).toEqual(calls.toReversed())
expect(calls[0]).not.toBe(reserved)
expect(calls.slice(-2)).toEqual(ids.slice(-2))
expect((yield* compileRequest(input)).body).toEqual(prepared.body)
expect(structuredClone(messages)).toEqual(before)
}),
)
}
it.effect("leaves non-Mistral Bedrock IDs unchanged", () =>
Effect.gen(function* () {
const ids = ["a", "tooluse_abcdefghi111111111", "tooluse_abcdefghi222222222", "Ab12Cd34E"]
const prepared = yield* compileRequest(
LLM.request({
model: bedrock.model("global.anthropic.claude-haiku-4-5-20251001-v1:0"),
messages: history(ids),
cache: "none",
}),
)
const body = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)(prepared.body)
expect(
body.messages.flatMap((message) =>
message.content.flatMap((part) => ("toolUse" in part ? [part.toolUse.toolUseId] : [])),
),
).toEqual(ids)
expect(
body.messages.flatMap((message) =>
message.content.flatMap((part) => ("toolResult" in part ? [part.toolResult.toolUseId] : [])),
),
).toEqual(ids.toReversed())
}),
)
@@ -76,7 +76,7 @@ async function mockServers(page: Page, requests: string[]) {
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
if (url.pathname === "/api/project") {
const project = {
id: current.projectID,
canonical: current.directory,
@@ -84,7 +84,7 @@ async function mockServers(page: Page, requests: string[]) {
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
return json(route, [project])
}
if (url.pathname === "/api/location")
return json(route, {
@@ -398,8 +398,6 @@ async function mockServers(
},
])
}
if (url.pathname === "/api/project/current")
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
if (url.pathname === "/api/session")
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
if (url.pathname === "/api/session/active")
@@ -592,7 +592,7 @@ async function mockServer(page: Page) {
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
return json(route, { location: { directory: sessionA.directory }, data: { resources: [], templates: [] } })
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
if (url.pathname === "/api/project") {
const project = {
id: sessionA.projectID,
canonical: sessionA.directory,
@@ -600,10 +600,7 @@ async function mockServer(page: Page) {
time: { created: 1, updated: 1 },
sandboxes: [],
}
return json(
route,
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
)
return json(route, [project])
}
if (url.pathname === "/api/location")
return json(route, {
-1
View File
@@ -77,7 +77,6 @@ const Group = HttpApiGroup.make("mock")
success: Json,
}),
)
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(HttpApiEndpoint.get("configPreferences", "/api/config/preferences", { success: Json }))
.add(
HttpApiEndpoint.patch("configUpdatePreferences", "/api/config/preferences", {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.2",
"version": "2.0.3",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -110,7 +110,7 @@ export function createHomeController() {
.list({ path: ".", location })
.then(async (files) => {
// TODO: Initialize empty directories when V2 exposes a native Git init API.
return ctx.sdk.api.project.current({ location })
return ctx.sdk.api.location.get({ location }).then((result) => result.project)
})
.then((project) => ctx.sync.child(item, { bootstrap: false })[1]("project", project.id))
.catch(() => undefined)
@@ -2,8 +2,6 @@ import type { Config, Path, Project, ProviderAuthResponse } from "@/runtime/serv
import type {
LocationGetInput,
LocationGetOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectListOutput,
} from "@opencode/client/promise"
import { showToast } from "@/shell/notifications/toast"
@@ -61,7 +59,6 @@ export const loadGlobalConfigQuery = (scope: ServerScope) =>
type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
@@ -132,6 +129,7 @@ export async function bootstrapDirectory(input: {
mcp: boolean
api: {
readonly project: ProjectApi
readonly location: LocationApi
}
store: Store<State>
setStore: SetStoreFunction<State>
@@ -155,8 +153,8 @@ export async function bootstrapDirectory(input: {
seededProject
? undefined
: () =>
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
input.setStore("project", project.id),
retry(() => input.api.location.get({ location: { directory: input.directory } })).then((location) =>
input.setStore("project", location.project.id),
),
].filter((task): task is () => Promise<void> => !!task)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"bin": {
@@ -8,11 +8,11 @@ describe("api request resolution", () => {
{
paths: {
"/api/session/{sessionID}": {
get: { operationId: "v2.session.get" },
get: { operationId: "session.get" },
},
},
},
"v2.session.get",
"session.get",
{ sessionID: "ses/a", workspace: "work" },
),
).toEqual({ method: "GET", path: "/api/session/ses%2Fa?workspace=work" })
@@ -21,8 +21,8 @@ describe("api request resolution", () => {
test("rejects a missing path parameter", () => {
expect(() =>
resolveOperation(
{ paths: { "/api/session/{sessionID}": { get: { operationId: "v2.session.get" } } } },
"v2.session.get",
{ paths: { "/api/session/{sessionID}": { get: { operationId: "session.get" } } } },
"session.get",
{},
),
).toThrow("Missing path parameter: sessionID")
@@ -30,6 +30,6 @@ describe("api request resolution", () => {
test("resolves curl-like method and path input", () => {
expect(rawRequest(["post", "/api/foo"])).toEqual({ method: "POST", path: "/api/foo" })
expect(rawRequest(["v2.session.list"])).toBeUndefined()
expect(rawRequest(["session.list"])).toBeUndefined()
})
})
+14 -36
View File
@@ -1,7 +1,7 @@
/** @jsxImportSource @opentui/solid */
// Split-footer status shown while a freshly launched CLI replaces a
// version-mismatched background service before the TUI attaches.
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer } from "@opentui/core"
import { render, useTerminalDimensions } from "@opentui/solid"
import { OPENCODE_VERSION } from "../version"
import { registerOpencodeSpinner } from "@opencode/tui/component/register-spinner"
@@ -30,17 +30,11 @@ const completionHold = 650
export type Handle = {
readonly begin: (from?: string) => boolean
readonly loading: () => void
readonly finish: () => Promise<Handoff | undefined>
readonly finish: () => Promise<undefined>
readonly fail: (message: string) => Promise<void>
readonly close: () => Promise<void>
}
export type Handoff = {
readonly renderer: CliRenderer
readonly mode: ThemeMode | null
readonly complete: () => void
}
export const make = (): Handle => {
let session: Promise<Session | undefined> | undefined
return {
@@ -72,7 +66,7 @@ export const make = (): Handle => {
type Session = {
readonly loading: () => Promise<void>
readonly finish: () => Promise<Handoff>
readonly finish: () => Promise<undefined>
readonly fail: (message: string) => Promise<void>
readonly close: () => Promise<void>
}
@@ -83,7 +77,6 @@ async function open(from?: string): Promise<Session> {
const [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running")
const [failure, setFailure] = createSignal("")
const [animating, setAnimating] = createSignal(true)
const [visible, setVisible] = createSignal(true)
let resolveOutcome: (() => void) | undefined
const renderer = await createCliRenderer({
stdin: process.stdin,
@@ -101,20 +94,17 @@ async function open(from?: string): Promise<Session> {
externalOutputMode: "capture-stdout",
consoleMode: "disabled",
})
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
await render(
() => (
<Show when={visible()}>
<UpdateFooter
from={from}
active={active}
outcome={outcome}
failure={failure}
animating={animating}
renderer={renderer}
onOutcomeSettled={() => resolveOutcome?.()}
/>
</Show>
<UpdateFooter
from={from}
active={active}
outcome={outcome}
failure={failure}
animating={animating}
renderer={renderer}
onOutcomeSettled={() => resolveOutcome?.()}
/>
),
renderer,
).catch((error) => {
@@ -148,10 +138,8 @@ async function open(from?: string): Promise<Session> {
if (completed) await setTimeout(hold)
}
let closing: Promise<void> | undefined
let transferred = false
const close = () =>
(closing ??= (async () => {
if (transferred) return
setAnimating(false)
if (renderer.isDestroyed) return
renderer.pause()
@@ -174,18 +162,8 @@ async function open(from?: string): Promise<Session> {
await waitForStage()
await transitionTo("success", completionHold)
})
const mode = await terminalMode
renderer.externalOutputMode = "passthrough"
renderer.screenMode = "alternate-screen"
renderer.consoleMode = "console-overlay"
renderer.requestRender()
await Promise.race([renderer.idle(), setTimeout(500)])
transferred = true
return {
renderer,
mode,
complete: () => setVisible(false),
}
await close()
return undefined
},
fail: (message) =>
settle(async () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"repository": {
+11 -8
View File
@@ -18,6 +18,7 @@ import type { PromptInput } from "@opencode/schema/prompt-input"
import type { AgentAttachment } from "@opencode/schema/prompt"
import type { Skill } from "@opencode/schema/skill"
import type { Event } from "@opencode/schema/event"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
import type { Schema } from "effect"
import type { EventLog } from "@opencode/schema/event-log"
@@ -36,7 +37,6 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
import type { Reference } from "@opencode/schema/reference"
import type { Worktree } from "@opencode/schema/worktree"
import type { Vcs } from "@opencode/schema/vcs"
import type { FileDiff } from "@opencode/schema/file-diff"
import type { WebSearch } from "@opencode/schema/websearch"
import type { Config } from "@opencode/schema/config"
@@ -361,6 +361,15 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
export type SessionDiffInput = {
readonly sessionID: Session.ID
readonly from?: SessionMessage.ID | undefined
readonly to?: SessionMessage.ID | undefined
readonly context?: number | undefined
}
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
export type SessionInboxListInput = { readonly sessionID: Session.ID }
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
export type SessionInboxListOperation<E = never> = (
@@ -1150,6 +1159,7 @@ export interface SessionApi<E = never> {
readonly commit: SessionRevertCommitOperation<E>
}
readonly context: SessionContextOperation<E>
readonly diff: SessionDiffOperation<E>
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
@@ -1479,16 +1489,9 @@ export type ProjectUpdateInput = {
export type ProjectUpdateOutput = Project.Info
export type ProjectUpdateOperation<E = never> = (input: ProjectUpdateInput) => Effect.Effect<ProjectUpdateOutput, E>
export type ProjectCurrentInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type ProjectCurrentOutput = Project.Current
export type ProjectCurrentOperation<E = never> = (input?: ProjectCurrentInput) => Effect.Effect<ProjectCurrentOutput, E>
export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly update: ProjectUpdateOperation<E>
readonly current: ProjectCurrentOperation<E>
}
export type FormRequestListInput = {
+14 -8
View File
@@ -68,6 +68,8 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -151,8 +153,6 @@ import type {
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
@@ -599,6 +599,17 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
),
)
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
preserveEffect<SessionDiffOutput>()(
raw["session.diff"]({
params: { sessionID: input["sessionID"] },
query: { from: input["from"], to: input["to"], context: input["context"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
preserveEffect<SessionInboxListOutput>()(
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
@@ -738,6 +749,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
commit: EndpointSessionRevertCommit(raw),
},
context: EndpointSessionContext(raw),
diff: EndpointSessionDiff(raw),
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
@@ -1015,15 +1027,9 @@ const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: Proj
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: ProjectCurrentInput) =>
preserveEffect<ProjectCurrentOutput>()(
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupProject = (raw: RawClient["server.project"]) => ({
list: EndpointProjectList(raw),
update: EndpointProjectUpdate(raw),
current: EndpointProjectCurrent(raw),
})
const EndpointFormRequestList = (raw: RawClient["server.form"]) => (input?: FormRequestListInput) =>
+14 -14
View File
@@ -62,6 +62,8 @@ import type {
SessionRevertCommitOutput,
SessionContextInput,
SessionContextOutput,
SessionDiffInput,
SessionDiffOutput,
SessionInboxListInput,
SessionInboxListOutput,
SessionInboxCancelInput,
@@ -145,8 +147,6 @@ import type {
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
@@ -849,6 +849,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
).then((value) => value.data),
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionDiffOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
query: { from: input["from"], to: input["to"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 500],
empty: false,
},
requestOptions,
).then((value) => value.data),
inbox: {
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionInboxListOutput }>(
@@ -1382,18 +1394,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
request<ProjectCurrentOutput>(
{
method: "GET",
path: `/api/project/current`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
form: {
request: {
+51 -10
View File
@@ -147,6 +147,14 @@ export type SessionProviderContextProvenance = {
endpoint: string
}
export type SessionMessageIdle = {
id: string
metadata?: { [x: string]: JsonValue }
time: { created: number }
type: "idle"
outcome: "succeeded" | "failed" | "interrupted"
}
export type SessionActive = { type: "running" }
export type SessionInboxDelivery = "steer" | "queue"
@@ -302,8 +310,6 @@ export type ProjectCommands = { start?: string }
export type ProjectTime = { created: number; updated: number; initialized?: number }
export type ProjectCurrent = { id: string; directory: string; canonical: string }
export type FormMetadata = { [x: string]: JsonValue }
export type FormValue = string | number | boolean | Array<string>
@@ -2210,6 +2216,7 @@ export type SessionMessageInfo =
| SessionMessageShell
| SessionMessageAssistant
| SessionMessageCompaction
| SessionMessageIdle
export type SessionMessageContentUpdated = {
id: string
@@ -3222,6 +3229,13 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["info"]
@@ -3532,6 +3546,13 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["messages"]
@@ -3842,6 +3863,13 @@ export type SessionImportInput = {
}
}
)
| {
readonly id: string
readonly metadata?: { readonly [x: string]: JsonValue }
readonly time: { readonly created: number }
readonly type: "idle"
readonly outcome: "succeeded" | "failed" | "interrupted"
}
>
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["location"]
@@ -4331,6 +4359,27 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
export type SessionDiffInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly from?: {
readonly from?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["from"]
readonly to?: {
readonly from?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["to"]
readonly context?: {
readonly from?: string | undefined
readonly to?: string | undefined
readonly context?: number | undefined
}["context"]
}
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
@@ -4861,14 +4910,6 @@ export type ProjectUpdateInput = {
export type ProjectUpdateOutput = Project
export type ProjectCurrentInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectCurrentOutput = ProjectCurrent
export type FormRequestListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+12
View File
@@ -1028,6 +1028,18 @@ export function createData(config: CreateDataInput) {
if (currentAssistant) currentAssistant.retry = undefined
})
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
// Mirror the projected idle marker so turn boundaries match before the next message read.
message.insert(event.data.sessionID, {
id: messageIDFromEvent(event.id),
type: "idle",
outcome:
event.type === "session.execution.succeeded"
? "succeeded"
: event.type === "session.execution.failed"
? "failed"
: "interrupted",
time: { created: event.created },
})
// An event can overtake the first read; queue a revalidation when that read is still active.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.2",
"version": "2.0.3",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+97 -180
View File
@@ -9,7 +9,7 @@
"/api/health": {
"get": {
"tags": ["health"],
"operationId": "v2.health.get",
"operationId": "health.get",
"parameters": [],
"security": [],
"responses": {
@@ -70,7 +70,7 @@
"/api/server": {
"get": {
"tags": ["server"],
"operationId": "v2.server.get",
"operationId": "server.get",
"parameters": [],
"security": [],
"responses": {
@@ -122,7 +122,7 @@
"/api/location": {
"get": {
"tags": ["location"],
"operationId": "v2.location.get",
"operationId": "location.get",
"parameters": [
{
"name": "location",
@@ -205,7 +205,7 @@
"/api/agent": {
"get": {
"tags": ["agent"],
"operationId": "v2.agent.list",
"operationId": "agent.list",
"parameters": [
{
"name": "location",
@@ -301,7 +301,7 @@
"/api/plugin": {
"get": {
"tags": ["plugin"],
"operationId": "v2.plugin.list",
"operationId": "plugin.list",
"parameters": [
{
"name": "location",
@@ -397,7 +397,7 @@
"/api/session": {
"get": {
"tags": ["session"],
"operationId": "v2.session.list",
"operationId": "session.list",
"parameters": [
{
"name": "workspace",
@@ -606,7 +606,7 @@
},
"post": {
"tags": ["session"],
"operationId": "v2.session.create",
"operationId": "session.create",
"parameters": [],
"security": [],
"responses": {
@@ -713,7 +713,7 @@
"/api/session/active": {
"get": {
"tags": ["session"],
"operationId": "v2.session.active",
"operationId": "session.active",
"parameters": [],
"security": [],
"responses": {
@@ -767,7 +767,7 @@
"/api/session/{sessionID}": {
"get": {
"tags": ["session"],
"operationId": "v2.session.get",
"operationId": "session.get",
"parameters": [
{
"name": "sessionID",
@@ -845,7 +845,7 @@
},
"delete": {
"tags": ["session"],
"operationId": "v2.session.remove",
"operationId": "session.remove",
"parameters": [
{
"name": "sessionID",
@@ -911,7 +911,7 @@
"/api/session/{sessionID}/fork": {
"post": {
"tags": ["session"],
"operationId": "v2.session.fork",
"operationId": "session.fork",
"parameters": [
{
"name": "sessionID",
@@ -1022,7 +1022,7 @@
"/api/session/{sessionID}/agent": {
"post": {
"tags": ["session"],
"operationId": "v2.session.switchAgent",
"operationId": "session.switchAgent",
"parameters": [
{
"name": "sessionID",
@@ -1105,7 +1105,7 @@
"/api/session/{sessionID}/model": {
"post": {
"tags": ["session"],
"operationId": "v2.session.switchModel",
"operationId": "session.switchModel",
"parameters": [
{
"name": "sessionID",
@@ -1188,7 +1188,7 @@
"/api/session/{sessionID}/rename": {
"post": {
"tags": ["session"],
"operationId": "v2.session.rename",
"operationId": "session.rename",
"parameters": [
{
"name": "sessionID",
@@ -1271,7 +1271,7 @@
"/api/session/{sessionID}/move": {
"post": {
"tags": ["session"],
"operationId": "v2.session.move",
"operationId": "session.move",
"parameters": [
{
"name": "sessionID",
@@ -1378,7 +1378,7 @@
"/api/session/{sessionID}/prompt": {
"post": {
"tags": ["session"],
"operationId": "v2.session.prompt",
"operationId": "session.prompt",
"parameters": [
{
"name": "sessionID",
@@ -1528,7 +1528,7 @@
"/api/session/{sessionID}/command": {
"post": {
"tags": ["session"],
"operationId": "v2.session.command",
"operationId": "session.command",
"parameters": [
{
"name": "sessionID",
@@ -1733,7 +1733,7 @@
"/api/session/{sessionID}/skill": {
"post": {
"tags": ["session"],
"operationId": "v2.session.skill",
"operationId": "session.skill",
"parameters": [
{
"name": "sessionID",
@@ -1844,7 +1844,7 @@
"/api/session/{sessionID}/synthetic": {
"post": {
"tags": ["session"],
"operationId": "v2.session.synthetic",
"operationId": "session.synthetic",
"parameters": [
{
"name": "sessionID",
@@ -1950,7 +1950,7 @@
"/api/session/{sessionID}/shell": {
"post": {
"tags": ["session"],
"operationId": "v2.session.shell",
"operationId": "session.shell",
"parameters": [
{
"name": "sessionID",
@@ -2048,7 +2048,7 @@
"/api/session/{sessionID}/compact": {
"post": {
"tags": ["session"],
"operationId": "v2.session.compact",
"operationId": "session.compact",
"parameters": [
{
"name": "sessionID",
@@ -2166,7 +2166,7 @@
"/api/session/{sessionID}/wait": {
"post": {
"tags": ["session"],
"operationId": "v2.session.wait",
"operationId": "session.wait",
"parameters": [
{
"name": "sessionID",
@@ -2242,7 +2242,7 @@
"/api/session/{sessionID}/revert/stage": {
"post": {
"tags": ["session"],
"operationId": "v2.session.revert.stage",
"operationId": "session.revert.stage",
"parameters": [
{
"name": "sessionID",
@@ -2377,7 +2377,7 @@
"/api/session/{sessionID}/revert/clear": {
"post": {
"tags": ["session"],
"operationId": "v2.session.revert.clear",
"operationId": "session.revert.clear",
"parameters": [
{
"name": "sessionID",
@@ -2462,7 +2462,7 @@
"/api/session/{sessionID}/revert/commit": {
"post": {
"tags": ["session"],
"operationId": "v2.session.revert.commit",
"operationId": "session.revert.commit",
"parameters": [
{
"name": "sessionID",
@@ -2537,7 +2537,7 @@
"/api/session/{sessionID}/context": {
"get": {
"tags": ["session"],
"operationId": "v2.session.context",
"operationId": "session.context",
"parameters": [
{
"name": "sessionID",
@@ -2630,7 +2630,7 @@
"/api/session/{sessionID}/instructions/entries": {
"get": {
"tags": ["session"],
"operationId": "v2.session.instructions.entry.list",
"operationId": "session.instructions.entry.list",
"parameters": [
{
"name": "sessionID",
@@ -2713,7 +2713,7 @@
"/api/session/{sessionID}/instructions/entries/{key}": {
"put": {
"tags": ["session"],
"operationId": "v2.session.instructions.entry.put",
"operationId": "session.instructions.entry.put",
"parameters": [
{
"name": "sessionID",
@@ -2800,7 +2800,7 @@
},
"delete": {
"tags": ["session"],
"operationId": "v2.session.instructions.entry.remove",
"operationId": "session.instructions.entry.remove",
"parameters": [
{
"name": "sessionID",
@@ -2874,7 +2874,7 @@
"/api/experimental/session/{sessionID}/log": {
"get": {
"tags": ["session"],
"operationId": "v2.session.log",
"operationId": "session.log",
"parameters": [
{
"name": "sessionID",
@@ -3059,7 +3059,7 @@
"/api/session/{sessionID}/interrupt": {
"post": {
"tags": ["session"],
"operationId": "v2.session.interrupt",
"operationId": "session.interrupt",
"parameters": [
{
"name": "sessionID",
@@ -3125,7 +3125,7 @@
"/api/session/{sessionID}/background": {
"post": {
"tags": ["session"],
"operationId": "v2.session.background",
"operationId": "session.background",
"parameters": [
{
"name": "sessionID",
@@ -3191,7 +3191,7 @@
"/api/session/{sessionID}/message/{messageID}": {
"get": {
"tags": ["session"],
"operationId": "v2.session.message",
"operationId": "session.message",
"parameters": [
{
"name": "sessionID",
@@ -3287,7 +3287,7 @@
"/api/session/{sessionID}/message": {
"get": {
"tags": ["session"],
"operationId": "v2.message.list",
"operationId": "message.list",
"parameters": [
{
"name": "sessionID",
@@ -3426,7 +3426,7 @@
"/api/model": {
"get": {
"tags": ["model"],
"operationId": "v2.model.list",
"operationId": "model.list",
"parameters": [
{
"name": "location",
@@ -3532,7 +3532,7 @@
"/api/model/default": {
"get": {
"tags": ["model"],
"operationId": "v2.model.default",
"operationId": "model.default",
"parameters": [
{
"name": "location",
@@ -3642,7 +3642,7 @@
"/api/generate": {
"post": {
"tags": ["generate"],
"operationId": "v2.generate.text",
"operationId": "generate.text",
"parameters": [
{
"name": "location",
@@ -3769,7 +3769,7 @@
"/api/provider": {
"get": {
"tags": ["provider"],
"operationId": "v2.provider.list",
"operationId": "provider.list",
"parameters": [
{
"name": "location",
@@ -3875,7 +3875,7 @@
"/api/provider/{providerID}": {
"get": {
"tags": ["provider"],
"operationId": "v2.provider.get",
"operationId": "provider.get",
"parameters": [
{
"name": "providerID",
@@ -3996,7 +3996,7 @@
"/api/integration": {
"get": {
"tags": ["integration"],
"operationId": "v2.integration.list",
"operationId": "integration.list",
"parameters": [
{
"name": "location",
@@ -4092,7 +4092,7 @@
"/api/integration/{integrationID}": {
"get": {
"tags": ["integration"],
"operationId": "v2.integration.get",
"operationId": "integration.get",
"parameters": [
{
"name": "integrationID",
@@ -4200,7 +4200,7 @@
"/api/integration/{integrationID}/connect/key": {
"post": {
"tags": ["integration"],
"operationId": "v2.integration.connect.key",
"operationId": "integration.connect.key",
"parameters": [
{
"name": "integrationID",
@@ -4318,7 +4318,7 @@
"/api/integration/{integrationID}/connect/oauth": {
"post": {
"tags": ["integration"],
"operationId": "v2.integration.connect.oauth",
"operationId": "integration.connect.oauth",
"parameters": [
{
"name": "integrationID",
@@ -4459,7 +4459,7 @@
"/api/integration/attempt/{attemptID}": {
"get": {
"tags": ["integration"],
"operationId": "v2.integration.attempt.status",
"operationId": "integration.attempt.status",
"parameters": [
{
"name": "attemptID",
@@ -4558,7 +4558,7 @@
},
"delete": {
"tags": ["integration"],
"operationId": "v2.integration.attempt.cancel",
"operationId": "integration.attempt.cancel",
"parameters": [
{
"name": "attemptID",
@@ -4642,7 +4642,7 @@
"/api/integration/attempt/{attemptID}/complete": {
"post": {
"tags": ["integration"],
"operationId": "v2.integration.attempt.complete",
"operationId": "integration.attempt.complete",
"parameters": [
{
"name": "attemptID",
@@ -4756,7 +4756,7 @@
"/api/mcp": {
"get": {
"tags": ["mcp"],
"operationId": "v2.mcp.list",
"operationId": "mcp.list",
"parameters": [
{
"name": "location",
@@ -4852,7 +4852,7 @@
"/api/mcp/resource": {
"get": {
"tags": ["mcp"],
"operationId": "v2.mcp.resource.catalog",
"operationId": "mcp.resource.catalog",
"parameters": [
{
"name": "location",
@@ -4945,7 +4945,7 @@
"/api/credential/{credentialID}": {
"patch": {
"tags": ["credential"],
"operationId": "v2.credential.update",
"operationId": "credential.update",
"parameters": [
{
"name": "credentialID",
@@ -5044,7 +5044,7 @@
},
"delete": {
"tags": ["credential"],
"operationId": "v2.credential.remove",
"operationId": "credential.remove",
"parameters": [
{
"name": "credentialID",
@@ -5128,7 +5128,7 @@
"/api/project": {
"get": {
"tags": ["project"],
"operationId": "v2.project.list",
"operationId": "project.list",
"parameters": [],
"security": [],
"responses": {
@@ -5170,93 +5170,10 @@
"summary": "List projects"
}
},
"/api/project/current": {
"get": {
"tags": ["project"],
"operationId": "v2.project.current",
"parameters": [
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
},
"workspace": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
"responses": {
"200": {
"description": "Project.Current",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Project.Current"
}
}
}
},
"400": {
"description": "InvalidRequestError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvalidRequestError"
}
}
}
},
"401": {
"description": "UnauthorizedError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UnauthorizedError"
}
}
}
}
},
"description": "Resolve the project for the requested location.",
"summary": "Get current project"
}
},
"/api/project/{projectID}/directories": {
"get": {
"tags": ["project"],
"operationId": "v2.project.directories",
"operationId": "project.directories",
"parameters": [
{
"name": "projectID",
@@ -5347,7 +5264,7 @@
"/api/form/request": {
"get": {
"tags": ["form"],
"operationId": "v2.form.request.list",
"operationId": "form.request.list",
"parameters": [
{
"name": "location",
@@ -5450,7 +5367,7 @@
"/api/session/{sessionID}/form": {
"get": {
"tags": ["form"],
"operationId": "v2.session.form.list",
"operationId": "session.form.list",
"parameters": [
{
"name": "sessionID",
@@ -5533,7 +5450,7 @@
},
"post": {
"tags": ["form"],
"operationId": "v2.session.form.create",
"operationId": "session.form.create",
"parameters": [
{
"name": "sessionID",
@@ -5642,7 +5559,7 @@
"/api/session/{sessionID}/form/{formID}": {
"get": {
"tags": ["form"],
"operationId": "v2.session.form.get",
"operationId": "session.form.get",
"parameters": [
{
"name": "sessionID",
@@ -5740,7 +5657,7 @@
"/api/session/{sessionID}/form/{formID}/state": {
"get": {
"tags": ["form"],
"operationId": "v2.session.form.state",
"operationId": "session.form.state",
"parameters": [
{
"name": "sessionID",
@@ -5831,7 +5748,7 @@
"/api/session/{sessionID}/form/{formID}/reply": {
"post": {
"tags": ["form"],
"operationId": "v2.session.form.reply",
"operationId": "session.form.reply",
"parameters": [
{
"name": "sessionID",
@@ -5935,7 +5852,7 @@
"/api/session/{sessionID}/form/{formID}/cancel": {
"post": {
"tags": ["form"],
"operationId": "v2.session.form.cancel",
"operationId": "session.form.cancel",
"parameters": [
{
"name": "sessionID",
@@ -6022,7 +5939,7 @@
"/api/permission/request": {
"get": {
"tags": ["permission"],
"operationId": "v2.permission.request.list",
"operationId": "permission.request.list",
"parameters": [
{
"name": "location",
@@ -6118,7 +6035,7 @@
"/api/permission/saved": {
"get": {
"tags": ["permission"],
"operationId": "v2.permission.saved.list",
"operationId": "permission.saved.list",
"parameters": [
{
"name": "projectID",
@@ -6186,7 +6103,7 @@
"/api/permission/saved/{id}": {
"delete": {
"tags": ["permission"],
"operationId": "v2.permission.saved.remove",
"operationId": "permission.saved.remove",
"parameters": [
{
"name": "id",
@@ -6230,7 +6147,7 @@
"/api/session/{sessionID}/permission": {
"post": {
"tags": ["permission"],
"operationId": "v2.session.permission.create",
"operationId": "session.permission.create",
"parameters": [
{
"name": "sessionID",
@@ -6383,7 +6300,7 @@
},
"get": {
"tags": ["permission"],
"operationId": "v2.session.permission.list",
"operationId": "session.permission.list",
"parameters": [
{
"name": "sessionID",
@@ -6466,7 +6383,7 @@
"/api/session/{sessionID}/permission/{requestID}": {
"get": {
"tags": ["permission"],
"operationId": "v2.session.permission.get",
"operationId": "session.permission.get",
"parameters": [
{
"name": "sessionID",
@@ -6562,7 +6479,7 @@
"/api/session/{sessionID}/permission/{requestID}/reply": {
"post": {
"tags": ["permission"],
"operationId": "v2.session.permission.reply",
"operationId": "session.permission.reply",
"parameters": [
{
"name": "sessionID",
@@ -6671,7 +6588,7 @@
"/api/fs/read/*": {
"get": {
"tags": ["filesystem"],
"operationId": "v2.fs.read",
"operationId": "fs.read",
"parameters": [
{
"name": "location",
@@ -6755,7 +6672,7 @@
"/api/fs/list": {
"get": {
"tags": ["filesystem"],
"operationId": "v2.fs.list",
"operationId": "fs.list",
"parameters": [
{
"name": "location",
@@ -6866,7 +6783,7 @@
"/api/fs/find": {
"get": {
"tags": ["filesystem"],
"operationId": "v2.fs.find",
"operationId": "fs.find",
"parameters": [
{
"name": "location",
@@ -6994,7 +6911,7 @@
"/api/command": {
"get": {
"tags": ["command"],
"operationId": "v2.command.list",
"operationId": "command.list",
"parameters": [
{
"name": "location",
@@ -7090,7 +7007,7 @@
"/api/skill": {
"get": {
"tags": ["skill"],
"operationId": "v2.skill.list",
"operationId": "skill.list",
"parameters": [
{
"name": "location",
@@ -7186,7 +7103,7 @@
"/api/event": {
"get": {
"tags": ["event"],
"operationId": "v2.event.subscribe",
"operationId": "event.subscribe",
"parameters": [],
"security": [],
"responses": {
@@ -7309,7 +7226,7 @@
"/api/pty": {
"get": {
"tags": ["pty"],
"operationId": "v2.pty.list",
"operationId": "pty.list",
"parameters": [
{
"name": "location",
@@ -7403,7 +7320,7 @@
},
"post": {
"tags": ["pty"],
"operationId": "v2.pty.create",
"operationId": "pty.create",
"parameters": [
{
"name": "location",
@@ -7530,7 +7447,7 @@
"/api/pty/{ptyID}": {
"get": {
"tags": ["pty"],
"operationId": "v2.pty.get",
"operationId": "pty.get",
"parameters": [
{
"name": "ptyID",
@@ -7644,7 +7561,7 @@
},
"put": {
"tags": ["pty"],
"operationId": "v2.pty.update",
"operationId": "pty.update",
"parameters": [
{
"name": "ptyID",
@@ -7797,7 +7714,7 @@
},
"delete": {
"tags": ["pty"],
"operationId": "v2.pty.remove",
"operationId": "pty.remove",
"parameters": [
{
"name": "ptyID",
@@ -7896,7 +7813,7 @@
"/api/pty/{ptyID}/connect-token": {
"post": {
"tags": ["pty"],
"operationId": "v2.pty.connect.token",
"operationId": "pty.connect.token",
"parameters": [
{
"name": "ptyID",
@@ -8022,7 +7939,7 @@
"/api/pty/{ptyID}/connect": {
"get": {
"tags": ["pty"],
"operationId": "v2.pty.connect",
"operationId": "pty.connect",
"parameters": [
{
"name": "ptyID",
@@ -8127,7 +8044,7 @@
"/api/shell": {
"get": {
"tags": ["shell"],
"operationId": "v2.shell.list",
"operationId": "shell.list",
"parameters": [
{
"name": "location",
@@ -8221,7 +8138,7 @@
},
"post": {
"tags": ["shell"],
"operationId": "v2.shell.create",
"operationId": "shell.create",
"parameters": [
{
"name": "location",
@@ -8345,7 +8262,7 @@
"/api/shell/{id}": {
"get": {
"tags": ["shell"],
"operationId": "v2.shell.get",
"operationId": "shell.get",
"parameters": [
{
"name": "id",
@@ -8459,7 +8376,7 @@
},
"delete": {
"tags": ["shell"],
"operationId": "v2.shell.remove",
"operationId": "shell.remove",
"parameters": [
{
"name": "id",
@@ -8558,7 +8475,7 @@
"/api/shell/{id}/timeout": {
"patch": {
"tags": ["shell"],
"operationId": "v2.shell.timeout",
"operationId": "shell.timeout",
"parameters": [
{
"name": "id",
@@ -8696,7 +8613,7 @@
"/api/shell/{id}/output": {
"get": {
"tags": ["shell"],
"operationId": "v2.shell.output",
"operationId": "shell.output",
"parameters": [
{
"name": "id",
@@ -8864,7 +8781,7 @@
"/api/question/request": {
"get": {
"tags": ["question"],
"operationId": "v2.question.request.list",
"operationId": "question.request.list",
"parameters": [
{
"name": "location",
@@ -8960,7 +8877,7 @@
"/api/session/{sessionID}/question": {
"get": {
"tags": ["question"],
"operationId": "v2.session.question.list",
"operationId": "session.question.list",
"parameters": [
{
"name": "sessionID",
@@ -9043,7 +8960,7 @@
"/api/session/{sessionID}/question/{requestID}/reply": {
"post": {
"tags": ["question"],
"operationId": "v2.session.question.reply",
"operationId": "session.question.reply",
"parameters": [
{
"name": "sessionID",
@@ -9135,7 +9052,7 @@
"/api/session/{sessionID}/question/{requestID}/reject": {
"post": {
"tags": ["question"],
"operationId": "v2.session.question.reject",
"operationId": "session.question.reject",
"parameters": [
{
"name": "sessionID",
@@ -9217,7 +9134,7 @@
"/api/reference": {
"get": {
"tags": ["reference"],
"operationId": "v2.reference.list",
"operationId": "reference.list",
"parameters": [
{
"name": "location",
@@ -9313,7 +9230,7 @@
"/api/experimental/project/{projectID}/copy": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.create",
"operationId": "projectCopy.create",
"parameters": [
{
"name": "projectID",
@@ -9430,7 +9347,7 @@
},
"delete": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.remove",
"operationId": "projectCopy.remove",
"parameters": [
{
"name": "projectID",
@@ -9539,7 +9456,7 @@
"/api/experimental/project/{projectID}/copy/refresh": {
"post": {
"tags": ["projectCopy"],
"operationId": "v2.projectCopy.refresh",
"operationId": "projectCopy.refresh",
"parameters": [
{
"name": "projectID",
@@ -9628,7 +9545,7 @@
"/api/vcs/status": {
"get": {
"tags": ["vcs"],
"operationId": "v2.vcs.status",
"operationId": "vcs.status",
"parameters": [
{
"name": "location",
@@ -9724,7 +9641,7 @@
"/api/vcs/diff": {
"get": {
"tags": ["vcs"],
"operationId": "v2.vcs.diff",
"operationId": "vcs.diff",
"parameters": [
{
"name": "location",
@@ -9843,7 +9760,7 @@
"/api/debug/location": {
"get": {
"tags": ["debug"],
"operationId": "v2.debug.location.list",
"operationId": "debug.location.list",
"parameters": [],
"security": [],
"responses": {
@@ -9886,7 +9803,7 @@
},
"delete": {
"tags": ["debug"],
"operationId": "v2.debug.location.evict",
"operationId": "debug.location.evict",
"parameters": [
{
"name": "location",
+22 -22
View File
@@ -235,32 +235,32 @@ describe("OpenAPI.fromSpec", () => {
path: "/api/fs/read/*",
reason: "binary responses are not supported",
})
expect(toolAt(result.tools, "v2.health.get")).not.toBeUndefined()
expect(toolAt(result.tools, "v2.session.get")).not.toBeUndefined()
expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined()
expect(toolAt(result.tools, "health.get")).not.toBeUndefined()
expect(toolAt(result.tools, "session.get")).not.toBeUndefined()
expect(toolAt(result.tools, "session.create")).not.toBeUndefined()
const sessionGet = toolAt(result.tools, "v2.session.get")
const sessionGet = toolAt(result.tools, "session.get")
expect(Tool.isTool(sessionGet)).toBe(true)
if (!Tool.isTool(sessionGet)) throw new Error("v2.session.get was not generated")
if (!Tool.isTool(sessionGet)) throw new Error("session.get was not generated")
expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
expect(outputTypeScript(sessionGet)).toContain("id: string")
expect(outputTypeScript(sessionGet)).toContain("additions: number")
const switchAgent = toolAt(result.tools, "v2.session.switchAgent")
const switchAgent = toolAt(result.tools, "session.switchAgent")
expect(Tool.isTool(switchAgent)).toBe(true)
if (!Tool.isTool(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
if (!Tool.isTool(switchAgent)) throw new Error("session.switchAgent was not generated")
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
const instructionPut = toolAt(result.tools, "v2.session.instructions.entry.put")
const instructionPut = toolAt(result.tools, "session.instructions.entry.put")
expect(Tool.isTool(instructionPut)).toBe(true)
if (!Tool.isTool(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
if (!Tool.isTool(instructionPut)) throw new Error("session.instructions.entry.put was not generated")
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined()
expect(Tool.isTool(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
expect(toolAt(result.tools, "v2.pty.connect.token")).not.toBeUndefined()
expect(toolAt(result.tools, "session_instructions_entry_put_2")).toBeUndefined()
expect(Tool.isTool(toolAt(result.tools, "pty.connect"))).toBe(false)
expect(toolAt(result.tools, "session.log")).toBeUndefined()
expect(toolAt(result.tools, "event.subscribe")).toBeUndefined()
expect(toolAt(result.tools, "fs.read")).toBeUndefined()
expect(toolAt(result.tools, "pty.connect.token")).not.toBeUndefined()
})
test("preserves operation path sanitization and collision handling", () => {
@@ -978,7 +978,7 @@ describe("OpenAPI.fromSpec", () => {
expect(spec.security).toStrictEqual([])
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
const health = toolAt(result.tools, "v2.health.get")
const health = toolAt(result.tools, "health.get")
const healthInput = Tool.isTool(health) && isRecord(health.input) ? health.input : undefined
expect(healthInput).toMatchObject({ type: "object", properties: {} })
const input = isRecord(healthInput) ? healthInput : {}
@@ -1005,7 +1005,7 @@ describe("OpenAPI.fromSpec", () => {
expect(result.value).toMatchObject({
items: [
{
path: "tools.opencode.v2.health.get",
path: "tools.opencode.health.get",
description: "Check whether the API server is ready to accept requests.",
},
],
@@ -1026,8 +1026,8 @@ describe("OpenAPI.fromSpec", () => {
runtime
.execute(
`
const existing = await tools.opencode.v2.session.get({ sessionID: "ses_123" })
const created = await tools.opencode.v2.session.create({ id: "ses_456" })
const existing = await tools.opencode.session.get({ sessionID: "ses_123" })
const created = await tools.opencode.session.create({ id: "ses_456" })
return { existing, created }
`,
)
@@ -1047,8 +1047,8 @@ describe("OpenAPI.fromSpec", () => {
test("serializes deep-object query parameters from the opencode fixture", async () => {
const client = recordingClient(() => json({ directory: "/tmp" }))
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get")
if (!Tool.isTool(location)) throw new Error("v2.location.get was not generated")
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "location.get")
if (!Tool.isTool(location)) throw new Error("location.get was not generated")
await Effect.runPromise(
location
@@ -1470,7 +1470,7 @@ describe("OpenAPI.fromSpec", () => {
})
const result = await Effect.runPromise(
runtime.execute("return await tools.opencode.v2.session.get({})").pipe(Effect.provide(layer)),
runtime.execute("return await tools.opencode.session.get({})").pipe(Effect.provide(layer)),
)
expect(result).toMatchObject({ ok: false })
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.2",
"version": "2.0.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.2",
"version": "2.0.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.2",
"version": "2.0.3",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+75 -64
View File
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode/util/process"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -308,7 +309,7 @@ const layer = Layer.effect(
operationName: OperationError["operation"],
repository: Repository,
args: string[],
options?: { stdin?: string; env?: Record<string, string> },
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
) {
const result = yield* proc
.run(
@@ -317,7 +318,7 @@ const layer = Layer.effect(
env: options?.env,
extendEnv: true,
}),
{ stdin: options?.stdin },
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
)
.pipe(
Effect.mapError(
@@ -331,7 +332,8 @@ const layer = Layer.effect(
),
)
const text = result.stdout.toString("utf8")
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
if (result.exitCode === 0)
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
return yield* new OperationError({
operation: operationName,
directory: repository.worktree,
@@ -385,9 +387,7 @@ const layer = Layer.effect(
maximumUntrackedFileBytes?: number
}) {
const list = (args: string[]) =>
repositoryOperation("refresh", input.repository, args).pipe(
Effect.map((result) => result.text.split("\0").filter(Boolean)),
)
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
const [tracked, untracked] = yield* Effect.all(
[
list(["diff-files", "--name-only", "-z", "--", input.scope]),
@@ -464,13 +464,7 @@ const layer = Layer.effect(
directory: input.repository.worktree,
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
})
return new Set(
result.stdout
.toString("utf8")
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file)),
)
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
})
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
@@ -499,19 +493,23 @@ const layer = Layer.effect(
to: TreeID
}) {
// Undo needs both paths of a rename, not only its destination.
return (yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text
.split("\0")
.filter(Boolean)
.map((file) => RelativePath.make(file))
return nuls(
(yield* repositoryOperation("list_files", input.repository, [
"diff",
"--name-only",
"--no-renames",
"-z",
input.from,
input.to,
])).text,
).map((file) => RelativePath.make(file))
})
/**
* Three batched invocations over the tree pair instead of three per file. An
* explicit empty selection diffs nothing; an absent one diffs every changed path.
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
*/
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
repository: Repository
from: TreeID
@@ -519,49 +517,57 @@ const layer = Layer.effect(
context?: number
paths?: readonly RelativePath[]
}) {
const paths = input.paths ?? (yield* treeFiles(input))
return yield* Effect.forEach(paths, (file) =>
Effect.gen(function* () {
const statusText = (yield* repositoryOperation("diff", input.repository, [
if (input.paths?.length === 0) return []
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
const [names, numbers, patch] = yield* Effect.all(
[
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
repositoryOperation(
"diff",
"--name-status",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.trim()
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
const stats = (yield* repositoryOperation("diff", input.repository, [
"diff",
"--numstat",
"--no-renames",
input.from,
input.to,
"--",
file,
])).text.split("\t")
const binary = stats[0] === "-" || stats[1] === "-"
const patch = binary
? ""
: (yield* repositoryOperation("diff", input.repository, [
"diff",
`--unified=${input.context ?? 3}`,
"--no-renames",
input.from,
input.to,
"--",
file,
])).text
return {
file,
status,
additions: binary ? 0 : Number(stats[0] ?? 0),
deletions: binary ? 0 : Number(stats[1] ?? 0),
patch,
} satisfies File.Diff
input.repository,
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
),
],
{ concurrency: 3 },
)
const statuses = nuls(names.text)
const files = statuses.flatMap((code, index) => {
const file = statuses[index + 1]
if (index % 2 !== 0 || !file) return []
return [
{
file: RelativePath.make(file),
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
} as const,
]
})
const stats = new Map(
nuls(numbers.text).flatMap((line) => {
const [additions, deletions, ...file] = line.split("\t")
if (!additions || !deletions || file.length === 0) return []
return [
[
file.join("\t"),
additions === "-" || deletions === "-"
? { binary: true, additions: 0, deletions: 0 }
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
] as const,
]
}),
)
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
return files.map((entry) => {
const stat = stats.get(entry.file)
return {
...entry,
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
} satisfies File.Diff
})
})
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
@@ -733,6 +739,11 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
)
}
/** Split NUL-terminated git output into its records. */
function nuls(text: string) {
return text.split("\0").filter(Boolean)
}
function resolvePath(cwd: string, value: string) {
const trimmed = value.replace(/[\r\n]+$/, "")
if (!trimmed) return cwd
+24
View File
@@ -55,8 +55,11 @@ import { SessionModelTransport } from "./session/model-transport.js"
import { llmClient } from "./effect/app-node-platform.js"
import { Snapshot } from "./snapshot.js"
import { Session } from "./session/session.js"
import { SessionDiff, TurnRangeError } from "./session/diff.js"
import { LocationServiceMap } from "./location-service-map.js"
import { FSUtil } from "@opencode/util/fs-util"
import type { EventLog } from "@opencode/schema/event-log"
import type { FileDiff } from "@opencode/schema/file-diff"
import { Job } from "./job.js"
import type { Command } from "./command.js"
import { SessionEnvironment } from "./session/environment.js"
@@ -109,6 +112,7 @@ export {
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
export { TurnRangeError }
export interface Interface {
readonly list: (input?: ListInput) => Effect.Effect<{
@@ -135,6 +139,13 @@ export interface Interface {
readonly context: (
sessionID: SessionSchema.ID,
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
readonly diff: (input: {
readonly sessionID: SessionSchema.ID
readonly from?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
/**
* Durable admitted session work not yet visible in projected history,
* ordered by admission. Includes unpromoted user and synthetic inputs and
@@ -227,6 +238,7 @@ const layer = Layer.effect(
const moves = yield* SessionMove.Service
const jobs = yield* Job.Service
const environments = yield* SessionEnvironment.Service
const locations = yield* LocationServiceMap.Service
const sessions = yield* Session.make()
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
@@ -359,6 +371,17 @@ const layer = Layer.effect(
yield* result.get(sessionID)
return yield* store.context(sessionID)
}),
diff: Effect.fn("Session.diff")(function* (input) {
const session = yield* result.get(input.sessionID)
const active = yield* execution.isActive(input.sessionID)
return yield* SessionDiff.turn(db, locations, {
session,
active,
from: input.from,
to: input.to,
context: input.context,
})
}),
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
@@ -448,6 +471,7 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
SessionInbox.node,
SessionMove.node,
SessionProjector.node,
LocationServiceMap.node,
FSUtil.node,
App.node,
],
+138
View File
@@ -0,0 +1,138 @@
export * as SessionDiff from "./diff.js"
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
import { Context, Effect, Schema } from "effect"
import { Location } from "@opencode/schema/location"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { Snapshot } from "../snapshot.js"
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
sessionID: SessionSchema.ID,
field: Schema.Literals(["from", "to"]),
message: Schema.String,
}) {}
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
/**
* Diff the files changed by the turn containing a user message. A turn runs from
* the first prompt after the Session was last idle until the next idle marker, so
* prompts steered in while it was busy belong to the same turn; `to` extends the
* range through the turn containing a later user message. Compares the range's
* first recorded start snapshot with its last recorded end snapshot; only a step
* still running in the active Session compares against the working copy. Like VCS
* diffs, an omitted `context` yields full-file patches.
*
* A Session without any idle marker predates them, so its prompts span until the
* next user message instead.
*
* Snapshot trees live in the repository of the Location that captured them, so a
* range spanning a location switch is rejected rather than diffed wrongly.
*/
export const turn = Effect.fn("SessionDiff.turn")(function* (
db: Database.Interface["db"],
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
input: {
readonly session: SessionSchema.Info
/** The process is currently executing this Session. */
readonly active: boolean
readonly from?: SessionMessage.ID
readonly to?: SessionMessage.ID
readonly context?: number
},
) {
const sessionID = input.session.id
const rows = yield* db
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
or(
inArray(SessionMessageTable.type, ["user", "idle"]),
input.from ? eq(SessionMessageTable.id, input.from) : undefined,
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const users = rows.filter((row) => row.type === "user")
const markers = rows.filter((row) => row.type === "idle")
const resolve = Effect.fn(function* (field: "from" | "to", id: SessionMessage.ID) {
const row = rows.find((row) => row.id === id)
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
if (row.type !== "user")
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
return row
})
const anchor = input.from ? yield* resolve("from", input.from) : users[users.length - 1]
if (!anchor) return []
const last = input.to ? yield* resolve("to", input.to) : anchor
if (last.seq < anchor.seq)
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
const legacy = markers.length === 0
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
const steps = yield* db
.select({
seq: SessionMessageTable.seq,
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
})
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, start),
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const first = steps[0]
const final = steps[steps.length - 1]
const from = steps.find((step) => step.start)?.start
if (!first || !final || !from) return []
const switches = yield* db
.select({
seq: SessionMessageTable.seq,
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
})
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
const before = switches.findLast((row) => row.seq < first.seq)?.location
const after = switches.find((row) => row.seq > first.seq)?.previous
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
const recorded = steps.findLast((step) => step.end)?.end
return yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
const running = input.active && final.completed === null
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
if (!to) return []
return yield* snapshot.diff({
from: Snapshot.ID.make(from),
to: Snapshot.ID.make(to),
context: input.context ?? PATCH_CONTEXT_LINES,
})
}).pipe(Effect.provide(locations.get(location)))
})
+20 -3
View File
@@ -60,6 +60,21 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
})
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
clearCurrentRetry.pipe(
Effect.andThen(
adapter.appendMessage(
SessionMessage.Idle.make({
id: SessionMessage.ID.fromEvent(event.id),
type: "idle",
outcome,
metadata: event.metadata,
time: { created },
}),
),
),
)
const project = pipe(
Match.type<SessionEvent.DurableEvent>(),
Match.discriminatorsExhaustive("type")({
@@ -124,9 +139,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.inbox.cancelled": () => Effect.void,
"session.inbox.delivery.changed": () => Effect.void,
"session.execution.started": () => Effect.void,
"session.execution.succeeded": () => clearCurrentRetry,
"session.execution.failed": () => clearCurrentRetry,
"session.execution.interrupted": () => clearCurrentRetry,
"session.execution.succeeded": () => idle("succeeded"),
"session.execution.failed": () => idle("failed"),
// Shutdown keeps the execution claim and the resumed drain continues the turn.
"session.execution.interrupted": (event) =>
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
"session.instructions.updated": (event) => {
if (event.data.text === undefined) return Effect.void
return adapter.appendMessage(
@@ -226,6 +226,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
switch (message.type) {
case "agent-switched":
case "model-switched":
case "idle":
return []
case "location-switched":
return [
+33 -16
View File
@@ -131,38 +131,55 @@ const layer = Layer.effect(
)
})
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
const comparison = {
return {
source: repo.source,
repository: repo.snapshotRepository,
from: Git.TreeID.make(input.from),
to: Git.TreeID.make(input.to),
}
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
const ignored = yield* git.index
.ignored({ repository: repo.source, paths: files })
})
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
const ignored = Effect.fnUntraced(function* (
operation: "files" | "diff",
source: Git.Repository,
paths: readonly RelativePath[],
) {
return yield* git.index
.ignored({ repository: source, paths })
.pipe(Effect.mapError((cause) => failure(operation, cause)))
return {
input: comparison,
files,
ignored,
}
})
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
const comparison = yield* compare("files", input)
return comparison.files.filter((file) => !comparison.ignored.has(file))
const compared = yield* comparison("files", input)
const changed = yield* git.tree
.files({ repository: compared.repository, from: compared.from, to: compared.to })
.pipe(Effect.mapError((cause) => failure("files", cause)))
const skipped = yield* ignored("files", compared.source, changed)
return changed.filter((file) => !skipped.has(file))
})
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
const comparison = yield* compare("diff", input)
return yield* git.tree
if (input.paths?.length === 0) return []
const compared = yield* comparison("diff", input)
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
const diffs = yield* git.tree
.diff({
...comparison.input,
repository: compared.repository,
from: compared.from,
to: compared.to,
context: input.context,
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
paths: input.paths,
})
.pipe(Effect.mapError((cause) => failure("diff", cause)))
const skipped = yield* ignored(
"diff",
compared.source,
diffs.map((file) => RelativePath.make(file.file)),
)
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
})
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
+6 -3
View File
@@ -2,6 +2,7 @@ export * as OpenCodeTools from "./opencode.js"
import { SystemPart, ToolFailure } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { AbsolutePath } from "@opencode/schema/schema"
import { Session } from "@opencode/schema/session"
import { Effect, Schema } from "effect"
@@ -25,15 +26,17 @@ const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePat
export const Plugin = {
id: "opencode.tools",
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
yield* ctx.session.hook("context", (event) =>
const hook = (event: SessionHooks["context"]) =>
Effect.sync(() => {
event.system.push(
SystemPart.make(
"When you create a worktree outside the current working directory and intend to use it as your primary working directory, consider using `execute` to call `tools.opencode.session_move` and make the worktree the session's working directory.",
),
)
}),
)
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
yield* ctx.tool
.transform((draft) => {
draft.namespace({ name: "opencode", description: "OpenCode session and runtime tools." })
+6 -3
View File
@@ -1,6 +1,7 @@
export * as PatchTool from "./patch.js"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { ToolFailure } from "@opencode/ai"
import { FileDiff } from "@opencode/schema/file-diff"
import { Effect, Result, Schema } from "effect"
@@ -292,7 +293,7 @@ export const Plugin = {
)
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
const hook = (event: SessionHooks["context"]) =>
Effect.sync(() => {
const usePatch =
event.model.id.includes("gpt-") && !event.model.id.includes("oss") && !event.model.id.includes("gpt-4")
@@ -302,8 +303,10 @@ export const Plugin = {
return
}
delete event.tools.patch
}),
)
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
}
+6 -3
View File
@@ -2,6 +2,7 @@ export * as ShellTool from "./shell.js"
import { ToolFailure } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import type { ShellCreateBefore } from "@opencode/plugin/effect/shell"
import type { Tool } from "@opencode/schema/tool"
import { Deferred, Effect, Schema, Scope } from "effect"
@@ -271,12 +272,14 @@ export const Plugin = {
)
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
const hook = (event: SessionHooks["context"]) =>
Effect.gen(function* () {
const tool = event.tools[name]
if (!tool) return
tool.description = description(ShellSelect.name(yield* compatibleShell))
}),
)
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
}
+6 -3
View File
@@ -2,6 +2,7 @@ export * as SubagentTool from "./subagent.js"
import { ToolFailure } from "@opencode/ai"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Effect, Schema } from "effect"
import { Agent } from "../../agent.js"
import { Config } from "../../config.js"
@@ -234,7 +235,7 @@ export const Plugin = {
)
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
const hook = (event: SessionHooks["context"]) =>
Effect.gen(function* () {
const tool = event.tools[name]
if (!tool) return
@@ -258,7 +259,9 @@ export const Plugin = {
`- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`,
),
].join("\n")
}),
)
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
}
+6 -3
View File
@@ -1,6 +1,7 @@
export * as WebSearchTool from "./websearch.js"
import type { Context } from "@opencode/plugin/effect/plugin"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { ToolFailure } from "@opencode/ai"
import { Effect, Schema, Semaphore } from "effect"
import { HttpClientError } from "effect/unstable/http"
@@ -180,14 +181,16 @@ export const Plugin = {
)
.pipe(Effect.orDie)
yield* ctx.session.hook("context", (event) =>
const hook = (event: SessionHooks["context"]) =>
Effect.gen(function* () {
const disabled = yield* websearch.default().pipe(
Effect.as(false),
Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)),
)
if (disabled) delete event.tools[name]
}),
)
})
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
yield* ctx.session.hook("generate", hook)
}),
}
+37
View File
@@ -6,6 +6,7 @@ import { Effect } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Git } from "@opencode/core/git"
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
import { VcsPatch } from "@opencode/core/vcs/patch"
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -196,6 +197,42 @@ describe("Git trees", () => {
}),
)
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const git = yield* Git.Service
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
if (!repository) throw new Error("Repository not found")
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
yield* Effect.promise(async () => {
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
})
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
["a-caf\u00e9.txt", "added", 1, 0],
["a-small.txt", "added", 1, 0],
["b-large.txt", "added", lines, 0],
["c-binary.bin", "added", 0, 0],
])
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
expect(diffs[1]?.patch).toContain("+small\n")
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
expect(diffs[3]?.patch).toBe("")
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
}),
)
it.live("captures, compares, previews, and restores scoped trees", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
+2 -1
View File
@@ -66,7 +66,8 @@ export const registerToolPlugin = <R>(
const context = host({
...overrides,
session: {
hook: () => Effect.succeed({ dispose: Effect.void }),
...overrides.session,
hook: overrides.session?.hook ?? (() => Effect.succeed({ dispose: Effect.void })),
},
tool: {
transform: tools.transform,
+198
View File
@@ -0,0 +1,198 @@
import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect } from "effect"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { Model } from "@opencode/core/model"
import { Plugin } from "@opencode/core/plugin"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionDiff } from "@opencode/core/session/diff"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionInbox } from "@opencode/core/session/inbox"
import { SessionMessage } from "@opencode/core/session/message"
import { SessionProjector } from "@opencode/core/session/projector"
import { Snapshot } from "@opencode/core/snapshot"
import { Money } from "@opencode/schema/money"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { tempGlobalLayer } from "./fixture/global"
import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
),
)
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
file.file,
file.status,
file.additions,
file.deletions,
]
describe("Session.diff", () => {
it.live(
"diffs the busy period containing a user message and ranges across later turns",
() =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped()
const directory = path.join(tmp.path, "project")
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
yield* Effect.promise(async () => {
await fs.mkdir(directory)
await write("first.txt", "first\n")()
await write("second.txt", "second\n")()
await write("manual.txt", "manual\n")()
await $`git init -q`.cwd(directory).quiet()
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
})
const sessions = yield* Session.Service
const database = yield* Database.Service
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
const diff = (input?: { from?: SessionMessage.ID; to?: SessionMessage.ID }) =>
sessions
.diff({ sessionID: created.id, context: 0, ...input })
.pipe(Effect.map((files) => files.map(summarize)))
expect(yield* diff()).toEqual([])
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const snapshot = yield* Snapshot.Service
const usage = {
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
const prompt = Effect.fn(function* (text: string) {
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
return admitted.id
})
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
const before = yield* snapshot.capture()
if (!before) throw new Error("Start snapshot missing")
const assistantMessageID = SessionMessage.ID.create()
yield* bus.publish(SessionEvent.Step.Started, {
sessionID: created.id,
assistantMessageID,
agent: Agent.defaultID,
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
snapshot: before,
})
yield* Effect.promise(edit)
if (end === "running") return assistantMessageID
const after = end === "recorded" ? yield* snapshot.capture() : undefined
yield* bus.publish(SessionEvent.Step.Ended, {
sessionID: created.id,
assistantMessageID,
finish: "stop",
...usage,
snapshot: after,
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
})
return assistantMessageID
})
const idle = (outcome: "succeeded" | "failed") =>
outcome === "succeeded"
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
: bus.publish(SessionEvent.Execution.Failed, {
sessionID: created.id,
error: { type: "unknown", message: "failed" },
})
// Before any idle marker exists, a prompt's turn ends at the next prompt.
const first = yield* prompt("Edit the first file")
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
yield* Effect.promise(write("manual.txt", "manual edited\n"))
const second = yield* prompt("Edit the second file")
yield* step(write("second.txt", "second edited\n"), "recorded")
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
expect(yield* diff({ from: first })).toEqual([["first.txt", "modified", 1, 1]])
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
yield* idle("succeeded")
const third = yield* prompt("Add a third file")
yield* step(write("third.txt", "third\n"), "recorded")
const steer = yield* prompt("Also add a fourth file")
yield* step(write("fourth.txt", "fourth\n"), "recorded")
yield* idle("failed")
const busy = [
["fourth.txt", "added", 1, 0],
["third.txt", "added", 1, 0],
]
expect(yield* diff()).toEqual(busy)
expect(yield* diff({ from: steer })).toEqual(busy)
expect(yield* diff({ from: second })).toEqual([
["first.txt", "modified", 1, 1],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
])
expect(yield* diff({ from: first, to: third })).toEqual([
["first.txt", "modified", 1, 1],
["fourth.txt", "added", 1, 0],
["manual.txt", "modified", 1, 1],
["second.txt", "modified", 1, 1],
["third.txt", "added", 1, 0],
])
const full = yield* sessions.diff({ sessionID: created.id, from: first })
expect(full[0]?.patch).toContain("-first\n+first edited\n")
expect(yield* diff({ from: steer, to: second }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "to",
})
expect(yield* diff({ from: firstStep }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.TurnRangeError",
field: "from",
})
expect(yield* diff({ from: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
// A completed step without an end snapshot falls back to the last recorded end.
yield* prompt("Edit both files again")
yield* step(write("first.txt", "first edited twice\n"), "recorded")
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
yield* idle("succeeded")
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
// Only a step still running in the active session compares against the working copy.
yield* prompt("Delete the manual file")
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
expect(yield* diff()).toEqual([])
const session = yield* sessions.get(created.id)
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
// Reverting removes later history, markers included; a fork keeps the copied turns.
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
yield* sessions.revert.commit(created.id)
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
expect(yield* diff({ from: steer }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
["third.txt", "added", 1, 0],
])
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
}),
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
{ timeout: 30_000 },
)
})
+3 -2
View File
@@ -561,7 +561,9 @@ describe("SessionRestart background recovery", () => {
expect(yield* restarted.pendingBackground).toEqual([])
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
expect(yield* sessions.messages({ sessionID })).toMatchObject([
// Recovery ends a busy period, so an idle marker follows the notification.
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
expect(messages).toMatchObject([
{
id: background.notificationID,
type: "synthetic",
@@ -569,7 +571,6 @@ describe("SessionRestart background recovery", () => {
metadata: { state: "completed" },
},
])
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
}),
)
}
+43 -1
View File
@@ -9,11 +9,14 @@ import { Formatter } from "@opencode/core/formatter"
import { FileMutation } from "@opencode/core/file-mutation"
import { Location } from "@opencode/core/location"
import { FileAccess } from "@opencode/core/file-access"
import { Model } from "@opencode/core/model"
import { Permission } from "@opencode/core/permission"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { Tool } from "@opencode/core/tool"
import { PatchTool } from "@opencode/core/tool/plugin/patch"
import type { SessionHooks } from "@opencode/plugin/effect/session"
import { transformEnvironmentFiles } from "./fixture/environment"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
@@ -22,9 +25,20 @@ import { testEffect } from "./lib/effect"
import { permissionLayer } from "./lib/permission"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const sessionHooks = new Map<string, (event: SessionHooks["context"]) => Effect.Effect<void>>()
const patchToolNode = makeLocationNode({
name: "test/patch-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
layer: Layer.effectDiscard(
registerToolPlugin(PatchTool.Plugin, {
session: {
hook: (name, callback) =>
Effect.sync(() => {
sessionHooks.set(name, callback as (event: SessionHooks["context"]) => Effect.Effect<void>)
return { dispose: Effect.void }
}),
},
}),
),
deps: [
Tool.node,
FileAccess.node,
@@ -153,6 +167,34 @@ const withTempTool = <A, E, R>(body: (directory: string, registry: Tool.Interfac
)
describe("PatchTool", () => {
it.live("selects the same edit tools for compaction and generate requests as the agent loop", () =>
withTempTool(() =>
Effect.gen(function* () {
const event = (id: string): SessionHooks["context"] => ({
sessionID,
agent: toolIdentity.agent,
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }),
system: [],
messages: [],
tools: Object.fromEntries(
["patch", "edit", "write", "read"].map((name) => [name, { description: name, input: { type: "object" } }]),
),
options: {},
})
for (const name of ["context", "compaction", "generate"]) {
const hook = sessionHooks.get(name)
expect(hook).toBeDefined()
const claude = event("claude-sonnet-4")
yield* hook!(claude)
expect(Object.keys(claude.tools)).toEqual(["edit", "write", "read"])
const gpt = event("gpt-5")
yield* hook!(gpt)
expect(Object.keys(gpt.tools)).toEqual(["patch", "read"])
}
}),
),
)
it.live("registers and sequentially applies add, update, and delete hunks", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.2",
"version": "2.0.3",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.2",
"version": "2.0.3",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.2",
"version": "2.0.3",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.2",
"version": "2.0.3",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"scripts": {
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"repository": {
+2 -2
View File
@@ -14,7 +14,7 @@ export const AgentGroup = HttpApiGroup.make("server.agent")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.agent.list",
identifier: "agent.list",
summary: "List agents",
description: "Retrieve currently registered agents.",
}),
@@ -30,7 +30,7 @@ export const AgentGroup = HttpApiGroup.make("server.agent")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.agent.get",
identifier: "agent.get",
summary: "Get agent",
description: "Retrieve a single currently registered agent.",
}),
+1 -1
View File
@@ -13,7 +13,7 @@ export const CommandGroup = HttpApiGroup.make("server.command")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.command.list",
identifier: "command.list",
summary: "List commands",
description: "Retrieve currently registered commands.",
}),
+4 -4
View File
@@ -13,7 +13,7 @@ export const ConfigGroup = HttpApiGroup.make("server.config")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.config.get",
identifier: "config.get",
summary: "Get configuration",
description:
"Return configuration documents and discovery sources for the requested location, from lowest to highest priority.",
@@ -25,7 +25,7 @@ export const ConfigGroup = HttpApiGroup.make("server.config")
success: Config.Preferences,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.config.preferences",
identifier: "config.preferences",
summary: "Get global preferences",
description: "Return preferences from the highest-precedence global configuration document.",
}),
@@ -37,7 +37,7 @@ export const ConfigGroup = HttpApiGroup.make("server.config")
success: Config.Preferences,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.config.updatePreferences",
identifier: "config.updatePreferences",
summary: "Update global preferences",
description: "Patch preferences in the highest-precedence global configuration document.",
}),
@@ -48,7 +48,7 @@ export const ConfigGroup = HttpApiGroup.make("server.config")
success: Schema.Array(ConfigShell.Option),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.config.shells",
identifier: "config.shells",
summary: "List available shells",
description: "Return shells available to terminal and agent execution.",
}),
+3 -3
View File
@@ -14,7 +14,7 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.credential.update",
identifier: "credential.update",
summary: "Update credential",
description: "Update a stored credential label.",
}),
@@ -30,7 +30,7 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.credential.activate",
identifier: "credential.activate",
summary: "Activate credential",
description: "Activate a stored integration credential.",
}),
@@ -45,7 +45,7 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.credential.remove",
identifier: "credential.remove",
summary: "Remove credential",
description: "Remove a stored integration credential.",
}),
+2 -2
View File
@@ -9,7 +9,7 @@ export const DebugGroup = HttpApiGroup.make("server.debug")
success: Schema.Array(Location.Ref),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.debug.location.list",
identifier: "debug.location.list",
summary: "List loaded locations",
description: "List locations currently loaded by the server.",
}),
@@ -23,7 +23,7 @@ export const DebugGroup = HttpApiGroup.make("server.debug")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.debug.location.evict",
identifier: "debug.location.evict",
summary: "Evict a loaded location",
description: "Dispose the requested location's cached services so its next use boots them fresh.",
}),
+1 -1
View File
@@ -45,7 +45,7 @@ const make = <const Definitions extends ReadonlyArray<Definition>>(definitions:
success: HttpApiSchema.StreamSse({ data: EventSchema }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.event.subscribe",
identifier: "event.subscribe",
summary: "Subscribe to events",
description:
"Subscribe to native events and plugin RPC events across all server locations. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
+7 -7
View File
@@ -43,7 +43,7 @@ export const makeFormGroup = <
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.form.request.list",
identifier: "form.request.list",
summary: "List pending form requests",
description: "Retrieve pending forms for a location.",
}),
@@ -57,7 +57,7 @@ export const makeFormGroup = <
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.list",
identifier: "session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
@@ -73,7 +73,7 @@ export const makeFormGroup = <
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.create",
identifier: "session.form.create",
summary: "Create session form",
description: "Create a form for a session.",
}),
@@ -88,7 +88,7 @@ export const makeFormGroup = <
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.get",
identifier: "session.form.get",
summary: "Get session form",
description: "Retrieve a form for a session.",
}),
@@ -103,7 +103,7 @@ export const makeFormGroup = <
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.state",
identifier: "session.form.state",
summary: "Get form state",
description: "Retrieve the current state for a form.",
}),
@@ -119,7 +119,7 @@ export const makeFormGroup = <
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.reply",
identifier: "session.form.reply",
summary: "Reply to form",
description: "Submit an answer to a pending form.",
}),
@@ -134,7 +134,7 @@ export const makeFormGroup = <
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.form.cancel",
identifier: "session.form.cancel",
summary: "Cancel form",
description: "Cancel a pending form.",
}),
+3 -3
View File
@@ -30,7 +30,7 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.fs.read",
identifier: "fs.read",
summary: "Read file",
description: "Serve one file relative to the requested location.",
}),
@@ -44,7 +44,7 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.fs.list",
identifier: "fs.list",
summary: "List directory",
description:
"List direct children using an absolute path or a path relative to the requested location, including parents and siblings outside its directory. Entry paths remain relative to the requested location; listing does not switch locations.",
@@ -59,7 +59,7 @@ export const FileSystemGroup = HttpApiGroup.make("server.fs")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.fs.find",
identifier: "fs.find",
summary: "Find files",
description: "Find recursively ranked filesystem entries relative to the requested location.",
}),
+1 -1
View File
@@ -16,7 +16,7 @@ export const GenerateGroup = HttpApiGroup.make("server.generate")
error: [InvalidRequestError, ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.generate.text",
identifier: "generate.text",
summary: "Generate text",
description:
"Run one stateless model generation using the server's base configuration and return the assistant text. Uses the base configuration's default model when none is specified.",
+1 -1
View File
@@ -17,7 +17,7 @@ export const HealthGroup = HttpApiGroup.make("server.health")
success: ServiceStatus.Health,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.health.get",
identifier: "health.get",
summary: "Check server health",
description: "Report the owning server process and its application status.",
}),
+11 -11
View File
@@ -15,7 +15,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.integration.list",
identifier: "integration.list",
summary: "List integrations",
description: "Retrieve available integrations and their authentication methods.",
}),
@@ -30,7 +30,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.integration.get",
identifier: "integration.get",
summary: "Get integration",
description: "Retrieve one integration and its authentication methods.",
}),
@@ -46,7 +46,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.experimental.integration.wellknown.add",
identifier: "experimental.integration.wellknown.add",
summary: "Add wellknown integration",
description: "Discover and persist an experimental wellknown integration source.",
}),
@@ -67,7 +67,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.integration.connect.key",
identifier: "integration.connect.key",
summary: "Connect with key",
description: "Run a key authentication method and store the resulting credential.",
}),
@@ -88,7 +88,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.integration.oauth.connect",
identifier: "integration.oauth.connect",
summary: "Begin OAuth connection",
description: "Start an OAuth attempt and return the authorization details.",
}),
@@ -103,7 +103,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.integration.oauth.status",
identifier: "integration.oauth.status",
summary: "Get OAuth attempt status",
description: "Poll the current status of an OAuth attempt.",
}),
@@ -124,7 +124,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.integration.oauth.complete",
identifier: "integration.oauth.complete",
summary: "Complete OAuth connection",
description: "Complete a code-based OAuth attempt and store the resulting credential.",
}),
@@ -139,7 +139,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.integration.oauth.cancel",
identifier: "integration.oauth.cancel",
summary: "Cancel OAuth connection",
description: "Cancel an OAuth attempt and release its resources.",
}),
@@ -159,7 +159,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.integration.command.connect",
identifier: "integration.command.connect",
summary: "Begin command connection",
description: "Start a command authentication attempt.",
}),
@@ -174,7 +174,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.integration.command.status",
identifier: "integration.command.status",
summary: "Get command attempt status",
description: "Poll the current status and output of a command authentication attempt.",
}),
@@ -189,7 +189,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.integration.command.cancel",
identifier: "integration.command.cancel",
summary: "Cancel command connection",
description: "Cancel a command authentication attempt and terminate its process.",
}),
+1 -1
View File
@@ -35,7 +35,7 @@ export const LocationGroup = HttpApiGroup.make("server.location")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.location.get",
identifier: "location.get",
summary: "Get location",
description: "Resolve the requested location or the server default location.",
}),
+6 -6
View File
@@ -14,7 +14,7 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.mcp.list",
identifier: "mcp.list",
summary: "List MCP servers",
description: "Retrieve configured MCP servers and their connection status.",
}),
@@ -32,7 +32,7 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.mcp.add",
identifier: "mcp.add",
summary: "Add MCP server",
description: "Add an MCP server at runtime or replace an existing one, connecting it immediately.",
}),
@@ -48,7 +48,7 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.mcp.remove",
identifier: "mcp.remove",
summary: "Remove MCP server",
description: "Stop an MCP server and remove it from the runtime set until restart.",
}),
@@ -64,7 +64,7 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.mcp.connect",
identifier: "mcp.connect",
summary: "Connect MCP server",
description: "Connect an MCP server at runtime, overriding a disabled configuration until restart.",
}),
@@ -80,7 +80,7 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.mcp.disconnect",
identifier: "mcp.disconnect",
summary: "Disconnect MCP server",
description: "Disconnect an MCP server at runtime, removing its tools until reconnected.",
}),
@@ -94,7 +94,7 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.mcp.resource.catalog",
identifier: "mcp.resource.catalog",
summary: "List MCP resources",
description: "Retrieve resources and resource templates from connected MCP servers.",
}),
+1 -1
View File
@@ -53,7 +53,7 @@ export const MessageGroup = HttpApiGroup.make("server.message")
error: [InvalidCursorError, SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.message.list",
identifier: "message.list",
summary: "Get session messages",
description:
"Retrieve projected messages for a session, optionally filtered by type. Items keep the requested order across pages; use cursor.next or cursor.previous to move through the ordered timeline, passing the same type filter on each page.",
+1 -1
View File
@@ -19,7 +19,7 @@ export const MigrationGroup = HttpApiGroup.make("server.migration")
success: V1MigrationStatus,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.experimental.migration.v1.status",
identifier: "experimental.migration.v1.status",
summary: "Get V1 migration status",
description: "Return the progress of the V1 to V2 session history migration.",
}),
+2 -2
View File
@@ -15,7 +15,7 @@ export const ModelGroup = HttpApiGroup.make("server.model")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.model.list",
identifier: "model.list",
summary: "List models",
description:
"Retrieve the current snapshot of available models ordered by release date. The snapshot may precede initial plugin settlement.",
@@ -31,7 +31,7 @@ export const ModelGroup = HttpApiGroup.make("server.model")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.model.default",
identifier: "model.default",
summary: "Get default model",
description: "Retrieve the model used when a session has no explicit model selection.",
}),
+8 -8
View File
@@ -27,7 +27,7 @@ export const makePermissionGroup = <
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.request.list",
identifier: "permission.request.list",
summary: "List pending permission requests",
description: "Retrieve pending permission requests for a location.",
}),
@@ -39,7 +39,7 @@ export const makePermissionGroup = <
success: Schema.Struct({ data: Schema.Array(PermissionSaved.Info) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.list",
identifier: "permission.saved.list",
summary: "List saved permissions",
description: "Retrieve saved permissions, optionally filtered by project.",
}),
@@ -51,7 +51,7 @@ export const makePermissionGroup = <
success: HttpApiSchema.NoContent,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.permission.saved.remove",
identifier: "permission.saved.remove",
summary: "Remove saved permission",
description: "Remove a saved permission by ID.",
}),
@@ -79,7 +79,7 @@ export const makePermissionGroup = <
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.create",
identifier: "session.permission.create",
summary: "Create permission request",
description: "Evaluate and, when approval is required, create a permission request for a session.",
}),
@@ -92,7 +92,7 @@ export const makePermissionGroup = <
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.list",
identifier: "session.permission.list",
summary: "List session permission requests",
description: "Retrieve pending permission requests owned by a session.",
}),
@@ -107,7 +107,7 @@ export const makePermissionGroup = <
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.get",
identifier: "session.permission.get",
summary: "Get permission request",
description: "Retrieve a pending permission request owned by a session.",
}),
@@ -126,7 +126,7 @@ export const makePermissionGroup = <
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.reply",
identifier: "session.permission.reply",
summary: "Reply to pending permission request",
description: "Respond to a pending permission request owned by a session.",
}),
@@ -142,7 +142,7 @@ export const makePermissionGroup = <
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.permission.rules",
identifier: "session.permission.rules",
summary: "Replace session permission rules",
description:
"Replace the session-scoped permission rules. Rules are evaluated after the agent's rules, and the last matching rule wins.",
@@ -106,7 +106,7 @@ export const PersistentPtyGroup = HttpApiGroup.make("server.experimental")
error: [ForbiddenError, PtyNotFoundError, ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.persistentPty.connect",
identifier: "persistentPty.connect",
summary: "Connect to a persistent PTY",
description: "Stream persistent PTY output through the OpenCode server.",
transform: (operation) => ({
+4 -4
View File
@@ -14,7 +14,7 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.list",
identifier: "plugin.list",
summary: "List plugins",
description: "Retrieve enabled server plugins and their current status.",
}),
@@ -28,7 +28,7 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.awaitActivation",
identifier: "plugin.awaitActivation",
summary: "Wait for plugin activation",
description:
"Wait for configured plugin activation at a Location to settle, including missing-package installs. Completion does not imply every plugin succeeded or background resource discovery finished. Cancelling this wait does not cancel activation.",
@@ -45,7 +45,7 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.check",
identifier: "plugin.check",
summary: "Check plugin updates",
description: "Check one or all package plugins for available updates.",
}),
@@ -61,7 +61,7 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.plugin.update",
identifier: "plugin.update",
summary: "Update plugins",
description:
"Update package plugins concurrently and notify active locations to reload them. Responds once every update has finished; fails when any update fails.",
+2 -17
View File
@@ -1,7 +1,6 @@
import { Project } from "@opencode/schema/project"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
import { ProjectNotFoundError } from "../errors.js"
const root = "/api/project"
@@ -13,7 +12,7 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
success: Schema.Array(Project.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.project.list",
identifier: "project.list",
summary: "List projects",
description: "List known projects.",
}),
@@ -27,26 +26,12 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
error: ProjectNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.project.update",
identifier: "project.update",
summary: "Update project",
description: "Update the project canonical directory, display metadata, and workspace commands.",
}),
),
)
.add(
HttpApiEndpoint.get("project.current", `${root}/current`, {
query: LocationQuery,
success: Project.Current,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.project.current",
summary: "Get current project",
description: "Resolve the project for the requested location.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "project",
+2 -2
View File
@@ -15,7 +15,7 @@ export const ProviderGroup = HttpApiGroup.make("server.provider")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.list",
identifier: "provider.list",
summary: "List providers",
description: "Retrieve active AI providers so clients can show provider availability and configuration.",
}),
@@ -31,7 +31,7 @@ export const ProviderGroup = HttpApiGroup.make("server.provider")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.provider.get",
identifier: "provider.get",
summary: "Get provider",
description: "Retrieve a single AI provider so clients can inspect its availability and endpoint settings.",
}),
+7 -7
View File
@@ -27,7 +27,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.list",
identifier: "pty.list",
summary: "List PTY sessions",
description: "List PTY sessions for a location, including exited sessions retained until removal.",
}),
@@ -42,7 +42,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.create",
identifier: "pty.create",
summary: "Create PTY session",
description: "Create a pseudo-terminal session for a location.",
}),
@@ -58,7 +58,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.get",
identifier: "pty.get",
summary: "Get PTY session",
description: "Get one PTY session, including its exit code once exited.",
}),
@@ -75,7 +75,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.update",
identifier: "pty.update",
summary: "Update PTY session",
description: "Update the title or viewport size of one PTY session.",
}),
@@ -91,7 +91,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.remove",
identifier: "pty.remove",
summary: "Remove PTY session",
description: "Terminate and remove one PTY session.",
}),
@@ -108,7 +108,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.connect.token",
identifier: "pty.connect.token",
summary: "Create PTY WebSocket token",
description: "Create a short-lived single-use ticket for opening a PTY WebSocket connection.",
}),
@@ -123,7 +123,7 @@ export const PtyGroup = HttpApiGroup.make("server.pty")
error: [ForbiddenError, PtyNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.pty.connect",
identifier: "pty.connect",
summary: "Connect to PTY session",
description: "Establish a WebSocket connection streaming PTY output and accepting terminal input.",
transform: (operation) => ({
+1 -1
View File
@@ -13,7 +13,7 @@ export const ReferenceGroup = HttpApiGroup.make("server.reference")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.reference.list",
identifier: "reference.list",
summary: "List references",
description: "List references available in the requested location.",
}),
+1 -1
View File
@@ -21,7 +21,7 @@ export const RpcGroup = HttpApiGroup.make("server.rpc")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.rpc.call",
identifier: "rpc.call",
summary: "Call a plugin RPC",
description: "Dispatch a method to the currently registered RPC at the requested location.",
}),
+1 -1
View File
@@ -7,7 +7,7 @@ export const ServerGroup = HttpApiGroup.make("server.server")
success: Schema.Struct({ urls: Schema.Array(Schema.String) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.server.get",
identifier: "server.get",
summary: "Get server information",
description: "Return the URLs that can be used to connect to this server.",
}),
+64 -38
View File
@@ -31,6 +31,7 @@ import { Permission } from "@opencode/schema/permission"
import { Location } from "@opencode/schema/location"
import { SessionEvent } from "@opencode/schema/session-event"
import { EventLog } from "@opencode/schema/event-log"
import { FileDiff } from "@opencode/schema/file-diff"
const ParentIDFilter = Schema.Union([
Session.ID,
@@ -141,7 +142,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: [InvalidCursorError, InvalidRequestError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.list",
identifier: "session.list",
summary: "List sessions",
description:
"Retrieve sessions in the requested order. Items keep that order across pages; use cursor.next or cursor.previous to move through the ordered list.",
@@ -161,7 +162,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: InvalidRequestError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.stats",
identifier: "session.stats",
summary: "Get session statistics",
description: "Aggregate local session activity, usage, and tool reliability for a time range.",
}),
@@ -181,7 +182,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
success: Schema.Struct({ data: Session.Info }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.create",
identifier: "session.create",
summary: "Create session",
description: "Create a session at the requested location.",
}),
@@ -197,7 +198,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.import",
identifier: "session.import",
summary: "Import session",
description:
"Import a projected session transcript at the requested location. If parentID is supplied, the parent session must already exist; import parents before children.",
@@ -212,7 +213,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: [SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.export",
identifier: "session.export",
summary: "Export session",
description: "Export a complete projected session transcript.",
}),
@@ -223,7 +224,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
success: Schema.Struct({ data: Schema.Record(Session.ID, SessionActive) }),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.active",
identifier: "session.active",
summary: "List active sessions",
description:
"Retrieve foreground Session drains currently owned by this OpenCode process. Sessions absent from the result are inactive.",
@@ -237,7 +238,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.get",
identifier: "session.get",
summary: "Get session",
description: "Retrieve a session by ID.",
}),
@@ -250,7 +251,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.remove",
identifier: "session.remove",
summary: "Delete session",
description: "Delete a session and its child sessions.",
}),
@@ -266,7 +267,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.fork",
identifier: "session.fork",
summary: "Fork session",
description: "Create a child session by copying projected history through or before a message boundary.",
}),
@@ -282,7 +283,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.switchAgent",
identifier: "session.switchAgent",
summary: "Switch session agent",
description: "Switch the agent used by subsequent provider turns.",
}),
@@ -298,7 +299,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.switchModel",
identifier: "session.switchModel",
summary: "Switch session model",
description: "Switch the model used by subsequent provider turns.",
}),
@@ -314,7 +315,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.rename",
identifier: "session.rename",
summary: "Rename session",
description: "Update the session title.",
}),
@@ -328,7 +329,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: [SessionNotFoundError, InvalidRequestError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.move",
identifier: "session.move",
summary: "Move session",
description: "Move a session to another project directory, optionally transferring local changes.",
}),
@@ -350,7 +351,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.prompt",
identifier: "session.prompt",
summary: "Send message",
description: "Durably admit one session input and schedule agent-loop execution unless resume is false.",
}),
@@ -370,7 +371,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.command",
identifier: "session.command",
summary: "Run command",
description: "Execute a slash command callback immediately.",
}),
@@ -390,7 +391,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.skill",
identifier: "session.skill",
summary: "Activate skill",
description: "Activate a skill for a session by appending a skill message and resuming execution.",
}),
@@ -413,7 +414,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.synthetic",
identifier: "session.synthetic",
summary: "Add synthetic message",
description: "Durably admit synthetic session input and schedule execution unless resume is false.",
}),
@@ -432,7 +433,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.shell",
identifier: "session.shell",
summary: "Run shell command",
description:
"Execute one shell command in the session's working directory. Emits a shell.started event before execution and a shell.ended event with the merged output after.",
@@ -452,7 +453,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.compact",
identifier: "session.compact",
summary: "Compact session",
description:
"Durably admit a session compaction request. Steers by default: it runs at the next step boundary instead of waiting behind queued prompts.",
@@ -468,7 +469,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.wait",
identifier: "session.wait",
summary: "Wait for session",
description: "Wait for a session agent loop to become idle.",
}),
@@ -484,7 +485,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.revert.stage",
identifier: "session.revert.stage",
summary: "Stage session revert",
description: "Stage or move a reversible session boundary and optionally apply its file changes.",
}),
@@ -497,7 +498,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: [SessionNotFoundError, SessionBusyError, UnknownError],
})
.middleware(sessionLocationMiddleware)
.annotateMerge(OpenApi.annotations({ identifier: "v2.session.revert.clear", summary: "Clear staged revert" })),
.annotateMerge(OpenApi.annotations({ identifier: "session.revert.clear", summary: "Clear staged revert" })),
)
.add(
HttpApiEndpoint.post("session.revert.commit", "/api/session/:sessionID/revert/commit", {
@@ -507,7 +508,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({ identifier: "v2.session.revert.commit", summary: "Commit staged revert" }),
OpenApi.annotations({ identifier: "session.revert.commit", summary: "Commit staged revert" }),
),
)
.add(
@@ -517,12 +518,37 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: [SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.context",
identifier: "session.context",
summary: "Get session context",
description: "Retrieve the active context messages for a session (all messages after the last compaction).",
}),
),
)
.add(
HttpApiEndpoint.get("session.diff", "/api/session/:sessionID/diff", {
params: { sessionID: Session.ID },
query: Schema.Struct({
from: Schema.optional(SessionMessage.ID).annotate({
description: "User message whose turn to diff. Defaults to the turn of the newest user message.",
}),
to: Schema.optional(SessionMessage.ID).annotate({
description: "Later user message whose turn ends the range. Defaults to the turn of `from` alone.",
}),
context: Schema.NumberFromString.pipe(Schema.decodeTo(NonNegativeInt), Schema.optional).annotate({
description: "Unchanged lines around each hunk. Omit for full-file patches.",
}),
}),
success: Schema.Struct({ data: Schema.Array(FileDiff.Info) }),
error: [InvalidRequestError, MessageNotFoundError, SessionNotFoundError, UnknownError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.diff",
summary: "Diff session turns",
description:
"Structured per-file diffs of the files a turn changed. A turn runs from the first prompt after the session was last idle until its next idle marker, so prompts steered in while it was busy belong to the same turn; `to` extends the range through a later turn. Compares the range's first recorded snapshot with its last; a step still running in the active session compares against the working copy. Ranges that span a location change are rejected. In sessions without any idle marker, a prompt's turn spans until the next user message.",
}),
),
)
.add(
HttpApiEndpoint.get("session.inbox.list", "/api/session/:sessionID/inbox", {
params: { sessionID: Session.ID },
@@ -530,7 +556,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.inbox.list",
identifier: "session.inbox.list",
summary: "List session inbox",
description:
"List durable enqueued session work not yet delivered, ordered by enqueue sequence. Includes user, synthetic, compaction, and move items.",
@@ -544,7 +570,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.inbox.cancel",
identifier: "session.inbox.cancel",
summary: "Cancel inbox input",
description: "Cancel an inbox item that has not yet been delivered.",
}),
@@ -557,7 +583,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.inbox.steer",
identifier: "session.inbox.steer",
summary: "Steer queued item",
description: "Change a queued inbox item to steer delivery and wake session execution.",
}),
@@ -570,7 +596,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.inbox.queue",
identifier: "session.inbox.queue",
summary: "Queue steered item",
description: "Change a steered inbox item to queued delivery.",
}),
@@ -585,7 +611,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.instructions.entry.list",
identifier: "session.instructions.entry.list",
summary: "List instruction entries",
description: "List API-managed instruction entries attached to the session.",
}),
@@ -601,7 +627,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.instructions.entry.put",
identifier: "session.instructions.entry.put",
summary: "Put instruction entry",
description:
"Attach or replace one durable instruction entry. Changes announce as updates at the next step boundary.",
@@ -617,7 +643,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.instructions.entry.remove",
identifier: "session.instructions.entry.remove",
summary: "Remove instruction entry",
description:
"Remove one instruction entry; the removal is announced to the model at the next step boundary.",
@@ -636,7 +662,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.generate",
identifier: "session.generate",
summary: "Generate text from session context",
description: "Generate transient text from the current session context without mutating session history.",
}),
@@ -655,7 +681,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.log",
identifier: "session.log",
summary: "Read the session log",
description:
"Experimental durable session event log. Reads events after an exclusive aggregate sequence and continues with live events when follow=true.",
@@ -676,7 +702,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.interrupt",
identifier: "session.interrupt",
summary: "Interrupt session execution",
description:
"Interrupt active execution owned by this OpenCode process. Returns interrupted=true when an active execution was interrupted and false for the idle no-op. When continue=true, execution resumes pending steering input and next-in-line control items (manual compaction, moves) while queued prompts remain parked.",
@@ -692,7 +718,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.session.background",
identifier: "session.background",
summary: "Background blocking session tools",
description:
"Move active foreground backgroundable tools for this session into background observation. Idle requests are a no-op.",
@@ -706,7 +732,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: [SessionNotFoundError, MessageNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.message",
identifier: "session.message",
summary: "Get session message",
description: "Retrieve one projected message owned by the Session.",
}),
@@ -720,7 +746,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.environment",
identifier: "session.environment",
summary: "Set session environment",
description: "Replace the process environment used by local shell commands for this session.",
}),
@@ -734,7 +760,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.session.view",
identifier: "session.view",
summary: "View session",
description: "Mark the idle transition observed by the viewer as viewed.",
}),
+6 -6
View File
@@ -19,7 +19,7 @@ export const ShellGroup = HttpApiGroup.make("server.shell")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.shell.list",
identifier: "shell.list",
summary: "List running shell commands",
description: "List currently running shell commands for a location. Exited commands are not included.",
}),
@@ -34,7 +34,7 @@ export const ShellGroup = HttpApiGroup.make("server.shell")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.shell.create",
identifier: "shell.create",
summary: "Run shell command",
description:
"Spawn one non-interactive shell command for a location. Combined stdout/stderr is captured to a file pageable via output.",
@@ -51,7 +51,7 @@ export const ShellGroup = HttpApiGroup.make("server.shell")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.shell.get",
identifier: "shell.get",
summary: "Get shell command",
description: "Get one shell command, including its status and exit code once exited.",
}),
@@ -68,7 +68,7 @@ export const ShellGroup = HttpApiGroup.make("server.shell")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.shell.timeout",
identifier: "shell.timeout",
summary: "Update shell timeout",
description: "Replace a running shell command's timeout from now, or clear it with zero.",
}),
@@ -84,7 +84,7 @@ export const ShellGroup = HttpApiGroup.make("server.shell")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.shell.output",
identifier: "shell.output",
summary: "Read shell output",
description: "Page through captured combined output by absolute byte cursor.",
}),
@@ -100,7 +100,7 @@ export const ShellGroup = HttpApiGroup.make("server.shell")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.shell.remove",
identifier: "shell.remove",
summary: "Remove shell command",
description: "Terminate and remove one shell command and its retained output.",
}),
+1 -1
View File
@@ -13,7 +13,7 @@ export const SkillGroup = HttpApiGroup.make("server.skill")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.skill.list",
identifier: "skill.list",
summary: "List skills",
description: "Retrieve currently registered skills.",
}),
+5 -5
View File
@@ -29,7 +29,7 @@ export const VcsGroup = HttpApiGroup.make("server.vcs")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.vcs.get",
identifier: "vcs.get",
summary: "VCS info",
description: "Get current and default branch information for the requested location.",
}),
@@ -44,7 +44,7 @@ export const VcsGroup = HttpApiGroup.make("server.vcs")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.vcs.base",
identifier: "vcs.base",
summary: "VCS review base",
description:
"Infer a local review base from named branch creation history, or the repository default only when currently on that branch. Returns null before the first commit or when the provider lacks base metadata; ambiguous Git history requires an explicit base on diff requests.",
@@ -59,7 +59,7 @@ export const VcsGroup = HttpApiGroup.make("server.vcs")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.vcs.status",
identifier: "vcs.status",
summary: "VCS status",
description: "List uncommitted working-copy changes relative to the requested location.",
}),
@@ -73,7 +73,7 @@ export const VcsGroup = HttpApiGroup.make("server.vcs")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.vcs.branches",
identifier: "vcs.branches",
summary: "VCS branches",
description: "List local and remote branches available at the requested location.",
}),
@@ -88,7 +88,7 @@ export const VcsGroup = HttpApiGroup.make("server.vcs")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.vcs.diff",
identifier: "vcs.diff",
summary: "VCS diff",
description:
"Diff HEAD to the working copy (working), the base merge-base to the working copy (branch), or the base merge-base to HEAD (committed). Omitting base preserves repository-default comparison; supplying it overrides the comparison without saving it.",
+2 -2
View File
@@ -15,7 +15,7 @@ export const WebSearchGroup = HttpApiGroup.make("server.websearch")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.websearch.providers",
identifier: "websearch.providers",
summary: "List web search providers",
description: "Return the registered web search providers.",
}),
@@ -31,7 +31,7 @@ export const WebSearchGroup = HttpApiGroup.make("server.websearch")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.websearch.query",
identifier: "websearch.query",
summary: "Search the web",
description:
"Run one web search through the selected provider. Specify a provider to override the configured default.",
+2 -2
View File
@@ -14,7 +14,7 @@ export const WorkspaceGroup = HttpApiGroup.make("server.workspace")
error: [ConflictError, ProviderNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.workspace.create",
identifier: "workspace.create",
summary: "Create workspace",
description:
"Create a logical workspace. A caller-supplied ID is idempotent when retried with the same provider; reusing it with another provider returns a conflict.",
@@ -28,7 +28,7 @@ export const WorkspaceGroup = HttpApiGroup.make("server.workspace")
error: UnknownError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.workspace.destroy",
identifier: "workspace.destroy",
summary: "Destroy workspace",
description:
"Make a workspace not exist. This operation is idempotent: an already-missing workspace succeeds with `destroyed: false`, while a workspace removed by this request returns `destroyed: true`.",
+4 -4
View File
@@ -26,7 +26,7 @@ export const WorktreeGroup = HttpApiGroup.make("server.worktree")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.list",
identifier: "worktree.list",
summary: "List worktrees",
description:
"Discover worktrees through the requested location's strategies and return its project's inventory.",
@@ -43,7 +43,7 @@ export const WorktreeGroup = HttpApiGroup.make("server.worktree")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.create",
identifier: "worktree.create",
summary: "Create worktree",
description:
"Create a local worktree using the location's registered strategy and directory defaults, then run the project's setup script.",
@@ -60,7 +60,7 @@ export const WorktreeGroup = HttpApiGroup.make("server.worktree")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.remove",
identifier: "worktree.remove",
summary: "Remove worktree",
description: "Remove a managed worktree from the requested location's project using its recorded strategy.",
}),
@@ -75,7 +75,7 @@ export const WorktreeGroup = HttpApiGroup.make("server.worktree")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.refresh",
identifier: "worktree.refresh",
summary: "Refresh worktrees",
description: "Discover worktrees from the requested location and reconcile the shared project inventory.",
}),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"repository": {
+14
View File
@@ -280,6 +280,18 @@ export const Compaction = Schema.Union([CompactionRunning, CompactionCompleted,
)
export type Compaction = CompactionRunning | CompactionCompleted | CompactionFailed
/**
* Marks the Session going idle: every step since the previous marker belongs to
* one turn, including prompts steered in while it was busy. A shutdown does not
* record one, since the resumed execution continues the same turn.
*/
export interface Idle extends Schema.Schema.Type<typeof Idle> {}
export const Idle = Schema.Struct({
...Base,
type: Schema.tag("idle"),
outcome: Schema.Literals(["succeeded", "failed", "interrupted"]),
}).annotate({ identifier: "Session.Message.Idle" })
export const Info = Schema.Union([
AgentSelected,
ModelSelected,
@@ -291,6 +303,7 @@ export const Info = Schema.Union([
Shell,
Assistant,
Compaction,
Idle,
]).annotate({ identifier: "Session.Message.Info" })
export type Info =
| AgentSelected
@@ -303,4 +316,5 @@ export type Info =
| Shell
| Assistant
| Compaction
| Idle
export type Type = Info["type"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.2",
"version": "2.0.3",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
"version": "2.0.2",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"repository": {
-10
View File
@@ -1,4 +1,3 @@
import { Location } from "@opencode/core/location"
import { Project } from "@opencode/core/project"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -20,14 +19,5 @@ export const ProjectHandler = HttpApiBuilder.group(Api, "server.project", (handl
),
),
),
)
.handle("project.current", () =>
Location.Service.use((location) =>
Effect.succeed({
id: location.project.id,
directory: location.project.directory,
canonical: location.project.canonical,
}),
),
),
)
+23 -1
View File
@@ -1,5 +1,6 @@
import { Session } from "@opencode/core/session"
import { SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import type { Snapshot } from "@opencode/core/snapshot"
import { MessageNotFoundError, SessionNotFoundError, UnknownError } from "@opencode/protocol/errors"
import { Effect } from "effect"
export function missingSession(error: Session.NotFoundError) {
@@ -9,6 +10,14 @@ export function missingSession(error: Session.NotFoundError) {
})
}
export function missingMessage(error: Session.MessageNotFoundError) {
return new MessageNotFoundError({
sessionID: error.sessionID,
messageID: error.messageID,
message: `Message not found: ${error.messageID}`,
})
}
export function failedMessageDecode(error: Session.MessageDecodeError) {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError("failed to decode session message").pipe(
@@ -18,3 +27,16 @@ export function failedMessageDecode(error: Session.MessageDecodeError) {
),
)
}
/** Snapshot repositories are host state clients cannot repair, so surface only a log reference. */
export function failedSnapshot(operation: string, sessionID: Session.ID) {
return (error: Snapshot.Error) => {
const ref = `err_${crypto.randomUUID().slice(0, 8)}`
return Effect.logError(`failed to ${operation}`, { cause: error }).pipe(
Effect.annotateLogs({ ref, sessionID }),
Effect.andThen(
Effect.fail(new UnknownError({ message: "Unexpected server error. Check server logs for details.", ref })),
),
)
}
}

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