# Session harness app: three forms, candidate tasks, risks **Date**: 2026-10-16 **Author**: researcher, model id `claude-opus-5-5` **Status**: findings and options for the user. No verdict. **Asked by**: Igor, session [2026-10-15-21-grotto-work](../journals/2026-10-15-21-grotto-work.md) (entry "User long-term idea: a dedicated Rust app") **Inputs**: [goals/headless-forks.md](../goals/headless-forks.md), [designs/2026-10-16-01-headless-forks.md](../designs/2026-10-16-01-headless-forks.md) (claim ids such as `F4a` and risk ids such as `R1` refer to it and to the research), [research/2026-10-16-00-claude-headless-fork-sessions.md](../research/2026-10-16-00-claude-headless-fork-sessions.md), [research/2026-10-15-14-claude-code-tasks-and-pcvelz.md](../research/2026-10-15-14-claude-code-tasks-and-pcvelz.md), [reports/dirigent/](./dirigent/). I also took a read-only look at the Dirigent repo (`G:\dev\projects\dirigent`, HEAD `b95d026f`, 2026-10-15) to see which crates already exist. Web: one search, on the Rust MCP SDK. Working name in this report: **stable**. It comes from the user's "stable for the constables", meaning something that keeps the harnesses. A headless session is a *constable*. A trained base is a *mount* kept in the stable. These are placeholders, not proposals. --- ## 0. Summary - The near-term Spire helper (`spire:forks`) already fixes the app's core: one owner of the `claude -p` command line, a launch fingerprint, an env scrub, a registry and ledger, numeric keep-warm and rebase rules, and per-call usage. A Rust app is that core with a longer lifetime and more callers. The **JSON contract of the helper is the natural seam**: keep it and all three forms can sit behind it. - Dirigent already has most of the parts in Rust: - `dirigent_anth` parses Claude Code JSONL transcripts, dedups streamed messages, links subagents and reads plan-usage gauges. - `dirigent_process` manages process lifetimes, using Windows Job Objects. - `dirigent_taskrunner` and `dirigent_logs` run child processes and capture their output. - `dirigent_fermata` is a precedent for "library + CLI + harness adapter" in three layers. So the gap between "standalone CLI" and "Dirigent feature" is smaller than it looks, provided the core crate does not depend on Dirigent. - The three forms differ mainly in **who owns time**: - The CLI is per call. Bookkeeping ticks only while someone is calling it, which is how the Spire helper works today. - Dirigent is a long-running server with a watchdog, so it can keep bases warm, enforce TTLs and push signals. - MCP is a face only. It inherits the time model of whatever sits behind it. - **The token MCP** (§6), the user's main target: agents and orchestrators read their own and their children's token counts. Apart from headless `result` events, Claude Code exposes almost none of this live, so the data must come from transcripts (`~/.claude/projects/…`, subagents under `subagents/agent-.jsonl`). Identity, meaning "which session am I", is the hard part, not counting. It is read-only and can ship first. - **Lightweight agent access** (§7): launch / resume / fork / message / status / wait / stop / list over stored launch profiles, as CLI and MCP, and as a Dirigent connector later. These are turn-based headless sessions, not live TUI sessions. - I found eight workpad tasks that fit, all marked **candidate, confirm** (§8). Bundling ideas, including my own proposals, are in §12. Two fit strongly: orchestrator token introspection (lean-athanor "Later") and the experiment's transcript-based token accounting. Plan-usage and spend-limit awareness also fits strongly. It comes from the Dirigent reports. - The main risks: - Claude Code flag and event churn: the app pins against a moving CLI. - OAuth: the 1 h TTL requires OAuth, and OAuth requires not using `--bare`, which means inheriting discovery. - Cache-TTL rules: docs-only, account-dependent, and they drop silently to 5 min over the plan limit. - Detached-process survival on Windows: an untested design risk (R1) that the forms solve differently. --- ## 1. What the app is responsible for (all forms) These come from the goal and the design. They are the same in every form. | Responsibility | Source | Notes | |---|---|---| | Train a base session | goal §Mechanism; design §3.4 `train` | `claude -p --session-id ` with the full fingerprint | | Fork per task with a brief | design `fork` | `--resume --fork-session`; flags re-passed identically (`4b`, `4c`) | | Rebase (absorb a delta into a new base) | design `rebase` | lineage: `parent`, `superseded` | | Continue a fork (one more turn) | design `continue` | `--resume` without `--fork-session` | | Keep-warm / expire / re-train bookkeeping | goal §Bookkeeping; design §5 | TTL 1 h or 5 min (`F4a`); a hit resets it (`F4c`); pings below the break-even count | | Launch fingerprint, checked for drift | design §3.3 | binary path + version + size + mtime, full model id, effort, mode, tools, MCP, agent hash, preamble hash, cwd, pinned env | | Env scrub | design §4; `F3a`, `F3b` | 12 Desktop vars plus model, TTL and auth overrides | | Usage and token accounting | design §6; `3d`, `F4e` | per-call deltas from cumulative `modelUsage`/`total_cost_usd`; real model from `modelUsage`, not `init.model` (`F2a`) | | Token-count access for agents | goal "Later"; journal 2026-10-16 | "CC doesn't let an agent read its own tokens". The app can answer for sessions it launched, and for others by transcript | | Supervision | design §3.5 | stall and timeout watchdogs, kill-tree, `permission_denials` → `needs_attention` | Two responsibilities appear only in a longer-lived app, so the Spire helper does not have them: - **Cross-run bases.** The design keeps state per run because nothing outlives the TTL. A daemon could keep a project base warm across runs, but only while pinging, so this is a cost question, not a mechanism question. - **Plan-usage awareness.** It predicts the drop to 5 min (`F4a`, R11) and the spend-limit kills (Dirigent retrospective: "a kill cost 3 h"). `dirigent_anth` already reads plan gauges (`anth_usage.rs`: `UsageGauge { percent_used, resets }`, read via a PTY). --- ## 2. Form 1: standalone Rust CLI ### Responsibilities Everything in §1, run per invocation, with no daemon required. Its job is to be what `forks.py` (the uv helper) is now, as a single binary that needs no runtime (no Node, no uv, no Python). ### Command surface (sketch) Keep the helper's verbs and exit codes (design §3.4: `0` ok, `2` refused, `3` wait timed out, `4` job failed). Add verbs for the cross-run, accounting and probe cases. ``` stable where # binary, versions, scrub check (same JSON as helper) stable init --state [launch fields] # --run kept as an alias that derives stable train --state --base B1 --brief f.md stable fork --state --base B1 --block 1.3 --brief f.md [--mode parallel] stable rebase --state --base B1 --as B2 --brief f.md stable continue --state --job j0007 --brief f.md stable wait --state --all|--any| --timeout 540 stable usage --state [--phase n] [--block id] --format json|md stable list | expire | kill | tick # as helper # new in the app stable tokens --session |--transcript [--dedup-by message-id] [--price ] # four columns + priced; works on any CC transcript, not only launched ones stable context --session # current context size of a session ("own tokens") stable plan usage # plan gauges → predicted TTL bucket stable probe # run the same prompt under N launch variants, record init + usage stable daemon [--state …] # optional: run the tick loop + pings; same state files ``` `stable tokens` and `stable context` cover the "token-count access for agents" wish. An agent calls them through Bash with its own session id. Its session id is in `CLAUDE_CODE_SESSION_ID` (`F3a` lists that var as inherited, but whether it is the *agent's* id or the host's is unverified). `stable probe` is the hook for the harness-behaviour candidate (§8, C4). The token MCP (§6) and agent access (§7) extend this surface. ### State and storage - Keep the helper's layout per state dir (design §3.2): `registry.json`, `ledger.jsonl`, `briefs/`, `notes/`, `results/`, `logs/`, `raw/` (gitignored). The Spire convention puts it beside the run log. The CLI should accept any directory, so non-Spire callers are not bound to the workpad. - Optional user-level index (`%LOCALAPPDATA%\stable\index.jsonl` or the XDG equivalent). It lists the known state dirs so `stable list --all` and the daemon can find bases across runs. It is an index only; the per-run files stay the source. - Locking: the same `open(wx)` lockfile plus write-temp-then-rename that the helper uses. In Rust: `fs2`/`fd-lock` or a plain `create_new` lockfile. The helper's files are the contract, so a Rust CLI and the Python helper could even read each other's state dirs during a migration. That only works if the file schemas are frozen, which is a decision, not a given. ### Migration from the Spire helper - **Option A: keep the JSON contract byte-stable.** `SKILL.md` switches from `uv run forks.py …` to `stable …` when `stable where` succeeds, and falls back to the helper otherwise. The Athanor procedure does not change. This costs a written schema: stdout objects, exit codes and the ledger line (design §6) as a versioned spec with a `"schema": 1` field. Today the contract lives in the design and the code. - **Option B: contract v2.** Rename `--run` to `--state`, add fields (plan bucket, transcript-derived context size). The skill text changes once. Worth it only if v1 shows real defects in the trial. - **Option C: helper as shim.** `forks.py` becomes a ~30-line wrapper that execs `stable` when present. A single skill text, two runtimes. In every option the trial results (design §10) should be in before the contract freezes, because design §5 thresholds and R5 (usage semantics) may change fields. ### Packaging and distribution - `cargo install`, GitHub/Gitea release binaries (Windows x64, Linux x64/arm64, macOS arm64), optionally `cargo-dist` for installers. A Reliquary plugin could ship a skill that only *documents* `stable` and checks for it. Shipping binaries inside a plugin is possible but heavy, and the marketplace is text-first. - Single static binary is the main gain over the helper: design R14 ("Node may be missing") and the uv amendment's "python not on PATH" both go away. ### Windows concerns - **Detached survival (R1).** Claude Code's Bash tool may run its children inside a Job Object with kill-on-close. That is how `dirigent_process/src/windows.rs` treats its own children (`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`), and it is the obvious mechanism behind R1. A Rust supervisor can try `CREATE_BREAKAWAY_FROM_JOB | DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`. Breakaway fails if the job lacks `JOB_OBJECT_LIMIT_BREAKAWAY_OK`. If it fails, the remaining options are the helper's `--attach` fallback or a daemon that was started outside the Bash tool (form 1 `daemon`, or form 2). - **Kill-tree.** Use Job Objects of its own: assign each `claude.exe` to a job, kill the job. This is cleaner than `taskkill /T /F`. - **Paths.** The transcript dir slug is derived from the Windows path (`C--Users-…`, `7`, `6d`). Normalise cwd with `dunce` (Dirigent already uses it in `dirigent_tools`). - **Two binaries** (`F5a`): the PATH install and the Desktop's `$CLAUDE_CODE_EXECPATH` under `AppData\Local\Packages\Claude_…`. The Desktop path changes on every Desktop update (it contains the version and a hash), so a long-lived registry must treat it as drift, never as a stable key. - Git Bash quoting no longer matters, because the scrub is an env map passed to `CreateProcessW`. - Console windows: use `CREATE_NO_WINDOW` for children. ### What it needs from Claude Code - `-p`, `--resume`, `--fork-session`, `--session-id`, `--output-format stream-json --verbose`, `--model` (full id), `--effort`, `--permission-mode`, `--tools`, `--allowedTools`/`--disallowedTools`, `--strict-mcp-config --mcp-config`, `--append-system-prompt-file`, `--agent`/`--agents`, `--max-turns`, `--max-budget-usd` (`5c`). - Events: the `system/init` fields (`session_id`, `model`, `tools`, `permissionMode`, `plugins`, `agents`; `apiKeySource` assumed), assistant events (`message.model`, per-message `usage`), and the `result` event (`usage`, `modelUsage`, `total_cost_usd`, `num_turns`, `is_error`, `permission_denials`, `subagent_stats`) (`1a`). - Env: `CLAUDE_CODE_PROMPT_CACHE_TTL` (≥ 2.1.242, `F4b`), `DISABLE_AUTOUPDATER` (assumed honoured), and the scrub list. - Transcripts: `~/.claude/projects//.jsonl`, 30-day retention (`6d`), for context size and for accounting of sessions it did not launch. - `/usage` output for plan gauges. It is only reachable through a PTY today, the way `dirigent_anth` does it, which makes it fragile. --- ## 3. Form 2: Dirigent built-in feature ### Responsibilities Everything in §1, plus what a long-running server makes possible: - **Owned time.** The watchdog-supervised server runs the keep-warm tick continuously. TTL expiry and stall detection happen even when no agent is in its turn. Pings stay a policy choice: the design pings only while the Athanor is active, and a server could widen that. - **Signals into running work** (the lean-athanor "Later" wording). Dirigent can push "fork j0007 stalled", "plan at 92 %, TTL bucket will drop" or "base B1 cold in 6 min" to the orchestrator, the UI or Matrix. Polling (`wait`) stays the fallback. - **Archival for free.** Fork transcripts go through `dirigent_archivist` like any Claude session, and `dirigent_anth` already does dedup, lineage and subagent links. Per-fork usage then lands in the session-token-stats pipeline (Dirigent `goals/session-token-stats.md` wants `tokens_cache_read`/`tokens_cache_creation`/`cost` per session already). - **UI.** A "stable" view showing bases, lineage, warm/expiring/cold, forks running, priced ledger. This matches the Dirigent brainstorm's "Integrations → claude" facade (`brainstorm/rework_claude_concept.md`) and "token metering & caps" (`brainstorm/dirigent_as_acp.md:22, :191`). - **Policy.** `dirigent_fermata` is a harness-agnostic policy gate with a Claude hook adapter. Forks launched by the stable could get fermata hooks by default, which narrows R16 (forks with full Bash under `dontAsk`). ### API surface (sketch) - **Rust**: a `StableService` in the server, wrapping the core crate (§5), registered like the `TaskRunner`. - **REST** under the existing versioned API (`dirigent_rest`, `/api/v1`): - `POST /stable/bases` (train) - `POST /stable/bases/{b}/forks` - `POST /stable/bases/{b}/rebase` - `POST /stable/jobs/{j}/continue` - `GET /stable/jobs/{j}`, plus SSE `GET /stable/events` for signals - `GET /stable/usage?run=…&phase=…` - `GET /sessions/{id}/tokens` for any archived session - `DELETE /stable/bases/{b}` (expire) - `DELETE /stable/jobs/{j}` (kill) - **CLI**: the same `stable` verbs as form 1, as a thin client that talks to the server when it is up. With the helper's JSON on stdout, a Spire skill cannot tell the difference. - **ACP**: Dirigent is an ACP orchestrator. A headless fork could also be offered as an ACP session to Dirigent's clients. That is a larger step, and nothing in the inputs asks for it. ### State and storage - Dirigent's data dir (`DIRIGENT_DATA_DIR`) holds the registry and ledger, or a small table next to the archivist. Fork transcripts are archived by the archivist as they are now. - Spire still wants the run-log-adjacent copy (design §3.2, "the reviewer and a successor Athanor find it"). Options: Dirigent writes or mirrors the per-run state dir as an export, or the run log carries only `Forks: dirigent://stable/run/` and the reviewer reads usage through the CLI. The second makes workpad artefacts depend on a running server, which matters for Codex and for other machines. ### Migration from the Spire helper - Same choice as form 1 (stable v1 contract, v2, or shim). The extra step: the skill's `where` detects a running Dirigent (named pipe `\\.\pipe\dirigent` on Windows, socket on macOS, per `dirigent_watchdog`) and routes there, otherwise to a local `stable` binary, otherwise to the uv helper. - The design chose "its own Spire skill… easier to lift into Dirigent later" (goal, Decided). The lift then means: SKILL.md stays, and only the backend changes. ### Packaging and distribution Ships inside Dirigent's existing installers and watchdog. No separate distribution, but users without Dirigent get nothing unless form 1 also exists. The user said "absorbable as a default feature", which implies form 1 exists and Dirigent embeds the same crate. ### Windows concerns - R1 goes away when the server is the parent: the server is started by the watchdog, outside any Claude Code job object. This is the cleanest answer to R1 among the three forms. - Dirigent already lists 13 Windows "path/lock" failures in `dirigent_archivist` tests (handoff-lean-finish §rules). Transcript reading while Claude Code writes the file needs share-mode-tolerant opens (`FILE_SHARE_READ|WRITE`). Tail-reading the live JSONL is a known Windows trap. - The server runs as the user, so OAuth credentials and `~/.claude` are the same as in the interactive sessions. A service account would break auth. ### What it needs from Claude Code Same as form 1, plus: - live transcript tailing (for signals mid-fork, not only at `result`); - a stable transcript schema (the archivist depends on it already); - possibly hook events: Claude Code hooks posting to Dirigent would give "turn ended / tool denied" signals without stream parsing. Fermata already has a Claude hook adapter. --- ## 4. Form 3: MCP server (a face over form 1 or 2) ### Responsibilities Only a face. It exposes the §1 verbs as MCP tools so an agent can call them without Bash and without a `Bash(node:*)`/`Bash(uv:*)` permission rule (design R4). It owns nothing that forms 1 or 2 don't. ### Tool surface (sketch) `stable_train`, `stable_fork`, `stable_rebase`, `stable_continue`, `stable_wait` (bounded by the MCP call timeout), `stable_usage`, `stable_list`, `stable_expire`, `stable_kill`, and the read-only tools `session_tokens` and `session_context`. Inputs and outputs are the helper's JSON objects, so the contract carries over directly. Resources could expose `stable://run//ledger` and the registry. ### State and storage None of its own. Over form 1 it is a stdio MCP process that calls the core crate in-process and writes the same state dirs. Over form 2 it is an HTTP/SSE MCP endpoint served by Dirigent, or a stdio shim that proxies to Dirigent's REST, like `dirigate bridge` does for ACP. ### Migration from the Spire helper SKILL.md would name MCP tools instead of Bash commands, so the procedure text changes, though the JSON does not. The design rejected MCP "now" because a script is quicker to prove and "lifts into Dirigent or an MCP wrapper unchanged in contract" (design §Rejected). ### Packaging and distribution - A Reliquary plugin can declare an MCP server in its manifest. Codex supports `mcpServers` in its plugin format too (`reports/2026-10-02-16-codex-plugin-icons-and-metadata.md:55`). This is the one form that reaches Codex and other agents uniformly (`research/2026-05-11-skills-ecosystem.md:109`: "MCP server: universal"). - Rust SDK: `rmcp`, the official Rust implementation (tokio; stdio, HTTP and other transports; tool macros). Docs.rs lists versions up to 3.1.0 against different spec revisions, so pin the version ([docs.rs rmcp 3.1.0](https://docs.rs/crate/rmcp/3.1.0/source/README.md), [Rust SDK overview](https://docsearch.algolia.com/mcp/docs/repo/modelcontextprotocol/rust-sdk)). I found no `rmcp` in Dirigent's `Cargo.lock`, so it would be a new dependency there. ### Windows concerns - A stdio MCP server is a child of the Claude Code session, so it lives in that session's process tree and possibly its job object. Forks it spawns face R1 again unless they break away or delegate to a daemon or Dirigent. - MCP tool-call timeouts limit `stable_wait`, so the poll loop stays. Long blocks (11–36 min in Dirigent) still need repeated waits. ### What it needs from Claude Code Same as the backend. One Claude Code-specific interaction: the forks run with `--strict-mcp-config` and an empty MCP config (design §3.3), so the stable's own MCP server is **not** visible inside forks. That is intended: forks are leaves. It also means "token-count access for agents" through MCP covers only the agent that has the server configured (the orchestrator), not the forks. Forks would need the CLI or a deliberate MCP config that contains only the stable's read-only tools. Adding it changes the fingerprint, so it has to be in the base from training. A question to verify: MCP tools are part of the tool list, so connecting or disconnecting the stable's MCP server in the *orchestrator's* session changes that session's cache prefix. That is harmless for forks but costs the orchestrator a cache miss once. --- ## 5. What is shared: a core crate A layering that would let all three forms reuse one implementation. It mirrors `dirigent_fermata`'s "core / harness / bin" rule: nothing inner imports anything outer. | Layer | Contents | Existing Dirigent parts that could be reused or extracted | |---|---|---| | `stable_core` (sync, no tokio, no Dirigent deps) | Fingerprint type + canonical hash; env-scrub tables; TTL/warm/rebase/re-train rules (design §5) as pure functions over registry + ledger; ledger and registry schemas (serde) with `schema` version; usage-delta arithmetic (`3d`, `F4e`); price table type | none directly; the rules are new | | `stable_claude` (Claude Code adapter) | Command-line builder per verb; `stream-json` event types; transcript reader for context size and accounting | `dirigent_anth` (transcript parse, streaming dedup, lineage, subagent links, `anth_usage` plan gauges). Depending on it is possible if `dirigent_anth` stays free of Dirigent-only deps (its `dirigent_config` dep is already behind an optional feature) | | `stable_proc` | Spawn with explicit env, Job Object kill-tree, breakaway attempt, stall/timeout watchdog, lockfiles | `dirigent_process` (Job Objects on Windows), `dirigent_taskrunner` (spawn/monitor), `dirigent_logs` (capture) | | faces | `bin/stable` (clap; the only place with stdout JSON + exit codes); `stable_mcp` (rmcp); Dirigent `StableService` + REST routes + UI | `dirigate` for the bridge/shim pattern; `dirigent_rest` | A harness trait in the adapter layer, the way fermata has `HarnessAdapter`, would keep a door open for Codex (`dirigent_codex` exists) without designing for it now. Whether Codex has a fork-session equivalent was not checked. Where the crate lives: three options. 1. Inside the Dirigent workspace, published as standalone crates the way fermata ships a `fermata` binary. 2. Its own repo, which Dirigent depends on. 3. Inside Reliquary. That is unlikely, since Reliquary is a text-first plugin marketplace. Option 1 gets the reuse cheaply. Option 2 keeps form 1 independent of Dirigent's release cadence. --- ## 6. The token MCP (the user's main target) The user's main target, relayed by the coordinator after the first draft: agents and orchestrators read their own token counts and their children's. This section treats that as its own product. It is read-only and needs no fork machinery, so it can ship before or without §2–§4. ### What Claude Code exposes vs. what must be read from transcripts | Data | Exposed live by Claude Code | Where it really lives | Status | |---|---|---|---| | Per-call tokens, real model, cost for a **headless** run | yes: `result` event (`usage`, `modelUsage`, `total_cost_usd`), cumulative across resume (`1a`, `3d`) | stdout of the launcher | verified (research) | | Per-message usage for **any** session | no API. The agent cannot ask the harness for it, which is the user's point (journal, 2026-10-16) | transcript lines `~/.claude/projects//.jsonl`; assistant messages carry `message.usage` and `message.model` | message-level `usage` presence assumed (design §5); `dirigent_anth` parses these files today | | Agent-tool **subagent** usage | only in the completion result the parent sees (design §6: "the Athanor copies what it sees") | `/subagents/agent-.jsonl` plus optional `agent-.meta.json` (`dirigent_anth/src/subagent.rs:19–20`), linked structurally to the spawning call (`resolve_subagent_links`) | verified in Dirigent's parser; layout version-dependent | | Headless children (`claude -p`) | invisible to Agent tracking (`6c`) | their own transcripts under the *child's* cwd slug; lineage only if the launcher records it (the stable's registry does) | verified (`6d`) | | Current context size | no tool for the agent itself. From memory, unverified: the status-line command receives a JSON payload with context-window fields in recent CLI versions, and hooks receive `session_id` and `transcript_path` on stdin | last assistant message's `usage` (input + cache read + cache creation) in the transcript | to verify | | Plan usage / limits | `/usage` in the TUI only | `dirigent_anth::anth_usage` reads it through a PTY (`UsageGauge { percent_used, resets }`) | fragile, works today | | Cache TTL bucket actually used | per message: `usage.cache_creation.ephemeral_1h_input_tokens` / `_5m_` | transcript or `result` | verified in `result` (`3c`) | | Prices | not exposed except as client-side `total_cost_usd` | a price table kept as data | ratios assumed (design §5) | Conclusion: apart from headless `result` events, **everything comes from transcripts**. The token MCP is in substance a transcript reader with lineage, dedup and pricing. That is `dirigent_anth` plus a small amount of new code. ### How an agent finds itself The hard part is identity, not counting. Options, roughly in order of reliability: 1. **The launcher tells it.** The stable puts `STABLE_SESSION=` (and the parent's id) into the env, or the brief names the id. This only covers sessions the stable launched. 2. **A hook tells it.** A `SessionStart` hook (the input includes `session_id` and `transcript_path`, from memory) writes a small "who am I" file or injects the id as context. This works for interactive root sessions too. Cost: one hook in user or project settings, and its output changes the prompt prefix once. 3. **The env.** `CLAUDE_CODE_SESSION_ID` is inherited (`F3a`), but it may be the *host's* id, not the subagent's (§11). Agent-tool subagents share the parent's session id and are told apart by agent id, so a subagent asking "me" needs `(session, agent_id)`. 4. **Heuristic.** Newest transcript in the cwd slug touched in the last N seconds. Wrong under parallel sessions in one cwd, which is exactly the Spire case. Use only as a labelled fallback. The MCP call should therefore accept an explicit `session` (+ optional `agent`). "Self" works only where 1 or 2 is in place, and the response says which method resolved it (`resolved_by: env|hook|launcher|heuristic`). ### Surface (MCP tools; the CLI mirrors them as `stable tokens …`) ``` tokens_self {agent?} -> {session, agent?, resolved_by, model(s), in, out, cache_write_1h, cache_write_5m, cache_read, context_now, cost_est, as_of} tokens_session {session, agent?, since?} -> same shape tokens_children {session, agent?, depth?: 1|all} -> [{child: agent_id | headless session, kind: subagent|fork|headless, model, tokens…, status?}], totals tokens_tree {session} -> nested tree with rolled-up totals (base history counted once, dedup by message id) context_now {session, agent?} -> {tokens, model, window, pct, source: transcript|result} plan_usage {} -> gauges + predicted TTL bucket (1h|5m) price {tokens…, model, table?} -> priced row (table id stamped) ``` Rules for the shape: - always four columns plus the 1 h/5 min split; - the real model from messages, never from `init`; - forks counted once (a fork transcript contains the copied base history, design §10 trial note); - a timestamp, because transcripts lag by up to a message. ### Data source and caching - Read-only over `~/.claude/projects/`. Incremental: remember a byte offset per file and parse only appended lines. Windows needs share-tolerant opens (§3). - Lineage from four places: (a) Claude Code's own subagent links, (b) the stable registry for headless forks, (c) the `parentUuid` tree, (d) optionally the env/hook breadcrumbs above. - Cache the per-file aggregates in a small index under the app's data dir. In Dirigent this already exists conceptually as session-token-stats (Dirigent `goals/session-token-stats.md`). ### Where it runs - **Standalone stdio MCP** (rmcp) over `stable_core` + `dirigent_anth`. It can be configured in the orchestrator's MCP config only. Forks run `--strict-mcp-config` and would get it only if their base was trained with it (fingerprint), see §4. - **Dirigent-hosted** MCP endpoint: same tools, answered from the archivist's pre-computed stats, plus push signals. - **CLI** `stable tokens self|session|children|tree` for agents without MCP (Codex, forks). It costs one Bash permission rule. ### Caveat Reading one's own tokens costs tokens. A `tokens_self` call returns about 200 tokens, and calling it every turn adds up. Its usefulness for decisions ("hand off now", "rebase now") is an open question for a trial, not a given. --- ## 7. Lightweight agent access (launch, resume, fork, message, status) The user's wider aim: a lightweight way to reach agents through Dirigent later. A minimal verb set that generalises the fork helper beyond the Athanor: | Verb | Meaning | Claude Code mechanism | Notes | |---|---|---|---| | `launch` | new session from a profile (fingerprint) + prompt | `claude -p --session-id` | profile = named, stored launch fingerprint (model id, tools, mode, agent file, cwd, env pins) | | `resume` | one more turn in an existing session | `--resume ` | refuses if a process already holds that id (interleave hazard, `2c`) | | `fork` | branch a session | `--resume --fork-session` | profile taken from the source session, not the caller | | `message` | send a turn and return without waiting | `resume` with detached supervision | a mailbox per session. Messages are queued and sent one at a time, never concurrently, because concurrent turns on one id interleave | | `status` | running/idle/stalled, last event, turns, tokens, warm state | stable registry + transcript tail | also covers sessions the stable did not launch (read-only: last activity, tokens) | | `wait` | block until done or timeout | file/registry poll or SSE | bounded per call (Bash 10 min, MCP timeout) | | `stop` | kill the process tree | Job Object / process group | | | `list` | sessions and profiles, filterable by run, project, parent | registry + transcript index | | **CLI**: `stable agent launch --profile impl-opus --prompt f.md`, `stable agent message --prompt f.md`, `stable agent status `, and so on. JSON on stdout, the helper's exit codes. **MCP**: `agent_launch`, `agent_resume`, `agent_fork`, `agent_message`, `agent_status`, `agent_wait`, `agent_stop`, `agent_list`. Same JSON. **Through Dirigent**: - A headless Claude session becomes one more connector type next to ACP connectors. - The same verbs map to REST plus SSE. - Messages from the UI or from Matrix (`dirigent_matrix`) go into the mailbox. - Transcripts are archived as usual. - The ACP bridge (`dirigate`) is the precedent for exposing a session over a second protocol. Limits to state up front: - These are **turn-based** headless sessions, not live interactive ones. Messaging a *running* interactive TUI session is not possible this way. `--input-format stream-json` on one long-lived process could keep a session open for streamed turns, but it cannot branch (design §Rejected). - Agent-tool subagents inside a session are not addressable from outside. You can read them, not message them. - Every `resume`/`message` re-passes the full profile. That is the fingerprint rule applied to every verb, and it is why a profile store is central. --- ## 8. Workpad tasks that would fit (all **candidates, confirm**) | # | Task | Path | Why it fits | |---|---|---|---| | C1 | Orchestrator introspection of subagent token use / signals into running work | `goals/lean-athanor-0.2.1.md:28` ("Later"); also `goals/lean-athanor.md:44`, `plugins/spire/skills/implementation/references/models.md` "Future work"; `journals/2026-10-05-21-athanor.md:20` ("dirigent will work on orchestrator token introspection / signals") | This is the "token-count access for agents" and "signals" responsibility directly. `stable tokens/context` plus form 2's SSE signals. Context sizes stop being guesstimates for sessions the stable launched or can read. | | C2 | Headless-forks goal, "Later" items | `goals/headless-forks.md:52–53` | Explicitly says a "Dirigent harness / MCP tool takes over the helper's job ('stable for the constables')" and asks for "token-count access for agents beyond the helper's per-call returns". This is the app's charter. | | C3 | Transcript-based token accounting for experiments and builds | `reports/2026-10-15-22-spire-vs-superpowers-plan-and-build-experiment.md` §Time and tokens (l.287–305), proposals l.402, l.414; open thread in `journals/2026-10-15-21-grotto-work.md:98` ("Was the superpowers SDD controller's transcript summed…?") | The report recomputed four-column totals from transcripts, deduplicated by message id, and could not price them. It flags that harness-reported numbers are "not sums". It asks to record tokens for inline blocks and to include Igor-dispatched reviewers. `stable tokens --dedup-by message-id --price` is that job as a deterministic tool. | | C4 | Harness behaviour across modes | `goals/harness-behavior-investigation.md` (open since 2026-09-18) | Questions 2, 4 and 5 (system-prompt structure per mode, context pressure, model selection and fallback) are measurable by launching the same prompt under controlled variants and recording `init` + `modelUsage`. Variants: Desktop env, scrubbed, `--bare`, plan vs. dontAsk. The research already found one such effect (plan mode silently upgrades Haiku to Sonnet, `F2a`). `stable probe` would be the rig. It covers headless/CLI variants only, not remote-control UI behaviour. | | C5 | "Read vs. cat" nested-CLAUDE.md harness factor | experiment report §6 (l.443ff.); journal l.30 (auto-mode prompt carries the same nudge) | Another measurable harness behaviour: a probe run with a known nested CLAUDE.md, comparing transcripts for whether it loaded. Same rig as C4. Weaker fit: a one-off experiment, not ongoing bookkeeping. | | C6 | Worktree tooling gaps | experiment report l.315–320 | Slug derived from the ticket key (a second worktree refused), no `--base`, a confirmation that silently did not appear because stdin was closed, shared-venv worktrees with no `.venv`. These are gaps in the *team's worktree tool*, which is not Claude Code. Fit is **partial**: the stable cares about cwd as a fingerprint field (`F4d`: forks must run in the base's cwd), and parallel tool-using forks may want per-fork worktrees (research caveat, design R3). A `stable fork --worktree` that creates and records a worktree would touch this. Fixing the worktree tool itself is out of scope. | | C7 | Test-runner locks / "one test runner per machine" | experiment report l.320 (rule stale since per-worktree test DBs); design §8.3 (parallel forks never run shared-state tests); Dirigent's 13 Windows path/lock test failures (`reports/dirigent/2026-10-15-chatgpt-importer-handoff-lean-finish.md`, rules) | A stable that runs parallel forks needs a machine-level resource lock ("only one fork runs the suite at a time" or "these N worktrees are isolated") as policy data in its registry, not in agent prose. Fit is **partial**: the lock primitive fits, but the per-project knowledge of what is safe belongs in project config (`spire:project` `## Build`). The 13 Dirigent failures are a separate bug ticket, not this app. | | C8 | Spend-limit and plan-usage awareness | `reports/dirigent/2026-10-14-chatgpt-importer-retrospective.md` §5 "Spend" ("Check the API budget before a long serial run. A kill cost 3 h."); `research/2026-10-05-21-athanor-usage-dirigent.md` §2.2 (six agents killed by HTTP 429, partial state left); design R11 | Plan gauges (`dirigent_anth::anth_usage`) plus per-call ledger give a deterministic preflight ("refuse parallel forks above X %"), a TTL-bucket prediction and a clean "stop before kill" state. Strong fit for form 2. | Related items outside this repo, found during the Dirigent look, are context only and not on the list: Dirigent `goals/session-token-stats.md` (pre-computed four-column stats per session) and `brainstorm/dirigent_as_acp.md:191` ("token metering & caps… per connector or per session"). I looked and did not find any test-runner-lock task as its own goal or plan in this workpad. C7 is assembled from mentions. `brainstorm/` has nothing on tokens or harnesses. Its `2026-11-15-implementation_thoughts.md` is about the design-to-plan stages, and its date is in the future relative to today. --- ## 9. Sequencing options | Option | Order | For | Against | |---|---|---|---| | S1 Helper first, Rust later | uv helper → trial → freeze contract v1 → `stable_core` + CLI → Dirigent embeds → MCP face | The trial decides whether forks pay at all (design "what would make this wrong") before any Rust is written. The contract is proven by use. | Two implementations of the rules for a while. The Python state-file schemas become a compatibility burden. | | S2 Rust CLI replaces the helper before the trial | skip or shorten the uv helper; trial on `stable` | One implementation. It fixes R1 and R14 with a real supervisor. | Builds a Rust app before knowing whether the mechanism pays. The uv decision (design amendment 01:33) was just made. | | S3 Dirigent first | build the feature in Dirigent; CLI as its client; standalone later | The server solves R1 and owns time. Archivist/anth reuse is immediate. Signals arrive early. | Users without Dirigent (other machines, Codex hosts) get nothing. It couples Spire runs to a running server. | | S4 Accounting first | `stable tokens/context` (read-only, transcript-based) as the first Rust deliverable, independent of forks | Lowest risk: no process launching. It serves C1, C3 and C8 right away, works on past sessions, and reuses `dirigent_anth` almost as is. | Does not touch the fork mechanism, which is the goal's core. | | S5 MCP early | MCP face over the uv helper | Removes the Bash permission question (R4) for the Athanor. Reaches Codex. | The design rejected it for now. Wait timeouts remain, and the face over a Python helper would be rewritten later. | S4 is independent and can combine with any of the others. --- ## 10. Risks ### Claude Code CLI churn - Flags and defaults move fast. In the inputs alone: - Tasks were removed by model at 2.1.233, then moved to an allowlist at 2.1.268. - Fork mode became the interactive default at 2.1.232. - Cost became cumulative on resume at 2.1.277. - Cache-TTL settings arrived at 2.1.242. - Model aliases change meaning by CLI version (`F2c`). - Two installs on one machine were 16 patch versions apart (`F5a`). - A Rust app compiled against one event shape breaks silently when `result` or `init` fields change. Mitigations: - Parse leniently, as `dirigent_anth` already does ("handles unknown fields"). - Record `binary.version` per call (design already does). - Keep a minimum/maximum tested-version table with a `where` warning. - Run a small contract test (T1–T7 of the research) on each new CLI version, possibly as `stable selftest`. - `stream-json` field parity with `json` is still an assumption (R5), and `apiKeySource` is assumed. - The Agent SDK (TypeScript and Python, from memory; I did not check for a Rust SDK) spawns the same CLI. It does not insulate against churn, so a Rust app calling the CLI directly is in the same position as the SDKs. ### OAuth - The 1 h TTL needs a subscription within plan usage (`F4a`). `--bare` refuses OAuth (`F3d`). So the app must run without `--bare`, which means discovery stays on: plugins, skills, hooks, CLAUDE.md, claude.ai connectors (`5a`, `F3b`). That drift surface has to be pinned by flags (`--strict-mcp-config`) and detected by `init` comparison (R9). - OAuth tokens refresh under the user's `~/.claude`. A daemon or server must run as the same user and must not race the interactive CLI's refresh. Whether concurrent refreshes from many headless processes are safe is untested. - Plan policy: many parallel headless sessions on one subscription may hit usage limits faster, or fall under terms about automated use. Whether subscription OAuth may be used for unattended orchestration at scale was not checked here. **Open, for the user.** - If the user ever switches to an API key: 5 min TTL, keep-warm economics change (break-even 11 pings instead of 19, design §5), and billing becomes real cost, not notional. ### Cache-TTL rules - Docs-only: hit-resets-timer (`F4c`), cross-cwd behaviour (`F4d`), and which prefix a ping refreshes (design §5 "Assumed"). Preflight 5 in the design is the only wall-time test planned. - Silent downgrade: over the plan limit the main bucket drops to 5 min with no error. Only the `ephemeral_5m` vs `ephemeral_1h` split in `usage.cache_creation` reveals it (design `ttl_downgrade`). A long-lived daemon pinging on a stale 1 h assumption would waste money. The rules must re-derive TTL from each call's usage, as the design does, not from config. - Claude Code upgrades change the system prompt and so invalidate every base (`F5b`; prompt-caching docs "Upgrading Claude Code"). A cross-run base store (forms 1 daemon / 2) must expire all bases on binary drift. A Desktop auto-update can trigger that mid-run. - Pricing ratios (read 0.1, 1 h write 2.0, 5 min write 1.25) are assumed and not re-checked (design §5). The price table must be data, not code. ### Other - **R1 detached survival** decides the CLI's architecture on Windows (§2). Form 2 avoids it. - **Agent-visible token counts** may change agent behaviour. An agent that can read its own context size may start self-truncating or rushing. Whether exposing it helps is a question for the trial, not an assumption. - **Safety**: forks with full Bash under `dontAsk` (R16). A Dirigent-hosted stable could attach fermata by default. A standalone CLI cannot assume it. - **Two writers**: the design keeps the run log single-writer (paste, not append). Form 2 pushing into workpad files would reopen that question. --- ## 11. Gaps and contradictions - **Runtime of the near-term helper**: the design body specifies Node (`forks.mjs`, §3.1, Decision 1). The 01:33 amendment and this brief say Python via uv (`forks.py`, PEP 723). The amendment wins, but the design body was not rewritten. The plan (`plans/2026-10-16-02-headless-forks.md`, named in the journal) is not in the repo yet, so I could not check which helper commands survived planning. This report assumes the design §3.4 verb set. - §6 relies on some things from memory that I did not verify: status-line and hook payload fields (`session_id`, `transcript_path`, context-window fields). The docs pages for hooks and status line should be checked before the token MCP's identity design is fixed. - `CLAUDE_CODE_SESSION_ID` as the way for an agent to learn its own session id is unverified: it may be the host's id, not the subagent's. Agent-tool subagents write their own transcripts under the parent session (`dirigent_anth` "Sub-Agent Loading"), so "own tokens" for an Agent-tool subagent probably needs the parent id plus the agent id. - I did not check: - whether Codex has a fork/resume-by-id equivalent; - OAuth concurrency or terms-of-use for unattended headless runs; - whether a Rust Claude Agent SDK exists; - `JOB_OBJECT_LIMIT_BREAKAWAY_OK` on Claude Code's Bash tool job. - Dirigent facts come from crate `CLAUDE.md` heads and a few greps, not a full read. "Reusable as is" for `dirigent_anth`/`dirigent_process` is a reading of their stated dependencies, not a build test. - Web budget used: 1 search, 0 page fetches. --- ## 12. Ideas for bundling Labels: **[from workpad]** means a bundle of the §8 candidates, grouped by shared mechanism. **[proposal, researcher]** means my own idea; nothing in the inputs asks for it. Every item is for the user to accept, reshape or drop. ### Bundle A: "tokens" (read-only, ships first) - **[from workpad]** C1 orchestrator introspection, C2's "token-count access for agents", C3 transcript-based accounting (dedup by message id, priced, reviewers included), C8 plan-usage awareness. Dirigent side: `goals/session-token-stats.md` (pre-computed four-column stats). - Shared mechanism: transcript reader + lineage + price table (`dirigent_anth` + `stable_core`). No process launching, so neither R1 nor OAuth risk applies. - Deliverable: the token MCP (§6) and `stable tokens` CLI. - **[proposal, researcher]** First consumer: rerun the 2026-10-15 experiment's accounting with it and settle the journal's open thread ("was the SDD controller summed?"). The result can be checked against the hand-made numbers, which also tests the tool. ### Bundle B: "stable" (fork mechanism) - **[from workpad]** C2 (the helper's job, taken over), C6 partial (`fork --worktree`), C7 partial (resource locks for parallel forks). - Shared mechanism: launch profiles, fingerprint, supervisor, registry. - **[proposal, researcher]** Gate it on the uv helper's trial (design §10). If forks do not pay, Bundle B shrinks to the agent-access verbs of §7 without keep-warm logic. ### Bundle C: "probe" (harness measurement rig) - **[from workpad]** C4 harness-behaviour investigation, C5 Read-vs-cat nested CLAUDE.md. - **[proposal, researcher]** `stable probe `: N launch variants × one prompt. It records `init`, real model, tool list, prompt-prefix hash and tokens, and diffs them. It reuses Bundle B's launcher, so it needs B's process layer but none of its bookkeeping. It would also catch CLI churn: run the same probe spec after each Claude Code update as a contract test (§10). ### Bundle D: "agent access via Dirigent" - **[from workpad, Dirigent]** `brainstorm/rework_claude_concept.md` "Integrations → claude" facade; `brainstorm/dirigent_as_acp.md` token metering and caps. - **[proposal, researcher]** A headless-Claude connector in Dirigent that implements the §7 verbs. Tokens come from Bundle A, launching from Bundle B. Messages arrive from UI, REST or Matrix into a per-session mailbox. Fermata hooks attach to every launched profile by default. ### Further proposals (researcher, unprompted) - **[proposal, researcher]** A **profile file format**, a TOML launch fingerprint (`profiles/impl-opus.toml`) that is checked into a project. Spire's `spire:project` `## Build` section could name profiles instead of models. It is shared by the helper, the CLI, Dirigent and MCP, and it is the smallest artefact that makes all forms agree. - **[proposal, researcher]** A **frozen JSON contract spec** under `docs/workpad/specs/` covering the helper's stdout objects, ledger line and exit codes, with `"schema": 1`. Without it, "keep the contract stable" across forms is only an intent. - **[proposal, researcher]** **Budget guard** as a cross-cutting rule: refuse launches above a plan-gauge threshold, and write a clean "stopped for budget" state instead of being killed by a 429 (C8; Dirigent's 3 h loss). - **[proposal, researcher]** **Self-test on CLI update**: `stable selftest` replays the research's T1–T7 (tiny, ~$0.13) and refuses to launch on a CLI version whose event shape or fork semantics changed. - **[proposal, researcher]** Name split, if wanted: *stable* for the app and fork manager, *ledger* or *tally* for the token MCP. The token part is useful without the rest, and a separate name makes it easier to ship alone.