# CAP support in tai (`Cap*`) Tai speaks the CLI Agent Protocol both directions: as an agent (`--cap-manifest`, `--cap-speak`) and as an orchestrator driving other CLI agents. Normative upstream: CAP Core v1 plus the coding profile; research archive is `research/cap-protocol/`. Protocol id `CAP_VERSION`, manifest schema `CAP_MANIFEST_SCHEMA`, coding profile id `CAP_PROFILE_CODING`, shipped manifest revision `CAP_MANIFEST_REVISION`. ## Conformance claim - Agent: CLI-invokable, publishes `TAI_CAP_MANIFEST` via `--cap-manifest`, matches the PTY contract and speaks stream-json via `--cap-speak`. Claims the `coding` profile. - Orchestrator: reads manifests via `CapRegistry`, implements PTY plus stream-json plus ACP-stdio plus A2A bindings (gRPC excluded, see Deviations), honors the §10 rules below, and serves the reverse RPC it advertises. ## Phases 1. Events, errors, manifest registry, `--cap-manifest`. DONE. 2. Agent `--cap-speak` stream-json binding, coding artifacts. DONE. 3. Orchestrator PTY driver (`CapPtyDriver` on `PtyProc` plus `TuiVtScreen`, parse). DONE. 4. Orchestrator stream-json driver plus §10 orchestration (`CapSession`, `CapFleet`, worktrees, budget). DONE. 5. ACP-stdio and A2A drivers, reverse RPC serving, research auto-answerer (`CapAnswerer`). DONE. ## Core events (`CapEvent`) Kinds in `CapEvent.KINDS`: Agent to orchestrator: `cap.session.ready`, `cap.text_chunk`, `cap.tool_call.start`, `cap.tool_call.delta`, `cap.tool_call.end`, `cap.plan`, `cap.thought`, `cap.ask_user`, `cap.permission.request`, `cap.usage`, `cap.error`, `cap.artifact`, `cap.fs.change`, `cap.pty.raw_bytes`. Orchestrator to agent: `cap.user_input.inject`, `cap.cancel`, `cap.session.config`, plus answers `cap.ask_user.answer` and `cap.permission.response`. Directions live in `CapEvent.DIRECTIONS`, required fields in `CapEvent.REQUIRED`. Enum vocab: `channel` is `assistant`, `thought` or `system`; `status` is `pending`, `in_progress`, `completed`, `cancelled` or `blocked`; `priority` is `urgent`, `high`, `medium` or `low`; `scope` (cancel) is `current_turn` or `session`; `risk_level` is `low`, `medium` or `high`; `decision` is `allow_once`, `allow_always` or `deny`; `permission_mode` is `none`, `confirm` or `interactive`; `stop_reason` is `end_turn`, `max_tokens`, `tool_use`, `stop_sequence`, `cancelled` or `error`. ## Errors `CAP_ERROR_CODES` maps numerics to symbols, `CAP_ERROR_SYMBOLS` maps back. Core: `-32001 cap_manifest_missing`, `-32002 cap_session_config_missing`, `-32003 cap_unsupported_capability`, `-32004 cap_budget_exceeded`, `-32005 cap_invalid_answer`, `-32006 cap_unknown_permission`, `-32007 cap_queued_input_unsupported`, `-32008 cap_cancel_unsupported`, `-32009 cap_binding_unavailable`, `-32010 cap_profile_unsupported`. Coding: `-32020 cap_scope_violation`, `-32021 cap_path_not_found`, `-32022 cap_io_error`, `-32023 cap_terminal_unavailable`, `-32024 cap_terminal_killed`. `CAP_ERROR_STRINGS` holds the `cap.error` string codes: core `rate_limit`, `auth_error`, `tool_timeout`, `context_overflow`, `model_unavailable`, `pty_died`, `parse_failed`; coding `git_dirty`, `test_failed`, `merge_conflict`, `language_server_down`. Wire failures raise `CapError`. ## Manifests (`CapManifest`, `CapRegistry`) Required (§5.2): `agent.name`, `agent.binary`, `agent.profiles`, `startup.command`, `startup.ready_when`, `pty.cols`, `pty.rows`, `capabilities.streaming_output`. Defaults (§5.3) in `CapManifest.DEFAULTS`: `pty.bracketed_paste` false, `pty.sigint_cancels_turn` hard, `pty.queued_input_supported` false, `capabilities.multi_session` false, `capabilities.ask_user.*` all false, `capabilities.streaming_tool_output` false, `cost.metered` false, `parse.idle` the `>`/`❯` pair, all other `parse.*` unset, `parse.error_lines` empty. Resolution order in `CapRegistry`: binary sibling `cap-agent.toml`, user-local `~/.config/cap/agents/`, system-wide `/usr/share/cap-agents/`, probe ` --cap-manifest` with timeout `CAP_PROBE_TIMEOUT` seconds. Override dirs via constructor args in tests. ## Bindings (`CapBinding`) Priority `CapBinding.ORDER`: `grpc`, `stream_json`, `acp_stdio`, `a2a`, `pty`. `CapBinding.UNSUPPORTED` is `grpc` only (stdlib has no HTTP/2 or protobuf); selection falls through past it. Manifest booleans `false` and absent fields mean the same: not supported. A2A tries draft endpoints first (`POST /message/send`, `/tasks/cancel`), then current A2A (`POST /message:send`, `POST /tasks/{id}:cancel`). The ACP server argv is out-of-band (fleet `argv`, chat `--argv`): the manifest carries no ACP command. Pipe drivers share `CapPipeReader` (non-blocking NDJSON, `CAP_STREAM_LINE_MAX` shared cap), `cap_stderr_drain`, `cap_proc_kill` and `cap_proc_reap`. ## Reverse RPC served by the orchestrator Core: `cap.user_io.show`, `cap.user_io.input`, `cap.notify`. Coding fs: `cap.fs.read`, `cap.fs.write`, `cap.fs.list`, `cap.fs.exists`, `cap.fs.delete`, `cap.fs.watch`. Coding terminal: `cap.terminal.create`, `cap.terminal.output`, `cap.terminal.wait`, `cap.terminal.kill`, `cap.terminal.release`. Scope enforcement: `workspace_only` is the session `cwd` subtree, `project_root` the VCS-root ancestor subtree, `unrestricted` nothing (discouraged); symlinks resolve before the check and MUST NOT escape. `CapAcpDriver` speaks ACP v1 (`CAP_ACP_VERSION`) over stdio: initialize handshake, `session/new`, prompt turns with the C.3 update mapping (message/thought chunks, tool calls, plan, usage deltas). It serves `fs/read_text_file` and `fs/write_text_file` (line windows, `CAP_ACP_FS_MAX` bytes), `terminal/*` (grants, `CAP_ACP_TERMINALS_MAX` concurrent, `CAP_ACP_TERM_BUF` retention, `CAP_ACP_TERM_WAIT_MAX` wait cap), form `elicitation/create` (URL declined), and tai `_cap/*` extensions for `fs.list`/`exists`/`delete` plus `user_io.show`/`input` and `notify`. Permission, elicitation and input answers flow through the unified `open_asks` stash. `CapA2aDriver` refuses cards without the `cap-protocol/v1` extension, posts messages (flavor sticky after discovery), and maps SSE payloads: raw `cap.*` events, `_meta.cap.kind` data parts, text parts, task states (input-required opens an ask, terminal states end the turn), plain artifacts. Answers return as marked data parts. HTTP timeout `CAP_A2A_TIMEOUT` (30). Tai does not expose sessions over A2A (server side unbuilt). ## Orchestration (§10) Sessions get `cap://` URNs stable per orchestrator. Master plans carry `_meta.cap.assigned_to` and `_meta.cap.depends_on`; sub-agents never talk directly, every `@cap://` directive routes through the orchestrator into the audit log. Coding sub-agents get git worktrees under `.cap-worktrees/`, merged by the orchestrator. Usage aggregates across the fleet and the orchestrator hard-cancels past budget. Interruption is cooperative: cancel the turn, keep partial text, resend with the correction. `CapSession` pumps one driver on its own thread, queues events, and blocks per turn in `ask` with `CAP_TURN_TIMEOUT` (600); `CapTurnResult` carries the turn events, end state, usage delta and joined text. `CapFleet` provisions git worktrees per coding session (plain dirs outside git, both tracked and removed on close), publishes validated master plans, routes `@cap://` mentions (`CAP_MENTION_RE`) through per-session pending queues (`CAP_ROUTE_QUEUE_MAX`, 16, idle-only delivery), refuses new turns past `budget_usd`, and keeps every mediation in the audit log. `collect_diffs` reports bounded per-worktree diffs (`CAP_FLEET_DIFF_MAX`, 65536); merging stays the operator's call. Poll cadence `CAP_SESSION_POLL` (0.1), worktree root `CAP_WORKTREE_DIR`. `CapFleet.set_answerer` installs a `CapAnswerer`: open asks and permission requests each get one daemon research thread (deduped, counted busy by `wait_idle`, audited). Policy first (deny list, allow list, `max_risk` over `CAP_ANSWER_RISKS`), then an isolated tai one-shot (`--auto`, own vault under `cap-research`) bounded by `CAP_ANSWER_TIMEOUT` (300) and `CAP_ANSWER_PARALLEL` (4); the value comes from the last `CAP_ANSWER_MARKER` line. Failures fall back safe (deny, False, unknown). CLI: `--auto-answer`, `--allow-tool`, `--deny-tool`, `--max-risk` (`cap_answer_flags`, `cap_build_answerer`); fleet file `auto_answer`, `allow_tools`, `deny_tools`, `max_risk`. ## Stream driver (`CapStreamDriver`) NDJSON over pipes with the C.1 mapping (init to ready, assistant blocks to chunk/thought/tool start, tool results to tool end, result to usage or error, tai `artifact` frames to `cap.artifact`, unknown lines counted in `ignored_frames`). stdout is non-blocking with a manual line buffer capped at `CAP_STREAM_LINE_MAX` (1048576); stderr drains on a daemon thread into the last `CAP_STREAM_STDERR_KEEP` (50) lines, quoted on unexpected exit. Turn cancel kills the one-shot process and the next send respawns lazily. ## PTY heuristics (`CapPtyDriver`) `CapDriver` is the shared base (identity, event queue, id sequences, send guards, spawn-then-ready start). States in `CapDriver.STATES`: starting, ready, working, input_required, done, failed, closed. `done` ends the turn with the session alive; `closed` and `failed` are terminal. Spawns under `PtyProc`, renders through `TuiVtScreen` (`text_lines`), writes terminal replies back. Settle window `CAP_PTY_SETTLE_MS` (800), prompt gate (no Done before the first real prompt), bounded resubmits `CAP_PTY_RESUBMITS_MAX` (3), wait-for-ready before the first prompt, 150ms text-to-Enter gap `CAP_PTY_SUBMIT_GAP_MS`, scrollback `CAP_PTY_SCROLLBACK` (10000), raw-bytes stream opt-in only via `raw_sink`. Idle is the idle regex on the last line plus 200ms silence `CAP_PTY_IDLE_MS`; boot ready needs the marker plus the same silence. Boot timeout `CAP_PTY_INIT_TIMEOUT` (60) when the manifest sets none. Line events from fresh rendered rows, first match wins: error lines to `cap.error` (`rate_limit`/`auth_error` by substring, else `error`), tool start/end pairing by call id, ask patterns to `cap.ask_user` (answered via `CapPtyDriver.answer`), plan/thought markers to `cap.plan` / `cap.thought`. Identical line events suppress repeats per turn. Cancel consults `capabilities.mid_turn_cancel` over `pty.sigint_cancels_turn` over hard; queued input consults `capabilities.queued_input` over `pty.queued_input_supported` over false. ## CLI surface - `--cap-manifest`: print `TAI_CAP_MANIFEST` and exit 0. No vault, no network, no boot. Phase 1. - `--cap-speak`: stream-json over stdio (agent side). Bulk session config via `CAP_SESSION_CONFIG` (JSON `cap.session.config`); `--cap-cwd`, `--cap-model`, `--cap-permission-mode`, `--cap-max-turns` override single fields. Phase 2. - `cap` orchestrator commands via `--cap` (`cap_cli_main`, `cap_cli_help`): `resolve` (`cap_resolve_main`), `chat` (`cap_chat_main`, task once plus tty REPL, `--argv` for ACP servers), `run` (`cap_run_main` over a `fleet.toml` validated by `cap_fleet_plan`). Results print via `cap_print_result`. Phase 4. `fleet.toml` schema: `[fleet]` with `start` (a session URN), `task`, optional `budget_usd`, `auto_answer`, `allow_tools`, `deny_tools`, `max_risk`; `[[sessions]]` with `name`, `agent` (registry key), optional `cwd`/`model`/`argv`; `[[routes]]` with `when` (source URN, `.done` suffix allowed) and `route_to`. Routes run statically in order, each feeding the source's last text into the target. ## Speak path (`CapSpeakSession`) `CapSessionConfig` parses env plus flags; `CapStreamJson` builds Claude-compatible frames and parses inbound user frames; `CapSpeakSession` runs each turn through `Agent.run_turn` with stdout parked on stderr and emits the transcript diff (assistant frames, grouped tool results, result frame) plus a `diff` artifact when the tree changed. `CAP_SPEAK_MAX_TURNS` (50) caps turns when unset; `CAP_SPEAK_DIFF_MAX` (65536) caps inline diff bytes. Permission mapping: `none` boots yolo, `confirm` and `interactive` boot auto (research instead of asking); stdin is never read for prompts. Artifacts travel as tai `artifact` frames (receivers ignore unknown types); the `transcript` artifact is a `file://` URI under `cap-transcripts/`, written at session end. Constants `CAP_SESSION_CONFIG_ENV`, `CAP_SPEAK_MAX_TURNS`, `CAP_SPEAK_DIFF_MAX`; entry `cap_speak_main`; cancel via `cap_interrupt` (SIGTERM acts like Ctrl-C, emits an error result, exits 130). ## Deviations from upstream - gRPC binding not implemented (stdlib). - Regex flavor: tai accepts Python named groups `(?P<>)` (every shipped example uses them) and rejects only lookaround and backreferences, against the letter of §5.4. - No fs/terminal mediation on the agent speak path: reverse RPC framing over stream-json is unspecified upstream. - A2A dual spelling (draft first, current fallback). - Worktrees under `.cap-worktrees/` per the profile text, not the reference CLI's `.cap/`. - PTY submit sends CR, not the spec's LF: CR is Enter in both canonical and raw terminal modes. - Idle/prompt rows never emit `cap.text_chunk`; no usage events on the PTY path (agents report none). - Tool end output is empty (PTY shows no result payload); plan entries carry the marker line as content. - A2A expose (serving tai sessions over A2A) is not built; tai drives A2A agents but does not serve them. - ACP `terminal/wait_for_exit` is capped at 600s and reports null exit codes when still running; v2 renames deferred. - `_cap/fs.list`/`exists`/`delete` and `_cap/user_io.*` shapes are tai-defined (the profile fixes methods, not JSON shapes).