Files
tai/cap.md
T
retoor cc1c130b51 CAP protocol both directions: agent speak plus orchestrator drivers (1.39.0)
- Agent side: --cap-manifest plus --cap-speak stream-json binding with
  transcript-diff frames, diff/transcript artifacts, session config
- Orchestrator: PTY driver (settle heuristics on PtyProc/TuiVtScreen),
  stream-json, ACP-stdio (permission/elicit/fs/terminal serving) and
  A2A drivers (dual draft/current spelling, SSE mapping)
- Sessions, fleets: worktrees, master plans, mediated routing,
  budget refusal, audit log, research auto-answerer, --cap CLI
- cap.md contract with drift tests; research/cap-protocol archive
- Suites 1119 + 16 green
2026-10-18 13:17:35 +02:00

13 KiB
Raw Blame History

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 <binary> --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).