Compare commits

..
Author SHA1 Message Date
Aiden Cline 91dad62ffd feat(codemode): add Web extension with policy-bound fetch 2026-09-15 16:10:49 -05:00
227 changed files with 5391 additions and 5591 deletions
+83 -83
View File
@@ -29,15 +29,15 @@ Review endpoints in document order. For each endpoint, select one disposition an
## Progress
- [x] Group 1: Foundation and placement (4)
- [x] Group 2: Configuration and capability catalogs (16)
- [x] Group 3: Credentials, integrations, MCP, and web search (22)
- [x] Group 4: Session lifecycle (12)
- [x] Group 5: Session execution and inputs (11)
- [x] Group 6: Session history and recovery (13)
- [x] Group 7: Inbox, permissions, and forms (19)
- [x] Group 8: Filesystem, worktrees, and VCS (12)
- [x] Group 9: PTYs, persistent terminals, and shells (24)
- [x] Group 10: Events, RPC, and experimental operations (6)
- [ ] Group 2: Configuration and capability catalogs (16)
- [ ] 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
@@ -100,7 +100,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 021 | `GET` | `/api/config` | `config.get` | Keep | Compatibility entries removed; response now contains only documents and OpenCode directories. |
| [x] 022 | `GET` | `/api/config/preferences` | `config.preferences` | Remove | Redundant special projection of global config. |
| [x] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | Remove | Redundant field-specific config mutation API. |
| [x] 024 | `GET` | `/api/config/shell` | `config.shells` | Keep | Required by the server Terminal shell setting. |
| [ ] 024 | `GET` | `/api/config/shell` | `config.shells` | | |
| [x] 024a | `PATCH` | `/api/experimental/config` | `experimental.config.update` | Change | Experimental global config mutation; initially accepts only `shell`. |
## Group 3: Credentials, integrations, MCP, and web search
@@ -111,21 +111,21 @@ Review endpoints in document order. For each endpoint, select one disposition an
|---|---|---|---|---|---|
| [x] 025 | `GET` | `/api/integration` | `integration.list` | Keep | Full integration inventory is consumed by authentication and integration-selection clients. |
| [x] 026 | `GET` | `/api/integration/{integrationID}` | `integration.get` | Change | Missing integration now returns typed `404` instead of optional data. |
| [x] 027 | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | Experimental-only | Retained outside the stable API commitment. |
| [x] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | Change | Missing integration returns typed `404`; key form answers retained. |
| [x] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | Keep | OAuth connection start contract retained. |
| [x] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | Change | Missing integration or OAuth attempt returns typed `404`. |
| [x] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
| [x] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | Change | Missing integration or OAuth attempt returns typed `404`; code remains mode-dependent. |
| [x] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | Change | Missing integration or command method returns typed `404`. |
| [x] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | Change | Missing integration or command attempt returns typed `404`. |
| [x] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
| [x] | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | Experimental-only | Retained outside the stable API commitment. |
| [ ] 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` | | |
| [x] 036 | `GET` | `/api/mcp` | `mcp.list` | Keep | MCP inventory and connection status retained. |
| [x] 037 | `PUT` | `/api/experimental/mcp/{server}` | `experimental.mcp.add` | Experimental-only | Runtime-only MCP override; does not persist configuration. |
| [x] 038 | `DELETE` | `/api/experimental/mcp/{server}` | `experimental.mcp.remove` | Experimental-only | Runtime removal override; missing server returns `404`. |
| [x] 039 | `POST` | `/api/experimental/mcp/{server}/connect` | `experimental.mcp.connect` | Experimental-only | Runtime connection override retained outside the stable API. |
| [x] 040 | `POST` | `/api/experimental/mcp/{server}/disconnect` | `experimental.mcp.disconnect` | Experimental-only | Runtime disconnection override retained outside the stable API. |
| [x] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | Keep | Reviewed separately by coworker. |
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | Deferred for later review. |
| [x] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | Change | Removed redundant location query; credentials and events are global. |
| [x] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | Change | Removed redundant location query; credentials and events are global. |
| [x] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | Change | Removed redundant location query; credentials and events are global. |
@@ -147,7 +147,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | Change | Request now accepts optional branded `before` message ID; omission copies full history. |
| [x] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | Keep | Subsequent-execution agent selection retained. |
| [x] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | Keep | Subsequent-execution model and optional variant selection retained. |
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.update` | Change | General session patch updates title and permissions; rules emit `session.permissions`. |
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.rename` | Change | Title-only rename now uses the session resource path. |
| [x] 057 | `POST` | `/api/session/{sessionID}/move` | `session.move` | Change | Removed inaccurate local-change transfer claim; delivery behavior retained. |
| [x] 058 | `POST` | `/api/session/{sessionID}/background` | `session.background` | Keep | Backgroundable foreground tools transition to background observation; idle requests remain no-ops. |
@@ -166,7 +166,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 065 | `POST` | `/api/experimental/session/{sessionID}/wait` | `experimental.session.wait` | Experimental-only | Race-free idle barrier retained outside the stable API. |
| [x] 066 | `POST` | `/api/session/{sessionID}/generate` | `session.generate` | Keep | Transient generation from session context retained. |
| [x] 067 | `POST` | `/api/session/{sessionID}/interrupt` | `session.interrupt` | Change | Renamed `continue` to `resume` across public and internal interruption APIs. |
| [x] 068 | `PUT` | `/api/session/{sessionID}/environment` | `session.environment` | Keep | Process-local environment replacement retained in the stable API. |
| [x] 068 | `PUT` | `/api/experimental/session/{sessionID}/environment` | `experimental.session.environment` | Experimental-only | Process-local environment replacement retained outside the stable API. |
| [x] 069 | `POST` | `/api/session/{sessionID}/view` | `session.view` | Change | Idle watermark now uses the standard epoch-millisecond timestamp schema. |
## Group 6: Session history and recovery
@@ -185,7 +185,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 077 | `GET` | `/api/experimental/session/{sessionID}/instructions/entries` | `experimental.session.instructions.entry.list` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] 078 | `PUT` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.put` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] 079 | `DELETE` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.remove` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] 080 | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
| [x] 081 | `GET` | `/api/session/{sessionID}/message/{messageID}` | `session.message.get` | Change | Normalized specific-message operation ID. |
| [x] 082 | `GET` | `/api/session/{sessionID}/message` | `session.message.list` | Change | Normalized session-scoped message-list operation ID. |
@@ -196,24 +196,24 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 083 | `GET` | `/api/session/{sessionID}/inbox` | `session.inbox.list` | Change | Inbox timestamps now use the standard nested `time.created` shape. |
| [x] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | Change | Cancellation is idempotent and returns `204` when the session exists. |
| [x] 085 | `PATCH` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.update` | Change | Consolidated delivery mutation with `delivery: "steer" | "queue"`. |
| [x] 086 | — | — | — | Remove | Replaced by `session.inbox.update`. |
| [x] 087 | `GET` | `/api/form` | `form.list` | Change | Removed redundant `request` path and operation namespace. |
| [x] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | Keep | Pending session form list retained with temporary MCP sentinel compatibility. |
| [x] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | Keep | External form creation and temporary MCP sentinel ownership retained. |
| [x] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | Change | Form definition and lifecycle state are now returned together. |
| [x] 091 | — | — | — | Remove | State is included by `session.form.get`. |
| [x] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | Keep | One-shot validated form reply retained. |
| [x] 093 | `DELETE` | `/api/session/{sessionID}/form/{formID}` | `session.form.cancel` | Change | Form cancellation now deletes the pending form resource. |
| [x] 094 | `GET` | `/api/permission/request` | `permission.request.list` | Keep | Pending-request namespace retained alongside saved permissions. |
| [x] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | Change | Added persisted creation and update timestamps under `time`. |
| [x] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | Keep | Idempotent saved-permission deletion retained. |
| [x] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | Keep | Non-blocking permission evaluation and pending-request creation retained. |
| [x] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | Keep | Pending session permission list retained. |
| [x] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | Keep | Specific pending permission read with ownership validation retained. |
| [x] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | Change | Renamed request field from `reply` to `decision`. |
| [x] 101 | — | — | — | Remove | Permission rules are updated through `session.update`. |
| [ ] 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
@@ -221,18 +221,18 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 102 | `GET` | `/api/fs/read/*` | `fs.read` | Keep | Relative wildcard file reads and raw byte responses retained. |
| [x] 103 | `GET` | `/api/fs/list` | `fs.list` | Keep | Existing path scope and minimal entry metadata retained. |
| [x] 104 | `GET` | `/api/fs/find` | `fs.find` | Keep | Existing ranked filesystem search retained. |
| [x] 105 | `GET` | `/api/worktree` | `worktree.list` | Keep | Reviewed separately by coworker. |
| [x] 106 | `POST` | `/api/worktree` | `worktree.create` | Keep | Reviewed separately by coworker. |
| [x] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | Keep | Reviewed separately by coworker. |
| [x] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | Keep | Reviewed separately by coworker. |
| [x] 109 | `GET` | `/api/vcs` | `vcs.get` | Change | Preserved branch nesting and added selected VCS provider ID. |
| [x] 110 | `GET` | `/api/vcs/base` | `vcs.base` | Keep | Review-base inference and nullable unavailable state retained. |
| [x] 111 | `GET` | `/api/vcs/status` | `vcs.status` | Keep | Existing working-copy status shape retained for now. |
| [x] 112 | `GET` | `/api/vcs/branch` | `vcs.branch.list` | Change | Singular collection path and normalized operation ID. |
| [x] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | Keep | Existing working, branch, and committed comparison modes retained. |
| [ ] 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
@@ -240,30 +240,30 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 114 | `GET` | `/api/pty` | `pty.list` | Keep | PTY endpoints reviewed together and retained. |
| [x] 115 | `POST` | `/api/pty` | `pty.create` | Keep | PTY endpoints reviewed together and retained. |
| [x] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | Keep | PTY endpoints reviewed together and retained. |
| [x] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | Keep | PTY endpoints reviewed together and retained. |
| [x] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | Keep | PTY endpoints reviewed together and retained. |
| [x] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | Keep | PTY endpoints reviewed together and retained. |
| [x] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | Keep | PTY endpoints reviewed together and retained. |
| [x] 121 | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | Experimental-only | Retained outside the stable API commitment. |
| [x] 122 | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | Experimental-only | Retained outside the stable API commitment. |
| [x] 123 | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | Experimental-only | Retained outside the stable API commitment. |
| [x] 124 | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | Experimental-only | Retained outside the stable API commitment. |
| [x] 125 | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | Experimental-only | Retained outside the stable API commitment. |
| [x] 126 | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | Experimental-only | Retained outside the stable API commitment. |
| [x] 127 | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | Experimental-only | Retained outside the stable API commitment. |
| [x] 128 | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | Experimental-only | Retained outside the stable API commitment. |
| [x] 129 | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | Experimental-only | Retained outside the stable API commitment. |
| [x] 130 | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | Experimental-only | Retained outside the stable API commitment. |
| [x] 131 | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | Experimental-only | Retained outside the stable API commitment. |
| [x] 132 | `GET` | `/api/shell` | `shell.list` | Change | Stable shell inventory retained; numeric timestamps documented as epoch milliseconds. |
| [x] 133 | `POST` | `/api/shell` | `shell.create` | Change | Timeout is optional and defaults to zero; caller metadata retained. |
| [x] 134 | `GET` | `/api/shell/{id}` | `shell.get` | Keep | Specific running or retained shell read retained. |
| [x] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | Change | Shell deletion is idempotent and returns `204` when already absent. |
| [x] 136 | — | — | — | Remove | Timeout mutation remains an internal Core shell operation. |
| [x] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | Keep | Existing byte-cursor text output paging retained. |
| [ ] 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` | | |
| [x] | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | Experimental-only | Retained outside the stable API commitment. |
| [ ] 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
@@ -271,9 +271,9 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 138 | `POST` | `/api/experimental/generate` | `experimental.generate.text` | Experimental-only | Stateless generation retained alongside session generation. |
| [x] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | Keep | Generic typed-error plugin RPC transport retained. |
| [x] 140 | `GET` | `/api/event` | `event.subscribe` | Keep | Unified native and dynamic plugin event stream retained. |
| [x] 141 | `GET` | `/api/debug/location` | `debug.location.list` | Keep | Loaded-location debug inventory retained. |
| [x] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | Keep | Idempotent loaded-location eviction retained. |
| [x] 143 | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | Experimental-only | Retained outside the stable API commitment. |
| [ ] 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` | | |
| [x] | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | Experimental-only | Retained outside the stable API commitment. |
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -113,7 +113,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.5",
"version": "2.0.3",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -253,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -280,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -321,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -409,7 +409,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -461,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -498,7 +498,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -514,7 +514,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -533,7 +533,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -546,7 +546,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -560,7 +560,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -575,7 +575,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -614,7 +614,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -644,7 +644,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -659,7 +659,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -683,7 +683,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -704,7 +704,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -726,7 +726,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -761,7 +761,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -781,7 +781,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -815,7 +815,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -834,7 +834,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -880,7 +880,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -894,7 +894,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -929,7 +929,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -964,7 +964,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -997,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1038,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.5",
"version": "2.0.3",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -168,7 +168,7 @@ else
package_scope="@opencode"
if [ -z "$requested_version" ]; then
metadata=$(curl -fsSL https://opencode.ai/update/api/latest/cli/npm || true)
metadata=$(curl -fsSL https://opencode.ai/update/api/beta/cli/npm || true)
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
package=$(echo "$metadata" | sed -n 's/.*"package":"\([^"]*\)".*/\1/p')
+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.5",
"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.5",
"version": "2.0.3",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -73,6 +73,11 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
)
if (event.type === "error") {
terminal = true
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame, cause),
),
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(event, `${options.name} stream error`, frame),
@@ -108,7 +108,7 @@ const incremental = (
return input.slice(baseline.length)
}
const code = (event: OpenResponses.Event) => OpenResponses.errorDetail(event).code
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
const rejected = (
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
+86 -28
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -333,13 +333,47 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put error details at the top level, under `error`, or under
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
// fields decode as opaque values and `errorDetail` reads them defensively, so an error frame can
// only fail on invalid JSON and otherwise always classifies with the raw body as the fallback.
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const asText = (value: unknown) =>
typeof value === "string" && value.length > 0 ? value : typeof value === "number" ? String(value) : undefined
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("error"),
status: Schema.optional(Schema.Number),
status_code: Schema.optional(Schema.Number),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
export const decodeKnownErrorEvent = (event: Event) =>
decodeWebSocketErrorEvent({
...event,
status: typeof event.status === "number" ? event.status : undefined,
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
headers: ProviderShared.isRecord(event.headers)
? Object.fromEntries(
Object.entries(event.headers).filter(
(entry): entry is [string, string | number | boolean] =>
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
),
)
: undefined,
})
export const Event = Schema.StructWithRest(
Schema.Struct({
@@ -360,18 +394,31 @@ export const Event = Schema.StructWithRest(
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
output: Schema.optional(Schema.Array(StreamItem)),
usage: optionalNull(OpenResponsesUsage),
error: Schema.optional(Schema.Unknown),
error: optionalNull(OpenResponsesErrorPayload),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
),
code: Schema.optional(Schema.Unknown),
message: Schema.optional(Schema.Unknown),
error: Schema.optional(Schema.Unknown),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
status: Schema.optional(Schema.Unknown),
status_code: Schema.optional(Schema.Unknown),
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
@@ -380,15 +427,14 @@ const decodeEventValue = Schema.decodeUnknownEffect(Event)
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
/**
* Decodes one WebSocket frame. Some providers and gateways answer a rejected `response.create` with a bare
* `{ "error": ... }` envelope and no event type; that reads as an error event so it classifies instead of
* failing decoding.
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
*/
export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && value.error != null
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
? { ...value, type: "error" }
: value,
),
@@ -1368,21 +1414,22 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
return [{ ...current, lifecycle }, events] satisfies StepResult
})
/** Error code and message from wherever the frame put them; top-level fields win over nested ones. */
export const errorDetail = (event: Event) => {
const raw = event.error ?? event.response?.error
const nested = typeof raw === "string" ? { message: raw } : ProviderShared.isRecord(raw) ? raw : undefined
return {
message: asText(event.message) ?? asText(nested?.message),
code: asText(event.code) ?? asText(nested?.code),
}
// Build the prettiest summary available from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops. Returns undefined when
// the payload carries no usable summary.
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}`
return message || code
}
// Prefix the code when both are present (`rate_limit_exceeded: Slow down`) so the failure mode is
// visible; fall back to the raw frame rather than a generic message when neither decodes.
export const providerFailure = (event: Event, fallback: string, body = ProviderShared.encodeJson(event)) => {
const detail = errorDetail(event)
const summary = detail.message && detail.code ? `${detail.code}: ${detail.message}` : (detail.message ?? detail.code)
const nested = event.error ?? event.response?.error ?? undefined
const summary = providerErrorMessage(event, nested)
const message = summary ?? (body === "{}" ? fallback : body)
const status =
typeof event.status === "number"
@@ -1465,7 +1512,18 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
if (event.type === "response.failed") return providerFailure(event, `${state.name} response failed`)
if (event.type === "error") return providerFailure(event, `${state.name} stream error`)
if (event.type === "error")
return decodeKnownErrorEvent(event).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(
state.id,
`${state.name} returned a malformed error event`,
ProviderShared.encodeJson(event),
cause,
),
),
Effect.flatMap(() => providerFailure(event, `${state.name} stream error`)),
)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
@@ -1,9 +1,8 @@
import { expect } from "bun:test"
import { Effect, Schema, Stream } from "effect"
import { Effect, Schema } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Azure, Meta, OpenAI, XAI } from "../../src/providers/index.js"
import { WebSocketTransport } from "../../src/route.js"
import { Meta } from "../../src/providers/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -11,105 +10,45 @@ import { sseEvents } from "../lib/sse.js"
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
it.effect("decodes error frames verbatim in shared SSE and WebSocket decoding", () =>
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const frame = {
type: "error",
sequence_number: 4,
code: "server_shutting_down",
message: "Server is shutting down. Please retry your request.",
param: null,
}
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
for (const frame of [
{ type: "error", sequence_number: 4, code: "server_shutting_down", message: "Shutting down", param: null },
const event = yield* decode(JSON.stringify(frame))
expect(event).toEqual({
type: "error",
sequence_number: 4,
error: { code: frame.code, message: frame.message, param: null },
})
for (const unchanged of [
event,
{ type: "error" },
{ type: "error", error: "Gateway failed" },
{ type: "error", error: { code: 429, message: "slow down" } },
{ type: "error", error: 42 },
{ type: "error", code: 500, message: ["not", "a", "string"] },
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
{
type: "response.failed",
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
},
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
]) {
expect(yield* decode(JSON.stringify(frame))).toEqual(frame)
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
}
}
}),
)
it.effect("reads bare WebSocket error envelopes as error events", () =>
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const message = "gRPC error: Response with id=resp_missing not found"
for (const error of [{ type: "api_error", message }, message, 42]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({ type: "error", error })
}
for (const frame of [{ error: null }, { message }]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame)).pipe(Effect.flip)).toBeDefined()
}
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
}),
)
it.effect("extracts error details from every shape and falls back to the raw frame", () =>
Effect.gen(function* () {
const cases: Array<[frame: Record<string, unknown>, message: string, tag: string]> = [
[
{ type: "error", code: "server_shutting_down", message: "Shutting down" },
"server_shutting_down: Shutting down",
"UnknownProvider",
],
[{ type: "error", error: "Gateway failed" }, "Gateway failed", "UnknownProvider"],
[{ type: "error", error: { code: 429, message: "slow down" } }, "429: slow down", "UnknownProvider"],
[{ type: "error", error: { message: "slow down" }, status: 429 }, "slow down", "RateLimit"],
[{ type: "error", code: 500, message: ["not", "a", "string"] }, "500", "UnknownProvider"],
[
{ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } },
"Gateway failed",
"UnknownProvider",
],
]
for (const [frame, message, tag] of cases) {
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
expect(error.message).toBe(message)
expect(error.reason._tag).toBe(tag)
expect(error.reason.body).toBe(JSON.stringify(frame))
}
for (const frame of [
{ type: "error", error: 42 },
{ type: "response.failed", response: { id: "resp_failed", error: ["weird"] } },
]) {
const event = yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))
const error = OpenResponses.providerFailure(event, "fallback", JSON.stringify(frame))
expect(error.message).toBe(JSON.stringify(frame))
expect(error.reason._tag).toBe("UnknownProvider")
}
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").message).toBe("fallback")
expect(OpenResponses.providerFailure({ type: "error" }, "fallback", "{}").reason._tag).toBe("ProviderInternal")
}),
)
for (const model of [
OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.6-sol"),
XAI.configure({ apiKey: "fixture" }).responses("grok-4.6"),
Azure.configure({ apiKey: "fixture", resourceName: "fixture" }).responses("deployment"),
]) {
it.effect(`preserves string error messages and raw bodies through ${model.provider} Responses`, () =>
Effect.gen(function* () {
const raw = '{ "type": "error", "error": "Gateway rejected the request", "trace": "original" }'
const webSocket = WebSocketTransport.makeDirect({
open: () => Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(raw), close: Effect.void }),
})
for (const options of [{ webSocket }, {}]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), options).pipe(
Effect.provide(fixedResponse(sseEvents(raw))),
Effect.flip,
)
expect(error.reason._tag).toBe("UnknownProvider")
expect(error.message).toBe("Gateway rejected the request")
expect(error.reason.body).toBe(raw)
}
}),
)
}
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
@@ -22,7 +22,7 @@ test("selects a base branch for a new workspace", async ({ page }) => {
pageMessages: () => ({ items: [] }),
vcsBranches: ["feature/api", "main", "origin/release"],
})
await page.route("**/api/vcs/branch?*", (route) => {
await page.route("**/api/vcs/branches?*", (route) => {
if (new URL(route.request().url()).searchParams.get("search") !== "feature") return route.fallback()
return route.fulfill({ json: { location: { directory }, data: ["feature/api"] } })
})
@@ -61,7 +61,7 @@ test("session settings use the remote server context", async ({ page }) => {
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-pending-a",
body: { decision: "once" },
body: { reply: "once" },
},
])
@@ -153,7 +153,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { decision: "once" },
body: { reply: "once" },
},
])
@@ -180,14 +180,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { decision: "once" },
body: { reply: "once" },
},
{
origin: serverA,
directory: undefined,
sessionID: childSessionA.id,
permissionID: "permission-background-a-child",
body: { decision: "once" },
body: { reply: "once" },
},
])
})
@@ -243,7 +243,7 @@ test("auto-accept sweeps again after a reconnect", async ({ page }) => {
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-offline-a",
body: { decision: "once" },
body: { reply: "once" },
},
])
// The reconnect sweep must resync active sessions instead of trusting
@@ -280,7 +280,7 @@ test("auto-accept approves a request discovered by opening a session", async ({
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-synced-a",
body: { decision: "once" },
body: { reply: "once" },
},
])
})
@@ -74,7 +74,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
item: { type: "user", payload: row.payload, delivery: row.delivery },
})
},
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => {
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => {
changes.push({ inboxID: input.inboxID, action: input.action })
log.push(`${input.action}:${input.inboxID}`)
const index = rows.findIndex((row) => row.id === input.inboxID)
@@ -85,11 +85,11 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
return
}
row.delivery = input.action
row.delivery = "steer"
emit("session.inbox.delivery.changed", {
sessionID: input.sessionID,
inboxID: input.inboxID,
delivery: input.action,
delivery: "steer",
})
},
}
@@ -48,10 +48,7 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (
request.method() === "DELETE" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request`
)
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
rejectRequests.push(request.url())
})
@@ -110,7 +107,7 @@ test("shows a pending permission dock", async ({ page }) => {
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
expect(request.postDataJSON()).toEqual({ decision: "once" })
expect(request.postDataJSON()).toEqual({ reply: "once" })
})
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@@ -587,7 +587,7 @@ async function mockServer(page: Page) {
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: sessionA.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form")
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/request")
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
+4 -5
View File
@@ -112,10 +112,10 @@ const Group = HttpApiGroup.make("mock")
)
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branch", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branches", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
@@ -173,7 +173,7 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(
HttpApiEndpoint.delete("sessionFormCancel", "/api/session/:sessionID/form/:formID", {
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
params: { ...SessionParams, formID: Schema.String },
success: NoContent,
}),
@@ -218,9 +218,8 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(
HttpApiEndpoint.patch("sessionInboxUpdate", "/api/session/:sessionID/inbox/:inboxID", {
HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", {
params: { ...SessionParams, inboxID: Schema.String },
payload: Schema.Struct({ delivery: Schema.Literals(["steer", "queue"]) }),
success: NoContent,
}),
)
+3 -7
View File
@@ -42,7 +42,7 @@ export interface MockServerConfig {
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
}
type MockStreamWindow = Window & {
@@ -454,13 +454,9 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
).pipe(Effect.andThen(noContent)),
sessionInboxUpdate: (ctx) =>
sessionInboxSteer: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({
sessionID: ctx.params.sessionID,
inboxID: ctx.params.inboxID,
action: ctx.payload.delivery,
}),
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }),
).pipe(Effect.andThen(noContent)),
sessionSwitchAgent: () => noContent,
sessionSwitchModel: () => noContent,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.5",
"version": "2.0.3",
"description": "",
"type": "module",
"exports": {
@@ -146,7 +146,7 @@ export function createHomeSessionsController(home: HomeController) {
const next = title.trim()
if (!next || next === sessionLabel(session)) return true
return ctx.sdk.api.session
.update({ sessionID: session.id, title: next })
.rename({ sessionID: session.id, title: next })
.then(() => {
ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next })
// Rename advances time.updated server-side; re-sync the canonical
-12
View File
@@ -9,10 +9,6 @@ export function useNewSessionCommands(input: {
empty: () => boolean
open: () => void
}
workspace: {
enabled: () => boolean
cycle: () => void
}
}) {
const command = useCommand()
const dialog = useDialog()
@@ -44,13 +40,5 @@ export function useNewSessionCommands(input: {
disabled: input.project.empty(),
onSelect: input.project.open,
},
{
id: "session.location.cycle",
title: language.t("command.session.location.cycle"),
category: language.t("command.category.workspace"),
keybind: "mod+alt+l",
disabled: !input.workspace.enabled(),
onSelect: input.workspace.cycle,
},
])
}
-4
View File
@@ -60,10 +60,6 @@ export default function NewSessionPage(props: { draftId: string }) {
empty: project.empty,
open: () => project.setOpen(true),
},
workspace: {
enabled: workspace.bar.visible,
cycle: workspace.selection.cycle,
},
})
createEffect(() => {
if (!composer.ready()) return
@@ -1,10 +1,5 @@
import { describe, expect, test } from "bun:test"
import {
cycleNewSessionWorktree,
resolveNewSessionBranch,
resolveNewSessionGit,
resolveNewSessionWorktree,
} from "./controller"
import { resolveNewSessionBranch, resolveNewSessionGit, resolveNewSessionWorktree } from "./controller"
describe("new session workspace selection", () => {
test("uses main when the workspace bar is unavailable", () => {
@@ -69,16 +64,4 @@ describe("new session workspace selection", () => {
expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true)
expect(resolveNewSessionGit({})).toBe(false)
})
test("cycles between local and a new worktree", () => {
expect(cycleNewSessionWorktree({ current: "main" })).toBe("create")
expect(cycleNewSessionWorktree({ current: "create" })).toBe("main")
})
test("includes the selected existing worktree in the cycle", () => {
const existing = "/project/feature"
expect(cycleNewSessionWorktree({ current: existing, existing })).toBe("main")
expect(cycleNewSessionWorktree({ current: "main", existing })).toBe("create")
expect(cycleNewSessionWorktree({ current: "create", existing })).toBe(existing)
})
})
@@ -37,12 +37,6 @@ export function resolveNewSessionGit(input: { projectVcs?: string; branch?: stri
return input.projectVcs === "git" || input.branch !== undefined
}
export function cycleNewSessionWorktree(input: { current: string; existing?: string }) {
if (input.current === "main") return "create"
if (input.current === "create") return input.existing ?? "main"
return "main"
}
export function createNewSessionWorkspaceController(input: {
selectedWorktree: () => string | undefined
selectedBranch: () => string | undefined
@@ -55,10 +49,7 @@ export function createNewSessionWorkspaceController(input: {
const data = useData()
const settings = useSettings()
const tabs = useTabs()
const [state, setState] = createStore({
search: "",
existing: undefined as { projectID: string; directory: string } | undefined,
})
const [state, setState] = createStore({ search: "" })
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
const currentProject = createMemo(() => {
const projectID = data.location.info({ directory: sdk().directory })?.project.id
@@ -151,7 +142,7 @@ export function createNewSessionWorkspaceController(input: {
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
({ directory, search }) =>
serverSDK.api.vcs
.branch.list({ location: { directory }, search, limit: 50 })
.branches({ location: { directory }, search, limit: 50 })
.then((response) => ({ directory, search, data: response.data }))
.catch(() => ({ directory, search, data: [] })),
)
@@ -168,8 +159,6 @@ export function createNewSessionWorkspaceController(input: {
createEffect(() => {
const selection = value()
if (selection === "main" || selection === "create") return
const project = currentProject()
if (project) setState("existing", { projectID: project.id, directory: selection })
void data.location.vcs.sync({ directory: selection }).catch(() => undefined)
})
const branch = createMemo(() =>
@@ -187,20 +176,6 @@ export function createNewSessionWorkspaceController(input: {
const local = workspaceSelectionDestination(worktree, project.worktree) === "main"
settings.workspaces.setLastUsed(serverSDK.scope, project.id, local ? "local" : "workspace")
}
const select = (worktree: string) => {
input.setSelectedBranch(undefined)
input.setSelectedWorktree(worktree)
remember(worktree)
}
// The remembered worktree may have been removed since it was selected. Cycling to a directory the
// inventory no longer contains would resolve back to the fallback and leave the cycle stuck.
const existing = () => {
const project = currentProject()
const previous = state.existing
if (!project || previous?.projectID !== project.id) return
if (!worktreeDirectories().some((item) => sameDirectory(item, previous.directory))) return
return previous.directory
}
return {
selection: {
@@ -218,8 +193,11 @@ export function createNewSessionWorkspaceController(input: {
input.setSelectedBranch(undefined)
},
remember,
set: select,
cycle: () => select(cycleNewSessionWorktree({ current: value(), existing: existing() })),
set: (worktree: string) => {
input.setSelectedBranch(undefined)
input.setSelectedWorktree(worktree)
remember(worktree)
},
create: (branch: string) => {
input.setSelectedBranch(branch)
input.setSelectedWorktree("create")
-1
View File
@@ -93,7 +93,6 @@ export const dict = {
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Archive session",
"command.session.location.cycle": "Cycle session location",
"command.palette": "Command palette",
@@ -98,7 +98,7 @@ describe("createRequestQueue", () => {
test("classifies git and worktree endpoints as slow", () => {
expect(isSlowRequest("/api/vcs")).toBe(true)
expect(isSlowRequest("/api/vcs/branch")).toBe(true)
expect(isSlowRequest("/api/vcs/branches")).toBe(true)
expect(isSlowRequest("/api/worktree")).toBe(true)
expect(isSlowRequest("/api/vcsx")).toBe(false)
expect(isSlowRequest("/api/session")).toBe(false)
+1 -3
View File
@@ -130,9 +130,7 @@ export function createSessionQueue(input: {
}
const steer = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox
.update({ sessionID: input.sessionID, inboxID: id, delivery: "steer" })
.catch(() => notify())
return server.api.session.inbox.steer({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
}
const remove = (id: string) => {
if (state.editing?.id === id) cancelEdit()
@@ -115,7 +115,7 @@ export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data
if (state.disposed || !enabled() || state.responded.has(permission.id)) return
remember(permission.id)
input.sdk.api.permission
.reply({ sessionID: permission.sessionID, requestID: permission.id, decision: "once" })
.reply({ sessionID: permission.sessionID, requestID: permission.id, reply: "once" })
.catch(() => {
// A reply failure leaves the request pending but invisible (the UI
// hides prompts while auto-approve is on), so retry a bounded number
+1 -1
View File
@@ -112,7 +112,7 @@ export function createSessionRequestModel() {
setStore("responding", perm.id)
serverSDK.api.permission
.reply({ sessionID: perm.sessionID, requestID: perm.id, decision: response })
.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response })
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
@@ -237,7 +237,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
const replyMutation = useMutation(() => ({
mutationFn: (answer: FormAnswer) =>
serverSDK.api.session.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
serverSDK.api.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
onMutate: () => {
props.onSubmit()
},
@@ -249,7 +249,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => serverSDK.api.session.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
mutationFn: () => serverSDK.api.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { FormAnswer, FormCreated, SessionFormReplyInput, OpenCodeEvent } from "@opencode/client/promise"
import type { FormAnswer, FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode/client/promise"
import { replyWebSearch } from "./websearch"
const consent: FormCreated["data"]["form"] = {
@@ -28,7 +28,7 @@ const provider: FormCreated["data"]["form"] = {
function fixture() {
const listeners = new Set<(event: OpenCodeEvent) => void>()
const replies: SessionFormReplyInput[] = []
const replies: FormReplyInput[] = []
const abort = new AbortController()
const emit = (event: OpenCodeEvent) => listeners.forEach((listener) => listener(event))
return {
@@ -45,7 +45,7 @@ function fixture() {
}
},
},
reply: async (input: SessionFormReplyInput) => {
reply: async (input: FormReplyInput) => {
replies.push(input)
},
create: (form = provider) => emit({ id: "evt_create", created: 0, type: "form.created", data: { form } }),
@@ -1,4 +1,4 @@
import type { FormInfo, FormOption, SessionFormReplyInput, FormStringField } from "@opencode/client/promise"
import type { FormInfo, FormOption, FormReplyInput, FormStringField } from "@opencode/client/promise"
import { createEffect, createMemo, createResource, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeEventStream } from "@/runtime/server/client"
@@ -14,7 +14,7 @@ export function createWebSearchRequest(input: {
connected: () => boolean
request: () => FormInfo | undefined
providers: (sessionID: string) => Promise<FormOption[]>
reply: (input: SessionFormReplyInput) => Promise<unknown>
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
const [store, setStore] = createStore({
@@ -93,7 +93,7 @@ export async function replyWebSearch(input: {
form: FormInfo
selection: string | false
signal: AbortSignal
reply: (input: SessionFormReplyInput) => Promise<unknown>
reply: (input: FormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
if (input.signal.aborted) return
@@ -80,13 +80,13 @@ export function createTimelineController(input: { session: TimelineSessionSource
)
})
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("session.tab.session"))
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("command.session.new"))
const parentMessages = createMemo(() => {
const id = input.session.data.parentID()
return id ? data.session.message.list(id) : emptyMessages
})
const parentTitle = createMemo(
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("session.tab.session"),
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("command.session.new"),
)
const childTaskDescription = createMemo(() => {
const id = input.session.identity.sessionID()
@@ -100,7 +100,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
parentID: input.session.data.parentID(),
taskDescription: childTaskDescription(),
title: titleLabel(),
fallback: language.t("session.tab.session"),
fallback: language.t("command.session.new"),
})
})
const showHeader = createMemo(() => !!input.session.identity.sessionID())
@@ -149,7 +149,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
if (!next || next === (titleLabel() ?? "")) return true
setPending("rename", true)
const success = await serverSDK.api.session
.update({ sessionID: id, title: next })
.rename({ sessionID: id, title: next })
.then(() => true)
.catch((error) => {
showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) })
@@ -209,7 +209,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
function DeleteDialog(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("session.tab.session"),
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
)
const confirm = async () => {
await remove(props.sessionID)
@@ -6,13 +6,7 @@ import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
import fuzzysort from "fuzzysort"
import {
DEFAULT_PALETTE_KEYBIND,
formatKeybind,
keyFromKeyboardEvent,
parseKeybind,
useCommand,
} from "@/shell/commands/command"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { SettingsList } from "@/settings/list"
@@ -75,6 +69,13 @@ function isModifier(key: string) {
return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta"
}
function normalizeKey(key: string) {
if (key === ",") return "comma"
if (key === "+") return "plus"
if (key === " ") return "space"
return key.toLowerCase()
}
function recordKeybind(event: KeyboardEvent) {
if (isModifier(event.key)) return
@@ -88,7 +89,7 @@ function recordKeybind(event: KeyboardEvent) {
if (event.altKey) parts.push("alt")
if (event.shiftKey) parts.push("shift")
const key = keyFromKeyboardEvent(event)
const key = normalizeKey(event.key)
if (!key) return
parts.push(key)
@@ -51,15 +51,6 @@ describe("command keybind helpers", () => {
).toBe(true)
})
test("matchKeybind uses the letter key for option-modified characters", () => {
expect(
matchKeybind(
parseKeybind("meta+alt+l"),
new KeyboardEvent("keydown", { key: "¬", code: "KeyL", metaKey: true, altKey: true }),
),
).toBe(true)
})
test("formatKeybind returns human readable output", () => {
const display = formatKeybind("ctrl+alt+arrowup")
+2 -9
View File
@@ -50,20 +50,13 @@ function normalizeKey(key: string) {
return key.toLowerCase()
}
export function keyFromKeyboardEvent(event: KeyboardEvent) {
const key = normalizeKey(event.key)
if (!event.altKey || /^[a-z0-9]$/.test(key)) return key
if (!event.code.startsWith("Key") || event.code.length !== 4) return key
return event.code.slice(3).toLowerCase()
}
function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) {
const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0)
return `${key}:${mask}`
}
function signatureFromEvent(event: KeyboardEvent) {
return signature(keyFromKeyboardEvent(event), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
}
function isAllowedEditableKeybind(id: string | undefined) {
@@ -186,7 +179,7 @@ export function parseKeybind(config: string): Keybind[] {
}
export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
const eventKey = keyFromKeyboardEvent(event)
const eventKey = normalizeKey(event.key)
for (const kb of keybinds) {
const keyMatch = kb.key === eventKey
+5 -10
View File
@@ -33,15 +33,6 @@ export default function Layout(props: ParentProps) {
},
install: installUpdate,
}
// A plain object avoids the compiler's conditional-prop memo, which leaks when read from event handlers.
const debugTools = import.meta.env.DEV
? {
get visible() {
return state.debugTools
},
toggle: () => setState("debugTools", (value) => !value),
}
: undefined
return (
<TitlebarRightProvider>
@@ -62,7 +53,11 @@ export default function Layout(props: ParentProps) {
<Titlebar
update={update}
verticalTabs={verticalTabs() ? { mount: state.tabsMount } : undefined}
debugTools={debugTools}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
/>
<div class="flex flex-1 min-h-0 min-w-0 flex-row">
<Show when={verticalTabs()}>
@@ -116,7 +116,7 @@ function SessionTabEntry(props: {
ctx.data.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.update({ sessionID: value.id, title })
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx
+1 -1
View File
@@ -792,7 +792,7 @@ function ChannelIndicator(props: {
if (!channel || channel === "prod") return null
const label = () => language.t(`titlebar.channel.${channel}`)
const debug = () => (channel === "dev" || channel === "local" ? props.debugTools : undefined)
const debug = () => (channel === "dev" ? props.debugTools : undefined)
return (
<Tooltip
placement={props.sidebar ? "right" : "bottom"}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.5",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"bin": {
+21 -18
View File
@@ -4,31 +4,34 @@ import { brotliCompressSync, constants } from "node:zlib"
import { collectFiles } from "./files"
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
if (options?.skipBuild) return "{}"
if (options?.skipBuild) return compress({})
const root = path.resolve(import.meta.dirname, "../../app")
await $`bun run build`
.cwd(root)
.env({ ...process.env, OPENCODE_CHANNEL: channel, VITE_OPENCODE_SERVER_MODE: "origin" })
return JSON.stringify(
Object.fromEntries(
await Promise.all(
(await collectFiles(path.join(root, "dist")))
.map((key) => key.replaceAll(path.sep, "/"))
.filter((key) => !key.endsWith(".map"))
.toSorted()
.map(async (key) => {
const source = path.join(root, "dist", key)
const body = Buffer.from(await Bun.file(source).arrayBuffer())
// Independent entries let the server materialize only assets the browser requests.
return [key, compress(body)] as const
}),
),
const assets = Object.fromEntries(
await Promise.all(
(await collectFiles(path.join(root, "dist")))
.map((key) => key.replaceAll(path.sep, "/"))
.filter((key) => !key.endsWith(".map"))
.toSorted()
.map(async (key) => {
const source = path.join(root, "dist", key)
const body = Buffer.from(await Bun.file(source).arrayBuffer())
const encoding = isText(key) ? "utf8" : "base64"
return [key, { encoding, content: body.toString(encoding) }] as const
}),
),
)
return compress(assets)
}
function compress(body: Buffer) {
return brotliCompressSync(body, {
params: { [constants.BROTLI_PARAM_QUALITY]: 6 },
function compress(assets: object) {
return brotliCompressSync(JSON.stringify(assets), {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
}).toString("base64")
}
function isText(key: string) {
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
}
+1 -2
View File
@@ -74,7 +74,7 @@ const appAssetsPlugin: BunPlugin = {
}))
build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({
loader: "js",
contents: `export default ${appArchive}`,
contents: `export default ${JSON.stringify(appArchive)}`,
}))
},
}
@@ -140,7 +140,6 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
...(executablePath ? { executablePath } : {}),
outfile: path.join(outdir, name, "bin", binary),
execArgv: [
"--smol",
`--user-agent=opencode/${Script.channel}/${Script.version}/cli`,
"--use-system-ca",
"--no-warnings",
+1 -1
View File
@@ -165,7 +165,7 @@ export async function streamTurn(input: {
continue
}
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
await input.client.session.form
await input.client.form
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
continue
+1 -1
View File
@@ -55,7 +55,7 @@ export async function replyPermission(input: {
await input.client.permission.reply({
sessionID: input.sessionID,
requestID: input.event.data.id,
decision: reply,
reply,
})
}
+18 -21
View File
@@ -1,51 +1,48 @@
import { Effect, FileSystem, Option } from "effect"
import { readFileSync } from "node:fs"
import path from "node:path"
import { brotliDecompressSync } from "node:zlib"
import { OPENCODE_LOCAL } from "./version"
export type AssetMap = Readonly<Record<string, string | Uint8Array>>
type EncodedAssetMap = Readonly<Record<string, string>>
type EncodedAssetMap = Readonly<Record<string, { readonly content: string; readonly encoding: "utf8" | "base64" }>>
export const load = Effect.fn("cli.app-assets.load")(function* () {
const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe(Effect.option)
if (Option.isSome(embedded) && (Object.keys(embedded.value.default).length > 0 || !OPENCODE_LOCAL))
return lazy(embedded.value.default, (key) =>
brotliDecompressSync(Buffer.from(embedded.value.default[key]!, "base64")),
)
if (Option.isSome(embedded) && embedded.value.default.length > 0) return decodeArchive(embedded.value.default)
if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build"))
return yield* sourceAssets()
return decode(yield* sourceAssets())
})
function decodeArchive(archive: string) {
const body = brotliDecompressSync(Buffer.from(archive, "base64")).toString()
return decode(JSON.parse(body) as EncodedAssetMap)
}
const sourceAssets = Effect.fnUntraced(function* () {
const fs = yield* FileSystem.FileSystem
const root = path.resolve(import.meta.dirname, "../../app/dist")
const files = yield* fs.readDirectory(root, { recursive: true })
const assets = Object.fromEntries(
return Object.fromEntries(
(yield* Effect.forEach(
files.filter((file) => !file.endsWith(".map")),
Effect.fnUntraced(function* (file) {
const target = path.join(root, file)
if ((yield* fs.stat(target)).type === "Directory") return
return [file, target] as const
const body = Buffer.from(yield* fs.readFile(target))
const encoding = isText(file) ? "utf8" : "base64"
return [file, { encoding, content: body.toString(encoding) }] as const
}),
{ concurrency: "unbounded" },
)).filter((asset) => asset !== undefined),
)
return lazy(assets, (key) => readFileSync(assets[key]!))
})
function lazy(assets: EncodedAssetMap, load: (key: string) => Uint8Array): AssetMap {
// Immutable browser caching makes retaining decompressed copies in the server unnecessary.
return new Proxy(
{},
{
get: (_, key) => {
if (typeof key !== "string" || assets[key] === undefined) return
const body = load(key)
return isText(key) ? Buffer.from(body).toString() : body
},
},
function decode(assets: EncodedAssetMap): AssetMap {
return Object.fromEntries(
Object.entries(assets).map(([key, asset]) => [
key,
asset.encoding === "utf8" ? asset.content : Buffer.from(asset.content, "base64"),
]),
)
}
+4 -4
View File
@@ -145,7 +145,7 @@ export async function runNonInteractivePrompt(input: Input) {
.reply({
sessionID: input.sessionID,
requestID: request.id,
decision: input.auto ? "once" : "reject",
reply: input.auto ? "once" : "reject",
})
.catch(() => {})
if (!input.auto) {
@@ -155,7 +155,7 @@ export async function runNonInteractivePrompt(input: Input) {
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
try {
await input.client.session.form.cancel(
await input.client.form.cancel(
{ sessionID: request.sessionID, formID: request.id },
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
)
@@ -695,10 +695,10 @@ export async function runNonInteractivePrompt(input: Input) {
const [permissions, forms, globals] = await Promise.all([
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.session.form.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.form.list({ sessionID: input.sessionID }).catch(() => undefined),
input.attached
? Promise.resolve(undefined)
: input.client.form
: input.client.form.request
.list({
location: { directory: input.location.directory },
})
+1 -1
View File
@@ -128,7 +128,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
const model = target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined
const variant = target.model?.variant
if (!target.resume && input.title !== undefined) {
await client.session.update({
await client.session.rename({
sessionID: target.session.id,
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
})
+4 -5
View File
@@ -26,12 +26,11 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
const key = url.pathname.replace(/^\//, "")
const requested = assets[key]
if ((key.startsWith("_assets/") || key.startsWith("icons/")) && requested === undefined)
if ((key.startsWith("_assets/") || key.startsWith("icons/")) && assets[key] === undefined)
return Effect.succeed(HttpServerResponse.empty({ status: 404, headers: { "cache-control": "no-store" } }))
const name = requested !== undefined ? key : "index.html"
const file = requested ?? assets["index.html"]
if (file === undefined) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
const name = assets[key] !== undefined ? key : "index.html"
const file = assets[name]
if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
if (request.method !== "GET" && request.method !== "HEAD")
return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
const html = name === "index.html"
+1 -1
View File
@@ -1,4 +1,4 @@
declare module "virtual:opencode-app-assets" {
const archive: Readonly<Record<string, string>>
const archive: string
export default archive
}
+1 -3
View File
@@ -748,9 +748,7 @@ describe("acp event behavior", () => {
expect(response.stopReason).toBe("end_turn")
expect(
fixture.requests.some(
(request) => request.method === "DELETE" && request.path === "/api/session/ses_form/form/frm_question",
),
fixture.requests.some((request) => request.path === "/api/session/ses_form/form/frm_question/cancel"),
).toBe(true)
} finally {
await fixture.stop()
@@ -574,7 +574,7 @@ function permissionReplies(fixture: Fixture) {
return fixture.requests.flatMap((request): Array<[string, string]> => {
const match = /^\/api\/session\/[^/]+\/permission\/([^/]+)\/reply$/.exec(request.path)
if (!match?.[1] || !request.body || typeof request.body !== "object") return []
const reply = "decision" in request.body ? request.body.decision : undefined
const reply = "reply" in request.body ? request.body.reply : undefined
return typeof reply === "string" ? [[decodeURIComponent(match[1]), reply]] : []
})
}
+3 -3
View File
@@ -128,7 +128,7 @@ export function createSseFixture(options: FixtureOptions = {}) {
const permission = /^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/.exec(url.pathname)
if (permission?.[1] && permission[2]) {
const reply = stringField(body, "decision")
const reply = stringField(body, "reply")
if (!reply) return new Response(null, { status: 400 })
await options.onPermissionReply?.({
sessionID: decodeURIComponent(permission[1]),
@@ -140,8 +140,8 @@ export function createSseFixture(options: FixtureOptions = {}) {
return new Response(null, { status: 204 })
}
const form = /^\/api\/session\/([^/]+)\/form\/([^/]+)$/.exec(url.pathname)
if (request.method === "DELETE" && form?.[1] && form[2]) {
const form = /^\/api\/session\/([^/]+)\/form\/([^/]+)\/cancel$/.exec(url.pathname)
if (form?.[1] && form[2]) {
await options.onFormCancel?.({
sessionID: decodeURIComponent(form[1]),
formID: decodeURIComponent(form[2]),
+11 -11
View File
@@ -240,17 +240,17 @@ async function run(input: {
})()
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.session.form, "list").mockImplementation(
spyOn(sdk.form, "list").mockImplementation(
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
)
spyOn(sdk.form, "list").mockImplementation(
spyOn(sdk.form.request, "list").mockImplementation(
() =>
ok({
location: { ...location, project: { id: "proj_1", directory: location.directory } },
data: input.pendingForms?.filter((item) => item.sessionID === "global") ?? [],
}) as never,
)
spyOn(sdk.session.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
let promptID = "msg_prompt"
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
@@ -426,10 +426,10 @@ describe("runNonInteractivePrompt", () => {
"x-opencode-directory": "%2Fwork%20tree",
},
}
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.list).toHaveBeenCalledWith({
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.request.list).toHaveBeenCalledWith({
location: { directory: "/work tree" },
})
})
@@ -440,10 +440,10 @@ describe("runNonInteractivePrompt", () => {
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
})
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.list).not.toHaveBeenCalled()
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith(
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.request.list).not.toHaveBeenCalled()
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
expect(sdk.form.cancel).not.toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),
)
+2 -2
View File
@@ -31,7 +31,7 @@ function appAssetsPlugin(archive: string): Plugin {
},
load(id) {
if (id !== "\0virtual:opencode-app-assets") return
return `export default ${archive}`
return `export default ${JSON.stringify(archive)}`
},
}
}
@@ -292,5 +292,5 @@ export default mainConfig({
channel: process.env.OPENCODE_CHANNEL ?? "local",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
appArchive: "{}",
appArchive: "",
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.5",
"version": "2.0.3",
"type": "module",
"license": "MIT",
"repository": {
+82 -74
View File
@@ -23,8 +23,8 @@ import type { Event } from "@opencode/schema/event"
import type { EventLog } from "@opencode/schema/event-log"
import type { Shell } from "@opencode/schema/shell"
import type { Provider } from "@opencode/schema/provider"
import type { Form } from "@opencode/schema/form"
import type { Integration } from "@opencode/schema/integration"
import type { Form } from "@opencode/schema/form"
import type { Mcp } from "@opencode/schema/mcp"
import type { Credential } from "@opencode/schema/credential"
import type { PermissionSaved } from "@opencode/schema/permission-saved"
@@ -233,13 +233,9 @@ export type SessionSwitchModelOperation<E = never> = (
input: SessionSwitchModelInput,
) => Effect.Effect<SessionSwitchModelOutput, E>
export type SessionUpdateInput = {
readonly sessionID: Session.ID
readonly title?: string | undefined
readonly permissions?: Permission.Ruleset | undefined
}
export type SessionUpdateOutput = void
export type SessionUpdateOperation<E = never> = (input: SessionUpdateInput) => Effect.Effect<SessionUpdateOutput, E>
export type SessionRenameInput = { readonly sessionID: Session.ID; readonly title: string }
export type SessionRenameOutput = void
export type SessionRenameOperation<E = never> = (input: SessionRenameInput) => Effect.Effect<SessionRenameOutput, E>
export type SessionMoveInput = {
readonly sessionID: Session.ID
@@ -364,15 +360,17 @@ export type SessionInboxCancelOperation<E = never> = (
input: SessionInboxCancelInput,
) => Effect.Effect<SessionInboxCancelOutput, E>
export type SessionInboxUpdateInput = {
readonly sessionID: Session.ID
readonly inboxID: SessionMessage.ID
readonly delivery: SessionInbox.Delivery
}
export type SessionInboxUpdateOutput = void
export type SessionInboxUpdateOperation<E = never> = (
input: SessionInboxUpdateInput,
) => Effect.Effect<SessionInboxUpdateOutput, E>
export type SessionInboxSteerInput = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type SessionInboxSteerOutput = void
export type SessionInboxSteerOperation<E = never> = (
input: SessionInboxSteerInput,
) => Effect.Effect<SessionInboxSteerOutput, E>
export type SessionInboxQueueInput = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type SessionInboxQueueOutput = void
export type SessionInboxQueueOperation<E = never> = (
input: SessionInboxQueueInput,
) => Effect.Effect<SessionInboxQueueOutput, E>
export type SessionInstructionsEntryListInput = { readonly sessionID: Session.ID }
export type SessionInstructionsEntryListOutput = ReadonlyArray<InstructionEntry.Info>
@@ -515,7 +513,7 @@ export type SessionLogOutput =
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.permissions"
readonly type: "session.permissions.updated"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?:
| {
@@ -1327,44 +1325,6 @@ export type SessionMessageGetOperation<E = never> = (
input: SessionMessageGetInput,
) => Effect.Effect<SessionMessageGetOutput, E>
export type SessionFormListInput = { readonly sessionID: string }
export type SessionFormListOutput = ReadonlyArray<Form.Info>
export type SessionFormListOperation<E = never> = (
input: SessionFormListInput,
) => Effect.Effect<SessionFormListOutput, E>
export type SessionFormCreateInput = {
readonly sessionID: string
readonly id?: Form.ID | undefined
readonly title: string
readonly metadata?: Form.Metadata | undefined
readonly fields: Form.Fields
}
export type SessionFormCreateOutput = Form.Info
export type SessionFormCreateOperation<E = never> = (
input: SessionFormCreateInput,
) => Effect.Effect<SessionFormCreateOutput, E>
export type SessionFormGetInput = { readonly sessionID: string; readonly formID: Form.ID }
export type SessionFormGetOutput = Form.Detail
export type SessionFormGetOperation<E = never> = (input: SessionFormGetInput) => Effect.Effect<SessionFormGetOutput, E>
export type SessionFormReplyInput = {
readonly sessionID: string
readonly formID: Form.ID
readonly answer: Form.Answer
}
export type SessionFormReplyOutput = void
export type SessionFormReplyOperation<E = never> = (
input: SessionFormReplyInput,
) => Effect.Effect<SessionFormReplyOutput, E>
export type SessionFormCancelInput = { readonly sessionID: string; readonly formID: Form.ID }
export type SessionFormCancelOutput = void
export type SessionFormCancelOperation<E = never> = (
input: SessionFormCancelInput,
) => Effect.Effect<SessionFormCancelOutput, E>
export type SessionEnvironmentInput = {
readonly sessionID: Session.ID
readonly variables: { readonly [x: string]: string }
@@ -1390,7 +1350,7 @@ export interface SessionApi<E = never> {
readonly fork: SessionForkOperation<E>
readonly switchAgent: SessionSwitchAgentOperation<E>
readonly switchModel: SessionSwitchModelOperation<E>
readonly update: SessionUpdateOperation<E>
readonly rename: SessionRenameOperation<E>
readonly move: SessionMoveOperation<E>
readonly prompt: SessionPromptOperation<E>
readonly command: SessionCommandOperation<E>
@@ -1409,7 +1369,8 @@ export interface SessionApi<E = never> {
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
readonly update: SessionInboxUpdateOperation<E>
readonly steer: SessionInboxSteerOperation<E>
readonly queue: SessionInboxQueueOperation<E>
}
readonly instructions: {
readonly entry: {
@@ -1423,13 +1384,6 @@ export interface SessionApi<E = never> {
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
readonly message: { readonly get: SessionMessageGetOperation<E> }
readonly form: {
readonly list: SessionFormListOperation<E>
readonly create: SessionFormCreateOperation<E>
readonly get: SessionFormGetOperation<E>
readonly reply: SessionFormReplyOperation<E>
readonly cancel: SessionFormCancelOperation<E>
}
readonly environment: SessionEnvironmentOperation<E>
readonly view: SessionViewOperation<E>
}
@@ -1730,12 +1684,50 @@ export interface ProjectApi<E = never> {
readonly update: ProjectUpdateOperation<E>
}
export type FormListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type FormListOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Form.Info> }
export type FormListOperation<E = never> = (input?: FormListInput) => Effect.Effect<FormListOutput, E>
export type FormRequestListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type FormRequestListOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Form.Info> }
export type FormRequestListOperation<E = never> = (
input?: FormRequestListInput,
) => Effect.Effect<FormRequestListOutput, E>
export type FormListInput = { readonly sessionID: string }
export type FormListOutput = ReadonlyArray<Form.Info>
export type FormListOperation<E = never> = (input: FormListInput) => Effect.Effect<FormListOutput, E>
export type FormCreateInput = {
readonly sessionID: string
readonly id?: Form.ID | undefined
readonly title: string
readonly metadata?: Form.Metadata | undefined
readonly fields: Form.Fields
}
export type FormCreateOutput = Form.Info
export type FormCreateOperation<E = never> = (input: FormCreateInput) => Effect.Effect<FormCreateOutput, E>
export type FormGetInput = { readonly sessionID: string; readonly formID: Form.ID }
export type FormGetOutput = Form.Info
export type FormGetOperation<E = never> = (input: FormGetInput) => Effect.Effect<FormGetOutput, E>
export type FormStateInput = { readonly sessionID: string; readonly formID: Form.ID }
export type FormStateOutput = Form.State
export type FormStateOperation<E = never> = (input: FormStateInput) => Effect.Effect<FormStateOutput, E>
export type FormReplyInput = { readonly sessionID: string; readonly formID: Form.ID; readonly answer: Form.Answer }
export type FormReplyOutput = void
export type FormReplyOperation<E = never> = (input: FormReplyInput) => Effect.Effect<FormReplyOutput, E>
export type FormCancelInput = { readonly sessionID: string; readonly formID: Form.ID }
export type FormCancelOutput = void
export type FormCancelOperation<E = never> = (input: FormCancelInput) => Effect.Effect<FormCancelOutput, E>
export interface FormApi<E = never> {
readonly request: { readonly list: FormRequestListOperation<E> }
readonly list: FormListOperation<E>
readonly create: FormCreateOperation<E>
readonly get: FormGetOperation<E>
readonly state: FormStateOperation<E>
readonly reply: FormReplyOperation<E>
readonly cancel: FormCancelOperation<E>
}
export type PermissionRequestListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
@@ -1785,7 +1777,7 @@ export type PermissionGetOperation<E = never> = (input: PermissionGetInput) => E
export type PermissionReplyInput = {
readonly sessionID: Session.ID
readonly requestID: Permission.ID
readonly decision: Permission.Reply
readonly reply: Permission.Reply
readonly message?: string | undefined
}
export type PermissionReplyOutput = void
@@ -1793,6 +1785,12 @@ export type PermissionReplyOperation<E = never> = (
input: PermissionReplyInput,
) => Effect.Effect<PermissionReplyOutput, E>
export type PermissionRulesInput = { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
export type PermissionRulesOutput = void
export type PermissionRulesOperation<E = never> = (
input: PermissionRulesInput,
) => Effect.Effect<PermissionRulesOutput, E>
export interface PermissionApi<E = never> {
readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
@@ -1800,6 +1798,7 @@ export interface PermissionApi<E = never> {
readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E>
readonly reply: PermissionReplyOperation<E>
readonly rules: PermissionRulesOperation<E>
}
export type FileListInput = {
@@ -2102,7 +2101,7 @@ export type ShellCreateInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly command: string
readonly cwd?: string | undefined
readonly timeout?: number | undefined
readonly timeout: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
}
export type ShellCreateOutput = { readonly location: Location.PublicRef; readonly data: Shell.Info }
@@ -2115,6 +2114,14 @@ export type ShellGetInput = {
export type ShellGetOutput = { readonly location: Location.PublicRef; readonly data: Shell.Info }
export type ShellGetOperation<E = never> = (input: ShellGetInput) => Effect.Effect<ShellGetOutput, E>
export type ShellTimeoutInput = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly timeout: number
}
export type ShellTimeoutOutput = { readonly location: Location.PublicRef; readonly data: Shell.Info }
export type ShellTimeoutOperation<E = never> = (input: ShellTimeoutInput) => Effect.Effect<ShellTimeoutOutput, E>
export type ShellOutputInput = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
@@ -2143,6 +2150,7 @@ export interface ShellApi<E = never> {
readonly list: ShellListOperation<E>
readonly create: ShellCreateOperation<E>
readonly get: ShellGetOperation<E>
readonly timeout: ShellTimeoutOperation<E>
readonly output: ShellOutputOperation<E>
readonly remove: ShellRemoveOperation<E>
}
@@ -2205,13 +2213,13 @@ export type VcsStatusInput = { readonly location?: { readonly directory?: string
export type VcsStatusOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: VcsStatusInput) => Effect.Effect<VcsStatusOutput, E>
export type VcsBranchListInput = {
export type VcsBranchesInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly search?: string | undefined
readonly limit?: number | undefined
}
export type VcsBranchListOutput = { readonly location: Location.PublicRef; readonly data: Vcs.BranchList }
export type VcsBranchListOperation<E = never> = (input?: VcsBranchListInput) => Effect.Effect<VcsBranchListOutput, E>
export type VcsBranchesOutput = { readonly location: Location.PublicRef; readonly data: Vcs.BranchList }
export type VcsBranchesOperation<E = never> = (input?: VcsBranchesInput) => Effect.Effect<VcsBranchesOutput, E>
export type VcsDiffInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
@@ -2226,7 +2234,7 @@ export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly base: VcsBaseOperation<E>
readonly status: VcsStatusOperation<E>
readonly branch: { readonly list: VcsBranchListOperation<E> }
readonly branches: VcsBranchesOperation<E>
readonly diff: VcsDiffOperation<E>
}
+130 -88
View File
@@ -39,8 +39,8 @@ import type {
SessionSwitchAgentOutput,
SessionSwitchModelInput,
SessionSwitchModelOutput,
SessionUpdateInput,
SessionUpdateOutput,
SessionRenameInput,
SessionRenameOutput,
SessionMoveInput,
SessionMoveOutput,
SessionPromptInput,
@@ -71,8 +71,10 @@ import type {
SessionInboxListOutput,
SessionInboxCancelInput,
SessionInboxCancelOutput,
SessionInboxUpdateInput,
SessionInboxUpdateOutput,
SessionInboxSteerInput,
SessionInboxSteerOutput,
SessionInboxQueueInput,
SessionInboxQueueOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -89,16 +91,6 @@ import type {
SessionBackgroundOutput,
SessionMessageGetInput,
SessionMessageGetOutput,
SessionFormListInput,
SessionFormListOutput,
SessionFormCreateInput,
SessionFormCreateOutput,
SessionFormGetInput,
SessionFormGetOutput,
SessionFormReplyInput,
SessionFormReplyOutput,
SessionFormCancelInput,
SessionFormCancelOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -158,8 +150,20 @@ import type {
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
FormListOutput,
FormCreateInput,
FormCreateOutput,
FormGetInput,
FormGetOutput,
FormStateInput,
FormStateOutput,
FormReplyInput,
FormReplyOutput,
FormCancelInput,
FormCancelOutput,
PermissionRequestListInput,
PermissionRequestListOutput,
PermissionSavedListInput,
@@ -174,6 +178,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileListInput,
FileListOutput,
FileFindInput,
@@ -221,6 +227,8 @@ import type {
ShellCreateOutput,
ShellGetInput,
ShellGetOutput,
ShellTimeoutInput,
ShellTimeoutOutput,
ShellOutputInput,
ShellOutputOutput,
ShellRemoveInput,
@@ -241,8 +249,8 @@ import type {
VcsBaseOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchListInput,
VcsBranchListOutput,
VcsBranchesInput,
VcsBranchesOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationListOutput,
@@ -442,12 +450,11 @@ const EndpointSessionSwitchModel = (raw: RawClient["server.session"]) => (input:
),
)
const EndpointSessionUpdate = (raw: RawClient["server.session"]) => (input: SessionUpdateInput) =>
preserveEffect<SessionUpdateOutput>()(
raw["session.update"]({
params: { sessionID: input["sessionID"] },
payload: { title: input["title"], permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
const EndpointSessionRename = (raw: RawClient["server.session"]) => (input: SessionRenameInput) =>
preserveEffect<SessionRenameOutput>()(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointSessionMove = (raw: RawClient["server.session"]) => (input: SessionMoveInput) =>
@@ -598,12 +605,18 @@ const EndpointSessionInboxCancel = (raw: RawClient["server.session"]) => (input:
),
)
const EndpointSessionInboxUpdate = (raw: RawClient["server.session"]) => (input: SessionInboxUpdateInput) =>
preserveEffect<SessionInboxUpdateOutput>()(
raw["session.inbox.update"]({
params: { sessionID: input["sessionID"], inboxID: input["inboxID"] },
payload: { delivery: input["delivery"] },
}).pipe(Effect.mapError(mapClientError)),
const EndpointSessionInboxSteer = (raw: RawClient["server.session"]) => (input: SessionInboxSteerInput) =>
preserveEffect<SessionInboxSteerOutput>()(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointSessionInboxQueue = (raw: RawClient["server.session"]) => (input: SessionInboxQueueInput) =>
preserveEffect<SessionInboxQueueOutput>()(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointSessionInstructionsEntryList =
@@ -673,48 +686,6 @@ const EndpointSessionMessageGet = (raw: RawClient["server.session"]) => (input:
),
)
const EndpointSessionFormList = (raw: RawClient["server.session"]) => (input: SessionFormListInput) =>
preserveEffect<SessionFormListOutput>()(
raw["session.form.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionFormCreate = (raw: RawClient["server.session"]) => (input: SessionFormCreateInput) =>
preserveEffect<SessionFormCreateOutput>()(
raw["session.form.create"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionFormGet = (raw: RawClient["server.session"]) => (input: SessionFormGetInput) =>
preserveEffect<SessionFormGetOutput>()(
raw["session.form.get"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionFormReply = (raw: RawClient["server.session"]) => (input: SessionFormReplyInput) =>
preserveEffect<SessionFormReplyOutput>()(
raw["session.form.reply"]({
params: { sessionID: input["sessionID"], formID: input["formID"] },
payload: { answer: input["answer"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionFormCancel = (raw: RawClient["server.session"]) => (input: SessionFormCancelInput) =>
preserveEffect<SessionFormCancelOutput>()(
raw["session.form.cancel"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointSessionEnvironment = (raw: RawClient["server.session"]) => (input: SessionEnvironmentInput) =>
preserveEffect<SessionEnvironmentOutput>()(
raw["session.environment"]({
@@ -742,7 +713,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
fork: EndpointSessionFork(raw),
switchAgent: EndpointSessionSwitchAgent(raw),
switchModel: EndpointSessionSwitchModel(raw),
update: EndpointSessionUpdate(raw),
rename: EndpointSessionRename(raw),
move: EndpointSessionMove(raw),
prompt: EndpointSessionPrompt(raw),
command: EndpointSessionCommand(raw),
@@ -761,7 +732,8 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
update: EndpointSessionInboxUpdate(raw),
steer: EndpointSessionInboxSteer(raw),
queue: EndpointSessionInboxQueue(raw),
},
instructions: {
entry: {
@@ -775,13 +747,6 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
interrupt: EndpointSessionInterrupt(raw),
background: EndpointSessionBackground(raw),
message: { get: EndpointSessionMessageGet(raw) },
form: {
list: EndpointSessionFormList(raw),
create: EndpointSessionFormCreate(raw),
get: EndpointSessionFormGet(raw),
reply: EndpointSessionFormReply(raw),
cancel: EndpointSessionFormCancel(raw),
},
environment: EndpointSessionEnvironment(raw),
view: EndpointSessionView(raw),
})
@@ -1041,12 +1006,70 @@ const adaptGroupProject = (raw: RawClient["server.project"]) => ({
update: EndpointProjectUpdate(raw),
})
const EndpointFormList = (raw: RawClient["server.form"]) => (input?: FormListInput) =>
preserveEffect<FormListOutput>()(
raw["form.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
const EndpointFormRequestList = (raw: RawClient["server.form"]) => (input?: FormRequestListInput) =>
preserveEffect<FormRequestListOutput>()(
raw["form.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupForm = (raw: RawClient["server.form"]) => ({ list: EndpointFormList(raw) })
const EndpointFormList = (raw: RawClient["server.form"]) => (input: FormListInput) =>
preserveEffect<FormListOutput>()(
raw["session.form.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormCreate = (raw: RawClient["server.form"]) => (input: FormCreateInput) =>
preserveEffect<FormCreateOutput>()(
raw["session.form.create"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormGet = (raw: RawClient["server.form"]) => (input: FormGetInput) =>
preserveEffect<FormGetOutput>()(
raw["session.form.get"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormState = (raw: RawClient["server.form"]) => (input: FormStateInput) =>
preserveEffect<FormStateOutput>()(
raw["session.form.state"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormReply = (raw: RawClient["server.form"]) => (input: FormReplyInput) =>
preserveEffect<FormReplyOutput>()(
raw["session.form.reply"]({
params: { sessionID: input["sessionID"], formID: input["formID"] },
payload: { answer: input["answer"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointFormCancel = (raw: RawClient["server.form"]) => (input: FormCancelInput) =>
preserveEffect<FormCancelOutput>()(
raw["session.form.cancel"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupForm = (raw: RawClient["server.form"]) => ({
request: { list: EndpointFormRequestList(raw) },
list: EndpointFormList(raw),
create: EndpointFormCreate(raw),
get: EndpointFormGet(raw),
state: EndpointFormState(raw),
reply: EndpointFormReply(raw),
cancel: EndpointFormCancel(raw),
})
const EndpointPermissionRequestList = (raw: RawClient["server.permission"]) => (input?: PermissionRequestListInput) =>
preserveEffect<PermissionRequestListOutput>()(
@@ -1105,7 +1128,15 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
preserveEffect<PermissionReplyOutput>()(
raw["session.permission.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { decision: input["decision"], message: input["message"] },
payload: { reply: input["reply"], message: input["message"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointPermissionRules = (raw: RawClient["server.permission"]) => (input: PermissionRulesInput) =>
preserveEffect<PermissionRulesOutput>()(
raw["session.permission.rules"]({
params: { sessionID: input["sessionID"] },
payload: { permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1116,6 +1147,7 @@ const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
list: EndpointPermissionList(raw),
get: EndpointPermissionGet(raw),
reply: EndpointPermissionReply(raw),
rules: EndpointPermissionRules(raw),
})
const EndpointFileList = (raw: RawClient["server.fs"]) => (input?: FileListInput) =>
@@ -1361,6 +1393,15 @@ const EndpointShellGet = (raw: RawClient["server.shell"]) => (input: ShellGetInp
),
)
const EndpointShellTimeout = (raw: RawClient["server.shell"]) => (input: ShellTimeoutInput) =>
preserveEffect<ShellTimeoutOutput>()(
raw["shell.timeout"]({
params: { id: input["id"] },
query: { location: input["location"] },
payload: { timeout: input["timeout"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointShellOutput = (raw: RawClient["server.shell"]) => (input: ShellOutputInput) =>
preserveEffect<ShellOutputOutput>()(
raw["shell.output"]({
@@ -1380,6 +1421,7 @@ const adaptGroupShell = (raw: RawClient["server.shell"]) => ({
list: EndpointShellList(raw),
create: EndpointShellCreate(raw),
get: EndpointShellGet(raw),
timeout: EndpointShellTimeout(raw),
output: EndpointShellOutput(raw),
remove: EndpointShellRemove(raw),
})
@@ -1443,9 +1485,9 @@ const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusIn
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsBranchList = (raw: RawClient["server.vcs"]) => (input?: VcsBranchListInput) =>
preserveEffect<VcsBranchListOutput>()(
raw["vcs.branch.list"]({
const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranchesInput) =>
preserveEffect<VcsBranchesOutput>()(
raw["vcs.branches"]({
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1461,7 +1503,7 @@ const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
get: EndpointVcsGet(raw),
base: EndpointVcsBase(raw),
status: EndpointVcsStatus(raw),
branch: { list: EndpointVcsBranchList(raw) },
branches: EndpointVcsBranches(raw),
diff: EndpointVcsDiff(raw),
})
+159 -107
View File
@@ -33,8 +33,8 @@ import type {
SessionSwitchAgentOutput,
SessionSwitchModelInput,
SessionSwitchModelOutput,
SessionUpdateInput,
SessionUpdateOutput,
SessionRenameInput,
SessionRenameOutput,
SessionMoveInput,
SessionMoveOutput,
SessionPromptInput,
@@ -65,8 +65,10 @@ import type {
SessionInboxListOutput,
SessionInboxCancelInput,
SessionInboxCancelOutput,
SessionInboxUpdateInput,
SessionInboxUpdateOutput,
SessionInboxSteerInput,
SessionInboxSteerOutput,
SessionInboxQueueInput,
SessionInboxQueueOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -83,16 +85,6 @@ import type {
SessionBackgroundOutput,
SessionMessageGetInput,
SessionMessageGetOutput,
SessionFormListInput,
SessionFormListOutput,
SessionFormCreateInput,
SessionFormCreateOutput,
SessionFormGetInput,
SessionFormGetOutput,
SessionFormReplyInput,
SessionFormReplyOutput,
SessionFormCancelInput,
SessionFormCancelOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -152,8 +144,20 @@ import type {
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
FormListOutput,
FormCreateInput,
FormCreateOutput,
FormGetInput,
FormGetOutput,
FormStateInput,
FormStateOutput,
FormReplyInput,
FormReplyOutput,
FormCancelInput,
FormCancelOutput,
PermissionRequestListInput,
PermissionRequestListOutput,
PermissionSavedListInput,
@@ -168,6 +172,8 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileReadInput,
FileReadOutput,
FileListInput,
@@ -217,6 +223,8 @@ import type {
ShellCreateOutput,
ShellGetInput,
ShellGetOutput,
ShellTimeoutInput,
ShellTimeoutOutput,
ShellOutputInput,
ShellOutputOutput,
ShellRemoveInput,
@@ -237,8 +245,8 @@ import type {
VcsBaseOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchListInput,
VcsBranchListOutput,
VcsBranchesInput,
VcsBranchesOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationListOutput,
@@ -636,12 +644,12 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
update: (input: SessionUpdateInput, requestOptions?: RequestOptions) =>
request<SessionUpdateOutput>(
rename: (input: SessionRenameInput, requestOptions?: RequestOptions) =>
request<SessionRenameOutput>(
{
method: "PATCH",
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
body: { title: input["title"], permissions: input["permissions"] },
body: { title: input["title"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
@@ -843,17 +851,27 @@ export function make(options: ClientOptions) {
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}`,
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
update: (input: SessionInboxUpdateInput, requestOptions?: RequestOptions) =>
request<SessionInboxUpdateOutput>(
steer: (input: SessionInboxSteerInput, requestOptions?: RequestOptions) =>
request<SessionInboxSteerOutput>(
{
method: "PATCH",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}`,
body: { delivery: input["delivery"] },
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/steer`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
queue: (input: SessionInboxQueueInput, requestOptions?: RequestOptions) =>
request<SessionInboxQueueOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/queue`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
@@ -959,70 +977,11 @@ export function make(options: ClientOptions) {
requestOptions,
).then((value) => value.data),
},
form: {
list: (input: SessionFormListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionFormListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: SessionFormCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionFormCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 409],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: SessionFormGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionFormGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: SessionFormReplyInput, requestOptions?: RequestOptions) =>
request<SessionFormReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/reply`,
body: { answer: input["answer"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
cancel: (input: SessionFormCancelInput, requestOptions?: RequestOptions) =>
request<SessionFormCancelOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
},
environment: (input: SessionEnvironmentInput, requestOptions?: RequestOptions) =>
request<SessionEnvironmentOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/environment`,
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/environment`,
body: { variables: input["variables"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
@@ -1088,7 +1047,7 @@ export function make(options: ClientOptions) {
request<{ readonly data: GenerateTextOutput }>(
{
method: "POST",
path: `/api/experimental/generate`,
path: `/api/generate`,
body: { prompt: input["prompt"], model: input["model"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
@@ -1408,17 +1367,87 @@ export function make(options: ClientOptions) {
),
},
form: {
list: (input?: FormListInput, requestOptions?: RequestOptions) =>
request<FormListOutput>(
request: {
list: (input?: FormRequestListInput, requestOptions?: RequestOptions) =>
request<FormRequestListOutput>(
{
method: "GET",
path: `/api/form/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
list: (input: FormListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormListOutput }>(
{
method: "GET",
path: `/api/form`,
query: { location: input?.["location"] },
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: FormCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 409],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: FormGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
state: (input: FormStateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormStateOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/state`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: FormReplyInput, requestOptions?: RequestOptions) =>
request<FormReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/reply`,
body: { answer: input["answer"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
cancel: (input: FormCancelInput, requestOptions?: RequestOptions) =>
request<FormCancelOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/cancel`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
},
permission: {
@@ -1508,7 +1537,19 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
body: { decision: input["decision"], message: input["message"] },
body: { reply: input["reply"], message: input["message"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
rules: (input: PermissionRulesInput, requestOptions?: RequestOptions) =>
request<PermissionRulesOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/rules`,
body: { permissions: input["permissions"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
@@ -1858,6 +1899,19 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
timeout: (input: ShellTimeoutInput, requestOptions?: RequestOptions) =>
request<ShellTimeoutOutput>(
{
method: "PATCH",
path: `/api/shell/${encodeURIComponent(input.id)}/timeout`,
query: { location: input["location"] },
body: { timeout: input["timeout"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
),
output: (input: ShellOutputInput, requestOptions?: RequestOptions) =>
request<ShellOutputOutput>(
{
@@ -1877,7 +1931,7 @@ export function make(options: ClientOptions) {
path: `/api/shell/${encodeURIComponent(input.id)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1990,20 +2044,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
branch: {
list: (input?: VcsBranchListInput, requestOptions?: RequestOptions) =>
request<VcsBranchListOutput>(
{
method: "GET",
path: `/api/vcs/branch`,
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
branches: (input?: VcsBranchesInput, requestOptions?: RequestOptions) =>
request<VcsBranchesOutput>(
{
method: "GET",
path: `/api/vcs/branches`,
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
diff: (input: VcsDiffInput, requestOptions?: RequestOptions) =>
request<VcsDiffOutput>(
{
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -7,9 +7,9 @@ import type {
AgentInfo,
CommandInfo,
ConfigEntry,
SessionFormCancelInput,
FormCancelInput,
FormInfo,
SessionFormReplyInput,
FormReplyInput,
IntegrationInfo,
LocationRef,
LocationGetOutput,
@@ -296,7 +296,7 @@ export function createData(config: CreateDataInput) {
return true
}
function settleForm(input: SessionFormCancelInput, ref: LocationRef | undefined, request: Promise<void>) {
function settleForm(input: FormCancelInput, ref: LocationRef | undefined, request: Promise<void>) {
return request
.catch((error: unknown) => {
if ((!isFormNotFoundError(error) && !isFormAlreadySettledError(error)) || error.id !== input.formID) throw error
@@ -697,7 +697,7 @@ export function createData(config: CreateDataInput) {
})
return
}
case "session.permissions":
case "session.permissions.updated":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
return
@@ -1742,7 +1742,7 @@ export function createData(config: CreateDataInput) {
const key = `session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`
return sync.run(key, async () => {
if (sessionID === "global") {
const response = await api().form.list({
const response = await api().form.request.list({
location: locationQuery(ref ?? defaultLocation()),
})
const location = {
@@ -1757,7 +1757,7 @@ export function createData(config: CreateDataInput) {
])
return
}
setStore("session", "form", sessionID, await api().session.form.list({ sessionID }))
setStore("session", "form", sessionID, await api().form.list({ sessionID }))
})
},
invalidate(sessionID: string, ref?: LocationRef) {
@@ -1765,11 +1765,11 @@ export function createData(config: CreateDataInput) {
`session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`,
)
},
reply(input: SessionFormReplyInput, ref?: LocationRef) {
return settleForm(input, ref, api().session.form.reply(input, formRequestOptions(input.sessionID, ref)))
reply(input: FormReplyInput, ref?: LocationRef) {
return settleForm(input, ref, api().form.reply(input, formRequestOptions(input.sessionID, ref)))
},
cancel(input: SessionFormCancelInput, ref?: LocationRef) {
return settleForm(input, ref, api().session.form.cancel(input, formRequestOptions(input.sessionID, ref)))
cancel(input: FormCancelInput, ref?: LocationRef) {
return settleForm(input, ref, api().form.cancel(input, formRequestOptions(input.sessionID, ref)))
},
},
},
+5 -5
View File
@@ -157,7 +157,7 @@ test("generate.text uses the locationless public contract", async () => {
})
expect(await client.generate.text({ prompt: "ping" })).toEqual({ text: "pong" })
expect(request?.url).toBe("http://localhost:3000/api/experimental/generate")
expect(request?.url).toBe("http://localhost:3000/api/generate")
expect(await request?.json()).toEqual({ prompt: "ping" })
})
@@ -546,13 +546,13 @@ test("session.inbox mutations use the public HTTP contract", async () => {
})
await client.session.inbox.cancel({ sessionID: "ses_test", inboxID: "msg_cancel" })
await client.session.inbox.update({ sessionID: "ses_test", inboxID: "msg_steer", delivery: "steer" })
await client.session.inbox.update({ sessionID: "ses_test", inboxID: "msg_queue", delivery: "queue" })
await client.session.inbox.steer({ sessionID: "ses_test", inboxID: "msg_steer" })
await client.session.inbox.queue({ sessionID: "ses_test", inboxID: "msg_queue" })
expect(requests).toEqual([
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/inbox/msg_cancel" },
{ method: "PATCH", url: "http://localhost:3000/api/session/ses_test/inbox/msg_steer" },
{ method: "PATCH", url: "http://localhost:3000/api/session/ses_test/inbox/msg_queue" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_queue/queue" },
])
})
+22 -5
View File
@@ -88,11 +88,8 @@ runtime.catalog() // structured tool descriptions
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
```
The Effect environment is inferred from the supplied tools. `hooks` surround every call the program makes into the
host: `tool.before`/`tool.after` receive `{ name, input }` with decoded input, `extension.before`/`extension.after`
receive `{ extension, name, args }`. An `after` hook also receives how the call ended (`success` with its value,
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
failure as a thrown error.
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
### `Values`
@@ -100,6 +97,26 @@ failure as a thrown error.
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
### `Extension.make` and `Web.make`
An extension is a set of named host functions installed as globals. Arguments arrive as copies, results return as
copies, and a function inside a result is callable the same way. Extension calls are not tool calls and are not shown
in the tool catalog, so hosts describe them in their own instructions.
`Web.make` is the built-in outbound HTTP extension. Nothing is reachable unless a host lists it:
```ts
const runtime = CodeMode.make({
extensions: [Web.make({ allow: ["https://api.example.com"], methods: ["GET", "POST"], maxBodyBytes: 1_048_576 })],
})
// program: const res = await fetch("https://api.example.com/users"); return await res.json()
```
`fetch(url, init?)` accepts `method`, `headers`, and a `string`, `Uint8Array`, or `URLSearchParams` body, and
resolves to `{ url, status, statusText, ok, redirected, headers: { get, has, entries }, text(), json(), bytes() }`.
Every redirect hop is checked against `allow`, bodies larger than `maxBodyBytes` are refused, and `timeoutMs` bounds
the whole request. `Web.signature` is the model-facing signature for host instructions.
### OpenAPI tools
`OpenAPI.fromSpec` converts an OpenAPI 3.x document into one tool per supported operation. Dotted `operationId` values
+15 -2
View File
@@ -467,8 +467,6 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
(`fetch.json`). Like any program function it vanishes at the data boundary.
- [x] Each call to an extension global runs inside the host's `extension.before`/`extension.after` hooks as
`{ extension, name, args }`, with the host's own error on failure; calls to functions inside results do not.
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
plain data).
@@ -476,6 +474,21 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [ ] Host classes. Stateful host objects are expressed as closures; a declared method table would be the next
step if `new X()` in a program is ever needed.
### Web
`Web.make({ allow, methods, maxBodyBytes, timeoutMs })` is an extension exposing `fetch`. It is unavailable unless a
host provides it.
- [x] `fetch(url, init?)` with `method`, `headers` (record or pairs), and a `string`, `Uint8Array`, or
`URLSearchParams` body. The response is a plain object: `url`, `status`, `statusText`, `ok`, `redirected`,
`headers.get/has/entries`, and `text()`, `json()`, `bytes()` over the fully read body.
- [x] Policy is enforced before any request: origins outside `allow` (or `"*"`), methods outside `methods` (default
`GET`, `HEAD`), non-http(s) URLs, and unsupported `init` keys (`signal`, `credentials`, ...) throw a
`TypeError` naming the problem. Redirects are followed by hand with each hop checked against `allow`, at most
five hops; 303 and 301/302-after-POST switch to GET like browsers. Bodies over `maxBodyBytes` throw a
`RangeError`; `timeoutMs` bounds the whole request.
- [ ] `Headers`, `Response`, and `Request` as runtime types; streaming bodies; `AbortSignal`; `FormData`; `Blob`.
## Errors and diagnostics
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.5",
"version": "2.0.3",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+4 -16
View File
@@ -7,14 +7,7 @@ import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime
import type { Tools } from "./tools.js"
/** A tool call admitted during an execution. */
export type {
CallResult,
ExtensionInvocation,
Hooks,
ToolCall,
ToolDescription,
ToolInvocation,
} from "./tool-runtime.js"
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
/** Signature-construction helpers for host-owned catalog instructions. */
export { searchSignature, toolExpression } from "./tool-runtime.js"
@@ -41,11 +34,9 @@ export type ResolvedExecutionLimits = {
}
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
export type Options<Provided extends Record<string, unknown> = {}> = {
export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
/** Explicit tools exposed to the program as `tools`. */
tools?: Provided & Tools<Services<Provided>>
/** Hooks around every tool and extension call the program makes; see `Hooks`. */
hooks?: ToolRuntime.Hooks<Services<Provided>>
/** Host functions exposed as globals; see `Extension.make`. */
extensions?: ReadonlyArray<Extension>
/** Resource limits enforced on each execution. */
@@ -156,10 +147,7 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
}
}
return {
get catalog() {
return prepared.catalog
},
execute: (code) =>
executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => extensionGlobals(ctx, extensions)),
catalog: prepared.catalog,
execute: (code) => executeProgram(code, prepared, limits, options, (ctx) => extensionGlobals(ctx, extensions)),
}
}
+1
View File
@@ -2,6 +2,7 @@ export * as CodeMode from "./codemode.js"
export * as Extension from "./extension.js"
export * as Namespace from "./namespace.js"
export * as Tool from "./tool.js"
export * as Web from "./web.js"
export * as OpenAPI from "./openapi/index.js"
export { searchSignature, toolExpression } from "./codemode.js"
export { ToolError, toolError } from "./tool-error.js"
+1 -1
View File
@@ -16,7 +16,7 @@ export const executeProgram = <R>(
code: string,
prepared: ToolRuntime.Prepared<R>,
limits: ResolvedExecutionLimits,
hooks: ToolRuntime.Hooks<R>,
hooks: ToolRuntime.ToolCallHooks<R>,
globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>,
): Effect.Effect<Result, never, R> => {
if (code.trim().length === 0) {
+13 -22
View File
@@ -1,7 +1,6 @@
import { Effect } from "effect"
import type { Extension } from "../extension.js"
import { coerceToString } from "../stdlib/value.js"
import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
import type { Interpreter } from "./interpreter.js"
import { createErrorValue, isErrorType } from "./intrinsics.js"
import { MAX_VALUE_DEPTH } from "./limits.js"
@@ -12,6 +11,7 @@ import {
define,
entries,
get,
type Native,
Arr,
Bytes,
DateObj,
@@ -128,35 +128,26 @@ export const extensionGlobals = <R>(
throw typeError(`${label} produced ${describeHost(value)}, which the program cannot hold.`)
}
// A host function as a program function. Arguments cross in; a global's call runs inside the host's extension
// hooks, which see the host's own error on failure; then whatever came back, or was thrown, crosses out so the
// program catches a copy. Functions inside results are part of a value's API and skip the hooks.
const wrap = (value: Function, label: string, describe?: (args: ReadonlyArray<unknown>) => ExtensionInvocation) =>
// A host function as a program function: arguments cross in, and whatever it returns, resolves, throws, or
// rejects with crosses out, so the program catches what the author threw.
const wrap = (value: Function, label: string): Native<R> =>
fn<R>(builtins, value.name, value.length, (_, values) => {
const args = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
const hooks = ctx.tools.hooks
const settle = (run: Effect.Effect<unknown, unknown, R>) =>
(describe === undefined
? run
: hooked(describe(args), hooks["extension.before"], hooks["extension.after"], run)
).pipe(
Effect.mapError((reason) => new Throw(fromHost(reason, label))),
Effect.map((settled) => fromHost(settled, label)),
)
const converted = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
const thrown = (reason: unknown) => new Throw(fromHost(reason, label))
let result: unknown
try {
result = value.apply(undefined, args)
result = value.apply(undefined, converted)
} catch (reason) {
return settle(Effect.fail(reason))
return Effect.fail(thrown(reason))
}
if (!(result instanceof Promise)) return settle(Effect.succeed(result))
return ctx.pending.create(settle(Effect.tryPromise({ try: () => result, catch: (reason) => reason })))
if (!(result instanceof Promise)) return fromHost(result, label)
return ctx.pending.create(
Effect.map(Effect.tryPromise({ try: () => result, catch: thrown }), (settled) => fromHost(settled, label)),
)
})
return extensions.flatMap((extension) =>
Object.entries(extension.globals).map(
([name, value]) => [name, wrap(value, name, (args) => ({ extension: extension.name, name, args }))] as const,
),
Object.entries(extension.globals).map(([name, value]) => [name, wrap(value, name)] as const),
)
}
+55 -82
View File
@@ -36,48 +36,26 @@ export type ToolCall = {
readonly name: string
}
/** A tool call the program is making, with its decoded input. */
export type ToolInvocation = { readonly name: string; readonly input: unknown }
/** A call the program is making to an extension global, with its arguments. */
export type ExtensionInvocation = {
readonly extension: string
export type ToolCallStarted = {
readonly index: number
readonly name: string
readonly args: ReadonlyArray<unknown>
readonly input: unknown
}
/** How a call ended; `after` hooks observe it and cannot change it. */
export type CallResult =
| { readonly status: "success"; readonly value: unknown }
| { readonly status: "failure"; readonly error: unknown }
| { readonly status: "interrupted" }
/** Hooks around every call the program makes into the host. A failing `before` denies the call. */
export type Hooks<R = never> = {
readonly "tool.before"?: ((call: ToolInvocation) => Effect.Effect<void, unknown, R>) | undefined
readonly "tool.after"?: ((call: ToolInvocation, result: CallResult) => Effect.Effect<void, never, R>) | undefined
readonly "extension.before"?: ((call: ExtensionInvocation) => Effect.Effect<void, unknown, R>) | undefined
readonly "extension.after"?:
| ((call: ExtensionInvocation, result: CallResult) => Effect.Effect<void, never, R>)
| undefined
export type ToolCallEnded = {
readonly index: number
readonly name: string
readonly input: unknown
readonly durationMs: number
readonly outcome: "success" | "failure" | "interrupted"
readonly message?: string
}
/** Runs `before`, then `run`, then `after` with how it ended, including when interrupted. */
export const hooked = <Call, A, R>(
call: Call,
before: ((call: Call) => Effect.Effect<void, unknown, R>) | undefined,
after: ((call: Call, result: CallResult) => Effect.Effect<void, never, R>) | undefined,
run: Effect.Effect<A, unknown, R>,
): Effect.Effect<A, unknown, R> => {
const observed =
after === undefined
? run
: Effect.onExit(run, (exit) => {
if (Exit.isSuccess(exit)) return after(call, { status: "success", value: exit.value })
if (Cause.hasInterruptsOnly(exit.cause)) return after(call, { status: "interrupted" })
return after(call, { status: "failure", error: Cause.squash(exit.cause) })
})
return before === undefined ? observed : Effect.andThen(before(call), observed)
export type ToolCallHooks<R = never> = {
/** Observes decoded tool input immediately before tool execution. */
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
}
export type ToolDescription = {
@@ -166,24 +144,15 @@ const flattenTools = <R>(
]
}
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => {
let signature: string | undefined
return {
path: visible.path,
description: visible.tool.description,
get signature() {
// Search ranks paths and descriptions first; only returned matches need their schemas rendered.
// Joining the final fragments avoids retaining the rendering's intermediate string ropes in JSC.
return (signature ??= [
toolExpression(visible.path),
isEmptyInput(visible.tool) ? "()" : `(input: ${inputTypeScript(visible.tool, true)})`,
`: Promise<${outputTypeScript(visible.tool, true)}>`,
].join(""))
},
}
}
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
path: visible.path,
description: visible.tool.description,
signature: isEmptyInput(visible.tool)
? `${toolExpression(visible.path)}(): Promise<${outputTypeScript(visible.tool, true)}>`
: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
})
/** Tools indexed once per runtime, with discovery materialized on demand. */
/** Tools indexed once per runtime: the lookup trie plus the model-facing catalog and search index. */
export type Prepared<R = never> = {
readonly root: ToolNode<R>
readonly catalog: ReadonlyArray<ToolDescription>
@@ -295,20 +264,12 @@ const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
export const prepare = <R>(tools: Tools<R>): Prepared<R> => {
const root = toolTrie(tools)
let searchIndex: ReadonlyArray<SearchEntry> | undefined
let catalog: ReadonlyArray<ToolDescription> | undefined
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
const visible = flattenTools(root).sort((left, right) => compareText(left.path, right.path))
return {
root,
get catalog() {
return (catalog ??= this.searchIndex.map((entry) => entry.description))
},
get searchIndex() {
// Executing known tools only needs the trie. Render discovery when it is actually read,
// ordering after canonical-path collisions settle so instruction bytes stay deterministic.
return (searchIndex ??= flattenTools(root)
.sort((left, right) => compareText(left.path, right.path))
.map(toSearchEntry))
},
catalog: visible.map(describeTool),
searchIndex: visible.map(toSearchEntry),
}
}
@@ -355,7 +316,6 @@ export class ToolRuntimeError extends Error {
/** The tool bridge of one execution. Arguments arrive and results leave as JSON; program values never enter. */
export type ToolRuntime<R = never> = {
readonly calls: Array<ToolCall>
readonly hooks: Hooks<R>
readonly execute: (
path: ReadonlyArray<string>,
args: Array<Json | undefined>,
@@ -368,10 +328,27 @@ export type ToolRuntime<R = never> = {
export const make = <R>(
prepared: Prepared<R>,
maxToolCalls: number | undefined,
hooks: Hooks<R> = {},
hooks?: ToolCallHooks<R>,
): ToolRuntime<R> => {
const calls: Array<ToolCall> = []
const root = prepared.root
const searchTool = makeSearchTool(prepared.searchIndex)
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
const onEnd = hooks?.onToolCallEnd
if (onEnd === undefined) return effect
const startedAt = Date.now()
return effect.pipe(
Effect.onExit((exit) => {
const durationMs = Date.now() - startedAt
if (Exit.isSuccess(exit)) return onEnd({ ...call, durationMs, outcome: "success" })
if (Cause.hasInterruptsOnly(exit.cause)) return onEnd({ ...call, durationMs, outcome: "interrupted" })
const error = Cause.squash(exit.cause)
const message = error instanceof Error ? error.message : Cause.pretty(exit.cause)
return onEnd({ ...call, durationMs, outcome: "failure", message })
}),
)
}
const recordCall = (call: ToolCall): void => {
if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
@@ -394,12 +371,14 @@ export const make = <R>(
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
),
})
yield* Effect.sync(() => recordCall({ name }))
return yield* hooked(
{ name, input },
hooks["tool.before"],
hooks["tool.after"],
const index = yield* Effect.sync(() => {
recordCall({ name })
return calls.length - 1
})
const call = { index, name, input }
return yield* observeEnd(
Effect.gen(function* () {
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
const raw = yield* Effect.suspend(() => tool.execute(input)).pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
@@ -421,22 +400,16 @@ export const make = <R>(
catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
})
}),
call,
)
})
return {
calls,
hooks,
keys: (path) => namespaceKeys(root, path),
search: (args) => Effect.suspend(() => executeTool("search", makeSearchTool(prepared.searchIndex), args)),
search: (args) => Effect.suspend(() => executeTool("search", searchTool, args)),
execute: (path, args) =>
Effect.suspend(() => {
const segments = canonicalSegments(path)
// Models often write `tools.search(...)` for the bare `search(...)`; honor it unless a tool owns that path.
if (segments.length === 1 && segments[0] === "search" && lookup(root, segments) === undefined)
return executeTool("search", makeSearchTool(prepared.searchIndex), args)
return executeTool(segments.join("."), resolve(root, path), args)
}),
Effect.suspend(() => executeTool(canonicalSegments(path).join("."), resolve(root, path), args)),
}
}
+140
View File
@@ -0,0 +1,140 @@
export * as Web from "./web.js"
import { Extension } from "./extension.js"
/** Outbound HTTP for programs. Nothing is reachable unless a host lists it. */
export type Options = {
/** Origins the program may request, such as `"https://api.example.com"`; `"*"` allows every origin. */
readonly allow: ReadonlyArray<string>
/** Request methods the program may use. Default: `GET` and `HEAD`. */
readonly methods?: ReadonlyArray<string>
/** Largest response body accepted, in bytes. Default: 1 MiB. */
readonly maxBodyBytes?: number
/** Time allowed for a request, including redirects and reading the body. Default: 30 seconds. */
readonly timeoutMs?: number
}
const MAX_REDIRECTS = 5
/** The model-facing signature of the `fetch` global, for hosts to include in their instructions. */
export const signature = `fetch(url: string | URL, init?: { method?: string; headers?: Record<string, string> | Array<[string, string]>; body?: string | Uint8Array | URLSearchParams }): Promise<{ url: string; status: number; statusText: string; ok: boolean; redirected: boolean; headers: { get(name: string): string | null; has(name: string): boolean; entries(): Array<[string, string]> }; text(): Promise<string>; json(): Promise<unknown>; bytes(): Promise<Uint8Array> }>`
export const make = (options: Options): Extension => {
const methods = new Set((options.methods ?? ["GET", "HEAD"]).map((method) => method.toUpperCase()))
const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024
const timeoutMs = options.timeoutMs ?? 30_000
const checkUrl = (url: URL, what: string) => {
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new TypeError(`fetch: ${what} ${url.href} must use http or https.`)
}
if (options.allow.includes("*") || options.allow.includes(url.origin)) return
throw new TypeError(`fetch: ${what} ${url.href} is not an allowed origin. Allowed: ${options.allow.join(", ")}.`)
}
// Redirects are followed by hand so every hop is checked against the allow list before it is requested.
const request = async (
url: URL,
method: string,
headers: Headers,
body: string | Uint8Array<ArrayBuffer> | URLSearchParams | undefined,
signal: AbortSignal,
hop: number,
): Promise<Response> => {
const response = await globalThis
.fetch(url, { method, headers, body, signal, redirect: "manual" })
.catch((cause: unknown) => {
if (signal.aborted) throw new Error(`fetch: request to ${url.href} timed out after ${timeoutMs}ms.`)
throw new TypeError(`fetch: request to ${url.href} failed: ${cause instanceof Error ? cause.message : cause}`)
})
const location = response.headers.get("location")
if (response.status < 300 || response.status > 399 || location === null) return response
if (hop === MAX_REDIRECTS) throw new TypeError(`fetch: ${url.href} redirected more than ${MAX_REDIRECTS} times.`)
const next = new URL(location, url)
checkUrl(next, "redirect to")
// Like browsers: 303 always switches to GET, 301/302 do so for POST, 307/308 keep the method and body.
const toGet = response.status === 303 || ((response.status === 301 || response.status === 302) && method === "POST")
return request(next, toGet ? "GET" : method, headers, toGet ? undefined : body, signal, hop + 1)
}
const readBody = async (response: Response, url: URL): Promise<Uint8Array> => {
const tooLarge = () => new RangeError(`fetch: response from ${url.href} exceeds ${maxBodyBytes} bytes.`)
if (Number(response.headers.get("content-length")) > maxBodyBytes) throw tooLarge()
if (response.body === null) return new Uint8Array()
const chunks: Array<Uint8Array> = []
let total = 0
for await (const chunk of response.body) {
total += chunk.byteLength
if (total > maxBodyBytes) throw tooLarge()
chunks.push(chunk)
}
const bytes = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}
return bytes
}
const fetch = async (input: unknown, init: unknown = {}) => {
if (typeof input !== "string" && !(input instanceof URL)) {
throw new TypeError("fetch: the first argument must be a URL string or URL.")
}
const url = URL.parse(String(input))
if (url === null) throw new TypeError(`fetch: ${JSON.stringify(String(input))} is not a valid URL.`)
checkUrl(url, "request to")
if (init === null || typeof init !== "object" || Array.isArray(init)) {
throw new TypeError("fetch: init must be an object with method, headers, and body.")
}
for (const key of Object.keys(init)) {
if (key !== "method" && key !== "headers" && key !== "body") {
throw new TypeError(`fetch: init.${key} is not supported here; only method, headers, and body are.`)
}
}
const given = init as { method?: unknown; headers?: unknown; body?: unknown }
const method = given.method === undefined ? "GET" : String(given.method).toUpperCase()
if (!methods.has(method)) {
throw new TypeError(`fetch: method ${method} is not allowed. Allowed: ${[...methods].join(", ")}.`)
}
if (given.headers !== undefined && (given.headers === null || typeof given.headers !== "object")) {
throw new TypeError("fetch: init.headers must be a { name: value } object or an array of [name, value] pairs.")
}
const raw = given.body
if (
raw !== undefined &&
typeof raw !== "string" &&
!(raw instanceof Uint8Array) &&
!(raw instanceof URLSearchParams)
) {
throw new TypeError("fetch: init.body must be a string, Uint8Array, or URLSearchParams.")
}
const body = raw instanceof Uint8Array ? Uint8Array.from(raw) : raw
const headers = new Headers(given.headers as Record<string, string> | Array<[string, string]> | undefined)
const signal = AbortSignal.timeout(timeoutMs)
const response = await request(url, method, headers, body, signal, 0)
const bytes = await readBody(response, url).catch((cause: unknown) => {
if (signal.aborted) throw new Error(`fetch: request to ${url.href} timed out after ${timeoutMs}ms.`)
throw cause
})
const received = Object.fromEntries(response.headers)
const text = () => new TextDecoder().decode(bytes)
return {
url: response.url,
status: response.status,
statusText: response.statusText,
ok: response.ok,
redirected: response.url !== url.href,
headers: {
get: (name: string) => received[String(name).toLowerCase()] ?? null,
has: (name: string) => String(name).toLowerCase() in received,
entries: () => Object.entries(received),
},
text: async () => text(),
json: async () => JSON.parse(text()) as unknown,
bytes: async () => bytes,
}
}
return Extension.make({ name: "web", globals: { fetch } })
}
+106 -152
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Schema } from "effect"
import { CodeMode, Extension, Tool, toolError } from "../src/index.js"
import { CodeMode, Tool, toolError } from "../src/index.js"
const run = (tool: Tool.Tool<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
@@ -160,154 +160,77 @@ describe("CodeMode host failure boundary", () => {
})
})
describe("CodeMode call hooks", () => {
const ended = (result: CodeMode.CallResult) => {
if (result.status !== "failure") return result.status
return `failure:${result.error instanceof Error ? result.error.message : String(result.error)}`
}
// Every hook logs its event, so the order across tools and extensions is visible.
const observe = (events: Array<string>): CodeMode.Hooks => ({
"tool.before": (call) => Effect.sync(() => void events.push(`tool.before ${JSON.stringify(call)}`)),
"tool.after": (call, result) => Effect.sync(() => void events.push(`tool.after ${call.name} ${ended(result)}`)),
"extension.before": (call) => Effect.sync(() => void events.push(`extension.before ${JSON.stringify(call)}`)),
"extension.after": (call, result) =>
Effect.sync(() => void events.push(`extension.after ${call.name} ${ended(result)}`)),
})
test("surround the tools actually invoked, with decoded input", async () => {
const events: Array<string> = []
describe("CodeMode tool-call observation", () => {
test("reports the tools actually invoked with decoded input", async () => {
const calls: Array<unknown> = []
const lookup = Tool.make({
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
execute: ({ query }) => Effect.succeed(query),
})
const runtime = CodeMode.make({ tools: { context: { lookup } }, hooks: observe(events) })
const success = await Effect.runPromise(
runtime.execute(`
const result = await Effect.runPromise(
CodeMode.make({
tools: { context: { lookup } },
onToolCallStart: (call) => Effect.sync(() => calls.push(call)),
}).execute(`
if (false) await tools.context.lookup({ query: "not called" })
return await tools.context.lookup({ query: "deployment failure" })
`),
)
expect(success).toMatchObject({ ok: true, value: "deployment failure" })
expect(result.ok).toBe(true)
expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }])
})
test("observes settled calls with outcome and duration", async () => {
const events: Array<{ phase: string; index: number; name: string; outcome?: string; message?: string }> = []
const lookup = Tool.make({
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
execute: ({ query }) =>
query === "boom"
? Effect.fail(toolError("Lookup refused"))
: query === "defect"
? Effect.die("broken")
: Effect.succeed(query),
})
const runtime = CodeMode.make({
tools: { context: { lookup } },
onToolCallStart: (call) =>
Effect.sync(() => {
events.push({ phase: "start", index: call.index, name: call.name })
}),
onToolCallEnd: (call) =>
Effect.sync(() => {
expect(call.durationMs).toBeGreaterThanOrEqual(0)
events.push({
phase: "end",
index: call.index,
name: call.name,
outcome: call.outcome,
...(call.message === undefined ? {} : { message: call.message }),
})
}),
})
const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`))
expect(success.ok).toBe(true)
const failure = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "boom" })`))
expect(failure.ok).toBe(false)
expect(events).toEqual([
'tool.before {"name":"context.lookup","input":{"query":"deployment failure"}}',
"tool.after context.lookup success",
'tool.before {"name":"context.lookup","input":{"query":"boom"}}',
"tool.after context.lookup failure:Lookup refused",
])
})
const defect = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "defect" })`))
expect(defect.ok).toBe(false)
test("surround each call to an extension global, with its arguments, but not functions inside results", async () => {
const events: Array<string> = []
const runtime = CodeMode.make({
extensions: [
Extension.make({
name: "web",
globals: {
fetch: async (url: string, init?: { method?: string }) => ({
url,
method: init?.method ?? "GET",
json: () => 1,
}),
fail: () => {
throw new RangeError("nope")
},
},
}),
],
hooks: observe(events),
})
const result = await Effect.runPromise(
runtime.execute(`
const res = await fetch("https://a.test/", { method: "POST" })
res.json()
try { fail() } catch {}
return res.url
`),
)
expect(result).toMatchObject({ ok: true, value: "https://a.test/", toolCalls: [] })
expect(events).toEqual([
'extension.before {"extension":"web","name":"fetch","args":["https://a.test/",{"method":"POST"}]}',
"extension.after fetch success",
'extension.before {"extension":"web","name":"fail","args":[]}',
"extension.after fail failure:nope",
])
})
test("a failing before hook denies the call; the program catches the host's error", async () => {
const runtime = CodeMode.make({
tools: {
lookup: Tool.make({
description: "Look up",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("ok"),
}),
},
extensions: [Extension.make({ name: "web", globals: { fetch: async (url: string) => url } })],
hooks: { "extension.before": (call) => Effect.fail(new Error(`${call.name} is not allowed`)) },
})
expect(
await Effect.runPromise(
runtime.execute(`
const denied = await fetch("https://a.test/").catch((e) => [e instanceof Error, e.message])
return [denied, await tools.lookup({})]
`),
),
).toMatchObject({ ok: true, value: [[true, "fetch is not allowed"], "ok"] })
expect(await Effect.runPromise(runtime.execute(`await fetch("https://a.test/")`))).toMatchObject({
ok: false,
error: { kind: "ExecutionFailure", message: "Error: fetch is not allowed" },
})
const refused = CodeMode.make({
tools: {
lookup: Tool.make({
description: "Look up",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("ok"),
}),
},
hooks: { "tool.before": () => Effect.fail(toolError("lookup is not allowed")) },
})
expect(
await Effect.runPromise(
refused.execute(`try { await tools.lookup({}) } catch (e) { return [e.name, e.message] }`),
),
).toMatchObject({ ok: true, value: ["Error", "lookup is not allowed"], toolCalls: [{ name: "lookup" }] })
expect(await Effect.runPromise(refused.execute(`await tools.lookup({})`))).toMatchObject({
ok: false,
error: { kind: "ToolFailure", message: "lookup is not allowed" },
})
})
test("see a tool defect as the failure the program gets, and an interrupted extension call", async () => {
const events: Array<string> = []
const broken = Tool.make({
description: "Broken",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.die("broken"),
})
const runtime = CodeMode.make({
tools: { broken },
extensions: [Extension.make({ name: "slow", globals: { forever: () => new Promise(() => {}) } })],
hooks: observe(events),
})
expect(await Effect.runPromise(runtime.execute(`await tools.broken({})`))).toMatchObject({ ok: false })
expect(await Effect.runPromise(runtime.execute(`forever(); return "done"`))).toMatchObject({
ok: true,
value: "done",
})
expect(events).toEqual([
'tool.before {"name":"broken","input":{}}',
"tool.after broken failure:broken",
'extension.before {"extension":"slow","name":"forever","args":[]}',
"extension.after forever interrupted",
expect(events).toStrictEqual([
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "success" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "broken" },
])
})
@@ -320,11 +243,15 @@ describe("CodeMode call hooks", () => {
execute: () => Effect.interrupt,
})
const exit = await Effect.runPromiseExit(
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).execute("return await tools.host.call({})"),
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.sync(() => events.push("start")),
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
}).execute("return await tools.host.call({})"),
)
expect(exit._tag).toBe("Failure")
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
expect(events).toEqual(["start", "end:interrupted"])
})
test("observes running calls interrupted during completion", async () => {
@@ -336,17 +263,39 @@ describe("CodeMode call hooks", () => {
execute: () => Effect.never,
})
const result = await Effect.runPromise(
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).execute(
'tools.host.call({}); return "done"',
),
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.sync(() => events.push("start")),
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
}).execute('tools.host.call({}); return "done"'),
)
expect(result).toMatchObject({ ok: true, value: "done" })
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
expect(events).toEqual(["start", "end:interrupted"])
})
test("ends calls interrupted during start observation", async () => {
const events: Array<string> = []
const call = Tool.make({
description: "Unused",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("unused"),
})
const exit = await Effect.runPromiseExit(
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.interrupt,
onToolCallEnd: (call) => Effect.sync(() => events.push(call.outcome)),
}).execute("return await tools.host.call({})"),
)
expect(exit._tag).toBe("Failure")
expect(events).toEqual(["interrupted"])
})
test("observes calls interrupted by the execution timeout", async () => {
const events: Array<string> = []
const outcomes: Array<string> = []
const call = Tool.make({
description: "Pending",
input: Schema.Struct({}),
@@ -354,13 +303,15 @@ describe("CodeMode call hooks", () => {
execute: () => Effect.never,
})
const result = await Effect.runPromise(
CodeMode.make({ tools: { host: { call } }, limits: { timeoutMs: 10 }, hooks: observe(events) }).execute(
"return await tools.host.call({})",
),
CodeMode.make({
tools: { host: { call } },
limits: { timeoutMs: 10 },
onToolCallEnd: (call) => Effect.sync(() => outcomes.push(call.outcome)),
}).execute("return await tools.host.call({})"),
)
expect(result).toMatchObject({ ok: false, error: { kind: "TimeoutExceeded" } })
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
expect(outcomes).toEqual(["interrupted"])
})
})
@@ -889,17 +840,20 @@ describe("CodeMode public contract", () => {
}
})
test("search is a counted tool call: it burns maxToolCalls and is observed", async () => {
const observed: Array<CodeMode.ToolInvocation> = []
test("search is a counted tool call: it burns maxToolCalls and fires the hooks", async () => {
const started: Array<string> = []
const ended: Array<string> = []
const limited = CodeMode.make({
tools,
limits: { maxToolCalls: 1 },
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call)) },
onToolCallStart: (call) => Effect.sync(() => void started.push(call.name)),
onToolCallEnd: (call) => Effect.sync(() => void ended.push(`${call.name}:${call.outcome}`)),
})
const result = await Effect.runPromise(limited.execute(`search({}); return search({})`))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded")
expect(observed).toEqual([{ name: "search", input: {} }])
expect(started).toEqual(["search"])
expect(ended).toEqual(["search:success"])
})
test("search is an opaque, shadowable global like other built-ins", async () => {
@@ -1135,7 +1089,7 @@ describe("CodeMode public contract", () => {
})
const runtime = CodeMode.make({
tools: { math: { double: transformed } },
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call.input)) },
onToolCallStart: (call) => Effect.sync(() => observed.push(call.input)),
})
const success = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: "21" })`))
-10
View File
@@ -192,16 +192,6 @@ describe("values are converted at the boundary, never shared", () => {
expect((await failure(`big()`, target)).message).toContain("big produced a bigint")
})
test("a value that cannot come out carries a location like any built-in error, sync or async", async () => {
const target = CodeMode.make({
extensions: [
Extension.make({ name: "odd", globals: { sym: () => Symbol("s"), later: async () => Symbol("s") } }),
],
})
expect((await failure(`sym()`, target)).location).toEqual((await failure(`JSON.parse("{")`, target)).location)
expect((await failure(`await later()`, target)).location).toBeDefined()
})
test("a class instance cannot come out", async () => {
class Other {}
const target = CodeMode.make({
-14
View File
@@ -337,17 +337,3 @@ describe("tool arguments cross in a useful form where JSON.stringify would give
expect(seen).toEqual({ s: [1, 2], r: {}, p: "a=1&b=2", m: {} })
})
})
describe("tools.search alias", () => {
test("tools.search(...) behaves like the bare search(...) when no tool owns that path", async () => {
const runtime = CodeMode.make({ tools: { api: { list: echo("List things", "listed") } } })
const direct = await value(runtime, `return search({ query: "list" })`)
expect(await value(runtime, `return tools.search({ query: "list" })`)).toStrictEqual(direct)
expect(await value(runtime, `return (await tools.search({ query: "list" })).items[0].path`)).toBe("tools.api.list")
})
test("a registered root-level search tool takes precedence", async () => {
const runtime = CodeMode.make({ tools: { search: echo("Custom search", "custom") } })
expect(await value(runtime, `return await tools.search({})`)).toBe("custom")
})
})
+221
View File
@@ -0,0 +1,221 @@
import { afterAll, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode, Web } from "../src/index.js"
const seen: Array<{ method: string; path: string; headers: Record<string, string>; body: string }> = []
const other = Bun.serve({
port: 0,
fetch: (request) => {
seen.push({ method: request.method, path: new URL(request.url).pathname, headers: {}, body: "" })
return new Response("other")
},
})
const server = Bun.serve({
port: 0,
fetch: async (request) => {
const url = new URL(request.url)
seen.push({
method: request.method,
path: url.pathname,
headers: Object.fromEntries(request.headers),
body: await request.text(),
})
switch (url.pathname) {
case "/json":
return Response.json({ hello: "world" }, { headers: { "X-Custom": "yes" } })
case "/text":
return new Response("plain text", { status: 201, statusText: "Created" })
case "/bytes":
return new Response(new Uint8Array([1, 2, 3]))
case "/bad-json":
return new Response("{oops", { headers: { "content-type": "application/json" } })
case "/redirect":
return Response.redirect(`${url.origin}/json`, 302)
case "/redirect-post":
return Response.redirect(`${url.origin}/text`, 307)
case "/redirect-away":
return Response.redirect(`${other.url.origin}/leaked`, 302)
case "/redirect-loop":
return Response.redirect(`${url.origin}/redirect-loop`, 302)
case "/big":
return new Response("x".repeat(2048))
case "/big-chunked": {
const stream = new ReadableStream({
start(controller) {
for (let i = 0; i < 4; i++) controller.enqueue(new Uint8Array(1024))
controller.close()
},
})
return new Response(stream)
}
case "/slow":
await Bun.sleep(300)
return new Response("late")
default:
return new Response("not found", { status: 404 })
}
},
})
afterAll(() => {
server.stop(true)
other.stop(true)
})
const origin = server.url.origin
const runtime = CodeMode.make({ extensions: [Web.make({ allow: [origin], methods: ["GET", "POST"] })] })
const value = async (code: string, target = runtime) => {
const result = await Effect.runPromise(target.execute(code))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const failure = async (code: string, target = runtime) => {
const result = await Effect.runPromise(target.execute(code))
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error.message
}
describe("fetch", () => {
test("GET json with status, headers, and body readers", async () => {
expect(
await value(`
const res = await fetch("${origin}/json")
return [res.status, res.ok, res.redirected, res.url, res.headers.get("X-Custom"), res.headers.has("content-type"), await res.json(), await res.text()]
`),
).toEqual([200, true, false, `${origin}/json`, "yes", true, { hello: "world" }, '{"hello":"world"}'])
})
test("status text, bytes, and header entries", async () => {
expect(
await value(`
const res = await fetch(new URL("${origin}/text"))
const bytes = await (await fetch("${origin}/bytes")).bytes()
return [res.status, res.statusText, await res.text(), [...bytes], bytes instanceof Uint8Array, res.headers.entries().some(([name]) => name === "content-type")]
`),
).toEqual([201, "Created", "plain text", [1, 2, 3], true, true])
})
test("POST with headers and each body kind", async () => {
seen.length = 0
await value(`
await fetch("${origin}/text", { method: "post", headers: { "X-A": "1" }, body: "hello" })
await fetch("${origin}/text", { method: "POST", headers: [["X-B", "2"]], body: new Uint8Array([104, 105]) })
await fetch("${origin}/text", { method: "POST", body: new URLSearchParams({ q: "x y" }) })
`)
expect(seen.map((request) => [request.method, request.body])).toEqual([
["POST", "hello"],
["POST", "hi"],
["POST", "q=x+y"],
])
expect(seen[0].headers["x-a"]).toBe("1")
expect(seen[1].headers["x-b"]).toBe("2")
expect(seen[2].headers["content-type"]).toContain("application/x-www-form-urlencoded")
})
test("a JSON parse failure is a catchable SyntaxError", async () => {
expect(
await value(`
try { await (await fetch("${origin}/bad-json")).json() } catch (e) { return [e instanceof SyntaxError, e.message.length > 0] }
`),
).toEqual([true, true])
})
test("a non-2xx response is returned, not thrown", async () => {
expect(await value(`const res = await fetch("${origin}/missing"); return [res.ok, res.status]`)).toEqual([
false,
404,
])
})
test("fetch is not a tool call", async () => {
const limited = CodeMode.make({ extensions: [Web.make({ allow: [origin] })], limits: { maxToolCalls: 0 } })
const result = await Effect.runPromise(limited.execute(`return (await fetch("${origin}/json")).status`))
expect(result.ok).toBe(true)
expect(result.toolCalls).toEqual([])
})
})
describe("policy", () => {
test("origins outside the allow list are refused before any request is made", async () => {
seen.length = 0
expect(await failure(`await fetch("${other.url.origin}/leaked")`)).toContain(
`request to ${other.url.origin}/leaked is not an allowed origin. Allowed: ${origin}.`,
)
expect(await failure(`await fetch("file:///etc/passwd")`)).toContain("must use http or https")
expect(await failure(`await fetch("not a url")`)).toContain('"not a url" is not a valid URL')
expect(await failure(`await fetch(42)`)).toContain("first argument must be a URL string or URL")
expect(seen).toEqual([])
})
test("* allows every origin", async () => {
const open = CodeMode.make({ extensions: [Web.make({ allow: ["*"] })] })
expect(await value(`return (await fetch("${other.url.origin}/")).status`, open)).toBe(200)
})
test("methods default to GET and HEAD", async () => {
const readOnly = CodeMode.make({ extensions: [Web.make({ allow: [origin] })] })
expect(await failure(`await fetch("${origin}/text", { method: "POST" })`, readOnly)).toContain(
"method POST is not allowed. Allowed: GET, HEAD.",
)
expect(await failure(`await fetch("${origin}/text", { method: "DELETE" })`)).toContain(
"method DELETE is not allowed. Allowed: GET, POST.",
)
})
test("init keys and shapes that mean nothing here are rejected by name", async () => {
expect(await failure(`await fetch("${origin}/json", { signal: 1 })`)).toContain(
"init.signal is not supported here; only method, headers, and body are.",
)
expect(await failure(`await fetch("${origin}/json", { credentials: "include" })`)).toContain("init.credentials")
expect(await failure(`await fetch("${origin}/json", "GET")`)).toContain("init must be an object")
expect(await failure(`await fetch("${origin}/json", { headers: "X: 1" })`)).toContain("init.headers must be")
expect(await failure(`await fetch("${origin}/json", { method: "POST", body: { a: 1 } })`)).toContain(
"init.body must be a string, Uint8Array, or URLSearchParams.",
)
})
test("redirects are followed within the allow list and reported", async () => {
expect(
await value(`const res = await fetch("${origin}/redirect"); return [res.redirected, res.url, await res.json()]`),
).toEqual([true, `${origin}/json`, { hello: "world" }])
seen.length = 0
expect(
await value(
`const res = await fetch("${origin}/redirect-post", { method: "POST", body: "keep" }); return res.status`,
),
).toBe(201)
expect(seen.map((request) => [request.path, request.method, request.body])).toEqual([
["/redirect-post", "POST", "keep"],
["/text", "POST", "keep"],
])
})
test("a redirect to a disallowed origin is refused and never requested", async () => {
seen.length = 0
expect(await failure(`await fetch("${origin}/redirect-away")`)).toContain(
`redirect to ${other.url.origin}/leaked is not an allowed origin`,
)
expect(seen.map((request) => request.path)).toEqual(["/redirect-away"])
})
test("redirect loops stop", async () => {
expect(await failure(`await fetch("${origin}/redirect-loop")`)).toContain("redirected more than 5 times")
})
test("oversized bodies are refused, declared or streamed", async () => {
const small = CodeMode.make({ extensions: [Web.make({ allow: [origin], maxBodyBytes: 1024 })] })
expect(await failure(`await fetch("${origin}/big")`, small)).toContain("exceeds 1024 bytes")
expect(await failure(`await fetch("${origin}/big-chunked")`, small)).toContain("exceeds 1024 bytes")
expect(await value(`return (await fetch("${origin}/json")).status`, small)).toBe(200)
})
test("slow requests time out", async () => {
const quick = CodeMode.make({ extensions: [Web.make({ allow: [origin], timeoutMs: 50 })] })
expect(await failure(`await fetch("${origin}/slow")`, quick)).toContain("timed out after 50ms")
})
})
test("the signature names the global and its shape", () => {
expect(Web.signature).toStartWith("fetch(url: string | URL, init?:")
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.5",
"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.5",
"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.5",
"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.5",
"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.5",
"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.5",
"version": "2.0.3",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+27 -44
View File
@@ -13,7 +13,6 @@ import type {
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition, normalizedName } from "../tool/runtime.js"
import { CodeModeCatalog } from "./catalog.js"
import { CodeModeWeb } from "./web.js"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -60,8 +59,8 @@ export type Inventory = {
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.",
"`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable; all other external access goes through `tools`.",
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
@@ -83,9 +82,9 @@ export const create = (
const files = yield* Ref.make<Array<CollectedFiles>>([])
const calls = yield* Ref.make<Array<ExecuteCall>>([])
const lock = Semaphore.makeUnsafe(1)
const record = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
const updateCalls = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
lock.withPermit(
Ref.updateAndGet(calls, update).pipe(Effect.tap((toolCalls) => context.progress({ toolCalls }))),
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
)
const result = yield* runtime(
inventory,
@@ -104,7 +103,27 @@ export const create = (
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return text === "" ? null : text
}),
progressHooks(record),
{
onToolCallStart: ({ index, name, input }) => {
const shown = displayInput(input)
return updateCalls((items) => {
const next = [...items]
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
return next
})
},
onToolCallEnd: ({ index, name, input, outcome }) => {
const shown = displayInput(input)
return updateCalls((items) => {
const next = [...items]
next[index] = {
...(items[index] ?? { tool: name, ...(shown ? { input: shown } : {}) }),
status: outcome === "success" ? "completed" : "error",
}
return next
})
},
},
).execute(code)
const toolCalls = yield* Ref.get(calls)
const collected = (yield* Ref.get(files))
@@ -139,42 +158,6 @@ export const create = (
} satisfies Info
}
// Rows appear in start order; the same call object arrives at both hooks, so a call finds its row again.
function progressHooks(record: (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) => Effect.Effect<unknown>) {
const rows = new WeakMap<object, number>()
const start = (call: object, entry: ExecuteCall) =>
record((items) => {
rows.set(call, items.length)
return [...items, entry]
})
const settle = (call: object, result: CodeMode.CallResult) => {
const index = rows.get(call)
if (index === undefined) return Effect.void
return record((items) => {
const next = [...items]
next[index] = { ...items[index], status: result.status === "success" ? "completed" : "error" }
return next
})
}
return {
"tool.before": (call) => {
const shown = displayInput(call.input)
return start(call, { tool: call.name, status: "running", ...(shown ? { input: shown } : {}) })
},
"tool.after": settle,
// Only listed extension functions get a row; anything else stays out of the TUI.
"extension.before": (call) => {
switch (call.name) {
case "fetch":
return start(call, { tool: call.name, status: "running", input: CodeModeWeb.display(call.args) })
default:
return Effect.void
}
},
"extension.after": settle,
} satisfies CodeMode.Hooks
}
export const catalog = (inventory: Inventory) => {
const pinned = new Set(
Array.from(inventory.tools.values())
@@ -221,7 +204,7 @@ function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool |
function runtime(
inventory: Inventory,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.Hooks,
hooks?: CodeMode.ToolCallHooks,
) {
// A path may carry namespace metadata, a callable tool, child tools, or all three.
const root: ToolNode = { children: new Map() }
@@ -236,7 +219,7 @@ function runtime(
})
}
const tools = renderTools(root)
return CodeMode.make<typeof tools>({ tools, extensions: [CodeModeWeb.extension], hooks })
return CodeMode.make<typeof tools>({ tools, ...hooks })
}
function getNode<T>(root: Node<T>, path: string) {
-46
View File
@@ -1,46 +0,0 @@
export * as CodeModeWeb from "./web.js"
import { Extension } from "@opencode/codemode"
const TIMEOUT_MS = 30_000
type Init = {
readonly method?: string
readonly headers?: Record<string, string> | Array<[string, string]>
readonly body?: string | Uint8Array<ArrayBuffer> | URLSearchParams
}
const fetch = async (input: string | URL, init: Init = {}) => {
const response = await globalThis.fetch(input, {
method: init.method,
headers: init.headers,
body: init.body,
signal: AbortSignal.timeout(TIMEOUT_MS),
})
const bytes = await response.bytes()
const headers = Object.fromEntries(response.headers)
const text = () => new TextDecoder().decode(bytes)
return {
url: response.url,
status: response.status,
statusText: response.statusText,
ok: response.ok,
redirected: response.redirected,
headers: {
get: (name: string) => headers[name.toLowerCase()] ?? null,
has: (name: string) => name.toLowerCase() in headers,
entries: () => Object.entries(headers),
},
text: async () => text(),
json: async () => JSON.parse(text()) as unknown,
bytes: async () => bytes,
}
}
export const extension = Extension.make({ name: "web", globals: { fetch } })
/** What to show for a fetch call: its method and URL. */
export const display = (args: ReadonlyArray<unknown>) => {
const [input, init] = args as [string | URL, Init | undefined]
return { method: init?.method?.toUpperCase() ?? "GET", url: String(input) }
}
+1 -6
View File
@@ -168,12 +168,7 @@ export const provider = (options: Options): OAuthClientProvider => {
if (!resource) return identity
if (!checkResourceAllowed({ requestedResource: identity, configuredResource: resource }))
throw new Error(`Protected resource ${resource} does not cover ${identity}`)
const canonical = new URL(resource)
// The transport dials the configured URL with extra query parameters and some servers echo
// that back as the resource. Query is transport detail, not identity: the token stays bound
// to the configured URL so a later refresh names the same resource the login did.
if (canonical.origin === identity.origin && canonical.pathname === identity.pathname) return identity
return canonical
return new URL(resource)
},
...(options.clientMetadataUrl ? { clientMetadataUrl: options.clientMetadataUrl } : {}),
...(redirect ? { state: () => redirect.state } : {}),
+57 -76
View File
@@ -68,7 +68,7 @@ export interface Interface extends State.Transformable<Editor> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Model") {}
type Data = {
models: Map<Provider.ID, ReadonlyMap<ID, Info>>
models: Map<Provider.ID, Map<ID, MutableInfo>>
defaultModel?: { providerID: Provider.ID; modelID: ID }
}
@@ -82,73 +82,59 @@ const layer = Layer.effect(
const state: State.Interface<Data, Editor> = State.create<Data, Editor>({
name: "model",
initial: () => ({
models: new Map((input?.available ?? []).map((record) => [record.provider.id, record.models])),
models: new Map(
(input?.available ?? []).map((record) => [
record.provider.id,
new Map(
Array.from(record.models, ([id, model]) => [
id,
{
...structuredClone(model),
id,
providerID: record.provider.id,
} as MutableInfo,
]),
),
]),
),
}),
editor: (data) => {
// Definitions are shared across Locations; a provider's map and a model are copied before their first edit.
const owned = new WeakSet<ReadonlyMap<ID, Info>>()
const drafts = new WeakSet<Info>()
const writable = (providerID: Provider.ID) => {
const current = data.models.get(providerID)
if (!current) return undefined
if (owned.has(current)) return current as Map<ID, Info>
const copy = new Map(current)
owned.add(copy)
data.models.set(providerID, copy)
return copy
}
const draft = (providerID: Provider.ID, modelID: ID) => {
const models = writable(providerID)
if (!models) return undefined
const current = models.get(modelID)
if (!current) return undefined
if (drafts.has(current)) return current as MutableInfo
const copy = structuredClone(current) as MutableInfo
drafts.add(copy)
models.set(modelID, copy)
return copy
}
return {
list: (providerID) => {
const ids = providerID === undefined ? Array.from(data.models.keys()) : [providerID]
return ids.flatMap((id) =>
Array.from(data.models.get(id)?.keys() ?? []).flatMap((modelID) => draft(id, modelID) ?? []),
)
editor: (data) => ({
list: (providerID) =>
providerID === undefined
? Array.from(data.models.values()).flatMap((models) => Array.from(models.values()))
: Array.from(data.models.get(providerID)?.values() ?? []),
get: (providerID, modelID) => data.models.get(providerID)?.get(modelID),
update: (providerID, modelID, update) => {
// Model edits cannot create/enable a provider or bypass its availability decision.
const models = data.models.get(providerID)
if (!models) return
const model = models.get(modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
update(model)
model.id = modelID
model.providerID = providerID
const provider = input?.records.get(providerID)?.provider
AISDKNative.rewrite(model, {
specifier: model.package ?? provider?.package,
providerID,
canonical: model.canonical ?? provider?.canonical,
modelID: model.modelID ?? modelID,
})
models.set(modelID, model)
},
remove: (providerID, modelID) => {
data.models.get(providerID)?.delete(modelID)
},
default: {
get: () => data.defaultModel,
set: (providerID, modelID) => {
data.defaultModel = { providerID, modelID }
},
get: draft,
update: (providerID, modelID, update) => {
// Model edits cannot create/enable a provider or bypass its availability decision.
const models = writable(providerID)
if (!models) return
const model = draft(providerID, modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
update(model)
model.id = modelID
model.providerID = providerID
const provider = input?.records.get(providerID)?.provider
AISDKNative.rewrite(model, {
specifier: model.package ?? provider?.package,
providerID,
canonical: model.canonical ?? provider?.canonical,
modelID: model.modelID ?? modelID,
})
drafts.add(model)
models.set(modelID, model)
},
remove: (providerID, modelID) => {
writable(providerID)?.delete(modelID)
},
default: {
get: () => data.defaultModel,
set: (providerID, modelID) => {
data.defaultModel = { providerID, modelID }
},
},
provider: {
list: () => Array.from(input?.records.values() ?? []),
get: (providerID) => input?.records.get(providerID),
},
}
},
},
provider: {
list: () => Array.from(input?.records.values() ?? []),
get: (providerID) => input?.records.get(providerID),
},
}),
// read() also refreshes dependencies changed inside a State.batch before notification.
notify: () => notify,
})
@@ -161,8 +147,6 @@ const layer = Layer.effect(
byProvider: ReadonlyMap<Provider.ID, ReadonlyMap<ID, Info>>
}
| undefined
// An unedited model keeps its shared definition object across rebuilds, so its merged output is reusable.
const merged = new WeakMap<Info, { provider: Provider.Info | undefined; model: Info }>()
const read = Effect.fn("Model.snapshot")(function* () {
while (true) {
const current = yield* providers.snapshot()
@@ -181,10 +165,9 @@ const layer = Layer.effect(
return [
providerID,
new Map(
Array.from(models, ([id, model]) => {
const reusable = merged.get(model)
if (reusable && reusable.provider === provider) return [id, reusable.model]
const value = {
Array.from(models, ([id, model]) => [
id,
{
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
@@ -193,10 +176,8 @@ const layer = Layer.effect(
settings: Provider.mergeOverlay(provider?.settings, model.settings),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
} satisfies Info
merged.set(model, { provider, model: value })
return [id, value]
}),
} satisfies Info,
]),
),
]
}),
+1 -5
View File
@@ -1,7 +1,7 @@
export * as PermissionSaved from "./saved.js"
import { eq } from "drizzle-orm"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer, Schema } from "effect"
import { Project } from "@opencode/schema/project"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
@@ -52,10 +52,6 @@ const layer = Layer.effect(
projectID: row.project_id,
action: row.action,
resource: row.resource,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
},
}),
)
})
+4 -10
View File
@@ -411,10 +411,11 @@ export const make = Effect.fn("PluginHost.make")(function* (
.pipe(
Effect.flatMap((request) =>
request?.sessionID === input.sessionID
? permission.reply({ requestID: input.requestID, reply: input.decision, message: input.message })
? permission.reply({ requestID: input.requestID, reply: input.reply, message: input.message })
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
rules: sessions.setPermissions,
},
plugin: {
list: () => response(plugin.list()),
@@ -458,9 +459,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
vcs: {
get: () => response(vcs.info()),
base: () => response(vcs.base()),
branch: {
list: (input) => response(vcs.branches({ search: input?.search, limit: input?.limit })),
},
branches: (input) => response(vcs.branches({ search: input?.search, limit: input?.limit })),
status: () => response(vcs.status()),
diff: (input) => response(vcs.diff(input.mode, { context: input.context, base: input.base })),
transform: vcs.transform,
@@ -533,12 +532,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
prompt: sessions.prompt,
generate: (input) => sessions.generate(input).pipe(Effect.map((text) => ({ text }))),
command: (input) => sessions.command({ ...input, command: input.name }),
update: Effect.fn(function* (input) {
yield* sessions.get(input.sessionID)
if (input.title !== undefined) yield* sessions.rename({ sessionID: input.sessionID, title: input.title })
if (input.permissions !== undefined)
yield* sessions.setPermissions({ sessionID: input.sessionID, permissions: input.permissions })
}),
rename: sessions.rename,
move: sessions.move,
synthetic: sessions.synthetic,
interrupt: (input) =>
+11 -4
View File
@@ -6,12 +6,12 @@ import type { SessionHooks } from "@opencode/plugin/effect/session"
import { Model } from "@opencode/schema/model"
import { Effect } from "effect"
export function identity(model: { readonly name: string; readonly ref: Model.Ref }) {
export function identity(model: { readonly provider: string; readonly name: string; readonly ref: Model.Ref }) {
return [
"# Your Model",
`- Provider: ${model.provider}`,
`- Name: ${model.name}`,
`- Provider ID: ${model.ref.providerID}`,
`- Model ID: ${model.ref.id}`,
`- ID: ${model.ref.providerID}/${model.ref.id}`,
].join("\n")
}
@@ -24,7 +24,14 @@ export const Plugin = define({
(yield* ctx.model.list()).data.find(
(model) => model.providerID === event.model.providerID && model.id === event.model.id,
) ?? Model.Info.default(event.model.providerID, event.model.id)
event.system.splice(1, 0, SystemPart.make(identity({ name: model.name, ref: event.model })))
const provider = (yield* ctx.provider.list()).data.find((provider) => provider.id === event.model.providerID)
event.system.splice(
1,
0,
SystemPart.make(
identity({ provider: provider?.name ?? event.model.providerID, name: model.name, ref: event.model }),
),
)
}).pipe(Effect.catch(() => Effect.void))
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
+1 -2
View File
@@ -167,8 +167,7 @@ export const AzurePlugin = define({
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
if (responsesWebSocketCapable(item.provider, draft))
draft.transport = item.provider.transport ?? "websocket"
if (responsesWebSocketCapable(item.provider, draft)) draft.transport = "websocket"
})
}
}
+1 -1
View File
@@ -270,7 +270,7 @@ export const OpenAIPlugin = define({
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
models.update(model.providerID, model.id, (draft) => {
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
draft.transport = "websocket"
if (!chatgpt) return
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
draft.enabled = false
@@ -311,7 +311,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
// Console config can change independently of local credential activity, so re-fetch
// periodically and only rebuild the catalog and search providers when the snapshot differs.
yield* Effect.sleep(Duration.minutes(1)).pipe(
yield* Effect.sleep(Duration.minutes(10)).pipe(
Effect.andThen(
loading.withPermit(
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
+1 -1
View File
@@ -98,7 +98,7 @@ export const XAIPlugin = define({
yield* ctx.model.transform((models) => {
for (const model of models.list(providerID)) {
models.update(providerID, model.id, (draft) => {
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
draft.transport = "websocket"
})
}
})
-3
View File
@@ -57,9 +57,6 @@ export const Plugin = define({
const hook = (event: SessionHooks["context"]) =>
Effect.gen(function* () {
const session = yield* ctx.session.get({ sessionID: event.sessionID }).pipe(Effect.orDie)
if (session.parentID) return
const active = sessions.get(event.sessionID)
const settings = yield* loadSettings()
if (!settings) {
+13 -22
View File
@@ -228,18 +228,12 @@ export interface Interface extends State.Transformable<Editor> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Provider") {}
// Every location references the same index for a shared immutable definition array.
const definitions = new WeakMap<readonly Model.Info[], Map<ID, ReadonlyMap<Model.ID, Model.Info>>>()
function index(providerID: ID, models: readonly Model.Info[]) {
const indexes = definitions.get(models) ?? new Map<ID, ReadonlyMap<Model.ID, Model.Info>>()
const cached = indexes.get(providerID)
const definitions = new WeakMap<readonly Model.Info[], ReadonlyMap<Model.ID, Model.Info>>()
function index(models: readonly Model.Info[]) {
const cached = definitions.get(models)
if (cached) return cached
// Model shares these definitions without copying, so a foreign definition takes this provider's identity here.
const result = freeze(
new Map(models.map((model) => [model.id, model.providerID === providerID ? model : { ...model, providerID }])),
true,
)
indexes.set(providerID, result)
definitions.set(models, indexes)
const result = freeze(new Map(models.map((model) => [model.id, model])), true)
definitions.set(models, result)
return result
}
@@ -287,7 +281,7 @@ const layer = Layer.effect(
add: (definition) => {
records.set(definition.info.id, {
provider: structuredClone(definition.info) as MutableInfo,
models: index(definition.info.id, definition.models),
models: index(definition.models),
sourceConnection: definition.sourceConnection,
})
},
@@ -306,7 +300,7 @@ const layer = Layer.effect(
},
models: {
set: (id, values) => {
entry(id).models = index(id, values)
entry(id).models = index(values)
},
update: (providerID, modelID, update) => {
const record = entry(providerID)
@@ -344,7 +338,7 @@ const layer = Layer.effect(
// Registrations may outlive a borrowed service layer; their later disposal must
// not query dependencies that have already closed.
yield* Effect.addFinalizer(() => State.shutdown(state.reload()))
let cached: { records: Snapshot["records"]; value: Snapshot } | undefined
let cached: { records: Snapshot["records"]; access: string; value: Snapshot } | undefined
const snapshot = Effect.fn("Provider.snapshot")(function* () {
while (true) {
const revision = integrations.revision()
@@ -353,6 +347,10 @@ const layer = Layer.effect(
const connections = yield* integrations.list()
// Either fold can disable a plugin that also contributed to the other domain.
if (revision !== integrations.revision() || records !== state.get()) continue
const access = JSON.stringify(
connections.map((integration) => [integration.id, integration.connections.map(IntegrationConnection.key)]),
)
if (cached?.records === records && cached.access === access) return cached.value
const byID = new Map(connections.map((integration) => [integration.id, integration]))
const available = Array.from(records.values()).filter((record) => {
if (record.provider.activation === "disabled") return false
@@ -368,15 +366,8 @@ const layer = Layer.effect(
if (integration?.connections.length) return true
return record.provider.integrationID === undefined && !integration
})
// A credential change that leaves the same definitions available is not a catalog change.
if (
cached?.records === records &&
cached.value.available.length === available.length &&
cached.value.available.every((record, index) => record === available[index])
)
return cached.value
const value = freeze({ records, available, providers: available.map((record) => record.provider) }, true)
cached = { records, value }
cached = { records, access, value }
return value
}
})
@@ -100,6 +100,7 @@ export const layer = (options?: Options) =>
const recoverShell = Effect.fnUntraced(function* (
background: Job.Background,
recovery: Extract<Job.Recovery, { kind: "shell" }>,
suspended: ReadonlySet<SessionSchema.ID>,
) {
const state = background.status === "running" ? "cancelled" : background.status
const text =
@@ -123,9 +124,7 @@ export const layer = (options?: Options) =>
state,
text,
}),
// Restart notices must not revive idle owners of long-lived shells.
// Interrupted executions resume separately after their notices are admitted.
resume: false,
...(suspended.has(recovery.sessionID) ? { resume: false } : {}),
})
.pipe(
Effect.catchTag("Session.NotFoundError", () => Effect.void),
@@ -209,7 +208,7 @@ export const layer = (options?: Options) =>
if ((yield* jobs.get(background.id))?.status === "running") return
const recovery = background.recovery
yield* recovery.kind === "shell"
? recoverShell(background, recovery)
? recoverShell(background, recovery, suspended)
: recoverSubagent(background, recovery, suspended)
}),
{ discard: true },
+1 -1
View File
@@ -131,7 +131,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
}),
"session.renamed": () => Effect.void,
"session.permissions": () => Effect.void,
"session.permissions.updated": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.inbox.delivered": () => Effect.void,
+9 -18
View File
@@ -320,24 +320,15 @@ export const layer = Layer.effect(
// which transport actually carries the request, so both hook families are always offered.
const webSocket =
input.webSocket === "session" && model.transport === "websocket"
? transport.bind(session.id, {
handshake: (connect) =>
hooks
.trigger("session", "experimental.ws.handshake", {
...scope,
url: connect.url,
headers: connect.headers,
})
.pipe(Effect.map((event) => ({ url: event.url, headers: event.headers }))),
send: (frame) =>
hooks
.trigger("session", "experimental.ws.send", { ...scope, frame })
.pipe(Effect.map((event) => event.frame)),
receive: (frame) =>
hooks
.trigger("session", "experimental.ws.receive", { ...scope, frame })
.pipe(Effect.map((event) => event.frame)),
})
? transport.bind(session.id, (connect) =>
hooks
.trigger("session", "experimental.ws.handshake", {
...scope,
url: connect.url,
headers: connect.headers,
})
.pipe(Effect.map((event) => ({ url: event.url, headers: event.headers }))),
)
: undefined
return {
+13 -21
View File
@@ -59,18 +59,11 @@ export interface Handshake {
readonly headers: Record<string, string>
}
/**
* Per-exchange taps. `handshake` runs before the connection is selected; `send` sees each outbound
* frame after the driver builds it; `receive` sees each inbound frame before the driver observes it.
*/
export interface Interceptor {
readonly handshake?: (connect: Handshake) => Effect.Effect<Handshake>
readonly send?: (frame: string) => Effect.Effect<string>
readonly receive?: (frame: string) => Effect.Effect<string>
}
export interface Interface {
readonly bind: (sessionID: SessionSchema.ID, interceptor?: Interceptor) => WebSocketChannelExecutor
readonly bind: (
sessionID: SessionSchema.ID,
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
) => WebSocketChannelExecutor
readonly close: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly closeAll: Effect.Effect<void>
}
@@ -285,7 +278,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
const start = Effect.fn("SessionModelTransport.start")(function* (
owner: State,
input: WebSocketChannelExchange,
interceptor?: Interceptor,
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
) {
if (owner.closed)
return yield* transportError("Session WebSocket owner is closed", {
@@ -295,8 +288,8 @@ export const makeLayer = (connector: WebSocketConnector) =>
delivery: "not-sent",
})
if (owner.httpFallback) return fallback(input)
const selected = interceptor?.handshake
? yield* interceptor.handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
const selected = handshake
? yield* handshake({ url: input.connect.url, headers: { ...input.connect.headers } })
: undefined
const exchange: WebSocketChannelExchange = selected
? { ...input, connect: { ...input.connect, url: selected.url, headers: Headers.fromInput(selected.headers) } }
@@ -361,9 +354,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
Effect.onInterrupt(() => closeChannel(owner, channel)),
)
if (create.mode === "full") channel.checkpoint = undefined
const message = interceptor?.send
? yield* interceptor.send(create.message).pipe(Effect.onInterrupt(() => closeChannel(owner, channel)))
: create.message
yield* Effect.logDebug("session websocket sending", {
sessionTransport: "websocket",
phase: "send",
@@ -374,7 +364,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
delivery: "send-attempted",
}
channel.active = active
const sent = yield* channel.connection.sendText(message).pipe(
const sent = yield* channel.connection.sendText(create.message).pipe(
Effect.withSpan("SessionModelTransport.send"),
Effect.onInterrupt(() => closeChannel(owner, channel)),
Effect.result,
@@ -415,7 +405,6 @@ export const makeLayer = (connector: WebSocketConnector) =>
}),
),
}),
Stream.mapEffect((frame) => (interceptor?.receive ? interceptor.receive(frame) : Effect.succeed(frame))),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
@@ -493,7 +482,10 @@ export const makeLayer = (connector: WebSocketConnector) =>
return { frames, complete, http: channel.connection.http }
})
const bind = (sessionID: SessionSchema.ID, interceptor?: Interceptor): WebSocketChannelExecutor => ({
const bind = (
sessionID: SessionSchema.ID,
handshake?: (connect: Handshake) => Effect.Effect<Handshake>,
): WebSocketChannelExecutor => ({
execute: (exchange) => {
const owner = state(sessionID)
let execution: WebSocketChannelExecution | undefined
@@ -503,7 +495,7 @@ export const makeLayer = (connector: WebSocketConnector) =>
},
frames: Stream.unwrap(
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
Effect.andThen(start(owner, exchange, interceptor)),
Effect.andThen(start(owner, exchange, handshake)),
Effect.tap((started) =>
Effect.sync(() => {
execution = started
+1 -1
View File
@@ -573,7 +573,7 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Permissions, (event) =>
yield* bus.project(SessionEvent.PermissionsUpdated, (event) =>
db
.update(SessionTable)
.set({ permission: event.data.permissions, time_updated: event.created })
+3 -6
View File
@@ -35,13 +35,10 @@ export function isRetryable(error: AIError) {
case "RateLimit":
case "ProviderInternal":
return true
// A WebSocket acknowledgment marks delivery accepted before model output may exist.
// Read failures can still recover; the Step chooses retry versus continuation from durable output.
// HTTP transport errors carry no delivery and always retry. WebSocket marks accepted and rejected
// requests as final; not-sent and ambiguous (no frame observed) are still pre-output.
case "Transport":
return (
error.reason.delivery !== "rejected" &&
(error.reason.delivery !== "accepted" || error.reason.operation === "read")
)
return error.reason.delivery !== "accepted" && error.reason.delivery !== "rejected"
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
// Unrecognized failures retry: classification records affirmative
+1 -1
View File
@@ -78,7 +78,7 @@ export const make = Effect.fn("Session.make")(function* () {
input: { permissions: Permission.Ruleset },
) {
yield* get(sessionID)
yield* bus.publish(SessionEvent.Permissions, { sessionID, permissions: input.permissions })
yield* bus.publish(SessionEvent.PermissionsUpdated, { sessionID, permissions: input.permissions })
})
const switchAgent = Effect.fn("Session.switchAgent")(function* (
sessionID: SessionSchema.ID,
+1 -1
View File
@@ -257,7 +257,7 @@ const layer = () =>
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout ?? 0,
timeout: input.timeout,
shell: input.shell ?? (yield* shell.resolve({ priority: "config" })),
env: {
...(sessionEnvironment ?? process.env),
+6 -18
View File
@@ -154,7 +154,6 @@ const layer = Layer.effect(
}
})
let catalog: { data: Data; names: string; value: CodeModeCatalog.Inventory } | undefined
const state = State.create<Data, Editor>({
name: "tool",
initial: () => ({
@@ -202,9 +201,8 @@ const layer = Layer.effect(
editor.tools.delete(id)
},
}),
notify: (value) => {
catalog = undefined
return Effect.forEach(
notify: (value) =>
Effect.forEach(
value.errors,
({ kind, name, namespace, error }) =>
Effect.logError(`Skipping invalid ${kind} registration`, {
@@ -213,8 +211,7 @@ const layer = Layer.effect(
error: error.message,
}),
{ discard: true },
)
},
),
})
return Service.of({
@@ -222,16 +219,15 @@ const layer = Layer.effect(
reload: state.reload,
snapshot: Effect.fn("Tool.snapshot")((permissions) =>
Effect.sync(() => {
const data = state.get()
const active = new Map<string, Tool.Info>()
const rules = permissions ?? []
for (const [name, tool] of data.tools) {
for (const [name, tool] of state.get().tools) {
if (whollyDisabled(tool.options?.permission ?? name, rules)) continue
active.set(name, tool)
}
const direct = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode === false))
const codeModeTools = new Map(Array.from(active).filter(([, tool]) => tool.options?.codemode !== false))
const namespaces = data.namespaces
const namespaces = state.get().namespaces
const codeModeInventory = { tools: codeModeTools, namespaces }
const codeModeEnabled = !whollyDisabled("execute", rules)
const codeModeTool = codeModeEnabled
@@ -241,15 +237,7 @@ const layer = Layer.effect(
),
)
: undefined
const names = Array.from(codeModeTools.keys()).join("\0")
// Discovery is immutable for a registry revision and visible tool set. Keep request
// definitions/executors fresh, but share the much larger rendered catalog across steps.
const codeModeCatalog = !codeModeEnabled
? undefined
: catalog?.data === data && catalog.names === names
? catalog.value
: CodeModeTool.catalog(codeModeInventory)
if (codeModeCatalog) catalog = { data, names, value: codeModeCatalog }
const codeModeCatalog = codeModeEnabled ? CodeModeTool.catalog(codeModeInventory) : undefined
return {
...(codeModeCatalog === undefined ? {} : { codeModeCatalog }),
definitions: [

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