- 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
13 KiB
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_MANIFESTvia--cap-manifest, matches the PTY contract and speaks stream-json via--cap-speak. Claims thecodingprofile. - 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
- Events, errors, manifest registry,
--cap-manifest. DONE. - Agent
--cap-speakstream-json binding, coding artifacts. DONE. - Orchestrator PTY driver (
CapPtyDriveronPtyProcplusTuiVtScreen, parse). DONE. - Orchestrator stream-json driver plus §10 orchestration
(
CapSession,CapFleet, worktrees, budget). DONE. - 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: printTAI_CAP_MANIFESTand exit 0. No vault, no network, no boot. Phase 1.--cap-speak: stream-json over stdio (agent side). Bulk session config viaCAP_SESSION_CONFIG(JSONcap.session.config);--cap-cwd,--cap-model,--cap-permission-mode,--cap-max-turnsoverride single fields. Phase 2.caporchestrator commands via--cap(cap_cli_main,cap_cli_help):resolve(cap_resolve_main),chat(cap_chat_main, task once plus tty REPL,--argvfor ACP servers),run(cap_run_mainover afleet.tomlvalidated bycap_fleet_plan). Results print viacap_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_exitis capped at 600s and reports null exit codes when still running; v2 renames deferred. _cap/fs.list/exists/deleteand_cap/user_io.*shapes are tai-defined (the profile fixes methods, not JSON shapes).