- 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
295 lines
13 KiB
Markdown
295 lines
13 KiB
Markdown
# 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).
|