@kodizm/acp 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +216 -562
  2. package/dist/index.js +8227 -102
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,169 +1,162 @@
1
1
  # kodizm-acp
2
2
 
3
- Kodizm runtime's ACP bridge. One protocol controls Claude Code, codex, and opencode CLIs under a single canonical surface, so the orchestrator wires every backend the same way: token rollup, model selection, system prompt replace + append, additional directories, MCP server injection, skill injection, image content blocks, thinking + output + tool streaming, subagent observability, resume + fork, permission + AskUserQuestion, MCP elicitation, dynamic tool dispatch, chatgpt token refresh, deferred-permission lifecycle (Pattern B incl. cross-process resume), debug capture, heartbeat liveness, structured failure events.
4
-
5
- Status: Phase 1, 1.5, 1.6, 1.7, 2, 3 complete. Three backends ship: `claude`, `codex`, `opencode`.
6
-
7
- ## Backend parity matrix
8
-
9
- Every Phase 1 / 1.5 / 1.6 / 1.7 / 2 / 3 surface is wired identically across the three shipping backends. The orchestrator does not branch on backend; the same canonical wire shape carries every feature.
10
-
11
- | Feature | Claude | Codex | Opencode |
12
- |---------|--------|-------|----------|
13
- | `session/new` + `session/load` + `session/fork` + `session/cancel` | yes | yes | yes |
14
- | `session/prompt` text content blocks | yes | yes | yes |
15
- | `session/prompt` image content blocks (`{ type: 'image', uri: 'file://...' \| 'http(s)://...' }`) | yes | yes (`localImage` / `image` UserInput) | yes (opencode `FilePart`) |
16
- | Token + cost rollup (`usage` event) | yes | yes | yes (from `Assistant.tokens` + `cost` on `message.updated`) |
17
- | Model selection (`model` field + `model_advertisement` event) | yes | yes (api-key auth honors override; chatgpt-mode keeps default) | yes (canonical `<providerID>/<modelID>` slash format) |
18
- | `additionalDirectories` -> sandbox | yes (SDK) | yes (`SandboxPolicy.WorkspaceWrite.writableRoots`) | n/a (opencode has no sandbox primitive; falls back to container symlink layout) |
19
- | `mcpServers` inline injection | yes (SDK options) | yes (CODEX_HOME `config.toml` + `[mcp_servers.*]`) | yes (per-session `sdk.mcp.add` + reverse-name map) |
20
- | `systemPrompt` replace / append | yes | yes (codex `baseInstructions` / `developerInstructions`) | yes (forwarded as-is to v1 prompt) |
21
- | `skills` pre-load + `skill_activation` events | yes (`skillEvents: true`) | n/a (codex has no skill loader) | n/a (opencode has no skill loader) |
22
- | `toolPolicy.defaultMode` (5 enum) | yes | yes | yes (translates to opencode `Ruleset` per-session) |
23
- | `toolPolicy.allow` / `deny` / `ask` | yes (SDK rules) | yes (sandbox + permission profile) | yes (lowercase native tool keys + mcp `<server>_<tool>` translation) |
24
- | `autoCompact` on/off | yes (`DISABLE_AUTO_COMPACT=1`) | yes (codex respects `model_auto_compact_token_limit`) | yes (opencode summarizer respects orchestrator decision) |
25
- | `permission_request` event + `session/request_permission` RPC | yes | yes (3 codex RPCs collapse to one canonical) | yes (`permission.asked` bus event collapses to one canonical) |
26
- | Legacy `applyPatchApproval` + `execCommandApproval` | n/a | yes (auto-aliased to v2 `item/*/requestApproval` shape) | n/a |
27
- | `question_request` + `session/ask_user_question` RPC | yes (AskUserQuestion tool) | yes (`item/tool/requestUserInput` + `mcpServer/elicitation/request` both feed in) | yes (opencode `Question.Service` + `tool/question.ts` native; `multiple` -> `multiSelect`, `custom` -> `_meta.customByQuestion`) |
28
- | `session/dynamic_tool_call` (orchestrator-hosted tools) | n/a | yes (codex `item/tool/call`) | n/a |
29
- | `session/codex_chatgpt_token_refresh` | n/a | yes (codex `account/chatgptAuthTokens/refresh`) | n/a |
30
- | `permission_deferred` + `permission_resumed` events (Pattern B) | yes | yes | yes (synthetic tool_result via `sdk.session.message.append` carrying `__KODIZM_PERMISSION_DEFERRED__`) |
31
- | Cross-process Pattern B (`hydrateSession()` API) | yes | yes | yes (`loadSession({sessionId, _meta.opencodeSessionId})`) |
32
- | `session/permission_deferred_persist` + `session/permission_deferred_state` RPCs | yes | yes | yes |
33
- | Pattern B JSONL injection | `~/.claude/projects/.../<sessionId>.jsonl` | `~/.codex/sessions/rollout-*-<threadId>.jsonl` (resume-by-threadId) | opencode SQLite via `sdk.session.message.append` |
34
- | `compaction_started` + `compaction_completed` events | yes | yes (`ContextCompaction` item lifecycle) | yes (`session.updated` `time.compacting` set + `session.compacted`) |
35
- | `subagent_spawn` + `subagent_complete` events | yes (Task tool) | yes (`CollabAgentToolCall`; chatgpt-mode tier-gated) | yes (opencode `task` tool spawns child sessions; `state.metadata.sessionID` = childId) |
36
- | `tool_call_begin` / `progress` / `end` events | yes | yes (CommandExecution / FileChange / McpToolCall) | yes (per-callID latched: exactly one begin + one end pair, despite multiple opencode `running` updates) |
37
- | `output_chunk` event | yes | yes | yes (`message.part.delta` `field=text` on `TextPart`) |
38
- | `thinking_chunk` event | yes | yes (`item/reasoning/summaryTextDelta` + `item/reasoning/textDelta`) | yes (`message.part.delta` `field=text` on `ReasoningPart`) |
39
- | `debug_log` (9 stages) + per-session toggle | yes | yes | yes |
40
- | `heartbeat` event | yes | yes | yes |
41
- | `session_failed` event + 7-value reason enum | yes | yes | yes (`ProviderAuthError` -> `auth_error`, `APIError 429/503` -> `rate_limit`, `APIError 5xx` -> `transport_error`, `MessageAbortedError` -> cancel sentinel) |
42
- | Per-reason `shouldExitOnReason` exit policy | yes | yes | yes |
43
- | Graceful SIGTERM shutdown hook | yes | yes (subprocess `kill()` + grace) | yes (`bridge.stop()` per session) |
44
- | `BackendStallError` (-32006) | yes | yes | yes |
45
-
46
- `DriverCapabilities` carries 8 flags. Phase 3 added `askQuestion: boolean` to advertise first-class user-question support; Claude (SDK plugin), codex (`requestUserInput` + elicitation), and opencode (native `Question.Service`) all set it `true`.
3
+ ACP bridge that drives Claude Code, codex, and opencode CLIs through one canonical wire.
47
4
 
48
- ## Install
5
+ ## What it is
6
+
7
+ `@kodizm/acp` is the Kodizm runtime's Agent Client Protocol bridge. It speaks one canonical JSON-RPC surface to the orchestrator, then translates each turn down to whichever CLI backend the session was opened against. The orchestrator never branches on backend; the same `session/new`, `session/prompt`, and `sessionUpdate` shapes carry every feature across all three.
8
+
9
+ Three CLIs are supported today: Claude Code (via `@anthropic-ai/claude-agent-sdk`), codex (via `codex app-server` subprocess), and opencode (via `createOpencodeServer` from `@opencode-ai/sdk`).
10
+
11
+ ## How it works
49
12
 
50
- ```bash
51
- bun install
52
- bun build src/index.ts --target=bun --outdir=dist
53
13
  ```
14
+ +------------------+ JSON-RPC over +------------------+ native protocol +-----------+
15
+ | Orchestrator | <----- NDJSON ----> | AcpServer | <-------------------> | Backend |
16
+ | (Kodizm core) | (stdio/pipe) | + BackendDriver | (SDK / subproc) | CLI |
17
+ +------------------+ +------------------+ +-----------+
18
+ ```
19
+
20
+ 1. `KODIZM_BACKEND` selects the driver at process boot. One process per backend.
21
+ 2. `AcpServer` validates every inbound request against the canonical schema, then routes to the `BackendDriver` interface.
22
+ 3. Each driver maps the canonical request to its CLI's native shape. Stream events flow back through `emit.send()` and surface as `sessionUpdate` notifications on the wire.
23
+
24
+ The driver contract is a single TypeScript interface with seven methods. The server never imports any concrete driver. New backends extend the registry; the wire layer does not change.
25
+
26
+ ## Backend support
27
+
28
+ | Feature | Claude | codex | opencode |
29
+ |---------|:------:|:-----:|:--------:|
30
+ | `session/new`, `session/prompt`, `session/cancel` | yes | yes | yes |
31
+ | `session/load` (resume) | yes | yes | yes |
32
+ | `session/fork` | yes | yes | yes |
33
+ | `session/compact` (manual) | yes | yes | yes |
34
+ | Image content blocks | yes | yes | yes |
35
+ | Token + cost rollup (`usage` event) | yes | yes | yes |
36
+ | `additionalDirectories` (sandbox) | yes | yes | n/a |
37
+ | `mcpServers` injection | yes | yes | yes |
38
+ | `systemPrompt` replace + append | yes | yes | yes |
39
+ | `skills` pre-load | yes | n/a | n/a |
40
+ | Permissions (`permission_request`) | yes | yes | yes |
41
+ | `askUserQuestion` | yes | yes | yes |
42
+ | Subagent events | yes | yes | yes |
43
+ | Thinking events | yes | yes | yes |
44
+ | Cross-process Pattern B resume | yes | yes | yes |
45
+ | Debug capture | yes | yes | yes |
46
+
47
+ > [!NOTE]
48
+ > The published bin (`kodizm-acp` from `dist/index.js`) currently wires only `KODIZM_BACKEND=claude`. Codex and opencode drivers are fully implemented and tested, but reaching them today requires programmatic embedding (see [API reference](#api-reference)).
54
49
 
55
- ## Environment
50
+ ## Install
56
51
 
57
- | Variable | Description |
58
- |----------|-------------|
59
- | `KODIZM_BACKEND` | `claude` (Phase 1) / `codex` (Phase 2) / `opencode` (Phase 3) |
60
- | `CODEX_API_KEY` or `OPENAI_API_KEY` | codex backend api-key auth (alternative to chatgpt OAuth) |
61
- | `CLAUDE_CODE_OAUTH_TOKEN` + `CLAUDE_CODE_REMOTE=1` | Subscription auth |
62
- | `ANTHROPIC_API_KEY` | API key auth (fallback) |
63
- | `OPENCODE_AUTH_CONTENT` | opencode per-provider auth blob (JSON keyed by providerID; layered onto subprocess env at `bridge.start`) |
64
- | `KODIZM_LOG_LEVEL` | `debug` / `info` / `warn` / `error`. Default `info` |
65
- | `KODIZM_DEBUG` | `1` enables process-wide debug capture |
66
- | `KODIZM_DEBUG_DIR` | Forensic JSONL dir, default `/tmp/kodizm-debug` |
67
- | `KODIZM_DEBUG_RAW_SECRETS` | `1` disables allow-list redaction (incident-only) |
68
- | `KODIZM_ACP_FORWARD_STDERR` | `1` tees codex subprocess stderr to parent stderr (diagnosis) |
52
+ ```bash
53
+ bun add @kodizm/acp
54
+ ```
69
55
 
70
- Stdout is reserved for ACP frames. Never log to stdout.
56
+ Requires Bun >= 1.1.0. The `codex` and `opencode` CLIs must be installed separately when you use those backends.
71
57
 
72
58
  ## Quick start
73
59
 
60
+ The bin runs over stdio:
61
+
74
62
  ```bash
75
- # Claude:
76
63
  echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}' \
77
- | KODIZM_BACKEND=claude CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-..." CLAUDE_CODE_REMOTE=1 \
78
- bun run dist/index.js
64
+ | KODIZM_BACKEND=claude \
65
+ CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-..." \
66
+ CLAUDE_CODE_REMOTE=1 \
67
+ bunx @kodizm/acp
68
+ ```
79
69
 
80
- # Codex (same protocol, just switch the backend env):
81
- echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}' \
82
- | KODIZM_BACKEND=codex OPENAI_API_KEY="sk-..." \
83
- bun run dist/index.js
70
+ Replace the credential pair with `ANTHROPIC_API_KEY=...` for the api-key path.
84
71
 
85
- # Opencode (auth blob via env so the per-session subprocess inherits it):
86
- echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}' \
87
- | KODIZM_BACKEND=opencode \
88
- OPENCODE_AUTH_CONTENT='{"opencode-go":{"type":"api","key":"..."}}' \
89
- bun run dist/index.js
90
- ```
72
+ ## Configuration
91
73
 
92
- Programmatic embedding:
74
+ ### Environment variables
93
75
 
94
- ```ts
95
- import { ClaudeDriver } from '@/backends/claude/driver.ts'
96
- import { query } from '@anthropic-ai/claude-agent-sdk'
76
+ | Variable | Required | Description |
77
+ |----------|:--------:|-------------|
78
+ | `KODIZM_BACKEND` | yes | `claude` / `codex` / `opencode` |
79
+ | `CLAUDE_CODE_OAUTH_TOKEN` + `CLAUDE_CODE_REMOTE=1` | claude (sub) | Subscription auth |
80
+ | `ANTHROPIC_API_KEY` | claude (api) | API key auth |
81
+ | `CLAUDE_CODE_PATH` | optional | Path to claude binary (default `/usr/local/bin/claude`) |
82
+ | `OPENAI_API_KEY` or `CODEX_API_KEY` | codex (api) | api-key path. Without it, codex falls back to chatgpt-mode auth in `~/.codex/auth.json` |
83
+ | `OPENCODE_AUTH_CONTENT` | opencode (env) | JSON keyed by providerID. Layered onto subprocess env for the bridge lifetime only. Without it, opencode reads `~/.local/share/opencode/auth.json` |
84
+ | `KODIZM_LOG_LEVEL` | optional | `debug` / `info` / `warn` / `error`. Default `info` |
85
+ | `KODIZM_DEBUG` | optional | `1` enables process-wide debug capture |
86
+ | `KODIZM_DEBUG_DIR` | optional | Forensic JSONL dir, default `/tmp/kodizm-debug` |
87
+ | `KODIZM_DEBUG_RAW_SECRETS` | incident-only | `1` disables redaction. Never set in production |
88
+ | `KODIZM_ACP_FORWARD_STDERR` | optional | `1` tees codex subprocess stderr to parent stderr |
97
89
 
98
- const driver = new ClaudeDriver({
99
- credentials: { type: 'subscription', token: process.env.CLAUDE_CODE_OAUTH_TOKEN! },
100
- agentInfo: { version: '0.0.1' },
101
- sdk: { query: ({ prompt, options }) => query({ prompt, options }) },
102
- })
90
+ Stdout is reserved for ACP frames. Logs go to stderr.
103
91
 
104
- const { sessionId } = await driver.newSession({
105
- cwd: process.cwd(),
106
- mcpServers: [],
107
- model: 'claude-haiku-4-5-20251001',
108
- })
92
+ ### Session options (`NewSessionRequest`)
109
93
 
110
- const events: SessionUpdateEvent[] = []
111
- const result = await driver.prompt(
112
- sessionId,
113
- { sessionId, prompt: [{ type: 'text', text: 'Say hi.' }] },
114
- { send: (event) => events.push(event) },
115
- )
94
+ ```ts
95
+ type NewSessionRequest = {
96
+ cwd: string // absolute path
97
+ mcpServers: McpServer[]
98
+ additionalDirectories?: string[] // absolute paths
99
+ systemPrompt?: string | { append: string }
100
+ model?: string // e.g. 'claude-haiku-4-5-20251001'
101
+ skills?: string[] // claude only
102
+ toolPolicy?: ToolPolicy
103
+ autoCompact?: boolean
104
+ permissionTimeoutMs?: number // mutually exclusive with permissionDeferTimeoutMs
105
+ permissionDeferTimeoutMs?: number
106
+ debug?: boolean
107
+ debugCaptureRawSdk?: boolean
108
+ debugCaptureRpc?: boolean
109
+ heartbeatIntervalMs?: number
110
+ inactivityThresholdMs?: number
111
+ _meta?: Record<string, unknown> // passthrough; canonical fields rejected
112
+ }
116
113
  ```
117
114
 
118
- ## API reference
119
-
120
- ### `createAcpServer({ transport, backend?, debugSink? })`
115
+ > [!WARNING]
116
+ > `permissionTimeoutMs` and `permissionDeferTimeoutMs` are mutually exclusive. Pick hard-deny on timeout OR soft-defer on timeout, not both. The schema rejects the conflict with a clear error.
121
117
 
122
- JSON-RPC 2.0 dispatcher over an NDJSON transport. When `backend` is set, the server auto-registers six lifecycle methods (`initialize`, `session/new`, `session/prompt`, `session/cancel`, `session/load`, `session/fork`) with schema validation + capability gating. When `debugSink` is set, every inbound + outbound frame tees through `record('rpc.in' | 'rpc.out', frame)`.
118
+ ### Tool policy
123
119
 
124
120
  ```ts
125
- import { createAcpServer } from '@/server/acp-server.ts'
126
- import { createNdjsonTransport } from '@/server/transport.ts'
127
- import { DebugRecorder } from '@/util/debug-recorder.ts'
128
-
129
- const recorder = new DebugRecorder({
130
- sessionId: 's1',
131
- emit: { send: (e) => server.notify('sessionUpdate', e) },
132
- debug: true,
133
- debugFilePath: '/tmp/kodizm-debug/s1.jsonl',
134
- })
135
-
136
- const server = createAcpServer({
137
- transport: createNdjsonTransport({ readable: Bun.stdin.stream(), writable }),
138
- backend: driver,
139
- debugSink: recorder,
140
- })
141
-
142
- await server.serve()
121
+ type ToolPolicy = {
122
+ defaultMode?: 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'bypassPermissions'
123
+ allow?: string[] // e.g. ['Read', 'Bash:git status', 'mcp:server/tool']
124
+ deny?: string[]
125
+ ask?: string[]
126
+ }
143
127
  ```
144
128
 
145
- ### `RPC_METHOD_ALIASES`
129
+ The parser lives in `src/wire/policy.ts`. Each backend translates the canonical pattern grammar to its native rule shape.
146
130
 
147
- The permission RPC name drifted between SDK drafts (`requestPermission` ↔ `session/request_permission`). Handlers registered under either form route to the same handler.
131
+ ### MCP servers
148
132
 
149
133
  ```ts
150
- server.on('session/request_permission', handler) // also: requestPermission
151
- server.on('session/ask_user_question', askHandler) // also: askUserQuestion
134
+ type McpServer = {
135
+ type: 'http'
136
+ name: string
137
+ url: string
138
+ headers?: { name: string; value: string }[]
139
+ }
152
140
  ```
153
141
 
142
+ Phase 1 ships the `http` transport. The codex driver writes per-server entries into a temporary `~/.codex/config.toml`; opencode adds servers via `sdk.mcp.add` per session.
143
+
144
+ ## API reference
145
+
154
146
  ### `BackendDriver`
155
147
 
156
- Every backend implements this contract.
148
+ Every backend implements this contract:
157
149
 
158
150
  ```ts
159
151
  interface BackendDriver {
152
+ capabilities(): DriverCapabilities
160
153
  initialize(params: InitializeRequest): Promise<InitializeResult>
161
154
  newSession(params: NewSessionRequest): Promise<NewSessionResult>
162
155
  prompt(sessionId: string, params: PromptRequest, emit: EventEmitter): Promise<PromptResult>
163
156
  cancel(request: CancelRequest): Promise<void>
164
157
  loadSession(params: LoadSessionRequest): Promise<NewSessionResult>
165
158
  forkSession(params: ForkSessionRequest): Promise<NewSessionResult>
166
- capabilities(): DriverCapabilities
159
+ compact(request: CompactSessionRequest, emit: EventEmitter): Promise<void>
167
160
  }
168
161
 
169
162
  interface DriverCapabilities {
@@ -176,484 +169,145 @@ interface DriverCapabilities {
176
169
  debug: boolean
177
170
  askQuestion: boolean
178
171
  }
179
-
180
- interface PromptResult {
181
- stopReason: 'end_turn' | 'cancelled' | 'process_died' | 'max_tokens' | 'tool_use' | 'session_failed'
182
- failureReason?: SessionFailedReason
183
- failureDetail?: string
184
- }
185
- ```
186
-
187
- ### `ClaudeDriver`
188
-
189
- ```ts
190
- const { sessionId } = await driver.newSession({
191
- cwd: '/workspace',
192
- mcpServers: [
193
- { type: 'http', name: 'kodizm', url: 'https://kodizm.com/mcp/internal',
194
- headers: [{ name: 'Authorization', value: 'Bearer kdz-int-jwt.x.y' }] },
195
- ],
196
- additionalDirectories: ['/data/shared'],
197
- systemPrompt: { append: 'Always respond in Turkish.' },
198
- model: 'claude-haiku-4-5-20251001',
199
- skills: ['my-coding'],
200
- toolPolicy: { defaultMode: 'default', allow: ['Read'], deny: ['Bash:git push*'] },
201
- autoCompact: false,
202
- permissionTimeoutMs: 30_000,
203
- permissionDeferTimeoutMs: 1_800_000,
204
- debug: true,
205
- heartbeatIntervalMs: 10_000,
206
- inactivityThresholdMs: 60_000,
207
- })
208
- ```
209
-
210
- Per-turn override:
211
-
212
- ```ts
213
- await driver.prompt(
214
- sessionId,
215
- { sessionId, prompt: [...], model: 'claude-sonnet-4-6' },
216
- emit,
217
- )
218
- ```
219
-
220
- Cancel:
221
-
222
- ```ts
223
- await driver.cancel({ sessionId })
224
- // emits cancelled event; prompt() returns stopReason='cancelled'
225
- ```
226
-
227
- ### `CodexDriver`
228
-
229
- Identical contract; just import the codex driver instead. The orchestrator does not branch on backend; every wire shape from `NewSessionRequest` down through `SessionUpdateEvent` is the same.
230
-
231
- ```ts
232
- import { CodexDriver } from '@/backends/codex/driver.ts'
233
- import { CodexAppServerProcess } from '@/backends/codex/app-server-spawn.ts'
234
-
235
- const driver = new CodexDriver({
236
- agentInfo: { version: '0.0.1' },
237
- spawnFactory: async (options) => {
238
- const proc = new CodexAppServerProcess({
239
- binaryPath: 'codex',
240
- configPath: options.configPath,
241
- })
242
- await proc.spawn()
243
- return proc
244
- },
245
- server, // for outbound canonical permission / ask / dynamic-tool / token-refresh RPCs
246
- deferredStore, // optional Pattern B store
247
- })
248
-
249
- const { sessionId } = await driver.newSession({
250
- cwd: '/workspace',
251
- mcpServers: [
252
- { type: 'http', name: 'kodizm', url: 'https://kodizm.com/mcp/internal' },
253
- ],
254
- toolPolicy: { defaultMode: 'default' },
255
- systemPrompt: { append: 'Always respond in Turkish.' },
256
- permissionDeferTimeoutMs: 1_800_000,
257
- debug: true,
258
- heartbeatIntervalMs: 10_000,
259
- inactivityThresholdMs: 60_000,
260
- })
261
- ```
262
-
263
- The driver:
264
- - spawns one `codex app-server --listen stdio://` subprocess per session (no `--config <path>` flag in app-server mode; per-key overrides via `-c key=value` and `[mcp_servers.*]` via CODEX_HOME's `config.toml`).
265
- - maps canonical `permissionMode` 5-enum to codex's `AskForApproval` (`untrusted` / `on-failure` / `on-request` / `never`) + pairs `plan` mode with `ReadOnly` sandbox.
266
- - maps canonical `systemPrompt` to codex's `baseInstructions` (string form) or `developerInstructions` (`{ append }` form) per `v2/ThreadStartParams.ts`.
267
- - forwards canonical image content blocks to codex `UserInput`: `file://` paths and bare absolute paths become `localImage`; `http(s)://` URLs become `image`.
268
- - collapses 7 codex serverRequest channels onto canonical wire:
269
- - 3 approval RPCs (`item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/permissions/requestApproval`) plus 2 legacy aliases (`applyPatchApproval`, `execCommandApproval`) -> canonical `permission_request` event with `name` discriminator (`codex_exec` / `codex_apply_patch` / `codex_permission_grant`).
270
- - `item/tool/requestUserInput` + `mcpServer/elicitation/request` -> canonical `question_request` event + `session/ask_user_question` RPC.
271
- - `item/tool/call` -> outbound `session/dynamic_tool_call` RPC.
272
- - `account/chatgptAuthTokens/refresh` -> outbound `session/codex_chatgpt_token_refresh` RPC.
273
- - maps codex `ContextCompaction` items to canonical `compaction_started` / `compaction_completed`.
274
- - maps codex `CollabAgentToolCall` items to canonical `subagent_spawn` / `subagent_complete` (parent `sessionId` + fresh Kodizm `childId`; codex thread ids never leak).
275
- - routes codex `item/reasoning/summaryTextDelta` + `item/reasoning/textDelta` to canonical `thinking_chunk` (separate from `output_chunk`).
276
- - writes Pattern B sentinel as `RolloutItem` line + resumes via `thread/resume { threadId }`.
277
-
278
- ### `CodexDriver.hydrateSession({ sessionId, codexThreadId, codexJsonlPath?, ... })`
279
-
280
- Cross-process Pattern B entry. A fresh driver instance can resume a Kodizm sessionId by replaying codex's `thread/resume` against the persisted threadId. Use this when the original driver instance died (container restart, deploy, machine shutdown) and a new driver needs to consume a deferred-permission cached answer or keep streaming on the same Kodizm `sessionId`.
281
-
282
- ```ts
283
- await driverB.hydrateSession({
284
- sessionId, // same Kodizm sessionId from Process A
285
- codexThreadId, // captured from Process A's session state
286
- codexJsonlPath, // optional; codex glob-resolves from threadId if absent
287
- cwd: process.cwd(),
288
- mcpServers: [],
289
- toolPolicy: { defaultMode: 'default' },
290
- })
291
-
292
- // Process B's first prompt automatically picks up the orchestrator's
293
- // cached answer from the deferredStore + fires permission_resumed.
294
- ```
295
-
296
- ### `OpencodeDriver`
297
-
298
- Same contract; switches the backend env to `opencode`. The driver spawns `opencode serve --port 0 --hostname 127.0.0.1` per Kodizm session via `createOpencodeServer` from `@opencode-ai/sdk`, then drives turns through v1 `sdk.session.prompt`.
299
-
300
- ```ts
301
- import { OpencodeDriver } from '@/backends/opencode/driver.ts'
302
-
303
- const driver = new OpencodeDriver({
304
- agentInfo: { version: '0.0.1' },
305
- server, // for outbound canonical permission + ask_user_question RPCs
306
- })
307
-
308
- const { sessionId } = await driver.newSession({
309
- cwd: '/workspace',
310
- mcpServers: [
311
- { type: 'http', name: 'kodizm', url: 'https://kodizm.com/mcp/internal' },
312
- ],
313
- model: 'opencode-go/deepseek-v4-flash', // canonical <providerID>/<modelID>
314
- toolPolicy: { defaultMode: 'default', ask: ['Bash'] },
315
- _meta: {
316
- opencodeAuth: '{"opencode-go":{"type":"api","key":"sk-..."}}',
317
- },
318
- })
319
-
320
- const events: SessionUpdateEvent[] = []
321
- const result = await driver.prompt(
322
- sessionId,
323
- { sessionId, prompt: [{ type: 'text', text: 'Run echo HELLO using bash.' }] },
324
- { send: (e) => events.push(e) },
325
- )
326
- // stopReason: 'end_turn'; events include permission_request, tool_call_begin/end, usage.
327
- ```
328
-
329
- The driver:
330
- - boots one opencode HTTP server per Kodizm session via `createOpencodeServer` (the SDK's official subprocess helper). Per-session lifecycle terminator is `bridge.stop()`.
331
- - creates the underlying opencode session via `sdk.session.create({permission})` where `permission` comes from `buildOpencodeRuleset(toolPolicy)`. Empty ruleset = opencode's own per-tool ask flow.
332
- - splits canonical model `<providerID>/<modelID>` into the SDK's `{providerID, modelID}` tuple. No-slash falls back to opencode's default provider.
333
- - uses v1 `sdk.session.prompt` (NOT v2, which only queues a message). The v1 path drives the LLM loop and returns when the assistant message completes.
334
- - subscribes to opencode's `/event` SSE stream and dispatches:
335
- - `message.part.delta` `field=text` on `TextPart` -> `output_chunk`
336
- - `message.part.delta` `field=text` on `ReasoningPart` -> `thinking_chunk`
337
- - `message.part.updated` tool/running -> `tool_call_begin` (one-shot per `callID`)
338
- - `message.part.updated` tool/completed|error -> `tool_call_end` (one-shot per `callID`)
339
- - `message.part.updated` task/running|completed|error -> `subagent_spawn` / `subagent_complete`
340
- - `session.updated` `info.time.compacting` newly set -> `compaction_started`
341
- - `session.compacted` -> `compaction_completed`
342
- - `message.updated` `role=assistant` `time.completed` set -> `usage` event
343
- - `permission.asked` -> canonical `permission_request` event + outbound `session/request_permission` RPC, replied via `sdk.permission.reply({requestID, reply})`
344
- - `question.asked` -> canonical `question_request` event + outbound `session/ask_user_question` RPC, replied via `sdk.question.reply({requestID, answers})`
345
- - maintains per-session `mcpReverseMap: Map<sanitizedServerName, originalServerName>` so opencode tool keys (`<sanitizedServer>_<tool>`, single underscore) reverse-translate to canonical `mcp__<server>__<tool>` (double underscore) for the orchestrator wire.
346
- - maps `ProviderAuthError`, `APIError 401/403` to `auth_error`; `APIError 429/503` to `rate_limit`; `APIError 5xx` + `ContextOverflowError` to `transport_error`; `MessageAbortedError` to the cancel sentinel; everything else to `sdk_throw`.
347
- - writes Pattern B deferred sentinel via `sdk.session.message.append` carrying `__KODIZM_PERMISSION_DEFERRED__ requestId=<id> tool=<name>`.
348
-
349
- ### `OpencodeDriver.loadSession({ sessionId, _meta.opencodeSessionId, ... })`
350
-
351
- Cross-process Pattern B entry for opencode. Same role as `CodexDriver.hydrateSession` but for opencode SQLite-persisted sessions:
352
-
353
- ```ts
354
- await driverB.loadSession({
355
- sessionId, // same Kodizm sessionId from Process A
356
- cwd: process.cwd(),
357
- mcpServers: [],
358
- _meta: {
359
- opencodeSessionId: 'ses_1fd1...', // captured from Process A's session state
360
- },
361
- })
362
- // Boots a fresh listener, validates the opencode session via sdk.session.get,
363
- // seats driver state. The next prompt resumes the same opencode session.
364
- ```
365
-
366
- ### `NewSessionRequest` schema
367
-
368
- | Field | Type | Phase | Description |
369
- |-------|------|-------|-------------|
370
- | `cwd` | absolute path | 1 | Workspace root |
371
- | `mcpServers` | array | 1 | MCP server inline injection |
372
- | `additionalDirectories` | array | 1 | Extra read roots |
373
- | `systemPrompt` | string \| `{append}` | 1 | Replace or append SDK preset |
374
- | `model` | string | 1 | Canonical `<providerID>/<modelID>` for opencode; raw model id for Claude / codex |
375
- | `skills` | string[] | 1 | Pre-loaded skill names |
376
- | `toolPolicy` | object | 1.5 | `<ToolName>:<pattern>` rules + `defaultMode` |
377
- | `autoCompact` | boolean | 1.5 | `false` injects `DISABLE_AUTO_COMPACT=1` |
378
- | `permissionTimeoutMs` | int ms | 1.5 | Hard-deny on permission RPC timeout |
379
- | `permissionDeferTimeoutMs` | int ms | 1.6 | Soft-defer on permission RPC timeout (Pattern B); mutually exclusive with `permissionTimeoutMs` |
380
- | `debug` | boolean | 1.7 | Per-session debug capture toggle |
381
- | `debugCaptureRawSdk` | boolean | 1.7 | Skip `sdk.message` + `sdk.error` |
382
- | `debugCaptureRpc` | boolean | 1.7 | Skip `rpc.in` + `rpc.out` |
383
- | `heartbeatIntervalMs` | int ms | 1.7 | Liveness ping cadence (default 10_000) |
384
- | `inactivityThresholdMs` | int ms | 1.7 | SDK message gap threshold (default 60_000) |
385
- | `_meta.opencodeAuth` | string \| object | 3 | opencode per-provider auth blob; layered onto `OPENCODE_AUTH_CONTENT` for `bridge.start` |
386
- | `_meta.opencodeSessionId` | string | 3 | opencode session id for cross-process resume via `loadSession` |
387
-
388
- ### Event union (`@/wire/events`)
389
-
390
- Discriminated on `type`. Every variant carries `sessionId`.
391
-
392
- | Event | Phase | Carries |
393
- |-------|-------|---------|
394
- | `output_chunk` | 1 | `text` |
395
- | `thinking_chunk` | 1 | `text` |
396
- | `tool_call_begin` | 1 | `toolUseId`, `name`, `input` |
397
- | `tool_call_progress` | 1 | `toolUseId`, `delta` |
398
- | `tool_call_end` | 1 | `toolUseId`, `result`, `isError` |
399
- | `usage` | 1 | 4 token counts + `costUsd` |
400
- | `subagent_spawn` / `subagent_complete` | 1 | child token slice |
401
- | `skill_activation` | 1 | `skillName`, `source` |
402
- | `model_advertisement` | 1 | `model` |
403
- | `process_died` / `cancelled` | 1 | terminal |
404
- | `permission_request` | 1.5 | `toolUseId`, `name`, `options[]`, `agentId?` |
405
- | `question_request` | 1.5 | `toolUseId`, `questions[]` |
406
- | `compaction_started` / `compaction_completed` | 1.5 | trigger + token counts |
407
- | `permission_deferred` / `permission_resumed` | 1.6 | Pattern B |
408
- | `debug_log` | 1.7 | `level`, `stage`, `capturedAt`, `payload`, `redacted?` |
409
- | `heartbeat` | 1.7 | `uptimeMs`, `lastSdkMs` |
410
- | `session_failed` | 1.7 | `reason`, `detail`, `capturedAt`, `cause?` |
411
-
412
- ### Permission flow (`@/backends/claude/permission-bridge`)
413
-
414
- ```ts
415
- import { buildCanUseTool } from '@/backends/claude/permission-bridge.ts'
416
-
417
- const canUseTool = buildCanUseTool({
418
- server,
419
- sessionId: 's1',
420
- emit,
421
- signal: abortController.signal,
422
- permissionTimeoutMs: 30_000,
423
- deferTimeoutMs: 1_800_000,
424
- onDefer: ({ toolName, input, options }) => driverDeferHandler(...),
425
- })
426
172
  ```
427
173
 
428
- Outcomes:
429
- - `selected.allow` → `{ behavior: 'allow', updatedInput }`
430
- - `selected.allow_always` → `{ behavior: 'allow', ..., updatedPermissions }`
431
- - `selected.reject` (or other) → `{ behavior: 'deny', message }`
432
- - `cancelled` → throws `Tool use aborted`
433
- - `AcpTimeoutError` → `{ behavior: 'deny', message: 'Permission RPC timed out' }`
434
- - `DEFERRED_SENTINEL` → calls `onDefer` (Pattern B JSONL injection + state persist)
174
+ Capability gating runs at the dispatcher: `loadSession` rejects with `MethodNotSupportedError` (-32601) when `resume` is false; `forkSession` rejects when `fork` is false. The other six flags are advisory metadata for the orchestrator.
435
175
 
436
- The opencode + codex bridges share the same `awaitPermissionResponse` race semantics; only the per-backend reply shape differs.
176
+ ### Public exports
437
177
 
438
- ### Deferred permission, Pattern B (`@/backends/{claude,codex,opencode}/deferred-permission`)
178
+ The bin (`src/index.ts`) is the only public surface. Importers get the runtime helpers, not the drivers themselves:
439
179
 
440
- Process A defers when `permissionDeferTimeoutMs` elapses without an answer:
180
+ | Export | Purpose |
181
+ |--------|---------|
182
+ | `SupportedBackend` | `'claude' \| 'codex' \| 'opencode'` |
183
+ | `BackendNotConfiguredError`, `UnknownBackendError` | startup-time errors |
184
+ | `resolveBackendFromEnv(env)` | parse `KODIZM_BACKEND` from a captured env |
185
+ | `installShutdownHook()` | wire SIGTERM + SIGINT to graceful shutdown |
186
+ | `performShutdown(graceMs?)` | run the shutdown side-effects manually |
187
+ | `registerActiveRecorder(r)` | track a `DebugRecorder` for shutdown flush |
188
+ | `registerShutdownFlusher(fn)` | register the transport flush callback |
189
+ | `SHUTDOWN_GRACE_MS` | default 3s grace budget |
190
+ | `main()` | the bin's entry function |
441
191
 
442
- ```ts
443
- // 1. writeDeferredSentinel(...) // synthetic transcript row
444
- // 2. deferredStore.set(sessionId, { toolUseId, ...input }) // state persistence
445
- // 3. emit { type: 'permission_deferred', sessionId, toolUseId, name }
446
- // 4. canUseTool returns { behavior: 'deny', interrupt: true } (SDK unwinds)
447
- ```
192
+ Drivers, wire types, and `createAcpServer` are internal. To embed programmatically, import them directly from their module paths under `src/`.
448
193
 
449
- Process B resumes when the orchestrator caches an answer:
194
+ ### Programmatic embedding
450
195
 
451
196
  ```ts
452
- // 1. one-shot deferredStore.get(sessionId) lookup at first prompt
453
- // 2. retry prefix injected into user prompt
454
- // 3. canUseTool wrap fires cached answer for matching tool name
455
- // 4. emit { type: 'permission_resumed', sessionId, toolUseId, decision }
456
- // 5. deferredStore.delete(sessionId) (one-shot consumption)
457
- ```
458
-
459
- Marker `__KODIZM_PERMISSION_DEFERRED__` flags rows the orchestrator's audit pipeline filters.
460
-
461
- ```ts
462
- import { type DeferredPermissionStore, InMemoryDeferredStore } from '@/session/deferred-store.ts'
197
+ import { ClaudeDriver } from '@kodizm/acp/src/backends/claude/driver.ts'
198
+ import { createAcpServer } from '@kodizm/acp/src/server/acp-server.ts'
199
+ import { createNdjsonTransport } from '@kodizm/acp/src/server/transport.ts'
200
+ import { query } from '@anthropic-ai/claude-agent-sdk'
463
201
 
464
202
  const driver = new ClaudeDriver({
465
- credentials, agentInfo, sdk, server,
466
- deferredStore: new InMemoryDeferredStore(),
203
+ credentials: { type: 'subscription', token: process.env.CLAUDE_CODE_OAUTH_TOKEN! },
204
+ agentInfo: { version: '0.5.4' },
205
+ sdk: { query: ({ prompt, options }) => query({ prompt, options }) },
467
206
  })
468
- ```
469
-
470
- Without `deferredStore`, the driver issues `session/permission_deferred_persist` (write) + `session/permission_deferred_state` (read) outbound RPCs.
471
207
 
472
- ### Debug capture (`@/util/debug-recorder`)
473
-
474
- ```ts
475
- import { DebugRecorder } from '@/util/debug-recorder.ts'
476
-
477
- const recorder = new DebugRecorder({
478
- sessionId: 's1',
479
- emit: { send: (e) => orchestratorWire.notify(e) },
480
- debug: true,
481
- debugFilePath: '/tmp/kodizm-debug/s1.jsonl',
482
- debugCaptureRawSdk: true,
483
- debugCaptureRpc: true,
484
- rawSecretsMode: false,
208
+ const server = createAcpServer({
209
+ transport: createNdjsonTransport({ readable, writable }),
210
+ backend: driver,
485
211
  })
486
212
 
487
- recorder.record('sdk.message', sdkMsg)
488
- recorder.record('rpc.in', frame)
489
- await recorder.flushPending()
490
- recorder.close()
213
+ await server.serve()
491
214
  ```
492
215
 
493
- Stages: `rpc.in`, `rpc.out`, `sdk.message`, `sdk.error`, `tool.permission_request`, `tool.permission_response`, `session.config`, `driver.state_change`, `transport.spawn`, `transport.exit`.
216
+ The `codex` and `opencode` drivers follow the same shape; their constructors take backend-specific factories (`spawnFactory` for codex, no factory for opencode since the SDK helper handles it).
494
217
 
495
- Allow-list redaction masks `sk-ant-(api|oat|ort)*`, `Bearer`, `apiKey=value`, `kdz-*` patterns. Override via `KODIZM_DEBUG_RAW_SECRETS=1`.
218
+ ## Wire reference
496
219
 
497
- ### Heartbeat + inactivity (`@/server/heartbeat`)
220
+ ### JSON-RPC methods (inbound)
498
221
 
499
- ```ts
500
- import { HeartbeatTimer } from '@/server/heartbeat.ts'
222
+ | Method | Purpose |
223
+ |--------|---------|
224
+ | `initialize` | handshake; returns `protocolVersion` + `agentInfo` + `capabilities` |
225
+ | `session/new` | open a session; returns `{ sessionId }` |
226
+ | `session/prompt` | drive a turn; streams `sessionUpdate` notifications, returns `PromptResult` |
227
+ | `session/cancel` | abort the in-flight prompt for a session |
228
+ | `session/load` | resume a prior session by id (gated on `resume`) |
229
+ | `session/fork` | branch a session with optional overrides (gated on `fork`) |
230
+ | `session/compact` | trigger manual context compaction |
501
231
 
502
- const timer = new HeartbeatTimer({
503
- sessionId: 's1',
504
- intervalMs: 10_000,
505
- emit,
506
- getLastSdkMs: () => state.lastSdkMessageAt,
507
- })
508
- timer.start(Date.now())
509
- // ... prompt runs ...
510
- timer.stop()
511
- ```
232
+ `PromptResult.stopReason` is one of: `end_turn`, `cancelled`, `process_died`, `max_tokens`, `tool_use`, `session_failed`. When `session_failed`, the result also carries `failureReason` and `failureDetail`.
512
233
 
513
- `ClaudeDriver.prompt()` and `CodexDriver.prompt()` instantiate one timer per turn when `heartbeatIntervalMs` is set. The inactivity probe runs alongside; when the SDK message gap exceeds `inactivityThresholdMs`, the driver emits `session_failed { reason: 'sdk_stall' }` + aborts the per-turn controller.
234
+ ### `sessionUpdate` events
514
235
 
515
- ### Structured failures (`@/backends/{claude,codex,opencode}/error-classifier`)
236
+ The `SessionUpdateEvent` discriminated union has 21 variants:
516
237
 
517
- ```ts
518
- import { classifyClaudeError } from '@/backends/claude/error-classifier.ts'
519
- import { classifyCodexError } from '@/backends/codex/error-classifier.ts'
520
- import { classifyOpencodeError } from '@/backends/opencode/error-classifier.ts'
521
- import { shouldExitOnReason } from '@/util/exit-policy.ts'
522
-
523
- const classified = classifyOpencodeError(err)
524
- // { reason: 'auth_error' | 'rate_limit' | 'transport_error' | 'sdk_throw' | ..., detail }
525
-
526
- if (shouldExitOnReason(classified.reason)) {
527
- // sdk_stall, transport_error, internal_panic, protocol_violation -> exit
528
- } else {
529
- // sdk_throw, auth_error, rate_limit -> stay alive, orchestrator may retry
530
- }
531
- ```
238
+ | Type | When |
239
+ |------|------|
240
+ | `output_chunk` | streaming model output |
241
+ | `thinking_chunk` | streaming reasoning |
242
+ | `tool_call_begin` / `progress` / `end` | tool lifecycle (one begin + one end per call) |
243
+ | `permission_request` | model wants to run a gated tool |
244
+ | `permission_deferred` / `permission_resumed` | Pattern B lifecycle |
245
+ | `question_request` | model asks the user a question |
246
+ | `usage` | token + cost rollup |
247
+ | `subagent_spawn` / `subagent_complete` | nested agent lifecycle |
248
+ | `skill_activation` | a skill loaded mid-turn (claude only) |
249
+ | `model_advertisement` | the actual model the backend chose |
250
+ | `process_died` | subprocess crash (codex / opencode) |
251
+ | `cancelled` | turn was cancelled |
252
+ | `compaction_started` / `compaction_completed` | context compaction; `trigger: 'manual' \| 'auto'` |
253
+ | `debug_log` | one of 10 stages: `rpc.in`, `rpc.out`, `sdk.message`, `sdk.error`, `tool.permission_request`, `tool.permission_response`, `session.config`, `driver.state_change`, `transport.spawn`, `transport.exit` |
254
+ | `heartbeat` | periodic liveness signal |
255
+ | `session_failed` | structured lifecycle failure |
532
256
 
533
- ### Graceful shutdown (`@/server/shutdown`)
257
+ ### Outbound RPCs (server to orchestrator)
534
258
 
535
- ```ts
536
- import { runShutdown } from '@/server/shutdown.ts'
537
-
538
- const result = await runShutdown({
539
- graceMs: 3_000,
540
- flushRecorders: async () => Promise.all(activeRecorders.map((r) => r.flushPending())),
541
- flushTransport: () => transport.flush(),
542
- emitFinal: (e) => server.notify('sessionUpdate', e),
543
- finalReason: 'transport_error',
544
- finalDetail: 'SIGTERM received',
545
- finalSessionIds: [...activeSessionIds],
546
- })
547
- // result.timedOut, result.errors
548
- ```
259
+ | Method | When |
260
+ |--------|------|
261
+ | `sessionUpdate` | every event above flows as a notification |
262
+ | `session/request_permission` | orchestrator decides on a `permission_request` |
263
+ | `session/ask_user_question` | orchestrator answers a `question_request` |
264
+ | `session/dynamic_tool_call` | codex orchestrator-hosted tool dispatch |
265
+ | `session/codex_chatgpt_token_refresh` | codex chatgpt-mode token rotation |
266
+ | `session/permission_deferred_persist` | Pattern B write fallback when no `deferredStore` |
267
+ | `session/permission_deferred_state` | Pattern B read fallback when no `deferredStore` |
549
268
 
550
- Bin entrypoint installs SIGTERM + SIGINT handlers automatically:
269
+ The permission and ask-question RPC names have legacy aliases (`requestPermission`, `askUserQuestion`); both forms route to the same handler.
551
270
 
552
- ```ts
553
- import { installShutdownHook, registerActiveRecorder, registerShutdownFlusher } from 'kodizm-acp'
271
+ ## Cross-process resume (Pattern B)
554
272
 
555
- installShutdownHook()
556
- const dispose = registerActiveRecorder(recorder)
557
- registerShutdownFlusher(() => transport.flush())
558
- ```
273
+ A driver instance can resume a session that an earlier process started. Useful when a container restart, deploy, or crash interrupts an active turn.
559
274
 
560
- ### Tool policy grammar (`@/wire/policy`)
275
+ - **claude**: standard `session/load` against the SDK's resume mode; the JSONL transcript on disk is authoritative.
276
+ - **codex**: `CodexDriver.hydrateSession({ sessionId, codexThreadId, ... })` replays codex's `thread/resume` against the persisted threadId.
277
+ - **opencode**: `OpencodeDriver.loadSession({ sessionId, _meta: { opencodeSessionId } })`; opencode's SQLite persistence survives process death.
561
278
 
562
- | Canonical | Claude SDK | Codex profile | Opencode ruleset |
563
- |-----------|------------|---------------|------------------|
564
- | `Read` | `Read` | profile entry | `{permission: 'read', pattern: '*', action: ...}` |
565
- | `Read:/workspace/**` | `Read(/workspace/**)` | sandbox path | `{permission: 'read', pattern: '/workspace/**', action: ...}` |
566
- | `Bash:git commit*` | `Bash(git commit:*)` | exec rule | `{permission: 'bash', pattern: 'git commit*', action: ...}` |
567
- | `mcp:kodizm` | `mcp__kodizm` | mcp gate | `{permission: 'kodizm_*', pattern: '*', action: ...}` |
568
- | `mcp:kodizm/*` | `mcp__kodizm__*` | wildcard | `{permission: 'kodizm_*', pattern: '*', action: ...}` |
569
- | `mcp:kodizm/create_task` | `mcp__kodizm__create_task` | exact | `{permission: 'kodizm_create_task', pattern: '*', action: ...}` |
279
+ Deferred permissions (the orchestrator decided to "ask later" on a tool gate) are persisted via either an injected `DeferredPermissionStore` or the `session/permission_deferred_persist` outbound RPC. The next process picks up where the prior one left off and emits `permission_resumed`.
570
280
 
571
- ```ts
572
- import { parseCanonicalPattern } from '@/wire/policy.ts'
281
+ ## Failure handling
573
282
 
574
- const parsed = parseCanonicalPattern('Bash:git commit*')
575
- // { toolName: 'Bash', argPattern: 'git commit*' }
576
- ```
283
+ `session_failed` carries one of seven reasons:
577
284
 
578
- ### Error codes (`@/server/errors`)
579
-
580
- | Error | Code |
581
- |-------|------|
582
- | `MethodNotFoundError` / `MethodNotSupportedError` | -32601 |
583
- | `InvalidParamsError` / `SessionNotFoundError` | -32602 |
584
- | `InternalError` | -32603 |
585
- | `ProcessDiedError` | -32001 |
586
- | `CancelledError` | -32002 |
587
- | `BackendDriverError` | -32003 |
588
- | `AcpTimeoutError` | -32004 |
589
- | `AcpProtocolError` | -32005 |
590
- | `BackendStallError` | -32006 |
591
-
592
- ## Codex CLI specifics
593
-
594
- Codex's app-server protocol drifts from claude's SDK in subtle ways the driver normalizes:
595
-
596
- - **Field shape is camelCase v2**: `threadId`, `turnId`, `itemId`, `approvalId`, `cachedInputTokens`, `tokenUsage.total.*`. Pre-v2 snake_case (`thread_id`, `item_id`) and the legacy `conversationId` / `callId` shape from `applyPatchApproval` / `execCommandApproval` are auto-aliased in the approval pipeline.
597
- - **Item type discriminator is camelCase**: `commandExecution`, `fileChange`, `mcpToolCall`, `contextCompaction`, `collabAgentToolCall`. Driver accepts both `PascalCase` and `camelCase` for forward + back compat.
598
- - **Reasoning chunks travel on dedicated methods**, NOT via `subtype='reasoning'` on `agentMessage/delta`. The driver routes `item/reasoning/summaryTextDelta` + `item/reasoning/textDelta` to canonical `thinking_chunk`.
599
- - **`codex app-server` has NO `--config <path>` flag.** To inject `[mcp_servers.*]` etc., set `CODEX_HOME` to a temp dir + write `config.toml` there. Per-key overrides via `-c key=value`.
600
- - **ChatGPT-mode auth ignores `model` overrides** and silently keeps `gpt-5.5`. API-key auth honors them.
601
- - **Some features only expose with explicit feature flags**:
602
- - `features.default_mode_request_user_input=true` for `requestUserInput` tool
603
- - `features.request_permissions_tool=true` for permission grants
604
- - `features.multi_agent_v2=true` for `spawn_agent` (still account-tier gated; chatgpt-mode never invokes it organically regardless of Plus / Free)
605
-
606
- ## Opencode CLI specifics
607
-
608
- Opencode's HTTP server diverges from the Claude SDK + codex stdio model in several places worth knowing:
609
-
610
- - **Boot mode is the SDK helper, not the raw runtime.** `createOpencodeServer` from `@opencode-ai/sdk` spawns `opencode serve --port 0 --hostname 127.0.0.1` and returns `{url, close}` once the listening marker hits stdout. The driver wraps that into `OpencodeHttpBridge` so `bridge.start({env})` + `bridge.stop()` mirrors the codex per-session subprocess invariant.
611
- - **v2 `session.prompt` does NOT drive the LLM loop.** It queues a message but returns immediately. Always use v1 `sdk.session.prompt({sessionID, model: {providerID, modelID}, parts, ...})` for actual turns.
612
- - **SDK reply shapes are flat.** `sdk.permission.reply({requestID, reply, message?})`, `sdk.question.reply({requestID, answers})`, `sdk.question.reject({requestID})`. NOT `{id, body: {...}}` (that was an older draft shape).
613
- - **MCP tool naming differs from codex / Claude.** opencode uses `<sanitizedServer>_<tool>` (single underscore); canonical wire uses `mcp__<server>__<tool>` (double underscore). The driver maintains a per-session reverse map populated at `sdk.mcp.add` time and resolves opencode tool keys back to canonical names via longest-prefix-match.
614
- - **Tool lifecycle fires multiple `running` updates per call.** opencode emits `message.part.updated` with `state.status='running'` repeatedly as the tool's input record fills in. The event-mapper has `toolBegan` + `toolEnded` `Set<callID>` latches so canonical `tool_call_begin` + `tool_call_end` each fire exactly once per call.
615
- - **Permission ruleset must be passed at session creation.** `sdk.session.create({permission: buildOpencodeRuleset(toolPolicy)})`. opencode has no per-driver default mode; everything is per-session.
616
- - **Auth via `OPENCODE_AUTH_CONTENT` env, not file mutation.** The driver layers `_meta.opencodeAuth` onto `process.env` for the duration of `bridge.start()` then restores. The opencode subprocess inherits the env and reads it at boot.
617
- - **Canonical `<providerID>/<modelID>` model wire.** Examples: `opencode-go/deepseek-v4-flash`, `github-copilot/gpt-5-mini`, `anthropic/claude-haiku-4-5`. Driver splits on first `/`. No-slash falls back to opencode's default provider.
618
- - **`ContextOverflowError` maps to `transport_error`.** The canonical `SessionFailedReason` union has no `compaction_failure`; opencode's overflow signal lands on `transport_error` so the orchestrator's retry path handles it.
619
- - **No native sandbox primitive.** opencode does not have an `additional_directories` equivalent; the kodizm-ai-docker image's symlink layout fills the gap (Phase 5 hardening).
620
-
621
- ## Run tests
622
-
623
- Always run from the package directory (Bun 1.3.10 has a subprocess lifecycle quirk under `bun test` when invoked from a parent dir).
285
+ | Reason | Container action |
286
+ |--------|------------------|
287
+ | `sdk_stall` | exit |
288
+ | `transport_error` | exit |
289
+ | `internal_panic` | exit |
290
+ | `protocol_violation` | exit |
291
+ | `sdk_throw` | stay alive (orchestrator may retry) |
292
+ | `auth_error` | stay alive (orchestrator can refresh credentials) |
293
+ | `rate_limit` | stay alive (orchestrator backs off) |
624
294
 
625
- ```bash
626
- cd packages/kodizm-acp
627
-
628
- # Unit + e2e (mocked SDK + fake codex subprocess + real opencode bridge)
629
- bun test test/unit test/e2e
295
+ The exit decision lives in `src/util/exit-policy.ts`. The bin consults it after a `session_failed` and triggers graceful shutdown when true.
630
296
 
631
- # Real-CLI smoke (requires CLAUDE_CODE_OAUTH_TOKEN, codex login, OR opencode-go auth)
632
- bun test test/integration
297
+ ## Development
633
298
 
634
- # Specific real-codex suites
635
- bun test test/integration/codex-features.smoke.test.ts
636
- bun test test/integration/codex-thinking.smoke.test.ts
637
- bun test test/integration/codex-mcp-elicit.smoke.test.ts
638
-
639
- # Real-LLM opencode F1-F6 (uses opencode-go/deepseek-v4-flash, ~$0.05/run)
640
- bun test test/integration/opencode-features.smoke.test.ts
299
+ ```bash
300
+ bun install
301
+ bun test test/unit # mocked; ~600 tests
302
+ bun test test/e2e # full ACP roundtrip
303
+ bun test test/integration # real-API smokes (requires creds)
304
+ bunx tsc --noEmit # typecheck
305
+ bunx biome check --write src test # lint + format
306
+ bun run build # compile to dist/index.js
641
307
  ```
642
308
 
643
- Coverage at last run (ChatGPT Plus + Claude OAuth + opencode-go on host):
644
-
645
- | Layer | Tests | Source |
646
- |-------|------:|--------|
647
- | Unit + e2e | 600+ | `test/unit/`, `test/e2e/` |
648
- | Codex baseline real-CLI | 3 | `codex-real.smoke` |
649
- | Codex features F1-F19 real-CLI | 21 | `codex-features.smoke`, `codex-features-extended.smoke`, `codex-features-complete.smoke` |
650
- | Codex deep F20-F28 real-CLI | 9 | `codex-features-deep.smoke` |
651
- | Codex final A1-A4 + B1-B4 + C1-C4 (deterministic fake fixture) | 17 | `codex-features-final.smoke` |
652
- | Codex dedicated real-CLI (auth_error, cross-process, thinking, auth_refresh, mcp_elicit) | 5 | per-file smokes |
653
- | Opencode F1-F6 real-LLM strict-asserted | 5 + 1 skip-branch | `opencode-features.smoke` against `opencode-go/deepseek-v4-flash` |
654
-
655
- Opencode smoke verifies F1 token rollup (non-zero in/out + cost), F2 model_advertisement, F3 multi-turn memory recall, F4 askUserQuestion (`question_request` event + outbound RPC + RED/BLUE option labels), F5 permission allow (one-shot `permission_request` + bash `tool_call_begin` + `tool_call_end` carrying the `echo` stdout), F6 fork (parent + fork get distinct opencode session ids). 27 strict expects; reliability validated 3-of-3 sequential isolated runs on F4 + F5.
309
+ The integration suite gates each backend on its own auth probe; tests skip cleanly when credentials are not present.
656
310
 
657
311
  ## License
658
312
 
659
- Apache-2.0.
313
+ Apache-2.0. See `LICENSE`.