# kodizm-acp

ACP bridge that drives the Claude Code and opencode CLIs through one canonical wire.

## What it is

`@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.

Two CLIs are supported: Claude Code (via `@anthropic-ai/claude-agent-sdk`) as the primary backend, and opencode (via `createOpencodeServer` from `@opencode-ai/sdk`) as the secondary one.

## How it works

```
+------------------+    JSON-RPC over    +------------------+    native protocol    +-----------+
|   Orchestrator   | <----- NDJSON ----> |    AcpServer     | <-------------------> |  Backend  |
|   (Kodizm core)  |     (stdio/pipe)    |  + BackendDriver |    (SDK / subproc)    |    CLI    |
+------------------+                     +------------------+                       +-----------+
```

1. `KODIZM_BACKEND` selects the driver at process boot. One process per backend.
2. `AcpServer` validates every inbound request against the canonical schema, then routes to the `BackendDriver` interface.
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.

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.

## Backend support

| Feature | Claude | opencode |
|---------|:------:|:--------:|
| `session/new`, `session/prompt`, `session/cancel` | yes | yes |
| `session/load` (resume) | yes | yes |
| `session/fork` | yes | yes |
| `session/compact` (manual) | yes | yes |
| Image content blocks | yes | yes |
| Token + cost rollup (`usage` event) | yes | yes |
| `additionalDirectories` (sandbox) | yes | n/a |
| `mcpServers` injection | yes | yes |
| `systemPrompt` replace + append | yes | yes |
| `skills` pre-load | yes | n/a |
| Permissions (`permission_request`) | yes | yes |
| `askUserQuestion` | yes | yes |
| Subagent events | yes | yes |
| Thinking events | yes | yes |
| Cross-process Pattern B resume | yes | yes |
| Debug capture | yes | yes |

> [!NOTE]
> The published bin (`kodizm-acp` from `dist/index.js`) wires both backends: set `KODIZM_BACKEND` to `claude` or `opencode` and the bin resolves the matching driver itself.

## Install

```bash
bun add @kodizm/acp
```

Requires Bun >= 1.1.0. The `opencode` CLI must be installed separately when you use that backend.

## Quick start

The bin runs over stdio:

```bash
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}' \
  | KODIZM_BACKEND=claude \
    CLAUDE_CODE_OAUTH_TOKEN="sk-ant-oat01-..." \
    CLAUDE_CODE_REMOTE=1 \
    bunx @kodizm/acp
```

Replace the credential pair with `ANTHROPIC_API_KEY=...` for the api-key path.

## Configuration

### Environment variables

| Variable | Required | Description |
|----------|:--------:|-------------|
| `KODIZM_BACKEND` | yes | `claude` / `opencode` |
| `CLAUDE_CODE_OAUTH_TOKEN` + `CLAUDE_CODE_REMOTE=1` | claude (sub) | Subscription auth |
| `ANTHROPIC_API_KEY` | claude (api) | API key auth |
| `CLAUDE_CODE_PATH` | optional | Path to claude binary (default `/usr/local/bin/claude`) |
| `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` |
| `KODIZM_LOG_LEVEL` | optional | `debug` / `info` / `warn` / `error`. Default `info` |
| `KODIZM_DEBUG` | optional | `1` enables process-wide debug capture |
| `KODIZM_DEBUG_DIR` | optional | Forensic JSONL dir, default `/tmp/kodizm-debug` |
| `KODIZM_DEBUG_RAW_SECRETS` | incident-only | `1` disables redaction. Never set in production |
| `KODIZM_ACP_FORWARD_STDERR` | optional | `1` tees a spawned backend subprocess's stderr to parent stderr |

Stdout is reserved for ACP frames. Logs go to stderr.

### Session options (`NewSessionRequest`)

```ts
type NewSessionRequest = {
  cwd: string                        // absolute path
  mcpServers: McpServer[]
  additionalDirectories?: string[]   // absolute paths
  systemPrompt?: string | { append: string }
  model?: string                     // e.g. 'claude-haiku-4-5-20251001'
  skills?: string[]                  // claude only
  toolPolicy?: ToolPolicy
  autoCompact?: boolean
  permissionTimeoutMs?: number       // mutually exclusive with permissionDeferTimeoutMs
  permissionDeferTimeoutMs?: number
  debug?: boolean
  debugCaptureRawSdk?: boolean
  debugCaptureRpc?: boolean
  heartbeatIntervalMs?: number
  inactivityThresholdMs?: number
  settingSources?: ('user' | 'project' | 'local')[]  // claude only; opt-out
  _meta?: Record<string, unknown>    // passthrough; canonical fields rejected
}
```

> [!WARNING]
> `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.

> [!NOTE]
> `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. Opencode (`AGENTS.md` / `CLAUDE.md` / `CONTEXT.md` from session `directory`) has no parallel field and ignores this option.

### Tool policy

```ts
type ToolPolicy = {
  defaultMode?: 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'bypassPermissions'
  allow?: string[]   // e.g. ['Read', 'Bash:git status', 'mcp:server/tool']
  deny?: string[]
  ask?: string[]
}
```

The parser lives in `src/wire/policy.ts`. Each backend translates the canonical pattern grammar to its native rule shape.

### MCP servers

```ts
type McpServer = {
  type: 'http'
  name: string
  url: string
  headers?: { name: string; value: string }[]
}
```

The `http` transport ships today. Opencode adds servers via `sdk.mcp.add` per session.

## API reference

### `BackendDriver`

Every backend implements this contract:

```ts
interface BackendDriver {
  capabilities(): DriverCapabilities
  initialize(params: InitializeRequest): Promise<InitializeResult>
  newSession(params: NewSessionRequest): Promise<NewSessionResult>
  prompt(sessionId: string, params: PromptRequest, emit: EventEmitter): Promise<PromptResult>
  cancel(request: CancelRequest): Promise<void>
  loadSession(params: LoadSessionRequest): Promise<NewSessionResult>
  forkSession(params: ForkSessionRequest): Promise<NewSessionResult>
  compact(request: CompactSessionRequest, emit: EventEmitter): Promise<void>
}

interface DriverCapabilities {
  resume: boolean
  fork: boolean
  fileUpload: boolean
  thinking: boolean
  subagent: boolean
  skillEvents: boolean
  debug: boolean
  askQuestion: boolean
}
```

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.

### Public exports

The bin (`src/index.ts`) is the only public surface. Importers get the runtime helpers, not the drivers themselves:

| Export | Purpose |
|--------|---------|
| `SupportedBackend` | `'claude' \| 'opencode'` |
| `BackendNotConfiguredError`, `UnknownBackendError` | startup-time errors |
| `resolveBackendFromEnv(env)` | parse `KODIZM_BACKEND` from a captured env |
| `installShutdownHook()` | wire SIGTERM + SIGINT to graceful shutdown |
| `performShutdown(graceMs?)` | run the shutdown side-effects manually |
| `registerActiveRecorder(r)` | track a `DebugRecorder` for shutdown flush |
| `registerShutdownFlusher(fn)` | register the transport flush callback |
| `SHUTDOWN_GRACE_MS` | default 3s grace budget |
| `main()` | the bin's entry function |

Drivers, wire types, and `createAcpServer` are internal. To embed programmatically, import them directly from their module paths under `src/`.

### Programmatic embedding

```ts
import { ClaudeDriver } from '@kodizm/acp/src/backends/claude/driver.ts'
import { createAcpServer } from '@kodizm/acp/src/server/acp-server.ts'
import { createNdjsonTransport } from '@kodizm/acp/src/server/transport.ts'
import { query } from '@anthropic-ai/claude-agent-sdk'

const driver = new ClaudeDriver({
  credentials: { type: 'subscription', token: process.env.CLAUDE_CODE_OAUTH_TOKEN! },
  agentInfo: { version: '0.5.4' },
  sdk: { query: ({ prompt, options }) => query({ prompt, options }) },
})

const server = createAcpServer({
  transport: createNdjsonTransport({ readable, writable }),
  backend: driver,
})

await server.serve()
```

The `opencode` driver follows the same shape; it takes no spawn factory, since the SDK helper handles the subprocess.

## Wire reference

### JSON-RPC methods (inbound)

| Method | Purpose |
|--------|---------|
| `initialize` | handshake; returns `protocolVersion` + `agentInfo` + `capabilities` |
| `session/new` | open a session; returns `{ sessionId }` |
| `session/prompt` | drive a turn; streams `sessionUpdate` notifications, returns `PromptResult` |
| `session/cancel` | abort the in-flight prompt for a session |
| `session/load` | resume a prior session by id (gated on `resume`) |
| `session/fork` | branch a session with optional overrides (gated on `fork`) |
| `session/compact` | trigger manual context compaction |

`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`.

### `sessionUpdate` events

The `SessionUpdateEvent` discriminated union has 21 variants:

| Type | When |
|------|------|
| `output_chunk` | streaming model output |
| `thinking_chunk` | streaming reasoning |
| `tool_call_begin` / `progress` / `end` | tool lifecycle (one begin + one end per call) |
| `permission_request` | model wants to run a gated tool |
| `permission_deferred` / `permission_resumed` | Pattern B lifecycle |
| `question_request` | model asks the user a question |
| `usage` | token + cost rollup |
| `subagent_spawn` / `subagent_complete` | nested agent lifecycle |
| `skill_activation` | a skill loaded mid-turn (claude only) |
| `model_advertisement` | the actual model the backend chose |
| `process_died` | subprocess crash (opencode) |
| `cancelled` | turn was cancelled |
| `compaction_started` / `compaction_completed` | context compaction; `trigger: 'manual' \| 'auto'` |
| `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` |
| `heartbeat` | periodic liveness signal |
| `session_failed` | structured lifecycle failure |

### Outbound RPCs (server to orchestrator)

| Method | When |
|--------|------|
| `sessionUpdate` | every event above flows as a notification |
| `session/request_permission` | orchestrator decides on a `permission_request` |
| `session/ask_user_question` | orchestrator answers a `question_request` |
| `session/permission_deferred_persist` | Pattern B write fallback when no `deferredStore` |
| `session/permission_deferred_state` | Pattern B read fallback when no `deferredStore` |

The permission and ask-question RPC names have legacy aliases (`requestPermission`, `askUserQuestion`); both forms route to the same handler.

## Cross-process resume (Pattern B)

A driver instance can resume a session that an earlier process started. Useful when a container restart, deploy, or crash interrupts an active turn.

- **claude**: standard `session/load` against the SDK's resume mode; the JSONL transcript on disk is authoritative.
- **opencode**: `OpencodeDriver.loadSession({ sessionId, _meta: { opencodeSessionId } })`; opencode's SQLite persistence survives process death.

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`.

## Failure handling

`session_failed` carries one of seven reasons:

| Reason | Container action |
|--------|------------------|
| `sdk_stall` | exit |
| `transport_error` | exit |
| `internal_panic` | exit |
| `protocol_violation` | exit |
| `sdk_throw` | stay alive (orchestrator may retry) |
| `auth_error` | stay alive (orchestrator can refresh credentials) |
| `rate_limit` | stay alive (orchestrator backs off) |

The exit decision lives in `src/util/exit-policy.ts`. The bin consults it after a `session_failed` and triggers graceful shutdown when true.

## Development

```bash
bun install
bun test test/unit                       # mocked; ~600 tests
bun test test/e2e                        # full ACP roundtrip
bun test test/integration                # real-API smokes (requires creds)
bunx tsc --noEmit                        # typecheck
bunx biome check --write src test        # lint + format
bun run build                            # compile to dist/index.js
```

The integration suite gates each backend on its own auth probe; tests skip cleanly when credentials are not present.

## License

Apache-2.0. See `LICENSE`.
