--- contract_version: "2.1" target_module: "ratatoskr.cli" scope: "Implement the non-interactive `--send` CLI presenter. One sync entry point (`main`) plus its async orchestrator (`_amain`) plus three helpers (`_parse_args`, `_render_event`, `_run_turn`). Composes `ratatoskr.sessions.create_session` (when `--new`) with `ratatoskr.sse_client.stream_turn` + `cancel_turn`. Renders the SSE event stream to stdout (agent message deltas only, raw text) and stderr (all other events, labeled) — the stdout/stderr split is the load-bearing scriptability contract. Handles SIGINT by issuing server-side `cancel_turn` then draining until the terminal `Cancelled` event arrives. No Textual import; no markdown rendering; no in-process reconnect (one-shot, `SseConnectionDropped` exits non-zero). First code-level consumer of issues #1 and #2 in the repo — the integration test the contract-mock unit tests cannot deliver." depends_on: - "httpx" - "ratatoskr.sse_client" - "ratatoskr.sessions" used_by: [] language: "python" complexity: "medium" estimated_loc: 280 confidence: 0.85 assumptions: - "Worldtree spec pin (`docs/conversation-api-spec.md` at v1.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`) is unchanged. Wire-level event shape and HTTP envelopes are consumed via the issue #1 / #2 modules; the CLI does not re-parse SSE." - "Issues #1 and #2 are landed on main and their public surfaces (FN signatures, Event variants, exception types) are stable. The CLI imports `stream_turn`, `cancel_turn`, the `Event` discriminated union (`WorkerPhase | Thinking | Text | TextBoundary | ToolStart | ToolResult | Done | Error | Cancelled`), `SseConnectFailed`, `SseConnectionDropped`, `MalformedSseId`, `TurnIdFlip`, `CancelFailed`, `CancelTurnNotFound`, `CancelAlreadyCompleted` from `ratatoskr.sse_client`; and `create_session`, `AgentNotFound`, `SessionApiFailed` from `ratatoskr.sessions`." - "`httpx.AsyncClient(base_url=server_url, headers={'Authorization': f'Bearer {api_key}'})` threads the base URL into the relative endpoint paths the sse_client / sessions modules use. The CLI does NOT pass base URLs into module-level calls." - "`asyncio.get_running_loop().add_signal_handler(signal.SIGINT, ...)` is the SIGINT mechanism (POSIX). Windows is out of scope per design-brief §6 negative clauses (`NOT cross-platform`)." - "stdout/stderr are line-buffered TextIO objects. Per-chunk `flush()` after each agent-delta write is the consumer's responsibility — the contract specifies the flush explicitly to keep streaming visible under pipes." open_questions: - "Should `worker_phase` / `thinking` events be rendered to stderr (verbose) or suppressed (quiet)? Draft: render to stderr — observability IS the product per design-brief §5. Add `--quiet` only if empirically noisy." - "Should `--agent` accept a default via a separate `WORLDTREE_DEFAULT_AGENT` env var? Draft: no — explicit by design. Add only if a workflow emerges that wants a default." - "Should the SIGINT-triggered `cancel_turn` HTTP call have its own timeout independent of the AsyncClient default? Draft: no — caller-owned client's default applies; if it stalls, user can SIGKILL. Revisit if the cancel endpoint is observed to hang in practice." prd: issue: 3 issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/3" body_sha256_16: "206ef51709d43b2c" lock_in_comment_id: null lock_in_sha256_16: null lock_in_at: null pinned_at: "2026-05-21T05:13:29+00:00" dependencies: - issue: 1 path: "src/ratatoskr/sse_client.py" reason: "Code-level import. CLI calls `stream_turn(client, session_id, content)` and `cancel_turn(client, session_id, turn_id)`, consumes the typed `Event` union, and exit-code-maps the public exception types." - issue: 2 path: "src/ratatoskr/sessions.py" reason: "Code-level import (conditional on `--new`). CLI calls `create_session(client, agent_id)` and reads `SessionInfo.session_id` to feed `stream_turn`." --- # CLI — Non-interactive --send stdout presenter ## Context `ratatoskr.cli` is the non-interactive `--send` presenter described in `docs/design-brief.md` §8b. One entry point — `main` — registered as the `ratatoskr` console script in `pyproject.toml`. Streams a single turn against a Worldtree session and exits; useful for CI smoke tests, scripted dev probes, `for i in {1..5}; do ratatoskr --send ...; done` loops, and paste-into-shell debugging. The CLI is the **first code-level composition** of `ratatoskr.sse_client` (issue #1) and `ratatoskr.sessions` (issue #2). The two upstream modules unit-test against `respx` HTTP mocks; the CLI is where their real-Worldtree integration discipline gets exercised end-to-end. The stdout/stderr split is intentional: agent message deltas (the user-visible payload) on stdout, all observability events (tool calls, persona affect, errors, lifecycle) labeled on stderr. This makes the CLI both pipeable (`ratatoskr --send "..." | grep ...`) and human-watchable (run without redirection, watch both streams interleave). The Textual TUI is the eventual primary product (design-brief §5) — separate issue, larger surface. This contract deliberately ships ONLY the stdout presenter. `ratatoskr.cli` MUST NOT import `textual` (the TUI lands as a sibling module under `ratatoskr.tui`; the CLI dispatches to neither in this issue). ## Data flow **Input:** - `argv: list[str] | None` — command-line argv (None ⇒ use `sys.argv[1:]`). - Environment: `WORLDTREE_API_KEY` (fallback for `--api-key`), `WORLDTREE_API_URL` (fallback for `--server`). **Parsed arguments** (`ParsedArgs` frozen dataclass): - `send_content: str | None` — the user message text (`--send `); None signals TUI mode (issue #4 amendment). - `session_id: str | None` — existing session (`--session `); mutex with `new`. - `new: bool` — mint a fresh session (`--new`); mutex with `session_id`. - `agent_id: str | None` — required iff `new=True`. - `api_key: str` — resolved from `--api-key` then `$WORLDTREE_API_KEY`. - `server_url: str` — resolved from `--server`, then `$WORLDTREE_API_URL`, then `http://localhost:8000`. - `raw: bool` — `--raw` opt-out from markdown rendering (issue #4 amendment). - `end_user_id: str | None` — `--end-user-id ` for per-end-user agents (issue #5 amendment); default None preserves the pre-#5 baseline for agents that don't require it (mimir). **Output (stdout):** - Raw text deltas from `Text` events, written without trailing newline per chunk. A single trailing newline is written after the terminal `Done` event so the next shell prompt lands on a fresh line. **Output (stderr):** - One labeled line per non-`Text` event. Format: - `[worker_phase] phase=streaming turn_id=42` - `[thinking] ` (truncate to 200 chars; long thinking blobs are noisy) - `[text_boundary] kind=sentence char_offset=128` - `[tool_start] name=read_file args={...}` - `[tool_result] name=read_file duration_ms=42 result=` (truncate result repr to 200 chars) - `[done] turn_id=42 model=glm5-turbo duration_ms=1234 usage=...` - `[error] turn_id=42 code=llm_output_invalid message=<...>` - `[cancelled] turn_id=42 reason=user_request partial_message_id=N|None` - Plus pre-stream lifecycle: - `[create_session] session_id=... agent_id=...` (when `--new`) - Plus the pre-first-event cancel label (INV-008): - `[cancelled] (before any event arrived)` — SIGINT fired before any event yielded a turn_id; CLI exits 3 without issuing `cancel_turn`. Distinct from the post-event `[cancelled] turn_id=... reason=... partial_message_id=...` line emitted by the `Cancelled` terminal event. - Plus failure paths (always to stderr): - `[auth_error] no API key (set --api-key or WORLDTREE_API_KEY)` - `[usage_error] --session and --new are mutually exclusive` - `[agent_not_found] agent_id=...` - `[session_api_failed] status=... body=` - `[sse_connect_failed] status=... body=` - `[connection_dropped] last_seen=` - `[malformed_sse_id] raw=...` - `[turn_id_flip] expected=... got=...` - `[cancel_failed] status=... body=` (during SIGINT-triggered cancel; informational, does not change the primary exit code) **Exit codes:** - `0` — clean `Done` terminal event received. - `2` — `Error` terminal event received (server signaled turn-level failure). - `3` — `Cancelled` terminal event received. Distinguishes user-cancel from server-cancel via stderr label only; the exit code is the same. - `10` — usage error (bad argv, mutex violation, missing required). - `11` — auth error (missing API key). - `12` — `AgentNotFound` from `create_session` (unknown `agent_id`). - `20` — server API failure: `SessionApiFailed` from `create_session`, OR `SseConnectFailed` from `stream_turn` (including HTTP 401 / 404 / 5xx before the stream opens). Status surfaced on stderr. - `21` — network failure: `SseConnectionDropped` (no in-process reconnect in `--send`), generic `httpx.ConnectError` / `httpx.ReadTimeout` raised before any HTTP call lands. - `22` — protocol failure: `MalformedSseId` or `TurnIdFlip` (server-side wire bug — surfaces them honestly, does not paper over). **Side effects:** outbound HTTP only (via the modules); writes to stdout / stderr; one SIGINT handler installed on the running event loop, uninstalled in cleanup. **On disk:** none. No config files, no state persistence. Cross-process resume is explicitly deferred to v2 per design-brief §8d. ## Invariants - **INV-001 [hard]**: `ratatoskr.cli` imports no in-repo modules other than `ratatoskr.sessions` and `ratatoskr.sse_client`. Standard-library imports (`argparse`, `asyncio`, `os`, `signal`, `sys`, `dataclasses`, `typing`) and the already-declared `httpx` dependency are unrestricted. It does NOT import `textual`, does NOT import `rich` (raw text on stdout — no markdown rendering per design-brief §6's "TUI-only" framing for `--send`). The Textual TUI lands as a sibling module in a later issue; the textual-boundary half of this invariant is enforced by an inline import-check test (`test_no_textual_import_in_cli`). - **INV-002 [hard]**: stdout is written to in exactly two cases: (a) `Text` event deltas (raw `event.content`, no newline appended, flushed per chunk per INV-010); (b) a single trailing newline written immediately after the terminal `Done` event (so the next shell prompt lands on a fresh line). No other event variant — `WorkerPhase`, `Thinking`, `TextBoundary`, `ToolStart`, `ToolResult`, `Error`, `Cancelled` — writes anything to stdout. The `Done` newline is the only non-`Text` stdout write and is part of the contract, not an exception to it. - **INV-003 [hard]**: All observability output (every non-`Text` event, every error label, every lifecycle label) goes to stderr. Redirecting stderr to `/dev/null` MUST leave a clean text-only stream on stdout suitable for piping. - **INV-004 [hard]**: `--session` and `--new` are mutually exclusive AND exactly one is required. Passing both, or neither, OR passing `--agent` together with `--session`, exits with code `10` and a `[usage_error] ...` line to stderr BEFORE any HTTP call is issued. - **INV-005 [hard]**: `--api-key` resolution order: explicit flag wins; otherwise `$WORLDTREE_API_KEY`. If neither is set, exit with code `11` and `[auth_error] ...` to stderr BEFORE any HTTP call is issued. - **INV-006 [hard]**: `--server` resolution order: explicit flag, then `$WORLDTREE_API_URL`, then default `http://localhost:8000`. The default is a documented local-dev posture (design-brief §6); no error if all three are absent. - **INV-007 [hard]**: SIGINT during a streaming turn issues exactly one server-side `cancel_turn(client, session_id, last_seen_turn_id)` call where `last_seen_turn_id` is the `turn_id` of the most recently yielded event. A SECOND SIGINT during the same drain re-issues nothing (the cancel is in-flight; subsequent SIGINTs are no-op until the terminal event arrives). The iterator continues to drain until the `Cancelled` terminal event lands; only then does the CLI exit. - **INV-008 [hard]**: SIGINT BEFORE the first event arrives (turn_id unknown — connection not yet established, or open but no events yielded) skips `cancel_turn` entirely and exits with code `3` immediately. The server's stall-watchdog handles the orphan turn (per spec). - **INV-009 [hard]**: `cancel_turn` failures during SIGINT handling (e.g., `CancelFailed`, `CancelTurnNotFound`, `CancelAlreadyCompleted`) write a `[cancel_failed] ...` line to stderr and continue draining the stream. They do NOT raise out of `_run_turn` — the primary exit code is determined by the stream's terminal event, not the cancel attempt's outcome. - **INV-010 [hard]**: stdout writes are flushed after every `Text` delta (`sys.stdout.flush()` per chunk). The terminal-newline write is also flushed. Without per-chunk flush, streaming is invisible under pipes — the load-bearing scriptability contract dies. ## Out of scope - **Textual TUI** — separate issue. This contract ships ONLY the stdout presenter. `ratatoskr.cli` does NOT import `textual`; INV-001 enforces. - **Markdown rendering on stdout** — design-brief §6 commits to markdown-default-on with `--raw` opt-out, but ONLY for the TUI. `--send` streams raw deltas always; no `rich`, no `--raw` flag in this contract. - **Two-stage Ctrl-C** (design-brief §8c) — one-shot mode has no idle state for the second-Ctrl-C-exits semantic. INV-007 lock simple cancel-then-drain. - **`--server-log` pane** — TUI-only observability surface (design-brief §5). - **`reconnect_turn` mid-process** — `--send` is one-shot; `SseConnectionDropped` exits with code `21`. In-process reconnect is a TUI feature. - **Recorded SSE snapshot fixtures** — captured by a separate follow-up issue. The `--send --new` invocation IS the recording probe but the snapshot replay test infrastructure is out of scope here. - **JSON output mode** — deferred until a scripted caller actually needs it. The stdout/stderr split provides enough structure for most piping use cases. - **`--quiet`** — deferred. If the default stderr labeling is empirically noisy in real use, add a `--quiet` flag in a follow-up. - **Windows support** — design-brief §6 commits to terminal-native POSIX. `asyncio.add_signal_handler` is POSIX-only; no Windows shim. ## Constraints - **[compatibility]** Module must work against the spec pin (`55101e909abcd2219833266b6f905c5bc956e0f0`, Worldtree v0.19.0). Spec bumps go through the issue #1 / #2 modules; the CLI is insulated from wire-level changes. - **[performance]** Streaming MUST NOT buffer the turn in memory. Per-chunk write + flush to stdout. The `Done.complete_response` field is observable on stderr (truncated) but NOT used as the source of stdout output (which is delta-by-delta). - **[security]** Module does not log full request/response bodies, does not log the `Authorization` header, does not log `--api-key` value. Per-event stderr labels limit content to truncated reprs (200 chars for `Thinking` content and `ToolResult.result`). - **[style]** Async-native. The sync entry point is a thin `asyncio.run(_amain(...))` wrapper. No nested event loops; no thread pools. argparse-only (no Click / Typer) for stdlib-only dep posture. ## Architecture ``` main(argv) [sync] │ ├─ _parse_args(argv) → ParsedArgs [argparse + env resolution + xor validation; raises UsageError on violation] │ └─ asyncio.run(_amain(args)) → int [exit code] │ ├─ httpx.AsyncClient(base_url=args.server_url, headers={Auth: Bearer args.api_key}) as client: │ ├─ IF args.new: │ info = await sessions.create_session(client, args.agent_id) │ session_id = info.session_id │ stderr ← f"[create_session] session_id={info.session_id} agent_id={info.agent_id}" │ ELSE: │ session_id = args.session_id │ ├─ sigint_event = asyncio.Event() │ loop.add_signal_handler(SIGINT, sigint_event.set) │ └─ return await _run_turn(client, session_id, args.send_content, sigint_event, stdout, stderr) │ ├─ async-iterate sse_client.stream_turn(client, session_id, content) ├─ race each __anext__() against sigint_event.wait() so a mid-stream SIGINT lands within one event boundary ├─ on each event: _render_event(event, stdout, stderr) ├─ on sigint_event set + last_turn_id known + not already cancelling: │ asyncio.create_task(sse_client.cancel_turn(client, session_id, last_turn_id)) │ set cancelling=True (idempotent across repeated sigints) ├─ on terminal event (Done | Error | Cancelled): break + map to exit code └─ on exception bubbled out of stream_turn: map to exit code per the table in Data flow ``` --- ```contract FN main(argv: list[str] | None = None) -> int BRIEF: Sync entry point registered as the `ratatoskr` console script. Parses argv, runs the async orchestrator under asyncio.run, returns the exit code. Catches `UsageError` (from _parse_args) and `_AuthError` and maps them to exit codes 10 / 11 before reaching the event loop. Everything else propagates through _amain. PRE: [PRE-001 hard] argv is None or a list of strings -- assert argv is None or all(isinstance(a, str) for a in argv) POST: [POST-001 return_value] returns an int exit code in the documented range (0, 2, 3, 10, 11, 12, 20, 21, 22) POST: [POST-002 side_effect] usage/auth errors are reported to stderr before the event loop runs -- assert capsys.readouterr().err includes "[usage_error]" or "[auth_error]" ERROR_ROUTING: UsageError: local_handling: write `[usage_error] {msg}` to stderr flow_control: abort state_recovery: none (no resources acquired before _parse_args) _AuthError: local_handling: write `[auth_error] no API key (set --api-key or WORLDTREE_API_KEY)` to stderr flow_control: abort state_recovery: none SystemExit (from argparse clean exits — `--help`, `--version`): local_handling: catch and return `exc.code` verbatim (typically 0); argparse already printed help/version to stdout flow_control: abort state_recovery: none STEPS: 1. [setup, flexibility=prescriptive] TRY: args = _parse_args(argv) ON UsageError as exc: WRITE f"[usage_error] {exc}\n" to stderr RETURN 10 ON _AuthError as exc: WRITE f"[auth_error] {exc}\n" to stderr RETURN 11 ON SystemExit as exc: RETURN int(exc.code) if exc.code is not None else 0 2. [branch, flexibility=prescriptive] IF args.send_content is None: # TUI mode (issue #4 amendment) — lazy import preserves INV-001 (no textual in cli at module scope) FROM ratatoskr.tui IMPORT run_tui RETURN run_tui(args) ELSE: RETURN asyncio.run(_amain(args)) TESTS: happy_returns_amain_exit_code [happy,tracer]: argv specifies a complete --send invocation; monkeypatch _amain to return 0 → main returns 0 usage_error_both_session_and_new [error]: argv has both --session and --new → returns 10; stderr "[usage_error]" auth_error_missing_key [error]: argv specifies --send/--new/--agent but neither --api-key nor WORLDTREE_API_KEY is set → returns 11; stderr "[auth_error]"; _amain never called no_argv_uses_sys_argv [trace]: argv=None → _parse_args is called with sys.argv[1:] (monkeypatched argparse capture confirms) help_exits_cleanly [happy]: argv=["--help"] → main returns 0 (or whatever code argparse exits with); _amain never called; help text was printed to stdout by argparse no_send_dispatches_to_tui [happy]: argv omits --send → main lazy-imports run_tui and calls it (NOT _amain); send_content marker is None (issue #4 amendment) ``` ```contract FN _parse_args(argv: list[str] | None) -> ParsedArgs BRIEF: argparse + env-fallback + xor-validation. Returns a frozen `ParsedArgs` on success. Raises `UsageError` on argument violations and `_AuthError` on missing API key (both caught by `main` and mapped to exit codes 10 / 11 BEFORE the event loop opens). PRE: [PRE-001 hard] argv is None or a list of strings -- assert argv is None or all(isinstance(a, str) for a in argv) POST: [POST-001 return_value] returns ParsedArgs with `send_content` non-empty, exactly one of `session_id` / `new` set, and `api_key` non-empty POST: [POST-002 return_value] when args.new, args.agent_id is non-empty; when args.session_id is set, args.agent_id is None POST: [POST-003 return_value] args.server_url is one of: explicit --server value, $WORLDTREE_API_URL value, or "http://localhost:8000" — in that resolution order (INV-006) ERROR_ROUTING: argparse.ArgumentError | SystemExit-from-argparse: local_handling: catch and re-raise as UsageError(msg) so main() can format it consistently flow_control: abort state_recovery: none xor violation (both --session and --new, or neither): local_handling: raise UsageError("--session and --new are mutually exclusive; pass exactly one") flow_control: abort state_recovery: none --agent passed with --session: local_handling: raise UsageError("--agent is required with --new and forbidden with --session") flow_control: abort state_recovery: none --new without --agent: local_handling: raise UsageError("--agent is required when --new is passed") flow_control: abort state_recovery: none api_key unresolved (no flag, no env): local_handling: raise _AuthError("no API key (set --api-key or WORLDTREE_API_KEY)") flow_control: abort state_recovery: none argparse SystemExit (clean exits — `--help`, `--version` etc., code=0): local_handling: allow to propagate from `_parse_args` to `main`; `main` catches and returns the code verbatim flow_control: passthrough — argparse already printed help/version to stdout; no further work needed state_recovery: none (no resources acquired before _parse_args) STEPS: 1. [setup, flexibility=prescriptive] Construct argparse.ArgumentParser: --send (str, OPTIONAL — issue #4 amendment: omitted → TUI mode marker; if passed, must be non-empty) --session (str, optional) --new (bool flag, default False) --agent (str, optional — required-with-validation in step 3) --api-key (str, optional — env fallback in step 4) --server (str, optional — env fallback + default in step 5) --raw (bool flag, default False — issue #4 amendment: TUI markdown opt-out) --end-user-id (str, optional, default None — issue #5: required by per-end-user agents; if passed, must be non-empty) 2. [sequential, flexibility=prescriptive] Parse argv: TRY: ns = parser.parse_args(argv if argv is not None else sys.argv[1:]) ON SystemExit: Re-raise as UsageError with argparse's captured message 2a. [branch, flexibility=prescriptive] IF ns.send is not None AND not ns.send: RAISE UsageError("--send content must be non-empty") # empty-string --send still invalid 2b. [branch, flexibility=prescriptive, issue #5] IF ns.end_user_id is not None AND not ns.end_user_id: RAISE UsageError("--end-user-id must be non-empty when passed") # mirrors empty-send check 3. [branch, flexibility=prescriptive] Validate session/new/agent triad per INV-004: IF ns.session and ns.new: RAISE UsageError("--session and --new are mutually exclusive") IF NOT ns.session AND NOT ns.new: RAISE UsageError("pass exactly one of --session or --new") IF ns.session AND ns.agent: RAISE UsageError("--agent is required with --new and forbidden with --session") IF ns.new AND NOT ns.agent: RAISE UsageError("--agent is required when --new is passed") 4. [sequential, flexibility=prescriptive] Resolve api_key per INV-005: api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or "" IF NOT api_key: RAISE _AuthError("no API key (set --api-key or WORLDTREE_API_KEY)") 5. [sequential, flexibility=prescriptive] Resolve server_url per INV-006: server_url = ns.server or os.environ.get("WORLDTREE_API_URL") or "http://localhost:8000" 5b. [sequential, flexibility=prescriptive, issue #5 amended 2026-05-23] Resolve end_user_id with env-var fallback: end_user_id = ns.end_user_id or os.environ.get("RATATOSKR_END_USER_ID") or None # Flag > $RATATOSKR_END_USER_ID > None. env.sh ships "ratatoskr-tui" as the project-stable partition. 6. [cleanup] RETURN ParsedArgs( send_content=ns.send, # may be None (TUI marker, issue #4 amendment) session_id=ns.session, new=ns.new, agent_id=ns.agent, api_key=api_key, server_url=server_url, raw=ns.raw, # issue #4 amendment end_user_id=end_user_id, # issue #5 amendment (env-var fallback amended 2026-05-23) ) TESTS: happy_new [happy,tracer]: argv=["--send", "hi", "--new", "--agent", "mimir", "--api-key", "k"] → ParsedArgs(send_content="hi", session_id=None, new=True, agent_id="mimir", api_key="k", server_url="http://localhost:8000") happy_existing_session [happy]: argv=["--send", "hi", "--session", "s-1", "--api-key", "k"] → ParsedArgs with session_id="s-1", new=False, agent_id=None api_key_from_env [trace]: monkeypatch WORLDTREE_API_KEY="from-env"; argv omits --api-key → ParsedArgs.api_key=="from-env" api_key_flag_beats_env [trace]: monkeypatch WORLDTREE_API_KEY="env"; argv has --api-key "flag" → ParsedArgs.api_key=="flag" server_default [trace]: argv omits --server and WORLDTREE_API_URL is unset → ParsedArgs.server_url=="http://localhost:8000" server_env_fallback [trace]: monkeypatch WORLDTREE_API_URL="http://t.local:9000"; argv omits --server → ParsedArgs.server_url=="http://t.local:9000" server_flag_beats_env [trace]: monkeypatch WORLDTREE_API_URL="env"; argv has --server "flag" → ParsedArgs.server_url=="flag" no_send_marks_tui_mode [happy]: argv=["--new", "--agent", "mimir", "--api-key", "k"] → ParsedArgs.send_content=None (TUI marker; issue #4 amendment — was UsageError pre-#4) raw_flag_default_false [trace]: --raw absent → ParsedArgs.raw==False (issue #4 amendment) raw_flag_set [trace]: --raw → ParsedArgs.raw==True (issue #4 amendment) usage_both_session_and_new [adversarial]: argv has both --session and --new → UsageError("mutually exclusive") usage_neither_session_nor_new [adversarial]: argv has --send but neither --session nor --new → UsageError("pass exactly one") usage_new_without_agent [adversarial]: argv=["--send","hi","--new","--api-key","k"] → UsageError("--agent is required when --new") usage_session_with_agent [adversarial]: argv=["--send","hi","--session","s-1","--agent","x","--api-key","k"] → UsageError("--agent is required with --new and forbidden with --session") auth_missing [error]: argv lacks --api-key and WORLDTREE_API_KEY unset → _AuthError empty_send [adversarial]: argv=["--send", "", "--new", "--agent", "x", "--api-key", "k"] → UsageError (non-empty enforced via argparse type=lambda or explicit check) happy_new_with_end_user_id [happy, issue #5]: argv includes --end-user-id alice → ParsedArgs.end_user_id == "alice" end_user_id_default_none [trace, issue #5]: argv omits --end-user-id AND env unset → ParsedArgs.end_user_id is None empty_end_user_id [adversarial, issue #5]: argv has --end-user-id "" → UsageError("--end-user-id must be non-empty when passed") end_user_id_from_env [trace, issue #5 amended 2026-05-23]: env RATATOSKR_END_USER_ID="ratatoskr-tui"; flag omitted → ParsedArgs.end_user_id == "ratatoskr-tui" end_user_id_flag_beats_env [trace, issue #5 amended 2026-05-23]: env set + flag passed → flag wins ``` ```contract FN _amain(args: ParsedArgs) -> int BRIEF: Async orchestrator. Opens an authenticated httpx.AsyncClient, optionally creates a fresh session, installs a SIGINT handler, drives the turn via _run_turn, returns the exit code. Maps top-level upstream exceptions (`AgentNotFound`, `SessionApiFailed`, generic httpx connection failures during create_session) to exit codes 12 / 20 / 21 before reaching _run_turn. PRE: [PRE-001 hard] args is a ParsedArgs (post-validation; PRE-002/PRE-003 of _parse_args hold) -- assert isinstance(args, ParsedArgs) POST: [POST-001 return_value] returns one of the documented exit codes (0, 2, 3, 12, 20, 21, 22) POST: [POST-002 side_effect] when args.new is True, exactly one POST /sessions was issued -- assert respx tracked the call POST: [POST-003 side_effect] when args.new is True, stderr contains ". create_session: session_id=... agent_id=..." before any stream events (issue #12 amendment: `[create_session]` demoted to `. create_session:` to match the telemetry hierarchy; written directly by `_amain` — bypasses `state.render` since it is not a wire-level Event variant) POST: [POST-004 side_effect] the SIGINT handler is removed in cleanup (loop.remove_signal_handler called) -- verified via teardown probe in test fixtures ERROR_ROUTING: AgentNotFound: local_handling: write `[agent_not_found] agent_id={exc.agent_id}` to stderr flow_control: abort state_recovery: none SessionApiFailed: local_handling: write `[session_api_failed] status={exc.status} body={exc.body!r}` to stderr flow_control: abort state_recovery: none httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError (from create_session): local_handling: write `[network_error] {type(exc).__name__}: {exc}` to stderr flow_control: abort state_recovery: none STEPS: 1. [setup, flexibility=prescriptive] OPEN httpx.AsyncClient(base_url=args.server_url, headers={"Authorization": f"Bearer {args.api_key}"}, timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)) via async-with — read=None disables the SSE-killing 5s default (issue #1 [compatibility] constraint) 2. [branch, flexibility=prescriptive] IF args.new: TRY: info = await sessions.create_session(client, args.agent_id, end_user_id=args.end_user_id) # issue #5: thread end_user_id when set; None preserves pre-#5 body shape ON AgentNotFound as exc: WRITE stderr; RETURN 12 ON SessionApiFailed as exc: WRITE stderr; RETURN 20 ON httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError as exc: WRITE stderr; RETURN 21 WRITE f". create_session: session_id={info.session_id} agent_id={info.agent_id}\n" to stderr (issue #12: demoted prefix; direct write bypasses state.render) SET session_id = info.session_id ELSE: SET session_id = args.session_id # pre-validated non-None 3. [sequential, flexibility=prescriptive] Install SIGINT handler: sigint_event = asyncio.Event() loop = asyncio.get_running_loop() loop.add_signal_handler(signal.SIGINT, sigint_event.set) 4. [sequential, flexibility=prescriptive] TRY: exit_code = await _run_turn(client, session_id, args.send_content, sigint_event, stdout=sys.stdout, stderr=sys.stderr) FINALLY: loop.remove_signal_handler(signal.SIGINT) 5. [cleanup] RETURN exit_code TESTS: happy_new_session_then_stream [happy,tracer]: respx mocks POST /sessions → 201 + the SSE POST → text+done; argv specifies --new --agent mimir → _amain returns 0; stderr has ". create_session:" before "[done]" (issue #12: demoted prefix; pre-amendment shape "[create_session]" forbidden) happy_existing_session [happy]: respx mocks the SSE POST only; argv specifies --session s-1 → _amain returns 0; respx tracked exactly 0 POST /sessions calls agent_not_found_exits_12 [error]: respx mocks POST /sessions → 404; --new → returns 12; stderr "[agent_not_found]"; stream_turn never invoked session_api_failed_exits_20 [error]: respx mocks POST /sessions → 500 with body → returns 20; stderr "[session_api_failed] status=500 body=..." connect_error_exits_21 [error]: respx simulates httpx.ConnectError on POST /sessions → returns 21; stderr "[network_error]" sigint_handler_installed_and_removed [trace]: probe asyncio loop signal handlers before/after _amain — handler present during _run_turn execution, absent after _amain returns no_textual_import [scenario]: import ratatoskr.cli and assert "textual" not in sys.modules induced by that import (INV-001 verified at the import boundary) ``` ```contract CLASS CliPresenterState # issue #12 amendment BRIEF: Stateful per-turn presenter for `--send` mode. Replaces the stateless `_render_event` (removed). Owns `thinking_buffer`, `thinking_open`, `text_written_since_newline`; coalesces thinking-event deltas into one growing stderr line per run; demotes telemetry events with a `. ` prefix; guarantees a stdout `\n` boundary before terminal labels (`[done]`, `[error]`, `[cancelled]`) when assistant text has been streamed. PROPERTIES: thinking_buffer: list[str] thinking_open: bool text_written_since_newline: bool INV-WIRE-001: One instance per `_amain` call (issue #12 INV-008). ``` ```contract FN CliPresenterState.render(self, event: Event, *, stdout: TextIO, stderr: TextIO) -> None # issue #12 amendment BRIEF: Render one event into stdout/stderr with editorial hierarchy + thinking coalescing per issue #12 INV-001..INV-007. ASCII-only output (no Unicode in CLI). Demoted-telemetry events get `. ` prefix on stderr; load-bearing events (Text on stdout; Done/Error/Cancelled on stderr) get no prefix. PRE: [PRE-001 hard] event is an instance of one of the Event union variants POST: [POST-001 side_effect] for Thinking: append delta to thinking_buffer; write to stderr (with `. thinking: ` prefix on the first delta of the run, content-only on subsequent deltas); set thinking_open=True POST: [POST-002 side_effect] for non-Thinking when thinking_open: write `\n` to stderr; clear buffer; thinking_open=False; THEN render the new event POST: [POST-003 side_effect] for Text: write event.content to stdout (no forced newline); set text_written_since_newline = not event.content.endswith("\n") (Volva F4 fix) POST: [POST-004 side_effect] for Done/Error/Cancelled: if text_written_since_newline, write `\n` to stdout + flush + reset flag (INV-005); then write the load-bearing terminal label to stderr (no demotion prefix); for Done, format `duration=` + `usage

in -> out ( total, cached)` via INV-006 / INV-007 helpers POST: [POST-005 side_effect] for demoted telemetry (WorkerPhase, TextBoundary, ToolStart, ToolResult): write `.