docs: restructure docs into diataxis (#121)

This commit is contained in:
banteg
2026-01-13 15:59:27 +04:00
committed by GitHub
parent d0e9a51a0f
commit e292c99ab0
52 changed files with 1538 additions and 1255 deletions
+382
View File
@@ -0,0 +1,382 @@
Below is a concrete implementation spec for the **Anthropic Claude Code (“claude” CLI / Agent SDK runtime)** runner shipped in Takopi (v0.3.0).
---
## Scope
### Goal
Provide the **`claude`** engine backend so Takopi can:
* Run Claude Code non-interactively via the **Agent SDK CLI** (`claude -p`). ([Claude Code][1])
* Stream progress in Telegram by parsing **`--output-format stream-json --verbose`** (newline-delimited JSON). Note: `--output-format` only works with `-p/--print`. ([Claude Code][1])
* Support resumable sessions via **`--resume <session_id>`** (Takopi emits a canonical resume line the user can reply with). ([Claude Code][1])
### Non-goals (v1)
* Interactive Q&A inside a single run (e.g., answering `AskUserQuestion` prompts mid-flight).
* Full “slash commands” integration (Claude Code docs note many slash commands are interactive-only). ([Claude Code][1])
* MCP prompt-handling for permissions (use allow rules instead).
---
## UX and behavior
### Engine selection
* Default: `takopi` (auto-router uses `default_engine` from config)
* Override: `takopi claude`
Takopi runs in auto-router mode by default; `takopi claude` or `/claude` selects
Claude for new threads.
### Resume UX (canonical line)
Takopi appends a **single backticked** resume line at the end of the message, like:
```text
`claude --resume 8b2d2b30-...`
```
Rationale:
* Claude Code supports resuming a specific conversation by session ID with `--resume`. ([Claude Code][1])
* The CLI reference also documents `--resume/-r` as the resume mechanism.
Takopi should parse either:
* `claude --resume <id>`
* `claude -r <id>` (short form from docs)
**Note:** Claude session IDs should be treated as **opaque strings**. Do not assume UUID format.
### Permissions / non-interactive runs
In `-p` mode, Claude Code can require tool approvals. Takopi cannot click/answer interactive prompts, so **users must preconfigure permissions** (via Claude Code settings or `--allowedTools`). Claudes settings system supports allow/deny tool rules. ([Claude Code][2])
**Safety note:** `-p/--print` skips the workspace trust dialog; only use this flag in trusted directories.
Takopi should document this clearly: if permissions arent configured and Claude tries to use a gated tool, the run may block or fail.
---
## Config additions
Takopi config lives at `~/.takopi/takopi.toml`.
Add a new optional `[claude]` section.
Recommended v1 schema:
```toml
# ~/.takopi/takopi.toml
default_engine = "claude"
[claude]
model = "claude-sonnet-4-5-20250929" # optional (Claude Code supports model override in settings too)
allowed_tools = ["Bash", "Read", "Edit", "Write"] # optional but strongly recommended for automation
dangerously_skip_permissions = false # optional (high risk; prefer sandbox use only)
use_api_billing = false # optional (keep ANTHROPIC_API_KEY for API billing)
```
Notes:
* `--allowedTools` exists specifically to auto-approve tools in programmatic runs. ([Claude Code][1])
* Claude Code tools (Bash/Edit/Write/WebSearch/etc.) and whether permission is required are documented. ([Claude Code][2])
* If `allowed_tools` is omitted, Takopi defaults to `["Bash", "Read", "Edit", "Write"]`.
* Takopi only reads `model`, `allowed_tools`, `dangerously_skip_permissions`, and `use_api_billing` from `[claude]`.
* By default Takopi strips `ANTHROPIC_API_KEY` from the subprocess environment so Claude uses subscription billing. Set `use_api_billing = true` to keep the key.
---
## Code changes (by file)
### 1) New file: `src/takopi/runners/claude.py`
#### Backend export
Expose a module-level `BACKEND = EngineBackend(...)` (from `takopi.backends`).
Takopi auto-discovers runners by importing `takopi.runners.*` and looking for
`BACKEND`.
`BACKEND` should provide:
* Engine id: `"claude"`
* `install_cmd`:
* Install command for `claude` (used by onboarding when missing on PATH).
* Error message should include official install options and “run `claude` once to authenticate”.
* Install methods include install scripts, Homebrew, and npm. ([Claude Code][4])
* Agent SDK / CLI can use Claude Code authentication from running `claude`, or API key auth. ([Claude][5])
* `build_runner()` should parse `[claude]` config and instantiate `ClaudeRunner`.
#### Runner implementation
Implement a new `Runner`:
#### Public API
* `engine: EngineId = "claude"`
* `format_resume(token) -> str`: returns `` `claude --resume {token}` ``
* `extract_resume(text) -> ResumeToken | None`: parse last match of `--resume/-r`
* `is_resume_line(line) -> bool`: matches the above patterns
* `run(prompt, resume)` async generator of `TakopiEvent`
#### Subprocess invocation
Use Agent SDK CLI non-interactively:
Core invocation:
* `claude -p --output-format stream-json --verbose` ([Claude Code][1])
* `--verbose` overrides config and is required for full stream-json output.
Resume:
* add `--resume <session_id>` if resuming. ([Claude Code][1])
Model:
* add `--model <name>` if configured. ([Claude Code][1])
Permissions:
* add `--allowedTools "<rules>"` if configured. ([Claude Code][1])
* add `--dangerously-skip-permissions` only if explicitly enabled (high risk; document clearly).
Prompt passing:
* Pass the prompt as the final positional argument after `--` (CLI expects `prompt` as an argument). This also protects prompts that begin with `-`. ([Claude Code][1])
Other flags:
* Claude exposes more CLI flags, but Takopi does not surface them in config.
#### Stream parsing
In stream-json mode, Claude emits newline-delimited JSON objects. ([Claude Code][1])
Per the official Agent SDK TypeScript reference, message types include:
* `system` with `subtype: 'init'` and fields like `session_id`, `cwd`, `tools`, `model`, `permissionMode`, `output_style`. ([Claude Code][3])
* `assistant` / `user` messages with Anthropic SDK message objects. ([Claude Code][3])
* final `result` message with:
* `subtype: 'success'` or error subtype(s),
* `is_error`,
* `result` (string on success),
* `usage`, `total_cost_usd`, `modelUsage`,
* `errors` list on failures,
* `permission_denials`. ([Claude Code][3])
Takopi should:
* Parse each line as JSON; on decode error emit a warning ActionEvent (like CodexRunner does) and continue.
* Prefer stdout for JSON; log stderr separately (do not merge).
* Treat unknown top-level fields (e.g., `parent_tool_use_id`) as optional metadata and ignore them unless needed.
#### Mapping to Takopi events
**StartedEvent**
* Emit upon first `system/init` message:
* `resume = ResumeToken(engine="claude", value=session_id)`
(treat `session_id` as opaque; do not validate as UUID)
* `title = model` (or user-specified config title; default `"claude"`)
* `meta` should include `cwd`, `tools`, `permissionMode`, `output_style` for debugging.
**Action events (progress)**
The core useful progress comes from tool usage.
Claude Code tools list is documented (Bash/Edit/Write/WebSearch/WebFetch/TodoWrite/Task/etc.). ([Claude Code][2])
Strategy:
* When you see an **assistant message** with a content block `type: "tool_use"`:
* Emit `ActionEvent(phase="started")` with:
* `action.id = tool_use.id`
* `action.kind` based on tool name (complete mapping):
* `Bash``command`
* `Edit`/`Write`/`NotebookEdit``file_change` (best-effort path extraction)
* `Read``tool`
* `Glob`/`Grep``tool`
* `WebSearch`/`WebFetch``web_search`
* `TodoWrite`/`TodoRead``note`
* `AskUserQuestion``note`
* `Task`/`Agent``tool`
* `KillShell``command`
* otherwise → `tool`
* `action.title`:
* Bash: use `input.command` if present
* Read/Write/Edit/NotebookEdit: use file path (best-effort; field may be `file_path` or `path`)
* Glob/Grep: use pattern
* WebSearch: use query
* WebFetch: use URL
* TodoWrite/TodoRead: short summary (e.g., “update todos”)
* AskUserQuestion: short summary (e.g., “ask user”)
* otherwise: tool name
* `detail` includes a compacted copy of input (or a safe summary).
* When you see a **user message** with a content block `type: "tool_result"`:
* Emit `ActionEvent(phase="completed")` for `tool_use_id`
* `ok = not is_error`
* `content` may be a string or an array of content blocks; normalize to a string for summaries
* `detail` includes a small summary (char count / first line / “(truncated)”)
This mirrors CodexRunners “started → completed” item tracking and renders well in existing `TakopiProgressRenderer`.
**CompletedEvent**
* Emit on `result` message:
* `ok = (is_error == false)` (treat `is_error` as authoritative; `subtype` is informational)
* `answer = result` on success; on error, a concise message using `errors` and/or denials
* `usage` attach:
* `total_cost_usd`, `usage`, `modelUsage`, `duration_ms`, `duration_api_ms`, `num_turns` ([Claude Code][3])
* Always include `resume` (same session_id).
* Emit exactly one completed event per run. After emitting it, ignore any
trailing JSON lines (do not emit a second completion).
* We do not use an idle-timeout completion; completion is driven by Claudes
`result` event or process exit handling.
**Permission denials**
Because result includes `permission_denials`, optionally emit warning ActionEvent(s) *before* CompletedEvent (CompletedEvent must be final):
* kind: `warning`
* title: “permission denied: <tool_name>”
This preserves the “warnings before started/completed” ordering principle Takopi already tests for CodexRunner.
#### Session serialization / locks
Must match Takopi runner contract:
* Lock key: `claude:<session_id>` (string) in a `WeakValueDictionary` of `anyio.Lock`.
* When resuming:
* acquire lock before spawning subprocess.
* When starting a new session:
* you dont know session_id until `system/init`, so:
* spawn process,
* wait until the **first** `system/init`,
* acquire lock for that session id **before** yielding StartedEvent,
* then continue yielding.
This mirrors CodexRunners correct behavior and ensures “new run + resume run” serialize once the session is known.
Assumption: Claude emits a single `system/init` per run. If multiple `init`
events arrive, ignore the subsequent ones (do not attempt to re-lock).
#### Cancellation / termination
Reuse the existing subprocess lifecycle pattern (like `CodexRunner.manage_subprocess`):
* Kill the process group on cancellation
* Drain stderr concurrently (log-only)
* Ensure locks release in `finally`
## Documentation updates
### README
Add a “Claude Code engine” section that covers:
* Installation (install script / brew / npm). ([Claude Code][4])
* Authentication:
* run `claude` once and follow prompts, or use API key auth (Agent SDK docs mention `ANTHROPIC_API_KEY`). ([Claude][5])
* Non-interactive permission caveat + how to configure:
* settings allow/deny rules,
* or `--allowedTools` / `[claude].allowed_tools`. ([Claude Code][2])
* Resume format: `` `claude --resume <id>` ``.
### `docs/developing.md`
Extend “Adding a Runner” with:
* “ClaudeRunner parses Agent SDK stream-json output”
* Mention key message types and the init/result messages.
---
## Test plan
Mirror the existing `CodexRunner` tests patterns.
### New tests: `tests/test_claude_runner.py`
1. **Contract & locking**
* `test_run_serializes_same_session` (stub `run_impl` like Codex tests)
* `test_run_allows_parallel_new_sessions`
* `test_run_serializes_new_session_after_session_is_known`:
* Provide a fake `claude` executable in tmp_path that:
* prints system/init with session_id,
* then waits on a file gate,
* a second invocation with `--resume` writes a marker file and exits,
* assert the resume invocation doesnt run until gate opens.
2. **Resume parsing**
* `format_resume` returns `claude --resume <id>`
* `extract_resume` handles both `--resume` and `-r`
3. **Translation / event ordering**
* Fake `claude` outputs:
* system/init
* assistant tool_use (Bash)
* user tool_result
* result success with `result: "ok"`
* Assert Takopi yields:
* StartedEvent
* ActionEvent started
* ActionEvent completed
* CompletedEvent(ok=True, answer="ok")
4. **Failure modes**
* `result` subtype error with `errors: [...]`:
* CompletedEvent(ok=False)
* permission_denials exist:
* warning ActionEvent(s) emitted before CompletedEvent
5. **Cancellation**
* Stub `claude` that sleeps; ensure cancellation kills it (pattern already used for codex subprocess cancellation tests).
---
## Implementation checklist (v0.3.0)
* [x] Export `BACKEND = EngineBackend(...)` from `src/takopi/runners/claude.py`.
* [x] Add `src/takopi/runners/claude.py` implementing the `Runner` protocol.
* [x] Add tests + stub executable fixtures.
* [x] Update README and developing docs.
* [ ] Run full test suite before release.
---
If you want, I can also propose the exact **event-to-action mapping table** (tool → kind/title/detail rules) you should start with, based on Claude Codes documented tool list (Bash/Edit/Write/WebSearch/etc.). ([Claude Code][2])
[1]: https://code.claude.com/docs/en/headless "Run Claude Code programmatically - Claude Code Docs"
[2]: https://code.claude.com/docs/en/settings "Claude Code settings - Claude Code Docs"
[3]: https://code.claude.com/docs/en/sdk/sdk-typescript "Agent SDK reference - TypeScript - Claude Docs"
[4]: https://code.claude.com/docs/en/quickstart "Quickstart - Claude Code Docs"
[5]: https://platform.claude.com/docs/en/agent-sdk/quickstart "Quickstart - Claude Docs"
@@ -0,0 +1,108 @@
# Claude `stream-json` event cheatsheet
`claude -p --output-format stream-json --verbose` writes **one JSON object per line**
(JSONL) with a required `type` field. (`--output-format` only works with `-p`.)
This cheatsheet is derived from `humanlayer/claudecode-go/types.go` and
`client_test.go`.
## Top-level event lines
### `system` (init)
Fields:
- `type`: `"system"`
- `subtype`: `"init"`
- `session_id`
- `tools`: array of tool names
- `mcp_servers`: array of `{name, status}`
- `cwd`, `model`, `permissionMode`, `apiKeySource` (optional)
Example:
```json
{"type":"system","subtype":"init","session_id":"session_01","cwd":"/repo","model":"sonnet","permissionMode":"auto","apiKeySource":"env","tools":["Bash","Read","Write","WebSearch"],"mcp_servers":[{"name":"approvals","status":"connected"}]}
```
### `assistant` / `user`
Fields:
- `type`: `"assistant"` or `"user"`
- `session_id`
- `message` (see below)
Example (assistant text):
```json
{"type":"assistant","session_id":"session_01","message":{"id":"msg_1","type":"message","role":"assistant","content":[{"type":"text","text":"Planning next steps."}],"usage":{"input_tokens":120,"output_tokens":45}}}
```
Example (assistant tool use):
```json
{"type":"assistant","session_id":"session_01","message":{"id":"msg_2","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls -la"}}]}}
```
Example (user tool result, string content):
```json
{"type":"user","session_id":"session_01","message":{"id":"msg_3","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"total 2\nREADME.md\nsrc\n"}]}}
```
Example (user tool result, array content):
```json
{"type":"user","session_id":"session_01","message":{"id":"msg_4","type":"message","role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_2","content":[{"type":"text","text":"Task completed"}]}]}}
```
Optional parent field (for nested tool usage):
```json
{"type":"assistant","parent_tool_use_id":"toolu_parent","session_id":"session_01", ...}
```
### `result`
Fields (success path):
- `type`: `"result"`
- `subtype`: `"success"` (or `"completion"`)
- `session_id`
- `total_cost_usd`, `is_error`, `duration_ms`, `duration_api_ms`, `num_turns`
- `result`: final answer string
- `usage`: usage object
- `modelUsage`: optional per-model usage
Example (success):
```json
{"type":"result","subtype":"success","session_id":"session_01","total_cost_usd":0.0123,"is_error":false,"duration_ms":12345,"duration_api_ms":12000,"num_turns":2,"result":"Done.","usage":{"input_tokens":150,"output_tokens":70,"service_tier":"standard","server_tool_use":{"web_search_requests":0}}}
```
Example (error + permission denials):
```json
{"type":"result","subtype":"error","session_id":"session_02","total_cost_usd":0.001,"is_error":true,"duration_ms":2000,"duration_api_ms":1800,"num_turns":1,"result":"","error":"Permission denied","permission_denials":[{"tool_name":"Bash","tool_use_id":"toolu_9","tool_input":{"command":"git fetch origin main"}}]}
```
## Message object (`message` field)
Fields:
- `id`, `type`, `role`
- `model` (optional)
- `content`: array of content blocks
- `usage` (assistant messages)
## Content block shapes (in `message.content[]`)
### Text
```json
{"type":"text","text":"Hello"}
```
### Tool use
```json
{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls -la"}}
```
### Tool result
String content:
```json
{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}
```
Array content (Task tool format):
```json
{"type":"tool_result","tool_use_id":"toolu_2","content":[{"type":"text","text":"Task done"}]}
```
@@ -0,0 +1,225 @@
# Claude Code -> Takopi event mapping (spec)
This document describes how the Claude Code runner translates Claude CLI JSONL events into Takopi events.
> **Authoritative source:** The schema definitions are in `src/takopi/schemas/claude.py` and the translation logic is in `src/takopi/runners/claude.py`. When in doubt, refer to the code.
The goal is to make a Claude runner feel identical to the Codex runner from the bridge/renderer point of view while preserving Takopi invariants (stable action ids, per-session serialization, single completed event).
---
## 1. Input stream contract (Claude CLI)
Claude Code CLI emits **one JSON object per line** (JSONL) when invoked with
`--output-format stream-json` (only valid with `-p/--print`).
Recommended invocation (matches claudecode-go):
```
claude -p --output-format stream-json --verbose -- <query>
```
Notes:
- `--verbose` is required for `stream-json` output (clis may otherwise drop events).
- `-p/--print` is required for `--output-format` and `--include-partial-messages`.
- `-- <query>` is required to safely pass prompts that start with `-`.
- Resuming uses `--resume <session_id>` and optional `--fork-session`.
- The CLI does **not** read the prompt from stdin in claudecode-go; it passes the
prompt as the final positional argument after `--`.
---
## 2. Resume tokens and resume lines
- Engine id: `claude`
- Canonical resume line (embedded in chat):
```
`claude --resume <session_id>`
```
Runner must implement its own regex because the resume format is
`claude --resume <session_id>`. Suggested regex:
```
(?im)^\s*`?claude\s+(?:--resume|-r)\s+(?P<token>[^`\s]+)`?\s*$
```
**Note:** Claude session IDs should be treated as opaque strings.
Resume rules:
- If a resume token is provided to `run()`, the runner MUST verify that any
`session_id` observed in the stream matches it.
- If the stream yields a different `session_id`, emit a fatal error and end the run.
---
## 3. Session lifecycle + serialization
Takopi requires **serialization per session id**:
- For new runs (`resume=None`), do **not** acquire a lock until a `session_id`
is observed (usually the first `system.init` event).
- Once the session id is known, acquire a lock for `claude:<session_id>` and hold
it until the run completes.
- For resumed runs, acquire the lock immediately on entry.
This matches the Codex runner behavior in `takopi/runners/codex.py`.
---
## 4. Event translation (Claude JSONL -> Takopi)
### 4.1 Top-level `system` events
Claude emits a system init event early in the stream:
```
{"type":"system","subtype":"init","session_id":"...", ...}
```
**Mapping:**
- Emit a Takopi `started` event as soon as `session_id` is known.
- Assume only one `system.init` per run; if more appear, ignore the subsequent
ones to avoid re-locking.
- Optional: emit a `note` action summarizing tools/MCP servers (debug-only).
### 4.2 `assistant` / `user` message events
Claude messages include a `message` object with a `content[]` array. Each content
block can represent text, tool usage, or tool results.
For each content block:
#### A) `type = "tool_use"`
**Mapping:** emit `action` with `phase="started"`.
- `action.id` = `content.id`
- `action.kind` = map from tool name (see section 5)
- `title`:
- if kind=`command`: use `input.command` if present
- else: tool name or derived label
- `detail` should include:
- `tool_name`, `tool_input`, `message_id`, `parent_tool_use_id` (if provided)
#### B) `type = "tool_result"`
**Mapping:** emit `action` with `phase="completed"`.
- `action.id` = `content.tool_use_id`
- `ok`:
- if `content.is_error` exists and is true -> `ok=False`
- else `ok=True`
- `detail` should include:
- `tool_use_id`, `content` (raw), `message_id`
The runner SHOULD keep a small in-memory map from `tool_use_id -> tool_name`
(learned from `tool_use`) so the completed action title can match the started
action title.
#### C) `type = "text"`
**Mapping:**
- Default: do **not** emit an action (avoid duplicate rendering).
- Store the latest assistant text as a fallback final answer if `result.result`
is empty or missing.
#### D) `type = "thinking"` or other unknown types
**Mapping:** optional `note` action (phase completed) with title derived from
content; otherwise ignore.
### 4.3 `result` events
The terminal event looks like:
```
{"type":"result","subtype":"success", ...}
```
**Mapping:** emit a single Takopi `completed` event:
- `ok = !event.is_error`
- `answer = event.result` (fallback to last assistant text if empty)
- `error = event.error` (if present)
- `resume = ResumeToken(engine="claude", value=event.session_id)`
- `usage = event.usage` (pass through)
- Emit exactly one `completed` event; ignore any trailing JSON lines afterward.
No idle-timeout completion is used.
#### Permission denials
`result.permission_denials` may contain tool calls that were blocked. Emit a
warning action for each denial *before* the final `completed` event:
- `action.kind = "warning"`
- `title = "permission denied: <tool_name>"`
- `detail = {tool_name, tool_use_id, tool_input}`
- `ok = False`, `level = "warning"`
### 4.4 Error handling / malformed lines
- If a JSONL line is invalid JSON: emit a warning action and continue.
- If the subprocess exits non-zero or the stream ends without a `result` event:
emit `completed` with `ok=False` and `error` explaining the failure.
- Emit **exactly one** `completed` event per run.
---
## 5. Tool name -> ActionKind mapping heuristics
Claude tool names can evolve. The runner SHOULD map based on tool name and input
shape. Suggested rules:
| Tool name pattern | ActionKind | Title logic |
| --- | --- | --- |
| `Bash`, `Shell` | `command` | `input.command` |
| `Write`, `Edit`, `MultiEdit`, `NotebookEdit` | `file_change` | `input.path` |
| `Read` | `tool` | `Read <path>` |
| `WebSearch` | `web_search` | `input.query` |
| (default) | `tool` | tool name |
For `file_change`, emit `detail.changes = [{"path": <path>, "kind": "update"}]`.
If input indicates creation (ex: `create: true`), use `kind: "add"`.
If a tool name is unknown, map to `tool` and include the full input in `detail`.
---
## 6. Usage mapping
Takopi `completed.usage` should mirror the Claude `result.usage` object
without transformation. Optionally include `modelUsage` inside `usage` or
`detail` if downstream consumers want it (currently unused by renderers).
---
## 7. Implementation checklist (v0.3.0)
Claude runner implementation summary (no Takopi domain model changes):
1. [x] Create `takopi/runners/claude.py` implementing `Runner` and (custom)
resume parsing.
2. [x] Define `BACKEND` in `takopi/runners/claude.py`:
- `install_cmd`: install command for the `claude` binary
- `build_runner`: read `[claude]` config + construct runner
3. [x] Add new docs (this file + `stream-json-cheatsheet.md`).
4. [x] Add fixtures in `tests/fixtures/` (see below).
5. [x] Add unit tests mirroring `tests/test_codex_*` but for Claude translation
and resume parsing (recommended, not required for initial handoff).
---
## 8. Suggested Takopi config keys
A minimal TOML config for Claude:
```toml
[claude]
# model: opus | sonnet | haiku
model = "sonnet"
allowed_tools = ["Bash", "Read", "Edit", "Write", "WebSearch"]
dangerously_skip_permissions = false
use_api_billing = false
```
Takopi only maps these keys to Claude CLI flags; other options should be configured in Claude Code settings.
If `allowed_tools` is omitted, Takopi defaults to `["Bash", "Read", "Edit", "Write"]`.
When `use_api_billing` is false (default), Takopi strips `ANTHROPIC_API_KEY` from the Claude subprocess environment to prefer subscription billing.
@@ -0,0 +1,345 @@
# Codex `exec --json` event cheatsheet
`codex exec --json` writes **one JSON object per line** (JSONL) to stdout. Each
line is a top-level **thread event** with a `type` field.
Below: **required + commonly emitted fields** for every line type plus a
**full-line example** for each shape that can be emitted. Fields noted as
optional may be omitted (or `null`) depending on Codex version and lifecycle.
Unknown fields may appear; ignore what you don't use.
## Top-level event lines (non-item)
### `thread.started`
Fields:
- `type`
- `thread_id`
Example:
```json
{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}
```
### `turn.started`
Fields:
- `type`
Example:
```json
{"type":"turn.started"}
```
### `turn.completed`
Fields:
- `type`
- `usage.input_tokens`
- `usage.cached_input_tokens`
- `usage.output_tokens`
Example:
```json
{"type":"turn.completed","usage":{"input_tokens":24763,"cached_input_tokens":24448,"output_tokens":122}}
```
### `turn.failed`
Fields:
- `type`
- `error.message`
Example:
```json
{"type":"turn.failed","error":{"message":"model response stream ended unexpectedly"}}
```
### `error`
Fields:
- `type`
- `message`
Example:
```json
{"type":"error","message":"stream error: broken pipe"}
```
Note: Codex may emit transient reconnect notices as `type="error"` with messages
like `"Reconnecting... 1/5"` while it retries a dropped stream. Treat those as
non-fatal progress updates (the turn continues).
## Item event lines (`item.*`)
Every item line includes:
- `type` (`item.started`, `item.updated`, or `item.completed`)
- `item.id`
- `item.type`
- fields for the specific `item.type` below
`item.id` is stable for the item; updates/completion reuse the same id.
### `agent_message` (only `item.completed`)
Fields:
- `item.text`
Example:
```json
{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Done. I updated the docs and added examples."}}
```
### `reasoning` (only `item.completed`, if enabled)
Fields:
- `item.text`
Example:
```json
{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"**Scanning docs for exec JSON schema**"}}
```
### `command_execution` (`item.started` and `item.completed`)
Fields:
- `item.command`
- `item.aggregated_output`
- `item.exit_code` (null or omitted until completion)
- `item.status` (`in_progress`, `completed`, `failed`)
Example (started):
```json
{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"","exit_code":null,"status":"in_progress"}}
```
Example (completed, success):
```json
{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"docs\nsrc\n","exit_code":0,"status":"completed"}}
```
Example (completed, failure):
```json
{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"bash -lc false","aggregated_output":"","exit_code":1,"status":"failed"}}
```
Note: `aggregated_output` is truncated to **64 KiB**; truncated output ends with
`\n...(truncated)`.
### `file_change` (only `item.completed`)
Fields:
- `item.changes[].path`
- `item.changes[].kind` (`add`, `delete`, `update`)
- `item.status` (`completed`, `failed`)
Example:
```json
{"type":"item.completed","item":{"id":"item_4","type":"file_change","changes":[{"path":"docs/exec-json-cheatsheet.md","kind":"add"},{"path":"docs/exec.md","kind":"update"}],"status":"completed"}}
```
### `mcp_tool_call` (`item.started` and `item.completed`)
Fields:
- `item.server`
- `item.tool`
- `item.arguments` (JSON value; defaults to `null` if absent)
- `item.result` (object or `null`; may be omitted)
- `item.result.content` (array of MCP content blocks)
- `item.result.structured_content` (JSON value or `null`)
- `item.error` (object or `null`; may be omitted)
- `item.error.message` (if `error` is present)
- `item.status` (`in_progress`, `completed`, `failed`)
Example (started):
```json
{"type":"item.started","item":{"id":"item_5","type":"mcp_tool_call","server":"docs","tool":"search","arguments":{"q":"exec --json"},"result":null,"error":null,"status":"in_progress"}}
```
Example (completed, success):
```json
{"type":"item.completed","item":{"id":"item_5","type":"mcp_tool_call","server":"docs","tool":"search","arguments":{"q":"exec --json"},"result":{"content":[{"type":"text","text":"Found 3 matches.","annotations":{"audience":["assistant"],"lastModified":"2025-01-01T00:00:00Z","priority":0.5}}],"structured_content":{"matches":3}},"error":null,"status":"completed"}}
```
Example (completed, failure):
```json
{"type":"item.completed","item":{"id":"item_6","type":"mcp_tool_call","server":"docs","tool":"search","arguments":{"q":"exec --json"},"result":null,"error":{"message":"tool timeout"},"status":"failed"}}
```
### `web_search` (only `item.completed`)
Fields:
- `item.query`
Example:
```json
{"type":"item.completed","item":{"id":"item_7","type":"web_search","query":"codex exec --json schema"}}
```
### `todo_list` (`item.started`, `item.updated`, and `item.completed`)
Fields:
- `item.items[].text`
- `item.items[].completed`
Example (started):
```json
{"type":"item.started","item":{"id":"item_8","type":"todo_list","items":[{"text":"Scan docs","completed":false},{"text":"Write cheatsheet","completed":false}]}}
```
Example (updated):
```json
{"type":"item.updated","item":{"id":"item_8","type":"todo_list","items":[{"text":"Scan docs","completed":true},{"text":"Write cheatsheet","completed":false}]}}
```
Example (completed):
```json
{"type":"item.completed","item":{"id":"item_8","type":"todo_list","items":[{"text":"Scan docs","completed":true},{"text":"Write cheatsheet","completed":true}]}}
```
### `error` (non-fatal warning as an item; only `item.completed`)
Fields:
- `item.message`
Example:
```json
{"type":"item.completed","item":{"id":"item_9","type":"error","message":"command output truncated"}}
```
## MCP content block shapes (`mcp_tool_call.result.content`)
`result.content` is an array of **content blocks**. Each block is one of the
types below; all optional fields may appear depending on the server.
### Text content
Fields:
- `type`
- `text`
- `annotations.audience` (optional)
- `annotations.lastModified` (optional)
- `annotations.priority` (optional)
Example block:
```json
{"type":"text","text":"Hello","annotations":{"audience":["assistant"],"lastModified":"2025-01-01T00:00:00Z","priority":0.5}}
```
### Image content
Fields:
- `type`
- `data` (base64)
- `mimeType`
- `annotations.*` (same as above, optional)
Example block:
```json
{"type":"image","data":"<base64>","mimeType":"image/png","annotations":{"audience":["assistant"]}}
```
### Audio content
Fields:
- `type`
- `data` (base64)
- `mimeType`
- `annotations.*` (optional)
Example block:
```json
{"type":"audio","data":"<base64>","mimeType":"audio/wav","annotations":{"audience":["assistant"]}}
```
### Resource link
Fields:
- `type`
- `name`
- `uri`
- `description` (optional)
- `mimeType` (optional)
- `size` (optional)
- `title` (optional)
- `annotations.*` (optional)
Example block:
```json
{"type":"resource_link","name":"docs/exec.md","uri":"file:///repo/docs/exec.md","description":"Exec docs","mimeType":"text/markdown","size":1234,"title":"exec.md","annotations":{"audience":["assistant"]}}
```
### Embedded resource
Fields:
- `type`
- `resource` (either text or blob contents)
- `annotations.*` (optional)
Example block (embedded text):
```json
{"type":"resource","resource":{"uri":"file:///repo/README.md","text":"Hello","mimeType":"text/markdown"},"annotations":{"audience":["assistant"]}}
```
Example block (embedded blob):
```json
{"type":"resource","resource":{"uri":"file:///repo/image.png","blob":"<base64>","mimeType":"image/png"},"annotations":{"audience":["assistant"]}}
```
## Consumer considerations (rendering + success/failure)
Use this section to decide what to surface to end users vs. what to treat as
machine-only metadata.
### What to render for users
- **Final answer:** render `item.completed` where `item.type = "agent_message"` as
the main response.
- **Progress updates (optional):**
- `item.completed` with `item.type = "reasoning"` can be shown as brief
activity breadcrumbs (only if you want to expose reasoning summaries).
- `item.started` / `item.completed` with `item.type = "command_execution"` can
be shown as “running command …” status lines without printing full output.
- `item.completed` with `item.type = "file_change"` can be rendered as a list
of changed paths and kinds (add/update/delete).
- `item.*` with `item.type = "todo_list"` can be shown as a progress checklist.
- **Errors:** render `type = "error"` and `item.type = "error"` as user-visible
warnings or failures.
### Fields you can safely skip for UX
- `command_execution.aggregated_output` is often noisy; many consumers omit or
truncate it, and rely on `command_execution.status` + `exit_code` instead.
- `mcp_tool_call.result.content` can be large and tool-specific; consider showing
only high-level status unless you know the tools schema.
- `usage` fields (`turn.completed.usage.*`) are typically telemetry-only.
### Success and failure signals
- **Turn success:** `type = "turn.completed"` indicates overall success.
- **Turn failure:** `type = "turn.failed"` with `error.message` indicates failure.
- **Item success/failure:** use `item.status` on the item payload:
- `command_execution.status`: `completed` = success, `failed` = failure.
- `file_change.status`: `completed` = patch applied, `failed` = patch failed.
- `mcp_tool_call.status`: `completed` = tool succeeded, `failed` = tool failed.
- **Fatal stream errors:** `type = "error"` means the JSONL stream itself hit an
unrecoverable error (except transient `"Reconnecting... X/Y"` notices, which
are non-fatal).
### Suggested minimal rendering
If you want a compact UI, the following is usually enough:
- Thread/turn lifecycle: `thread.started`, `turn.started`, `turn.completed` or
`turn.failed`
- Final answer: `item.completed` with `item.type = "agent_message"`
- Optional progress: `item.started` / `item.completed` for `command_execution`
and `file_change`
### Optional/conditional emission notes
- `turn.failed` only appears on failure; otherwise `turn.completed` is emitted.
- `reasoning` items only appear when reasoning summaries are enabled.
- `todo_list` items only appear when the plan tool is active; they are the
primary source of `item.updated`.
- `file_change` and `web_search` items are emitted only as `item.completed`
in the current `codex exec --json` stream.
@@ -0,0 +1,432 @@
# Codex -> Takopi event mapping
This document describes how Codex exec --json events are translated to Takopi's normalized event model.
> **Authoritative source:** The schema definitions are in `src/takopi/schemas/codex.py` and the translation logic is in `src/takopi/runners/codex.py`. When in doubt, refer to the code.
## The 3-event Takopi schema
The Takopi event model uses 3 event types. The `action` event includes a `phase` field to represent started/updated/completed lifecycles.
### 1) `started`
Emitted once **as soon as you know the resume token** (Codex: `thread.started.thread_id`).
```json
{
"type": "started",
"engine": "codex",
"resume": { "engine": "codex", "value": "0199..." },
"title": "Codex", // optional
"meta": { "raw": { ... } } // optional: for debugging
}
```
### 2) `action`
Emitted for **everything that is progress / updates / warnings / per-item lifecycle**.
```json
{
"type": "action",
"engine": "codex",
"action": {
"id": "item_5",
"kind": "tool", // command | tool | file_change | web_search | subagent | note | turn | warning | telemetry
"title": "docs.search", // short label for renderer
"detail": { ... } // structured payload (freeform)
},
"phase": "started", // started | updated | completed
"ok": true, // optional; present when phase=completed (or warnings)
"message": "optional text", // optional; logs/warnings can use this
"level": "info" // optional: debug|info|warning|error
}
```
### 3) `completed`
Emitted once at end-of-run with the **final answer** (from `agent_message`) and final status.
```json
{
"type": "completed",
"engine": "codex",
"resume": { "engine": "codex", "value": "0199..." }, // if known
"ok": true,
"answer": "Done. I updated the docs...",
"error": null,
"usage": { "input_tokens": 24763, "cached_input_tokens": 24448, "output_tokens": 122 } // optional
}
```
Why this fits Takopi cleanly:
* Your `started` corresponds to the old “session.started” concept (runner learns resume token; bridge can now safely serialize per thread).
* Your `action` is “everything that would have been action.started/action.completed/log/error” collapsed into one stream.
* Your `completed` corresponds to final `RunResult` + status, using Codexs `agent_message` as the answer source.
---
## How everything fits together (end-to-end)
From the bridge/runner point of view:
1. **Bridge receives Telegram prompt**
2. Bridge tries to extract a resume line (`codex resume <uuid>`) from the message/reply (runner-owned parsing).
3. Bridge calls `runner.run(prompt, resumeTokenOrNone)`
4. Codex runner spawns `codex exec --json ...` and reads JSONL line-by-line.
5. The *first moment the runner can know thread identity* is:
* `thread.started` → contains `thread_id` (this is your resume value)
6. Runner must (per Takopis concurrency invariant) **acquire the per-thread lock as soon as the new thread token is known**, before emitting `started`.
7. Runner translates subsequent Codex JSONL lines into `action` events for progress rendering.
8. Runner captures the final answer from `item.completed` where `item.type="agent_message"`.
9. Runner emits exactly one `completed` event when the run ends (`turn.completed` or failure), including the captured final answer.
---
## Direct translation: every Codex `exec --json` line → your 3-event schema
Codex emits two categories: **top-level lines** and **item lines**.
### A) Top-level lines
#### `thread.started`
Codex:
```json
{"type":"thread.started","thread_id":"0199..."}
```
→ Takopi:
* emit **`started`**:
* `resume.value = thread_id`
This is exactly the “learn resume tag” moment you described.
---
#### `turn.started`
Codex:
```json
{"type":"turn.started"}
```
→ Takopi (recommended):
* emit **`action`** with a synthetic action id, e.g. `"turn_0"`
* `kind="turn"`, `phase="started"`, `title="turn started"`
You *can* also drop it if your UI doesnt care, but if you want “every codex type translates”, this maps cleanly into `action`.
---
#### `turn.completed`
Codex includes usage:
```json
{"type":"turn.completed","usage":{...}}
```
→ Takopi:
* emit **`completed`**
* `ok=true`
* `answer = last seen agent_message text` (or `""` if none)
* `usage = usage` (optional)
This is your authoritative “run succeeded” boundary.
---
#### `turn.failed`
Codex:
```json
{"type":"turn.failed","error":{"message":"..."}}
```
→ Takopi:
* emit **`completed`**
* `ok=false`
* `error = error.message`
* `answer = last seen agent_message` (if any; usually empty)
This is “run ended, but failed”.
---
#### Top-level `error` (stream error)
Codex:
```json
{"type":"error","message":"stream error: broken pipe"}
```
Cheatsheet meaning: this is a **fatal stream failure** (not just a tool failure).
However, Codex may also emit transient reconnect notices as `type="error"` with
messages like `"Reconnecting... 1/5"` while it retries a dropped stream. Treat
those as non-fatal progress updates (do **not** end the run).
→ Takopi:
* if you havent emitted `completed` yet: emit **`completed`** with `ok=false` and `error=message`
* if you *already* emitted `completed`, treat it as an extra warning (or ignore; its “post-mortem noise”)
---
### B) Item lines: `item.started`, `item.updated`, `item.completed`
All item lines include `item.id` and it is stable across updates/completion.
That means your `action.action.id` should just be `item.id` — perfect match to “stable within a run”.
#### General rule (for any item.* line)
* `action.action.id = item.id`
* `action.phase = started | updated | completed`
* `action.action.kind` derived from `item.type`
* `action.action.detail` contains the relevant item fields (possibly trimmed)
Now, map each `item.type`:
---
## Item-type mapping: `item.type` → `action.kind/title/detail/ok`
Below is a “complete coverage” mapping for all item types listed in the cheatsheet.
### 1) `agent_message` (only `item.completed`)
Codex:
```json
{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"..."}}
```
→ Takopi:
* **do not emit an `action`** (recommended)
* instead: **store** `final_answer = item.text`
* final answer will be surfaced by the eventual `completed` event
Reason: you want `completed` to be “final answer delivery”, and you probably dont want the answer duplicated in progress rendering.
(If you *do* want to render it as it arrives, you can emit an `action` too, but then your renderer must avoid showing it twice.)
---
### 2) `reasoning` (only `item.completed`, if enabled)
Codex gives a text breadcrumb.
→ Takopi `action`:
* `kind="note"`
* `title="reasoning"` (or “thought”)
* `phase="completed"`
* `message=item.text` (or put it under `detail.text`)
This is usually safe to show as a short “what its doing” line (or ignore if you dont want to surface it).
---
### 3) `command_execution` (`item.started` and `item.completed`)
Codex fields include `command`, `status`, `aggregated_output` (often noisy), and
`exit_code` (null or omitted until completion).
→ Takopi `action`:
* `kind="command"`
* `title=item.command` (or a shortened version like `pytest`)
* `detail={ command, exit_code, status }` (optionally include output tail)
* `phase="started"` on `item.started`
* `phase="completed"` on `item.completed`
* `ok = (item.status == "completed")` (and `exit_code == 0` when present)
Note: “failed” command becomes `ok=false` but its still just an `action` completion — the overall run might still succeed later, depending on agent behavior.
---
### 4) `file_change` (only `item.completed`)
Codex contains `changes[]` and `status`.
→ Takopi `action`:
* `kind="file_change"`
* `title="file changes"`
* `detail={ changes }`
* `phase="completed"`
* `ok = (item.status == "completed")`
This is a great progress line for your UI (“updated docs/…, added …”).
---
### 5) `mcp_tool_call` (`item.started` and `item.completed`)
Codex contains server/tool/arguments/status and may include result/error on
completion. Result can be large; may include base64 in content blocks.
→ Takopi `action`:
* `kind="tool"`
* `title=f"{item.server}.{item.tool}"`
* `detail={ server, tool, arguments, status }`
* on completion, include *summary* of result:
* e.g. `detail.result_summary = { content_blocks: N, has_structured: bool }`
* include `detail.error_message` if failed
* `phase="started"` or `"completed"`
* `ok = (item.status == "completed")`
Recommendation: **do not dump** full `result.content` into `detail` if it can contain large blobs; keep a summary and optionally stash full raw elsewhere for debugging.
---
### 6) `web_search` (only `item.completed`)
Codex includes `query`.
→ Takopi `action`:
* `kind="web_search"`
* `title="web search"`
* `detail={ query }`
* `phase="completed"`
* `ok=true` (this is just “it did a search”; success/failure is typically not expressed here)
---
### 7) `todo_list` (`item.started`, `item.updated`, `item.completed`)
Codex includes checklist items with `completed` booleans.
→ Takopi `action`:
* `kind="note"` (or `"todo"`)
* `title="plan"`
* `detail={ items, done: count_done, total: count_total }`
* `phase` maps 1:1 to started/updated/completed
* `ok=true` when phase completed (optional)
This is the one case where `item.updated` is common; your unified `action` event is exactly the right shape for it.
---
### 8) Item `error` (non-fatal warning as an item; only `item.completed`)
Codex:
```json
{"type":"item.completed","item":{"id":"item_9","type":"error","message":"command output truncated"}}
```
Cheatsheet: this is a **non-fatal warning** (different from top-level fatal `error`).
→ Takopi `action`:
* `kind="warning"` (or `"note"`)
* `title="warning"`
* `message=item.message`
* `level="warning"`
* `phase="completed"`
* `ok=true` (because its informational) **or** omit `ok`
---
## Suggested “single-pass” translator logic (pseudocode)
This shows how to implement it without needing more than one pass or complicated buffering:
```python
final_answer = None
resume = None
did_emit_started = False
did_emit_completed = False
turn_index = 0
def emit(evt): yield evt # emit to the output event stream
for line in codex_jsonl_stream:
t = line["type"]
if t == "thread.started":
resume = {"engine": "codex", "value": line["thread_id"]}
# acquire per-thread lock here (for new sessions) before emitting started
emit({"type":"started","engine":"codex","resume":resume,"title":"Codex"})
did_emit_started = True
continue
if t == "turn.started":
emit({"type":"action","engine":"codex",
"action":{"id":f"turn_{turn_index}","kind":"turn","title":"turn started","detail":{}},
"phase":"started"})
continue
if t == "item.started" or t == "item.updated" or t == "item.completed":
item = line["item"]
item_type = item["type"]
item_id = item["id"]
if t == "item.completed" and item_type == "agent_message":
final_answer = item.get("text","")
continue
# map item_type -> kind/title/detail/ok
action_evt = map_item_to_action(item, phase=t.split(".")[1])
emit(action_evt)
continue
if t == "turn.completed":
emit({"type":"completed","engine":"codex","resume":resume,
"ok":True,"answer":final_answer or "",
"error":None,"usage":line.get("usage")})
did_emit_completed = True
continue
if t == "turn.failed":
emit({"type":"completed","engine":"codex","resume":resume,
"ok":False,"answer":final_answer or "",
"error":line["error"]["message"]})
did_emit_completed = True
continue
if t == "error": # fatal stream error
if not did_emit_completed:
emit({"type":"completed","engine":"codex","resume":resume,
"ok":False,"answer":final_answer or "",
"error":line.get("message")})
did_emit_completed = True
continue
# Optional: if stream ends without turn.completed/failed,
# emit completed with ok=False and error="unexpected EOF"
```
This design preserves the Takopi ordering/serialization principles: `started` happens as soon as resume token is known, actions stream in order, and exactly one `completed` closes the run.
---
## One practical note: what “completed” should mean
Even though you *learn* the final answer at `agent_message`, you generally want `completed` to be emitted at the **turn boundary** (`turn.completed` / `turn.failed`), because:
* you can attach usage (`turn.completed.usage`) only there,
* you guarantee `completed` is truly the last event,
* you still use `agent_message` as the authoritative answer payload.
That still matches your intent (“completed is when we get final answer”) because the answer comes from `agent_message`; you just *publish* it at the terminal boundary.
+9
View File
@@ -0,0 +1,9 @@
# Runners
Runner docs describe the **engine-specific** behavior: event shapes, JSON streaming, and integration notes.
- Claude: [Runner](claude/runner.md), [Stream JSON cheatsheet](claude/stream-json-cheatsheet.md), [Takopi events](claude/takopi-events.md)
- Codex: [Exec JSON cheatsheet](codex/exec-json-cheatsheet.md), [Takopi events](codex/takopi-events.md)
- OpenCode: [Runner](opencode/runner.md), [Stream JSON cheatsheet](opencode/stream-json-cheatsheet.md), [Takopi events](opencode/takopi-events.md)
- Pi: [Runner](pi/runner.md), [Stream JSON cheatsheet](pi/stream-json-cheatsheet.md), [Takopi events](pi/takopi-events.md)
+47
View File
@@ -0,0 +1,47 @@
# OpenCode Runner
This runner integrates with the [OpenCode CLI](https://github.com/sst/opencode).
Shipped in Takopi v0.5.0.
## Installation
```bash
npm i -g opencode-ai@latest
```
## Configuration
Add to your `takopi.toml`:
```toml
[opencode]
model = "claude-sonnet" # optional
```
## Usage
```bash
takopi opencode
```
## Resume Format
Resume line format: `` `opencode --session ses_XXX` ``
The runner recognizes both `--session` and `-s` flags (with or without `run`).
Note: The resume line is meant to reopen the interactive TUI session. `opencode run` is headless and requires a message or command, so it is not the canonical resume command.
## JSON Event Format
OpenCode outputs JSON events with the following types:
| Event Type | Description |
|------------|-------------|
| `step_start` | Beginning of a processing step |
| `tool_use` | Tool invocation with input/output |
| `text` | Text output from the model |
| `step_finish` | End of a step (reason: "stop" or "tool-calls" when present) |
| `error` | Error event |
See [stream-json-cheatsheet.md](./stream-json-cheatsheet.md) for detailed event format documentation.
@@ -0,0 +1,145 @@
# OpenCode `run --format json` Event Cheatsheet
`opencode run --format json` writes one JSON object per line (JSONL) to stdout.
Each line has a `type` field indicating the event type.
## Event Types
### `step_start`
Marks the beginning of a processing step.
Fields:
- `type`: `"step_start"`
- `timestamp`: Unix timestamp in milliseconds
- `sessionID`: Session identifier (format: `ses_XXX`)
- `part.id`: Part identifier
- `part.sessionID`: Session ID (duplicated)
- `part.messageID`: Message ID
- `part.type`: `"step-start"`
- `part.snapshot`: Git snapshot hash
Example:
```json
{"type":"step_start","timestamp":1767036059338,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e7ec7001qAZUB7eTENxPpI","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e702b0012XuEC4bGe0XhKa","type":"step-start","snapshot":"71db24a798b347669c0ebadb2dfad238f991753d"}}
```
### `tool_use`
Tool invocation event. Emitted when a tool finishes (`status == "completed"`).
Fields:
- `type`: `"tool_use"`
- `timestamp`: Unix timestamp in milliseconds
- `sessionID`: Session identifier
- `part.id`: Part identifier
- `part.callID`: Unique call ID for this tool invocation
- `part.tool`: Tool name (e.g., "bash", "read", "write", "grep")
- `part.state.status`: `"completed"` (the CLI JSON output does not emit pending/running tool states)
- `part.state.input`: Tool input parameters
- `part.state.output`: Tool output (when completed)
- `part.state.title`: Human-readable description
- `part.state.metadata`: Additional metadata (exit codes, etc.)
- `part.state.time.start`: Start timestamp
- `part.state.time.end`: End timestamp
Example:
```json
{"type":"tool_use","timestamp":1767036061199,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e85bb001CzBoN2dDlEZJnP","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e702b0012XuEC4bGe0XhKa","type":"tool","callID":"r9bQWsNLvOrJGIOz","tool":"bash","state":{"status":"completed","input":{"command":"echo hello","description":"Print hello to stdout"},"output":"hello\n","title":"Print hello to stdout","metadata":{"output":"hello\n","exit":0,"description":"Print hello to stdout"},"time":{"start":1767036061123,"end":1767036061173}}}}
```
### `text`
Text output from the model.
Fields:
- `type`: `"text"`
- `timestamp`: Unix timestamp in milliseconds
- `sessionID`: Session identifier
- `part.id`: Part identifier
- `part.type`: `"text"`
- `part.text`: The actual text content
- `part.time.start`: Start timestamp
- `part.time.end`: End timestamp
Example:
```json
{"type":"text","timestamp":1767036064268,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e8ff2002mxSx9LtvAlf8Ng","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e8627001yM4qKJCXdC7W1L","type":"text","text":"```\nhello\n```","time":{"start":1767036064265,"end":1767036064265}}}
```
### `step_finish`
Marks the end of a processing step.
Fields:
- `type`: `"step_finish"`
- `timestamp`: Unix timestamp in milliseconds
- `sessionID`: Session identifier
- `part.id`: Part identifier
- `part.type`: `"step-finish"`
- `part.reason`: Optional. `"stop"` (final) or `"tool-calls"` (continuing) when present.
- `part.snapshot`: Git snapshot hash
- `part.cost`: Cost in USD
- `part.tokens.input`: Input token count
- `part.tokens.output`: Output token count
- `part.tokens.reasoning`: Reasoning token count
- `part.tokens.cache.read`: Cache read tokens
- `part.tokens.cache.write`: Cache write tokens
Example (final step):
```json
{"type":"step_finish","timestamp":1767036064273,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e9209001ojZ4ECN1geZISm","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e8627001yM4qKJCXdC7W1L","type":"step-finish","reason":"stop","snapshot":"09dd05d11a4ac013136c1df10932efc0ad9116e8","cost":0.001,"tokens":{"input":671,"output":8,"reasoning":0,"cache":{"read":21415,"write":0}}}}
```
Example (tool-calls step):
```json
{"type":"step_finish","timestamp":1767036061205,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","part":{"id":"prt_b6b8e85fb001L4I3WHMqH6EQNI","sessionID":"ses_494719016ffe85dkDMj0FPRbHK","messageID":"msg_b6b8e702b0012XuEC4bGe0XhKa","type":"step-finish","reason":"tool-calls","snapshot":"ee3406d50c7d9048674bbb1a3e325d82513b74ed","cost":0,"tokens":{"input":21772,"output":110,"reasoning":0,"cache":{"read":0,"write":0}}}}
```
### `error`
Session error event.
Fields:
- `type`: `"error"`
- `timestamp`: Unix timestamp in milliseconds
- `sessionID`: Session identifier
- `error.name`: Error type
- `error.data.message`: Human-readable error (when available)
Example:
```json
{"type":"error","timestamp":1767036065000,"sessionID":"ses_494719016ffe85dkDMj0FPRbHK","error":{"name":"APIError","data":{"message":"Rate limit exceeded","statusCode":429,"isRetryable":true}}}
```
## Mapping to Takopi Events
| OpenCode Event | Takopi Event | Condition |
|----------------|--------------|-----------|
| `step_start` | `StartedEvent` | First occurrence |
| `tool_use` | `ActionEvent(phase="completed")` | `status == "completed"` |
| `text` | (accumulate text) | - |
| `step_finish` | `CompletedEvent` | `reason == "stop"` |
| `step_finish` | (ignored) | `reason == "tool-calls"` |
| `error` | `CompletedEvent(ok=False)` | - |
If `step_finish` omits `reason`, Takopi treats a clean process exit as successful completion and emits `CompletedEvent(ok=True)` with accumulated usage.
## Session ID Format
OpenCode uses session IDs in the format: `ses_XXXXXXXXXXXXXXXXXXXX`
Example: `ses_494719016ffe85dkDMj0FPRbHK`
## Tool Types
Common tool names in OpenCode:
- `bash`: Shell command execution
- `read`: Read file contents
- `write`: Write file contents
- `edit`: Edit file contents
- `glob`: File pattern matching
- `grep`: Content search
- `webfetch`: Fetch web content
- `websearch`: Web search
- `task`: Spawn sub-agent tasks
@@ -0,0 +1,82 @@
# OpenCode to Takopi Event Mapping
This document describes how OpenCode JSON events are translated to Takopi's normalized event model.
> **Authoritative source:** The schema definitions are in `src/takopi/schemas/opencode.py` and the translation logic is in `src/takopi/runners/opencode.py`. When in doubt, refer to the code.
## Event Translation
### StartedEvent
Emitted on the first `step_start` event that contains a `sessionID`.
```
OpenCode: {"type":"step_start","sessionID":"ses_XXX",...}
Takopi: StartedEvent(engine="opencode", resume=ResumeToken(engine="opencode", value="ses_XXX"))
```
### ActionEvent
Tool usage is translated to action events. Note: `opencode run --format json` currently only emits `tool_use` events when the tool finishes (`status == "completed"`). Pending/running tool states exist in the schema but are not emitted by the CLI JSON stream.
**Started phase** (when tool is pending/running, if emitted by the JSON stream):
```
OpenCode: {"type":"tool_use","part":{"tool":"bash","state":{"status":"pending",...}}}
Takopi: ActionEvent(engine="opencode", action=Action(kind="command"), phase="started")
```
**Completed phase** (when tool finishes):
```
OpenCode: {"type":"tool_use","part":{"tool":"bash","state":{"status":"completed","metadata":{"exit":0}}}}
Takopi: ActionEvent(engine="opencode", action=Action(kind="command"), phase="completed", ok=True)
```
### CompletedEvent
Emitted on `step_finish` with `reason="stop"` or on `error` events.
**Success**:
```
OpenCode: {"type":"step_finish","part":{"reason":"stop","tokens":{...},"cost":0.001}}
Takopi: CompletedEvent(engine="opencode", ok=True, answer="<accumulated text>", usage={...})
```
If `step_finish` omits `reason`, Takopi treats a clean process exit as successful completion and emits `CompletedEvent(ok=True)` with the accumulated usage.
**Error**:
```
OpenCode: {"type":"error","error":{"name":"APIError","data":{"message":"API rate limit exceeded"}}}
Takopi: CompletedEvent(engine="opencode", ok=False, error="API rate limit exceeded")
```
## Tool Kind Mapping
| OpenCode Tool | Takopi ActionKind |
|---------------|-------------------|
| `bash`, `shell` | `command` |
| `edit`, `write`, `multiedit` | `file_change` |
| `read` | `tool` |
| `glob` | `tool` |
| `grep` | `tool` |
| `websearch`, `web_search` | `web_search` |
| `webfetch`, `web_fetch` | `web_search` |
| `todowrite`, `todoread` | `note` |
| `task` | `tool` |
| (other) | `tool` |
## Usage Accumulation
Token usage is accumulated across all `step_finish` events and reported in the final `CompletedEvent.usage`:
```json
{
"total_cost_usd": 0.001,
"tokens": {
"input": 22443,
"output": 118,
"reasoning": 0,
"cache_read": 21415,
"cache_write": 0
}
}
```
+133
View File
@@ -0,0 +1,133 @@
Below is a concrete implementation spec for the **Pi (pi-coding-agent CLI)** runner shipped in Takopi (v0.5.0).
---
## Scope
### Goal
Provide the **`pi`** engine backend so Takopi can:
* Run Pi non-interactively via the **pi CLI** (`pi --print`).
* Stream progress by parsing **`--mode json`** (newline-delimited JSON). Each line is a JSON object.
* Support resumable sessions via **`--session <path>`** (Takopi emits a canonical resume line the user can reply with).
### Non-goals (v1)
* Interactive TUI flows (session picker, prompts, etc.)
* RPC mode (requires a long-running process and JSON commands)
---
## UX and behavior
### Engine selection
* Default: `takopi` (auto-router uses `default_engine` from config)
* Override: `takopi pi`
### Resume UX (canonical line)
Takopi appends a **single backticked** resume line at the end of the message, like:
```text
`pi --session ccd569e0`
```
Notes:
* `pi --resume/-r` opens an interactive session picker, so Takopi uses `--session <path>` instead.
* The resume token is the **session id** (short prefix), derived from the first JSON
object in the session file. If the id cannot be read, Takopi falls back to the
session file path.
* If the path contains spaces, the runner will quote it.
### Non-interactive runs
Use `--print` and `--mode json` for headless JSONL output.
Pi does not accept `-- <prompt>` to protect prompts starting with `-`. Takopi prefixes a leading space if the prompt begins with `-` so it is not parsed as a flag.
---
## Config additions
Takopi config lives at `~/.takopi/takopi.toml`.
Add a new optional `[pi]` section.
Recommended schema:
```toml
# ~/.takopi/takopi.toml
default_engine = "pi"
[pi]
model = "..." # optional; passed as --model
provider = "..." # optional; passed as --provider
extra_args = [] # optional list of strings, appended verbatim
```
Notes:
* `extra_args` lets you pass new Pi flags without changing Takopi.
* Session files are stored under Pi's default session dir:
`~/.pi/agent/sessions/--<cwd>--` (with path separators replaced by `-`).
---
## Code changes (by file)
### 1) New file: `src/takopi/runners/pi.py`
Expose a module-level `BACKEND = EngineBackend(...)`.
#### Runner invocation
The runner should launch Pi in headless JSON mode:
```text
pi --print --mode json --session <session.jsonl> <prompt>
```
When resuming, `<session.jsonl>` is the resume token extracted from the chat.
#### Event translation
Pi JSONL output is `AgentSessionEvent` (from `@mariozechner/pi-agent-core`).
The runner should translate:
* `tool_execution_start` -> `action` (phase: started)
* `tool_execution_end` -> `action` (phase: completed)
* `agent_end` -> `completed`
For the final answer, use the most recent assistant message text (from
`message_end` events). For errors, if the assistant stopReason is `error` or
`aborted`, emit `completed(ok=false, error=...)`.
---
## Installation and auth
Install the CLI globally:
```text
npm install -g @mariozechner/pi-coding-agent
```
Auth is stored under `~/.pi/agent/auth.json`. Run `pi` once interactively to
set up credentials before using Takopi.
---
## Known pitfalls
* `--resume` is interactive; Takopi uses `--session <path>` instead.
* Prompts that start with `-` are interpreted as flags by the CLI. Takopi
prefixes a space to make them safe.
---
If you want, I can also add a sample `takopi.toml` snippet to the README or
include a small quickstart section for Pi in the onboarding panel.
@@ -0,0 +1,67 @@
# Pi `--mode json` event cheatsheet
`pi --print --mode json` writes **one JSON object per line** (JSONL) with a
required `type` field. These are `AgentSessionEvent` objects from
`@mariozechner/pi-agent-core`.
## Top-level event lines
### `agent_start`
```json
{"type":"agent_start"}
```
### `agent_end`
```json
{"type":"agent_end","messages":[{"role":"assistant","content":[{"type":"text","text":"Done."}],"stopReason":"stop","timestamp":123}]}
```
### `turn_start` / `turn_end`
```json
{"type":"turn_start"}
```
```json
{"type":"turn_end","message":{...},"toolResults":[...]}
```
### `message_start` / `message_update` / `message_end`
```json
{"type":"message_start","message":{"role":"assistant","content":[{"type":"text","text":"Working..."}]}}
```
```json
{"type":"message_update","message":{...},"assistantMessageEvent":{"type":"text_delta","delta":"...","contentIndex":0}}
```
```json
{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Done."}],"stopReason":"stop"}}
```
### `tool_execution_start`
```json
{"type":"tool_execution_start","toolCallId":"tool_1","toolName":"bash","args":{"command":"ls"}}
```
### `tool_execution_update`
```json
{"type":"tool_execution_update","toolCallId":"tool_1","toolName":"bash","args":{"command":"ls"},"partialResult":{"content":[{"type":"text","text":"..."}]}}
```
### `tool_execution_end`
```json
{"type":"tool_execution_end","toolCallId":"tool_1","toolName":"bash","result":{"content":[{"type":"text","text":"ok"}],"details":{}},"isError":false}
```
## Notes
* `message_end` with `role = "assistant"` contains the final assistant text.
* `assistantMessageEvent` in `message_update` provides streaming deltas.
* `tool_execution_*` events map cleanly to Takopi `action` events.
+152
View File
@@ -0,0 +1,152 @@
# Pi -> Takopi event mapping (spec)
This document describes how the Pi runner translates Pi CLI `--mode json` JSONL events into Takopi events.
> **Authoritative source:** The schema definitions are in `src/takopi/schemas/pi.py` and the translation logic is in `src/takopi/runners/pi.py`. When in doubt, refer to the code.
The goal is to make Pi feel identical to the Codex/Claude runners from the bridge/renderer point of view while preserving Takopi invariants (stable action ids, per-session serialization, single completed event).
---
## 1. Input stream contract (Pi CLI)
Pi CLI emits **one JSON object per line** (JSONL) when invoked with:
```
pi --print --mode json <prompt>
```
Notes:
- `--print` is required for non-interactive runs.
- `--mode json` outputs all agent events (no TUI banners).
- Pi does not support `-- <prompt>`; prompts starting with `-` must be
prefixed (Takopi does this automatically).
---
## 2. Resume tokens and resume lines
- Engine id: `pi`
- Canonical resume line (embedded in chat):
```
`pi --session <id>`
```
The token is the **short session id**, derived from the first JSON object in the
session file. If the id cannot be read, Takopi falls back to the session file path.
Why not `--resume`?
- `--resume/-r` opens an interactive session picker; it does not accept a
session token. Takopi must use `--session <path>` instead.
---
## 3. Session lifecycle + serialization
Takopi requires **serialization per session token**:
- For new runs (`resume=None`), do **not** acquire a lock until a `started`
event is emitted (Takopi emits this as soon as the first JSON event arrives).
- Once the session is known, acquire a lock for `pi:<session_path>` and hold it
until the run completes.
- For resumed runs, acquire the lock immediately on entry.
---
## 4. Event translation (Pi JSONL -> Takopi)
Pi emits `AgentSessionEvent` objects. Only a subset is required for Takopi.
### 4.1 `tool_execution_start`
Example:
```json
{"type":"tool_execution_start","toolCallId":"tool_1","toolName":"bash","args":{"command":"ls"}}
```
Mapping:
- Emit `action` with `phase="started"`.
- `action.id = toolCallId`.
- `action.kind` from tool name (see section 5).
- `action.title` derived from tool + args.
### 4.2 `tool_execution_end`
Example:
```json
{"type":"tool_execution_end","toolCallId":"tool_1","toolName":"bash","result":{...},"isError":false}
```
Mapping:
- Emit `action` with `phase="completed"`.
- `ok = !isError`.
- Carry `result` and `isError` in `detail` for debugging.
### 4.3 `message_end` (assistant)
Pi emits message lifecycle events. For `message_end` where `message.role == "assistant"`:
- Store the latest assistant text as the **final answer fallback**.
- If `stopReason` is `error` or `aborted`, store `errorMessage`.
- Capture `usage` for `completed.usage`.
### 4.4 `agent_end`
Example:
```json
{"type":"agent_end","messages":[...]}
```
Mapping:
- Emit a single `completed` event:
- `ok = true` unless the last assistant message has `stopReason` `error` or `aborted`.
- `answer = last assistant text` (from `message_end` or `agent_end.messages`).
- `error = errorMessage` if present.
- `resume = ResumeToken(engine="pi", value=session_path)`.
- `usage = last assistant usage`.
### 4.5 Other events
Ignore unknown events. If a JSONL line is malformed, emit a warning action and
continue (default `JsonlSubprocessRunner` behavior).
---
## 5. Tool name -> ActionKind mapping heuristics
Pi tool names are lower-case by default. Suggested mapping:
| Tool name | ActionKind | Title logic |
| --- | --- | --- |
| `bash` | `command` | `args.command` |
| `edit`, `write` | `file_change` | `args.path` |
| `read` | `tool` | `read: <path>` |
| `grep` | `tool` | `grep: <pattern>` |
| `find` | `tool` | `find: <pattern>` |
| `ls` | `tool` | `ls: <path>` |
| (default) | `tool` | tool name |
For `file_change`, include `detail.changes = [{"path": <path>, "kind": "update"}]`.
---
## 6. Usage mapping
Takopi `completed.usage` should mirror Pi's assistant `usage` object without
transformation.
---
## 7. Suggested Takopi config keys
A minimal TOML config for Pi:
```toml
[pi]
model = "..."
provider = "..."
extra_args = []
```
Use `extra_args` for any Pi CLI flags not explicitly mapped.