@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.
- package/README.md +216 -562
- package/dist/index.js +8227 -102
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,169 +1,162 @@
|
|
|
1
1
|
# kodizm-acp
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
##
|
|
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
|
-
##
|
|
50
|
+
## Install
|
|
56
51
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
### Environment variables
|
|
93
75
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
cwd: process.cwd(),
|
|
106
|
-
mcpServers: [],
|
|
107
|
-
model: 'claude-haiku-4-5-20251001',
|
|
108
|
-
})
|
|
92
|
+
### Session options (`NewSessionRequest`)
|
|
109
93
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
### Tool policy
|
|
123
119
|
|
|
124
120
|
```ts
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
129
|
+
The parser lives in `src/wire/policy.ts`. Each backend translates the canonical pattern grammar to its native rule shape.
|
|
146
130
|
|
|
147
|
-
|
|
131
|
+
### MCP servers
|
|
148
132
|
|
|
149
133
|
```ts
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
+
### Public exports
|
|
437
177
|
|
|
438
|
-
|
|
178
|
+
The bin (`src/index.ts`) is the only public surface. Importers get the runtime helpers, not the drivers themselves:
|
|
439
179
|
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
+
### Programmatic embedding
|
|
450
195
|
|
|
451
196
|
```ts
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
|
466
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
488
|
-
recorder.record('rpc.in', frame)
|
|
489
|
-
await recorder.flushPending()
|
|
490
|
-
recorder.close()
|
|
213
|
+
await server.serve()
|
|
491
214
|
```
|
|
492
215
|
|
|
493
|
-
|
|
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
|
-
|
|
218
|
+
## Wire reference
|
|
496
219
|
|
|
497
|
-
###
|
|
220
|
+
### JSON-RPC methods (inbound)
|
|
498
221
|
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
+
### `sessionUpdate` events
|
|
514
235
|
|
|
515
|
-
|
|
236
|
+
The `SessionUpdateEvent` discriminated union has 21 variants:
|
|
516
237
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
###
|
|
257
|
+
### Outbound RPCs (server to orchestrator)
|
|
534
258
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
269
|
+
The permission and ask-question RPC names have legacy aliases (`requestPermission`, `askUserQuestion`); both forms route to the same handler.
|
|
551
270
|
|
|
552
|
-
|
|
553
|
-
import { installShutdownHook, registerActiveRecorder, registerShutdownFlusher } from 'kodizm-acp'
|
|
271
|
+
## Cross-process resume (Pattern B)
|
|
554
272
|
|
|
555
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
572
|
-
import { parseCanonicalPattern } from '@/wire/policy.ts'
|
|
281
|
+
## Failure handling
|
|
573
282
|
|
|
574
|
-
|
|
575
|
-
// { toolName: 'Bash', argPattern: 'git commit*' }
|
|
576
|
-
```
|
|
283
|
+
`session_failed` carries one of seven reasons:
|
|
577
284
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
|
581
|
-
|
|
582
|
-
| `
|
|
583
|
-
| `
|
|
584
|
-
| `
|
|
585
|
-
| `
|
|
586
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
632
|
-
bun test test/integration
|
|
297
|
+
## Development
|
|
633
298
|
|
|
634
|
-
|
|
635
|
-
bun
|
|
636
|
-
bun test test/
|
|
637
|
-
bun test test/
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
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`.
|