@kodizm/acp 0.5.4 → 0.5.6
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 +219 -561
- package/dist/index.js +8239 -105
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,169 +1,166 @@
|
|
|
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
|
+
settingSources?: ('user' | 'project' | 'local')[] // claude only; opt-out
|
|
112
|
+
_meta?: Record<string, unknown> // passthrough; canonical fields rejected
|
|
113
|
+
}
|
|
116
114
|
```
|
|
117
115
|
|
|
118
|
-
|
|
116
|
+
> [!WARNING]
|
|
117
|
+
> `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.
|
|
119
118
|
|
|
120
|
-
|
|
119
|
+
> [!NOTE]
|
|
120
|
+
> `settingSources` is the claude-only opt-out for filesystem config layering. When omitted the SDK's own default fires, matching the standalone Claude Code CLI: project `CLAUDE.md` + `.claude/CLAUDE.md` + `.claude/rules/*.md` load from `cwd` walking up; user `~/.claude/CLAUDE.md` + `~/.claude/rules/*.md` and `CLAUDE.local.md` load too. Pass `[]` to disable every fs scope; pass a selective subset like `['project']` to load only project-tracked files. Codex (`AGENTS.md` from `cwd`) and opencode (`AGENTS.md` / `CLAUDE.md` / `CONTEXT.md` from session `directory`) have no parallel field and ignore this option.
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
### Tool policy
|
|
123
123
|
|
|
124
124
|
```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()
|
|
125
|
+
type ToolPolicy = {
|
|
126
|
+
defaultMode?: 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'bypassPermissions'
|
|
127
|
+
allow?: string[] // e.g. ['Read', 'Bash:git status', 'mcp:server/tool']
|
|
128
|
+
deny?: string[]
|
|
129
|
+
ask?: string[]
|
|
130
|
+
}
|
|
143
131
|
```
|
|
144
132
|
|
|
145
|
-
|
|
133
|
+
The parser lives in `src/wire/policy.ts`. Each backend translates the canonical pattern grammar to its native rule shape.
|
|
146
134
|
|
|
147
|
-
|
|
135
|
+
### MCP servers
|
|
148
136
|
|
|
149
137
|
```ts
|
|
150
|
-
|
|
151
|
-
|
|
138
|
+
type McpServer = {
|
|
139
|
+
type: 'http'
|
|
140
|
+
name: string
|
|
141
|
+
url: string
|
|
142
|
+
headers?: { name: string; value: string }[]
|
|
143
|
+
}
|
|
152
144
|
```
|
|
153
145
|
|
|
146
|
+
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.
|
|
147
|
+
|
|
148
|
+
## API reference
|
|
149
|
+
|
|
154
150
|
### `BackendDriver`
|
|
155
151
|
|
|
156
|
-
Every backend implements this contract
|
|
152
|
+
Every backend implements this contract:
|
|
157
153
|
|
|
158
154
|
```ts
|
|
159
155
|
interface BackendDriver {
|
|
156
|
+
capabilities(): DriverCapabilities
|
|
160
157
|
initialize(params: InitializeRequest): Promise<InitializeResult>
|
|
161
158
|
newSession(params: NewSessionRequest): Promise<NewSessionResult>
|
|
162
159
|
prompt(sessionId: string, params: PromptRequest, emit: EventEmitter): Promise<PromptResult>
|
|
163
160
|
cancel(request: CancelRequest): Promise<void>
|
|
164
161
|
loadSession(params: LoadSessionRequest): Promise<NewSessionResult>
|
|
165
162
|
forkSession(params: ForkSessionRequest): Promise<NewSessionResult>
|
|
166
|
-
|
|
163
|
+
compact(request: CompactSessionRequest, emit: EventEmitter): Promise<void>
|
|
167
164
|
}
|
|
168
165
|
|
|
169
166
|
interface DriverCapabilities {
|
|
@@ -176,484 +173,145 @@ interface DriverCapabilities {
|
|
|
176
173
|
debug: boolean
|
|
177
174
|
askQuestion: boolean
|
|
178
175
|
}
|
|
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
176
|
```
|
|
186
177
|
|
|
187
|
-
|
|
178
|
+
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.
|
|
188
179
|
|
|
189
|
-
|
|
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
|
-
```
|
|
180
|
+
### Public exports
|
|
209
181
|
|
|
210
|
-
|
|
182
|
+
The bin (`src/index.ts`) is the only public surface. Importers get the runtime helpers, not the drivers themselves:
|
|
211
183
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
)
|
|
218
|
-
|
|
184
|
+
| Export | Purpose |
|
|
185
|
+
|--------|---------|
|
|
186
|
+
| `SupportedBackend` | `'claude' \| 'codex' \| 'opencode'` |
|
|
187
|
+
| `BackendNotConfiguredError`, `UnknownBackendError` | startup-time errors |
|
|
188
|
+
| `resolveBackendFromEnv(env)` | parse `KODIZM_BACKEND` from a captured env |
|
|
189
|
+
| `installShutdownHook()` | wire SIGTERM + SIGINT to graceful shutdown |
|
|
190
|
+
| `performShutdown(graceMs?)` | run the shutdown side-effects manually |
|
|
191
|
+
| `registerActiveRecorder(r)` | track a `DebugRecorder` for shutdown flush |
|
|
192
|
+
| `registerShutdownFlusher(fn)` | register the transport flush callback |
|
|
193
|
+
| `SHUTDOWN_GRACE_MS` | default 3s grace budget |
|
|
194
|
+
| `main()` | the bin's entry function |
|
|
219
195
|
|
|
220
|
-
|
|
196
|
+
Drivers, wire types, and `createAcpServer` are internal. To embed programmatically, import them directly from their module paths under `src/`.
|
|
221
197
|
|
|
222
|
-
|
|
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.
|
|
198
|
+
### Programmatic embedding
|
|
230
199
|
|
|
231
200
|
```ts
|
|
232
|
-
import {
|
|
233
|
-
import {
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
```
|
|
427
|
-
|
|
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)
|
|
435
|
-
|
|
436
|
-
The opencode + codex bridges share the same `awaitPermissionResponse` race semantics; only the per-backend reply shape differs.
|
|
437
|
-
|
|
438
|
-
### Deferred permission, Pattern B (`@/backends/{claude,codex,opencode}/deferred-permission`)
|
|
439
|
-
|
|
440
|
-
Process A defers when `permissionDeferTimeoutMs` elapses without an answer:
|
|
441
|
-
|
|
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
|
-
```
|
|
448
|
-
|
|
449
|
-
Process B resumes when the orchestrator caches an answer:
|
|
450
|
-
|
|
451
|
-
```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'
|
|
201
|
+
import { ClaudeDriver } from '@kodizm/acp/src/backends/claude/driver.ts'
|
|
202
|
+
import { createAcpServer } from '@kodizm/acp/src/server/acp-server.ts'
|
|
203
|
+
import { createNdjsonTransport } from '@kodizm/acp/src/server/transport.ts'
|
|
204
|
+
import { query } from '@anthropic-ai/claude-agent-sdk'
|
|
463
205
|
|
|
464
206
|
const driver = new ClaudeDriver({
|
|
465
|
-
credentials
|
|
466
|
-
|
|
207
|
+
credentials: { type: 'subscription', token: process.env.CLAUDE_CODE_OAUTH_TOKEN! },
|
|
208
|
+
agentInfo: { version: '0.5.4' },
|
|
209
|
+
sdk: { query: ({ prompt, options }) => query({ prompt, options }) },
|
|
467
210
|
})
|
|
468
|
-
```
|
|
469
|
-
|
|
470
|
-
Without `deferredStore`, the driver issues `session/permission_deferred_persist` (write) + `session/permission_deferred_state` (read) outbound RPCs.
|
|
471
211
|
|
|
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,
|
|
212
|
+
const server = createAcpServer({
|
|
213
|
+
transport: createNdjsonTransport({ readable, writable }),
|
|
214
|
+
backend: driver,
|
|
485
215
|
})
|
|
486
216
|
|
|
487
|
-
|
|
488
|
-
recorder.record('rpc.in', frame)
|
|
489
|
-
await recorder.flushPending()
|
|
490
|
-
recorder.close()
|
|
217
|
+
await server.serve()
|
|
491
218
|
```
|
|
492
219
|
|
|
493
|
-
|
|
220
|
+
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
221
|
|
|
495
|
-
|
|
222
|
+
## Wire reference
|
|
496
223
|
|
|
497
|
-
###
|
|
224
|
+
### JSON-RPC methods (inbound)
|
|
498
225
|
|
|
499
|
-
|
|
500
|
-
|
|
226
|
+
| Method | Purpose |
|
|
227
|
+
|--------|---------|
|
|
228
|
+
| `initialize` | handshake; returns `protocolVersion` + `agentInfo` + `capabilities` |
|
|
229
|
+
| `session/new` | open a session; returns `{ sessionId }` |
|
|
230
|
+
| `session/prompt` | drive a turn; streams `sessionUpdate` notifications, returns `PromptResult` |
|
|
231
|
+
| `session/cancel` | abort the in-flight prompt for a session |
|
|
232
|
+
| `session/load` | resume a prior session by id (gated on `resume`) |
|
|
233
|
+
| `session/fork` | branch a session with optional overrides (gated on `fork`) |
|
|
234
|
+
| `session/compact` | trigger manual context compaction |
|
|
501
235
|
|
|
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
|
-
```
|
|
236
|
+
`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
237
|
|
|
513
|
-
|
|
238
|
+
### `sessionUpdate` events
|
|
514
239
|
|
|
515
|
-
|
|
240
|
+
The `SessionUpdateEvent` discriminated union has 21 variants:
|
|
516
241
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
242
|
+
| Type | When |
|
|
243
|
+
|------|------|
|
|
244
|
+
| `output_chunk` | streaming model output |
|
|
245
|
+
| `thinking_chunk` | streaming reasoning |
|
|
246
|
+
| `tool_call_begin` / `progress` / `end` | tool lifecycle (one begin + one end per call) |
|
|
247
|
+
| `permission_request` | model wants to run a gated tool |
|
|
248
|
+
| `permission_deferred` / `permission_resumed` | Pattern B lifecycle |
|
|
249
|
+
| `question_request` | model asks the user a question |
|
|
250
|
+
| `usage` | token + cost rollup |
|
|
251
|
+
| `subagent_spawn` / `subagent_complete` | nested agent lifecycle |
|
|
252
|
+
| `skill_activation` | a skill loaded mid-turn (claude only) |
|
|
253
|
+
| `model_advertisement` | the actual model the backend chose |
|
|
254
|
+
| `process_died` | subprocess crash (codex / opencode) |
|
|
255
|
+
| `cancelled` | turn was cancelled |
|
|
256
|
+
| `compaction_started` / `compaction_completed` | context compaction; `trigger: 'manual' \| 'auto'` |
|
|
257
|
+
| `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` |
|
|
258
|
+
| `heartbeat` | periodic liveness signal |
|
|
259
|
+
| `session_failed` | structured lifecycle failure |
|
|
532
260
|
|
|
533
|
-
###
|
|
261
|
+
### Outbound RPCs (server to orchestrator)
|
|
534
262
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
finalDetail: 'SIGTERM received',
|
|
545
|
-
finalSessionIds: [...activeSessionIds],
|
|
546
|
-
})
|
|
547
|
-
// result.timedOut, result.errors
|
|
548
|
-
```
|
|
263
|
+
| Method | When |
|
|
264
|
+
|--------|------|
|
|
265
|
+
| `sessionUpdate` | every event above flows as a notification |
|
|
266
|
+
| `session/request_permission` | orchestrator decides on a `permission_request` |
|
|
267
|
+
| `session/ask_user_question` | orchestrator answers a `question_request` |
|
|
268
|
+
| `session/dynamic_tool_call` | codex orchestrator-hosted tool dispatch |
|
|
269
|
+
| `session/codex_chatgpt_token_refresh` | codex chatgpt-mode token rotation |
|
|
270
|
+
| `session/permission_deferred_persist` | Pattern B write fallback when no `deferredStore` |
|
|
271
|
+
| `session/permission_deferred_state` | Pattern B read fallback when no `deferredStore` |
|
|
549
272
|
|
|
550
|
-
|
|
273
|
+
The permission and ask-question RPC names have legacy aliases (`requestPermission`, `askUserQuestion`); both forms route to the same handler.
|
|
551
274
|
|
|
552
|
-
|
|
553
|
-
import { installShutdownHook, registerActiveRecorder, registerShutdownFlusher } from 'kodizm-acp'
|
|
275
|
+
## Cross-process resume (Pattern B)
|
|
554
276
|
|
|
555
|
-
|
|
556
|
-
const dispose = registerActiveRecorder(recorder)
|
|
557
|
-
registerShutdownFlusher(() => transport.flush())
|
|
558
|
-
```
|
|
277
|
+
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
278
|
|
|
560
|
-
|
|
279
|
+
- **claude**: standard `session/load` against the SDK's resume mode; the JSONL transcript on disk is authoritative.
|
|
280
|
+
- **codex**: `CodexDriver.hydrateSession({ sessionId, codexThreadId, ... })` replays codex's `thread/resume` against the persisted threadId.
|
|
281
|
+
- **opencode**: `OpencodeDriver.loadSession({ sessionId, _meta: { opencodeSessionId } })`; opencode's SQLite persistence survives process death.
|
|
561
282
|
|
|
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: ...}` |
|
|
283
|
+
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
284
|
|
|
571
|
-
|
|
572
|
-
import { parseCanonicalPattern } from '@/wire/policy.ts'
|
|
285
|
+
## Failure handling
|
|
573
286
|
|
|
574
|
-
|
|
575
|
-
// { toolName: 'Bash', argPattern: 'git commit*' }
|
|
576
|
-
```
|
|
287
|
+
`session_failed` carries one of seven reasons:
|
|
577
288
|
|
|
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).
|
|
289
|
+
| Reason | Container action |
|
|
290
|
+
|--------|------------------|
|
|
291
|
+
| `sdk_stall` | exit |
|
|
292
|
+
| `transport_error` | exit |
|
|
293
|
+
| `internal_panic` | exit |
|
|
294
|
+
| `protocol_violation` | exit |
|
|
295
|
+
| `sdk_throw` | stay alive (orchestrator may retry) |
|
|
296
|
+
| `auth_error` | stay alive (orchestrator can refresh credentials) |
|
|
297
|
+
| `rate_limit` | stay alive (orchestrator backs off) |
|
|
624
298
|
|
|
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
|
|
299
|
+
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
300
|
|
|
631
|
-
|
|
632
|
-
bun test test/integration
|
|
301
|
+
## Development
|
|
633
302
|
|
|
634
|
-
|
|
635
|
-
bun
|
|
636
|
-
bun test test/
|
|
637
|
-
bun test test/
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
303
|
+
```bash
|
|
304
|
+
bun install
|
|
305
|
+
bun test test/unit # mocked; ~600 tests
|
|
306
|
+
bun test test/e2e # full ACP roundtrip
|
|
307
|
+
bun test test/integration # real-API smokes (requires creds)
|
|
308
|
+
bunx tsc --noEmit # typecheck
|
|
309
|
+
bunx biome check --write src test # lint + format
|
|
310
|
+
bun run build # compile to dist/index.js
|
|
641
311
|
```
|
|
642
312
|
|
|
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.
|
|
313
|
+
The integration suite gates each backend on its own auth probe; tests skip cleanly when credentials are not present.
|
|
656
314
|
|
|
657
315
|
## License
|
|
658
316
|
|
|
659
|
-
Apache-2.0.
|
|
317
|
+
Apache-2.0. See `LICENSE`.
|