agent-dispatch 0.14.0__tar.gz → 0.15.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. agent_dispatch-0.15.1/.coverage +0 -0
  2. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/AGENTS.md +11 -2
  3. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/CHANGELOG.md +79 -0
  4. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/PKG-INFO +61 -6
  5. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/README.md +60 -5
  6. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/agents.example.yaml +6 -0
  7. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/pyproject.toml +1 -1
  8. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/__init__.py +1 -1
  9. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/cli.py +116 -0
  10. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/models.py +7 -0
  11. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/runner.py +124 -10
  12. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/server.py +37 -1
  13. agent_dispatch-0.15.1/src/agent_dispatch/servers.py +156 -0
  14. agent_dispatch-0.15.1/src/agent_dispatch/usage.py +365 -0
  15. agent_dispatch-0.15.1/tests/conftest.py +68 -0
  16. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/test_cli.py +129 -0
  17. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/test_runner.py +188 -0
  18. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/test_server.py +117 -1
  19. agent_dispatch-0.15.1/tests/test_servers.py +157 -0
  20. agent_dispatch-0.15.1/tests/test_usage.py +334 -0
  21. agent_dispatch-0.14.0/tests/conftest.py +0 -41
  22. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/.github/dependabot.yml +0 -0
  23. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/.github/workflows/ci.yml +0 -0
  24. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/.github/workflows/publish.yml +0 -0
  25. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/.gitignore +0 -0
  26. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/LICENSE +0 -0
  27. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/SECURITY.md +0 -0
  28. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/assets/mascot.png +0 -0
  29. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/cache.py +0 -0
  30. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/config.py +0 -0
  31. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/jobs.py +0 -0
  32. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/__init__.py +0 -0
  33. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/test_cache.py +0 -0
  34. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/test_config.py +0 -0
  35. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/test_jobs.py +0 -0
  36. {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/test_models.py +0 -0
Binary file
@@ -12,6 +12,8 @@ MCP server + CLI that lets Claude Code agents delegate tasks to agents in other
12
12
  |------|------|
13
13
  | `src/agent_dispatch/runner.py` | Sync subprocess wrapper around `claude -p` — the actual work |
14
14
  | `src/agent_dispatch/server.py` | Async FastMCP interface (21 MCP tools), wraps runner in `asyncio.to_thread` + semaphore |
15
+ | `src/agent_dispatch/usage.py` | Append-only dispatch journal (cost/duration/outcome) + aggregation for `stats` and the `typical` block |
16
+ | `src/agent_dispatch/servers.py` | Registry of live `serve` processes and the version each one runs (`doctor` reads it) |
15
17
  | `src/agent_dispatch/cli.py` | Click CLI: `init`, `add`, `update`, `remove`, `list`, `describe`, `test`, `doctor`, `jobs`, `job`, `cancel`, `gc`, `group` (add/list/inspect/update/remove), `serve` |
16
18
  | `src/agent_dispatch/models.py` | Pydantic v2 models (`AgentConfig`, `DispatchGroup`/`GroupMember`, `Settings`, `DispatchResult`) |
17
19
  | `src/agent_dispatch/config.py` | YAML config load/save + project auto-description |
@@ -28,7 +30,7 @@ pip install -e ".[dev]"
28
30
 
29
31
  ```bash
30
32
  ruff check src/ tests/
31
- python3 -m pytest tests/ -v # 637 tests, ~15s
33
+ python3 -m pytest tests/ -v # 714 tests, ~17s
32
34
  ```
33
35
 
34
36
  Tests must **never** invoke the real `claude` CLI. Runner tests mock `shutil.which` + `subprocess.run`/`Popen`; server tests mock `_get_config` + `runner.dispatch`. The one exception is `TestStreamPipeHandling`, which spawns a short-lived *python* subprocess: a pipe deadlock lives in the OS pipe buffer, so a mocked `Popen` structurally cannot reproduce it.
@@ -61,6 +63,13 @@ Tests must **never** invoke the real `claude` CLI. Runner tests mock `shutil.whi
61
63
  - Every state file (`agents.yaml`, job files) is written **temp file + `os.replace`**, never in place, and every load/mutate/save is wrapped in `config.ProcessLock` — the CLI and the MCP server are separate processes writing the same files, so a thread lock alone loses updates.
62
64
  - Anything that changes an agent's config must call `_invalidate_agent_cache` — the cache key holds the agent *name*, not its directory or permissions.
63
65
  - Only *clean* successes are cached: `cache.put` refuses failures, `denied_tools` results, `budget_exceeded` results, and results whose `outcome` is `partial`/`blocked`, so the documented "grant access / fix the cause, then re-dispatch" recovery is never short-circuited.
66
+ - **Coerce every number read off disk through `usage.as_float`.** `float("abc")` raises **ValueError** — not `JSONDecodeError`, not `OSError` — so it escaped the per-line handler in `usage.load` and the sort key in `servers.live_servers`, turning `agent-dispatch stats --days 7` and `doctor` into tracebacks over one hand-edited character. Same escape route as the `UnicodeDecodeError`-is-a-`ValueError` round. A journal or registry field is untrusted input: skip the record or sort it last, never raise from the command someone ran *because* something is already wrong.
67
+ - **`--json` output must be valid JSON on the empty path too.** `stats --json` printed prose when the journal had no records — i.e. on a fresh install, which is exactly when a script first pipes it somewhere.
68
+ - **The usage journal is written by a decorator, not at each return.** `runner._journaled` wraps `dispatch`/`dispatch_stream` because each has a dozen early returns; instrumenting them one by one guarantees the next new return path goes unrecorded. It records the outermost call only (`_use_session_flag` is False on the stream's old-CLI retry, which would otherwise double-count one dispatch). It also swallows exceptions itself even though `usage.record` already does: the result in hand is billed, and the guarantee has to hold at the boundary that owns the damage rather than on another module's promise (`test_a_broken_journal_never_breaks_a_paid_dispatch` removes record()'s guard to prove it).
69
+ - **A journal record is one `O_APPEND` write under `PIPE_BUF`**, never a locked read-modify-write: the CLI and every running server share one file, and this user has 14+ servers alive. Every field is capped so the line cannot approach 4096 bytes. Rotation is an atomic rename at ~2 MB, two generations kept.
70
+ - **Profiles are memoized on the journal's (path, size, mtime) and read a NARROWER tail than `stats`.** `list_agents`/`inspect_agent` run on the event-loop thread: a full 2 MB journal cost **24.8 ms** per discovery call at the 512 KB report window — 33x the config parse that 0.13.0 exists to have fixed. Now 3.3 ms cold, ~0 warm. Every profile consumer goes through `usage.profiles()`; never add a caller that re-parses per agent.
71
+ - **`usage_limit` is its own error type** (observed live: `You've hit your session limit · resets 6pm`). It is checked *before* the permission patterns and its hint says the limit is account-wide, so "try another agent" is not a workaround. Text-classified errors attach their advisory through the single `_classification_hint`, not per site.
72
+ - **Server liveness is an advisory lock, not a PID.** `servers.register` holds an exclusive `flock` on `<config dir>/servers/<pid>.json` for the process lifetime; a reader that *takes* that lock owns a dead entry and deletes it. `os.kill(pid, 0)` would believe a recycled PID and would never notice a SIGKILLed server. Never close that fd (re-registering closes the old one first).
64
73
  - **The dispatch protocol goes in the system prompt, not the task.** `_build_system_prompt` (runner.py) renders the protocol (non-interactive, time/spend budget, lead with the outcome, trailing `STATUS:` line) plus the agent's `instructions`, and `_build_command` passes it as `--append-system-prompt` — on `--resume` too, since the CLI re-applies an appended prompt on every launch. `_build_prompt` (the `-p` text) is unchanged, so the cache key is unchanged. The text always starts with a `##` header line so it can never be read as a flag. `settings.dispatch_protocol: false` turns the protocol off (for CLIs that predate the flag; `doctor` probes `claude --help` for it); per-agent `instructions` still go through when set.
65
74
  - **`outcome` is lifted from the LAST line of the result** (`_split_outcome`), before JSON parsing, by the shared `_build_success_result` (the success-side twin of `_build_error_result` — both `dispatch` and `dispatch_stream` go through it) and by the plain-text fallback tier. A bare marker line is removed from `result`; a marker with a trailing reason stays. `outcome` never flips `success`; `partial`/`blocked` add a `hint` with the `dispatch_session(...)` continuation, after the denial hint. In JSON mode the protocol omits the STATUS instruction (the JSON footer governs), but a STATUS line that arrives anyway is still stripped so `parsed_result` survives.
66
75
  - Remediation text is a contract: a hint that names a flag must name one that exists (`test_printed_budget_hint_is_a_runnable_command` feeds the printed flags back into the CLI). Run the command you print.
@@ -87,4 +96,4 @@ Python ≥ 3.10 · `from __future__ import annotations` everywhere · Pydantic v
87
96
 
88
97
  ## More detail
89
98
 
90
- [README.md](README.md) documents every MCP tool with parameter tables, response shapes, and the error-recovery map — it doubles as the behavioral spec. The test suite (`tests/`, 637 tests) encodes the exact expected behavior of every layer: when in doubt, read the tests for the module you're touching (`test_runner.py`, `test_server.py`, `test_cli.py`, ...).
99
+ [README.md](README.md) documents every MCP tool with parameter tables, response shapes, and the error-recovery map — it doubles as the behavioral spec. The test suite (`tests/`, 714 tests) encodes the exact expected behavior of every layer: when in doubt, read the tests for the module you're touching (`test_runner.py`, `test_server.py`, `test_cli.py`, ...).
@@ -7,6 +7,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.1] - 2026-09-09
11
+
12
+ Three defects in 0.15.0's own new code, found by re-auditing it the way this
13
+ project audits everything else — the fix is the most likely place for the next
14
+ bug, and a new file is a new place for an old one.
15
+
16
+ ### Fixed
17
+ - **A corrupt number in the journal or the server registry crashed the command
18
+ you run to diagnose it.** `float("abc")` raises **ValueError**, which is
19
+ neither a `json.JSONDecodeError` nor an `OSError`, so one hand-edited or
20
+ torn-written character made `agent-dispatch stats --days 7` and
21
+ `agent-dispatch doctor` end in a traceback instead of skipping the record.
22
+ This is the same escape route as the `UnicodeDecodeError`-is-a-`ValueError`
23
+ round in 0.12.1. All three sites now coerce through one shared
24
+ `usage.as_float`: an undatable record falls outside the window, a registry
25
+ entry with a mangled timestamp sorts last.
26
+ - **`stats --json` printed prose instead of JSON when the journal was empty** —
27
+ which is precisely the state of a fresh install, and therefore the first
28
+ thing a script piping it would hit. It now always emits the report object.
29
+ - Doc drift: `AGENTS.md` carried a stale test count.
30
+
31
+
32
+ ## [0.15.0] - 2026-09-09
33
+
34
+ The measurement round. 0.14.0 told a dispatched agent how to behave; this one
35
+ records what actually happened. Four live dispatches through the new protocol
36
+ came back **5/5 with a STATUS line and 0/5 ending in a clarifying question**,
37
+ covering every branch of the protocol. The case it was built for: an ambiguous
38
+ task ("how many users signed up in 30 days?", no project named) where the agent
39
+ stated its assumption, counted across every database, and returned `done`
40
+ rather than asking. A tool-less agent returned `blocked`. And under a
41
+ deliberately tight 45s budget an agent scoped the work down, delivered what it
42
+ had, listed three specific gaps ("31 mirrors, not 32 — I did not check which";
43
+ "HEAD only"; "mirrors may be stale") and returned `partial` with a resume hint —
44
+ a result the cache then refuses to store, so the retry runs fresh.
45
+
46
+ ### Added
47
+ - **Usage journal + `agent-dispatch stats`.** Every dispatch appends one line to
48
+ `usage.jsonl` — agent, ok, cost, duration, turns, outcome, error type, caller;
49
+ cache hits too, flagged `cached`. `stats [--days N --agent X --json]` turns it
50
+ into spend, median/p90 durations, outcome and failure counts per agent. Until
51
+ now `cost_usd` and `duration_ms` were returned once and discarded, so "which
52
+ agent burns money" had no answer and the only timing knowledge in the system
53
+ was hand-written prose in agent descriptions. Recording is one `O_APPEND`
54
+ write capped under `PIPE_BUF`, so the CLI and all running servers share one
55
+ file with no lock; it rotates at ~2 MB keeping two generations; a failure to
56
+ write it can never fail a dispatch. Off with `settings.usage_log: false`.
57
+ - **`typical` in `list_agents` and `inspect_agent`** — measured median/p90
58
+ seconds and median cost from that agent's own recent runs, so a caller can
59
+ size `timeout_seconds` from data instead of from a description. Absent below
60
+ three recorded dispatches: no data beats a "typical" derived from two runs.
61
+ Costs ~115 bytes per agent (3.6% of a discovery payload).
62
+ - **`error_type: "usage_limit"`** for the Claude *account's* rate/session limit,
63
+ observed live during this round's evaluation (`You've hit your session limit ·
64
+ resets 6pm`) — it used to land in the generic `cli_error` bucket. The hint
65
+ names the reset time and says the limit is account-wide, so dispatching a
66
+ different agent is not a workaround, and nothing was billed.
67
+ - **`doctor` now names the sessions running stale code.** Each server registers
68
+ `<config dir>/servers/<pid>.json` and holds an advisory lock on it for life;
69
+ `doctor` reports live servers by version and prints the pid and age of every
70
+ one still on an older release. A Python process holds its modules for life, so
71
+ an upgrade reaches an open Claude Code session only when it restarts — on this
72
+ machine 18 servers were running previous code with no way to tell which.
73
+ Liveness is the lock, not the PID: a recycled PID cannot fake a live server
74
+ and a SIGKILLed one cannot linger.
75
+
76
+ ### Changed
77
+ - **A timeout error now suggests a number, not a doubling.** With enough history
78
+ the message names the value that would have covered this agent's real p90
79
+ (padded 50%). Doubling the current timeout was wrong in both directions.
80
+
81
+ ### Fixed
82
+ - Discovery no longer pays for the journal on the event loop: profiles are
83
+ memoized on the journal's (path, size, mtime) and read a narrower tail than
84
+ the report. Measured on a full 2 MB journal, `list_agents` went from
85
+ **24.8 ms to 3.3 ms cold and ~0.01 ms warm** — the 512 KB version would have
86
+ been 33x the config parse that 0.13.0 exists to have eliminated.
87
+
88
+
10
89
  ## [0.14.0] - 2026-09-08
11
90
 
12
91
  The delegation round: what a dispatched agent is *told*, and what it tells
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agent-dispatch
3
- Version: 0.14.0
3
+ Version: 0.15.1
4
4
  Summary: MCP server that lets Claude Code agents delegate tasks to agents in other project directories
5
5
  Project-URL: Homepage, https://github.com/ginkida/agent-dispatch
6
6
  Project-URL: Repository, https://github.com/ginkida/agent-dispatch
@@ -134,13 +134,16 @@ Lists all configured agents. **Call this first** to see what's available.
134
134
  "capabilities": ["docker_logs", "deploy_debug"],
135
135
  "risky_capabilities": ["restart_services"],
136
136
  "permission_mode": "bypassPermissions",
137
- "allowed_tools": ["Bash", "Read", "Grep"]
137
+ "allowed_tools": ["Bash", "Read", "Grep"],
138
+ "typical": {"median_seconds": 143.3, "p90_seconds": 178.3, "median_cost_usd": 0.2666}
138
139
  }
139
140
  ]
140
141
  ```
141
142
 
142
143
  `mcp_servers`, `stacks`, and `dbs` are detected from the agent's project files (`.mcp.json`, `Dockerfile`, `pyproject.toml`, `Cargo.toml`, `prisma/`, `alembic.ini`, etc.) so callers can pick the right agent without dispatching a probe.
143
144
 
145
+ `typical` is **measured**, not declared: it comes from this agent's own recent dispatches in the [usage journal](#usage-journal--what-your-fleet-actually-costs) and appears only after a few runs. Use it to size `timeout_seconds` and to know what a call will cost before you make it. It is absent when the journal is off or the agent has too little history — no data is better than a typical duration derived from two runs.
146
+
144
147
  ### `inspect_agent`
145
148
 
146
149
  Cheap detailed lookup — reads the agent's files without spawning a `claude` session. Returns the full config (timeout, model, budget, permission mode, allowed/disallowed tools), detected MCP/stacks/DBs, plus short previews of `CLAUDE.md` and `README.md` when present.
@@ -152,6 +155,15 @@ Cheap detailed lookup — reads the agent's files without spawning a `claude` se
152
155
 
153
156
  Use this **before** `dispatch_async`/`dispatch` to confirm an agent has the tools and context for your task — much cheaper than a probe dispatch.
154
157
 
158
+ It also carries the agent's `instructions` (its standing orders) and the full `typical` block — the trimmed one in `list_agents` plus `dispatches`, `failed` and the `outcomes` breakdown:
159
+
160
+ ```json
161
+ "typical": {
162
+ "dispatches": 24, "median_seconds": 143.3, "p90_seconds": 178.3,
163
+ "median_cost_usd": 0.2666, "failed": 1, "outcomes": {"done": 21, "partial": 2}
164
+ }
165
+ ```
166
+
155
167
  ### Groups
156
168
 
157
169
  A **group** bundles related agents into a cross-project working set — typically a few code repos plus capability gateways (an `infra` agent with a Portainer MCP, an `analytics` agent with a browser + Yandex Metrica). It lets one orchestrating session coordinate work that spans code, deploy, and verification.
@@ -234,7 +246,7 @@ dispatch(
234
246
  }
235
247
  ```
236
248
 
237
- **`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `budget` (the `claude` CLI stopped the session at `max_budget_usd`), `cli_error` (other failures). Permission and budget errors include an actionable hint.
249
+ **`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `budget` (the `claude` CLI stopped the session at `max_budget_usd`), `usage_limit` (the Claude *account* hit its rate/session limit), `cli_error` (other failures). Permission, budget, usage-limit and timeout errors include an actionable hint.
238
250
 
239
251
  **Resumable timeouts:** every fresh dispatch pre-assigns a session UUID (`--session-id`), so a timed-out dispatch still returns a `session_id` — the partial transcript survives the kill. The timeout error spells out the recovery: resume with `dispatch_session(agent, "Continue where you left off", session_id=...)`, retry with a bigger `timeout_seconds`, or use `dispatch_async`.
240
252
 
@@ -533,10 +545,11 @@ Failures are deterministic: check `success`, then branch on `error_type`.
533
545
  | `error_type` | Meaning | Recovery |
534
546
  |--------------|---------|----------|
535
547
  | `permission` | A tool call was denied | `update_agent(name, allowed_tools="Bash,Read")` (least privilege) or `update_agent(name, permission_mode="bypassPermissions")`, then re-dispatch. The `error` text includes a hint with the exact fix. |
536
- | `timeout` | Process killed at the timeout | Resume the partial work: `dispatch_session(agent, "Continue where you left off", session_id=<from the error text>)`. Or retry with a bigger `timeout_seconds=`, or use `dispatch_async`. A *streaming* dispatch that produced its answer before the deadline returns that answer with a `hint` instead of failing. |
548
+ | `timeout` | Process killed at the timeout | Resume the partial work: `dispatch_session(agent, "Continue where you left off", session_id=<from the error text>)`. Or retry with a bigger `timeout_seconds=` — once the journal has history the error names the value that would have covered this agent's p90 — or use `dispatch_async`. A *streaming* dispatch that produced its answer before the deadline returns that answer with a `hint` instead of failing. |
537
549
  | `not_found` | Agent directory or `claude` CLI missing | `list_agents()` → check `healthy`. Re-add the agent with an existing path, or run `agent-dispatch doctor` to find what's missing. |
538
550
  | `recursion` | Dispatch nesting exceeded `max_dispatch_depth` (default 3) | Don't dispatch from dispatched agents; if the nesting is intentional, raise `max_dispatch_depth` in settings. |
539
551
  | `budget` | The `claude` CLI ended the session at the `max_budget_usd` spend cap — the answer is incomplete | Raise the cap (`update_agent(name, max_budget_usd=2.0)`), switch to a cheaper `model`, or split the task. The partial session is resumable: `dispatch_session(agent, "Continue where you left off", session_id=<from the result>)`. |
552
+ | `usage_limit` | The Claude **account** hit its usage/rate limit — not this agent, and nothing was billed | Wait for the reset named in the `error` text. Dispatching a *different* agent is not a workaround: every agent runs on the same account. If it recurs under load, lower `settings.max_concurrency`. |
540
553
  | `cli_error` | Anything else from the `claude` subprocess | Read the `error` text; run `agent-dispatch doctor` for environment issues; retry once if transient. |
541
554
 
542
555
  Three soft signals that arrive with `success: true`:
@@ -607,6 +620,9 @@ settings:
607
620
  max_concurrency: 5 # max parallel claude -p processes (per dispatch path)
608
621
  # dispatch_protocol: true # send every agent the dispatch protocol (non-interactive,
609
622
  # # time budget, STATUS line → `outcome`). false = raw claude -p.
623
+ # usage_log: true # record every dispatch in usage.jsonl (cost, duration, outcome).
624
+ # # Powers `agent-dispatch stats`, the `typical` block and the
625
+ # # measured timeout suggestion. false = record nothing.
610
626
  # job_retention_days: 30 # 0 (default) = never prune. See "Job retention" below.
611
627
  cache:
612
628
  enabled: true
@@ -616,6 +632,42 @@ settings:
616
632
 
617
633
  Config is reloaded on every tool call — add agents without restarting.
618
634
 
635
+ ### Usage journal — what your fleet actually costs
636
+
637
+ Every dispatch appends one line to `~/.config/agent-dispatch/usage.jsonl`
638
+ (override with `AGENT_DISPATCH_USAGE_LOG`): agent, success, cost, duration,
639
+ turns, `outcome`, error type, caller. Cache hits are recorded too, flagged
640
+ `cached`, so the report can show what the cache saves.
641
+
642
+ ```console
643
+ $ agent-dispatch stats --days 7
644
+ Usage (last 7 day(s))
645
+ dispatches: 33, 1 served from cache
646
+ spend: $13.0390
647
+ duration: median 87s, p90 2m40s, max 10m00s
648
+ outcomes: blocked 1, done 27, partial 3
649
+ failures: timeout 1, usage_limit 1
650
+
651
+ Per agent
652
+ analytic 18 runs $ 8.9441 median 2m04s p90 2m51s
653
+ done 15, partial 3
654
+ gitlab 12 runs $ 3.7949 median 60s p90 86s
655
+ done 12 | 1 cached
656
+ ```
657
+
658
+ `--agent NAME` narrows it, `--json` emits the same report as JSON.
659
+
660
+ The journal is what makes three other things work: the `typical` block in
661
+ `list_agents` / `inspect_agent`, the timeout error naming a value derived from
662
+ the agent's real p90 instead of a doubling guess, and any answer at all to
663
+ "which agent is expensive". Turn it off with `usage_log: false` in settings.
664
+
665
+ Properties worth knowing: the file is owner-only (`0o600`); each record is a
666
+ single `O_APPEND` write capped well under `PIPE_BUF`, so the CLI and every
667
+ running server can share it with no lock and no torn lines; it rotates to
668
+ `usage.jsonl.1` at ~2 MB and keeps two generations, so it is bounded at ~4 MB
669
+ forever. A failure to write it can never fail a dispatch.
670
+
619
671
  ### Job retention
620
672
 
621
673
  Every `dispatch_async` **and** every `dispatch(..., return_ref=True)` writes a
@@ -682,6 +734,8 @@ agent-dispatch MCP server
682
734
  ├─ System prompt += dispatch protocol + the agent's standing `instructions`
683
735
  ├─ Receives structured prompt with goal/caller/context/task
684
736
  └─ Returns result (+ its own STATUS → `outcome`) → cached when complete
737
+ │
738
+ └─ one line appended to usage.jsonl (cost, duration, outcome)
685
739
  ```
686
740
 
687
741
  ## Safety
@@ -689,7 +743,7 @@ agent-dispatch MCP server
689
743
  - **Recursion protection** — `AGENT_DISPATCH_DEPTH` env var tracks nesting. Default limit: 3. Best-effort across the subprocess boundary (see [SECURITY.md](SECURITY.md)).
690
744
  - **Argument-injection guard** — structured CLI fields (`session_id`, `model`, `permission_mode`, tool names) that start with `-` are rejected so they can't smuggle extra `claude` flags.
691
745
  - **Path-traversal guard** — caller-supplied `job_id`/`ref` values are validated as 32-char hex before any filesystem access.
692
- - **Owner-only state** — job files (`0o600`) and `agents.yaml` (`0o600`) are written for the owner only; their directories are `0o700`.
746
+ - **Owner-only state** — job files, `agents.yaml`, the usage journal and the server registry are all written `0o600`; their directories are `0o700`.
693
747
  - **Cost control** — `max_budget_usd` per agent or globally is passed to the `claude` CLI as `--max-budget-usd`, so a runaway dispatch is stopped at the cap and comes back as `error_type: "budget"` with a resumable `session_id`. An overshoot that lands over budget without stopping is flagged post-hoc with `budget_exceeded: true` + a hint.
694
748
  - **Concurrency** — `max_concurrency` (default: 5) caps parallel `claude -p` processes. Note: the sync and async dispatch paths use separate semaphores, so the worst-case total is `2 × max_concurrency`.
695
749
  - **Timeout** — per-agent or global (default: 300s). A streaming dispatch runs the agent in its own process group, so the deadline kills the whole tree: a process the agent left running in the background can't hold the dispatch (and its concurrency slot) open past the timeout.
@@ -710,7 +764,8 @@ See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassP
710
764
  | `agent-dispatch group <add\|list\|inspect\|update\|remove>` | Manage [groups](#groups) — cross-project working sets of agents |
711
765
  | `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
712
766
  | `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
713
- | `agent-dispatch doctor` | Diagnose installation: Claude CLI (incl. `--append-system-prompt` support), MCP registration, agent health, and group membership |
767
+ | `agent-dispatch stats [--days N --agent X --json]` | What dispatches cost: spend, durations, outcomes and failures per agent |
768
+ | `agent-dispatch doctor` | Diagnose installation: Claude CLI (incl. `--append-system-prompt` support), running servers on stale code, MCP registration, agent health, and group membership |
714
769
  | `agent-dispatch jobs [--status --limit]` | List async dispatch jobs (most recent first) |
715
770
  | `agent-dispatch job <id>` | Show one job: status, progress tail, result preview |
716
771
  | `agent-dispatch cancel <id>` | Cancel a pending job (running jobs: use the `dispatch_cancel` MCP tool) |
@@ -104,13 +104,16 @@ Lists all configured agents. **Call this first** to see what's available.
104
104
  "capabilities": ["docker_logs", "deploy_debug"],
105
105
  "risky_capabilities": ["restart_services"],
106
106
  "permission_mode": "bypassPermissions",
107
- "allowed_tools": ["Bash", "Read", "Grep"]
107
+ "allowed_tools": ["Bash", "Read", "Grep"],
108
+ "typical": {"median_seconds": 143.3, "p90_seconds": 178.3, "median_cost_usd": 0.2666}
108
109
  }
109
110
  ]
110
111
  ```
111
112
 
112
113
  `mcp_servers`, `stacks`, and `dbs` are detected from the agent's project files (`.mcp.json`, `Dockerfile`, `pyproject.toml`, `Cargo.toml`, `prisma/`, `alembic.ini`, etc.) so callers can pick the right agent without dispatching a probe.
113
114
 
115
+ `typical` is **measured**, not declared: it comes from this agent's own recent dispatches in the [usage journal](#usage-journal--what-your-fleet-actually-costs) and appears only after a few runs. Use it to size `timeout_seconds` and to know what a call will cost before you make it. It is absent when the journal is off or the agent has too little history — no data is better than a typical duration derived from two runs.
116
+
114
117
  ### `inspect_agent`
115
118
 
116
119
  Cheap detailed lookup — reads the agent's files without spawning a `claude` session. Returns the full config (timeout, model, budget, permission mode, allowed/disallowed tools), detected MCP/stacks/DBs, plus short previews of `CLAUDE.md` and `README.md` when present.
@@ -122,6 +125,15 @@ Cheap detailed lookup — reads the agent's files without spawning a `claude` se
122
125
 
123
126
  Use this **before** `dispatch_async`/`dispatch` to confirm an agent has the tools and context for your task — much cheaper than a probe dispatch.
124
127
 
128
+ It also carries the agent's `instructions` (its standing orders) and the full `typical` block — the trimmed one in `list_agents` plus `dispatches`, `failed` and the `outcomes` breakdown:
129
+
130
+ ```json
131
+ "typical": {
132
+ "dispatches": 24, "median_seconds": 143.3, "p90_seconds": 178.3,
133
+ "median_cost_usd": 0.2666, "failed": 1, "outcomes": {"done": 21, "partial": 2}
134
+ }
135
+ ```
136
+
125
137
  ### Groups
126
138
 
127
139
  A **group** bundles related agents into a cross-project working set — typically a few code repos plus capability gateways (an `infra` agent with a Portainer MCP, an `analytics` agent with a browser + Yandex Metrica). It lets one orchestrating session coordinate work that spans code, deploy, and verification.
@@ -204,7 +216,7 @@ dispatch(
204
216
  }
205
217
  ```
206
218
 
207
- **`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `budget` (the `claude` CLI stopped the session at `max_budget_usd`), `cli_error` (other failures). Permission and budget errors include an actionable hint.
219
+ **`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `budget` (the `claude` CLI stopped the session at `max_budget_usd`), `usage_limit` (the Claude *account* hit its rate/session limit), `cli_error` (other failures). Permission, budget, usage-limit and timeout errors include an actionable hint.
208
220
 
209
221
  **Resumable timeouts:** every fresh dispatch pre-assigns a session UUID (`--session-id`), so a timed-out dispatch still returns a `session_id` — the partial transcript survives the kill. The timeout error spells out the recovery: resume with `dispatch_session(agent, "Continue where you left off", session_id=...)`, retry with a bigger `timeout_seconds`, or use `dispatch_async`.
210
222
 
@@ -503,10 +515,11 @@ Failures are deterministic: check `success`, then branch on `error_type`.
503
515
  | `error_type` | Meaning | Recovery |
504
516
  |--------------|---------|----------|
505
517
  | `permission` | A tool call was denied | `update_agent(name, allowed_tools="Bash,Read")` (least privilege) or `update_agent(name, permission_mode="bypassPermissions")`, then re-dispatch. The `error` text includes a hint with the exact fix. |
506
- | `timeout` | Process killed at the timeout | Resume the partial work: `dispatch_session(agent, "Continue where you left off", session_id=<from the error text>)`. Or retry with a bigger `timeout_seconds=`, or use `dispatch_async`. A *streaming* dispatch that produced its answer before the deadline returns that answer with a `hint` instead of failing. |
518
+ | `timeout` | Process killed at the timeout | Resume the partial work: `dispatch_session(agent, "Continue where you left off", session_id=<from the error text>)`. Or retry with a bigger `timeout_seconds=` — once the journal has history the error names the value that would have covered this agent's p90 — or use `dispatch_async`. A *streaming* dispatch that produced its answer before the deadline returns that answer with a `hint` instead of failing. |
507
519
  | `not_found` | Agent directory or `claude` CLI missing | `list_agents()` → check `healthy`. Re-add the agent with an existing path, or run `agent-dispatch doctor` to find what's missing. |
508
520
  | `recursion` | Dispatch nesting exceeded `max_dispatch_depth` (default 3) | Don't dispatch from dispatched agents; if the nesting is intentional, raise `max_dispatch_depth` in settings. |
509
521
  | `budget` | The `claude` CLI ended the session at the `max_budget_usd` spend cap — the answer is incomplete | Raise the cap (`update_agent(name, max_budget_usd=2.0)`), switch to a cheaper `model`, or split the task. The partial session is resumable: `dispatch_session(agent, "Continue where you left off", session_id=<from the result>)`. |
522
+ | `usage_limit` | The Claude **account** hit its usage/rate limit — not this agent, and nothing was billed | Wait for the reset named in the `error` text. Dispatching a *different* agent is not a workaround: every agent runs on the same account. If it recurs under load, lower `settings.max_concurrency`. |
510
523
  | `cli_error` | Anything else from the `claude` subprocess | Read the `error` text; run `agent-dispatch doctor` for environment issues; retry once if transient. |
511
524
 
512
525
  Three soft signals that arrive with `success: true`:
@@ -577,6 +590,9 @@ settings:
577
590
  max_concurrency: 5 # max parallel claude -p processes (per dispatch path)
578
591
  # dispatch_protocol: true # send every agent the dispatch protocol (non-interactive,
579
592
  # # time budget, STATUS line → `outcome`). false = raw claude -p.
593
+ # usage_log: true # record every dispatch in usage.jsonl (cost, duration, outcome).
594
+ # # Powers `agent-dispatch stats`, the `typical` block and the
595
+ # # measured timeout suggestion. false = record nothing.
580
596
  # job_retention_days: 30 # 0 (default) = never prune. See "Job retention" below.
581
597
  cache:
582
598
  enabled: true
@@ -586,6 +602,42 @@ settings:
586
602
 
587
603
  Config is reloaded on every tool call — add agents without restarting.
588
604
 
605
+ ### Usage journal — what your fleet actually costs
606
+
607
+ Every dispatch appends one line to `~/.config/agent-dispatch/usage.jsonl`
608
+ (override with `AGENT_DISPATCH_USAGE_LOG`): agent, success, cost, duration,
609
+ turns, `outcome`, error type, caller. Cache hits are recorded too, flagged
610
+ `cached`, so the report can show what the cache saves.
611
+
612
+ ```console
613
+ $ agent-dispatch stats --days 7
614
+ Usage (last 7 day(s))
615
+ dispatches: 33, 1 served from cache
616
+ spend: $13.0390
617
+ duration: median 87s, p90 2m40s, max 10m00s
618
+ outcomes: blocked 1, done 27, partial 3
619
+ failures: timeout 1, usage_limit 1
620
+
621
+ Per agent
622
+ analytic 18 runs $ 8.9441 median 2m04s p90 2m51s
623
+ done 15, partial 3
624
+ gitlab 12 runs $ 3.7949 median 60s p90 86s
625
+ done 12 | 1 cached
626
+ ```
627
+
628
+ `--agent NAME` narrows it, `--json` emits the same report as JSON.
629
+
630
+ The journal is what makes three other things work: the `typical` block in
631
+ `list_agents` / `inspect_agent`, the timeout error naming a value derived from
632
+ the agent's real p90 instead of a doubling guess, and any answer at all to
633
+ "which agent is expensive". Turn it off with `usage_log: false` in settings.
634
+
635
+ Properties worth knowing: the file is owner-only (`0o600`); each record is a
636
+ single `O_APPEND` write capped well under `PIPE_BUF`, so the CLI and every
637
+ running server can share it with no lock and no torn lines; it rotates to
638
+ `usage.jsonl.1` at ~2 MB and keeps two generations, so it is bounded at ~4 MB
639
+ forever. A failure to write it can never fail a dispatch.
640
+
589
641
  ### Job retention
590
642
 
591
643
  Every `dispatch_async` **and** every `dispatch(..., return_ref=True)` writes a
@@ -652,6 +704,8 @@ agent-dispatch MCP server
652
704
  ├─ System prompt += dispatch protocol + the agent's standing `instructions`
653
705
  ├─ Receives structured prompt with goal/caller/context/task
654
706
  └─ Returns result (+ its own STATUS → `outcome`) → cached when complete
707
+ │
708
+ └─ one line appended to usage.jsonl (cost, duration, outcome)
655
709
  ```
656
710
 
657
711
  ## Safety
@@ -659,7 +713,7 @@ agent-dispatch MCP server
659
713
  - **Recursion protection** — `AGENT_DISPATCH_DEPTH` env var tracks nesting. Default limit: 3. Best-effort across the subprocess boundary (see [SECURITY.md](SECURITY.md)).
660
714
  - **Argument-injection guard** — structured CLI fields (`session_id`, `model`, `permission_mode`, tool names) that start with `-` are rejected so they can't smuggle extra `claude` flags.
661
715
  - **Path-traversal guard** — caller-supplied `job_id`/`ref` values are validated as 32-char hex before any filesystem access.
662
- - **Owner-only state** — job files (`0o600`) and `agents.yaml` (`0o600`) are written for the owner only; their directories are `0o700`.
716
+ - **Owner-only state** — job files, `agents.yaml`, the usage journal and the server registry are all written `0o600`; their directories are `0o700`.
663
717
  - **Cost control** — `max_budget_usd` per agent or globally is passed to the `claude` CLI as `--max-budget-usd`, so a runaway dispatch is stopped at the cap and comes back as `error_type: "budget"` with a resumable `session_id`. An overshoot that lands over budget without stopping is flagged post-hoc with `budget_exceeded: true` + a hint.
664
718
  - **Concurrency** — `max_concurrency` (default: 5) caps parallel `claude -p` processes. Note: the sync and async dispatch paths use separate semaphores, so the worst-case total is `2 × max_concurrency`.
665
719
  - **Timeout** — per-agent or global (default: 300s). A streaming dispatch runs the agent in its own process group, so the deadline kills the whole tree: a process the agent left running in the background can't hold the dispatch (and its concurrency slot) open past the timeout.
@@ -680,7 +734,8 @@ See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassP
680
734
  | `agent-dispatch group <add\|list\|inspect\|update\|remove>` | Manage [groups](#groups) — cross-project working sets of agents |
681
735
  | `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
682
736
  | `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
683
- | `agent-dispatch doctor` | Diagnose installation: Claude CLI (incl. `--append-system-prompt` support), MCP registration, agent health, and group membership |
737
+ | `agent-dispatch stats [--days N --agent X --json]` | What dispatches cost: spend, durations, outcomes and failures per agent |
738
+ | `agent-dispatch doctor` | Diagnose installation: Claude CLI (incl. `--append-system-prompt` support), running servers on stale code, MCP registration, agent health, and group membership |
684
739
  | `agent-dispatch jobs [--status --limit]` | List async dispatch jobs (most recent first) |
685
740
  | `agent-dispatch job <id>` | Show one job: status, progress tail, result preview |
686
741
  | `agent-dispatch cancel <id>` | Cancel a pending job (running jobs: use the `dispatch_cancel` MCP tool) |
@@ -100,6 +100,12 @@ settings:
100
100
  default_timeout: 300
101
101
  max_dispatch_depth: 3 # recursion protection: A -> B -> A
102
102
  max_concurrency: 5 # max parallel claude -p processes
103
+ # usage_log: true # Append one line per dispatch to usage.jsonl: agent,
104
+ # cost, duration, turns, outcome, error type, caller.
105
+ # Powers `agent-dispatch stats`, the measured `typical`
106
+ # block in list_agents/inspect_agent, and the timeout
107
+ # error's suggested value. Owner-only, rotates at ~2 MB.
108
+ # false = record nothing.
103
109
  # dispatch_protocol: true # Append the dispatch protocol to every agent's system
104
110
  # prompt: it is non-interactive (never end with a
105
111
  # question), its time/spend budget, lead with the
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-dispatch"
3
- version = "0.14.0"
3
+ version = "0.15.1"
4
4
  description = "MCP server that lets Claude Code agents delegate tasks to agents in other project directories"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """agent-dispatch: Delegate tasks between Claude Code agents across projects."""
2
2
 
3
- __version__ = "0.14.0"
3
+ __version__ = "0.15.1"
@@ -891,6 +891,37 @@ def doctor() -> None:
891
891
  else:
892
892
  warn("agent-dispatch not on PATH (MCP server still works via absolute path)")
893
893
 
894
+ section("Running servers")
895
+ from . import __version__, servers
896
+ from . import usage as usage_mod
897
+
898
+ live = servers.live_servers()
899
+ if not live:
900
+ ok("No agent-dispatch server is registered as running")
901
+ else:
902
+ drift = servers.version_drift(__version__)
903
+ stale_count = sum(drift.values())
904
+ ok(f"{len(live)} server process(es) registered, {len(live) - stale_count} on {__version__}")
905
+ # Printed in BOTH branches on purpose. A server only registers if it was
906
+ # started on a version that has this registry, so the count is a lower
907
+ # bound — on the machine this was written, 19 servers were running and
908
+ # exactly 1 of them appeared here. Reporting "1 server running" without
909
+ # this line would be a diagnostic that quietly under-counts.
910
+ click.echo(" Lower bound: only servers started on this version or later register here.")
911
+ if live:
912
+ if drift:
913
+ versions = ", ".join(f"{n}x {v}" for v, n in drift.items())
914
+ warn(f"{stale_count} server(s) still executing older code: {versions}")
915
+ click.echo(" A server keeps its modules in memory for life, so an upgrade")
916
+ click.echo(" reaches an open Claude Code session only when that session")
917
+ click.echo(" restarts. Restart them to pick up the installed version.")
918
+ for entry in live:
919
+ version = str(entry.get("version") or "unknown")
920
+ if version != __version__:
921
+ started = entry.get("started_at")
922
+ age = f", up {_age(usage_mod.as_float(started))}" if started else ""
923
+ click.echo(f" pid {entry.get('pid')}: {version}{age}")
924
+
894
925
  section("Config")
895
926
  cp = config_path()
896
927
  config: DispatchConfig | None = None
@@ -1180,6 +1211,91 @@ def jobs_gc(days: int, purge_all: bool) -> None:
1180
1211
  click.echo(f"Deleted {deleted} job(s) older than {days} day(s).")
1181
1212
 
1182
1213
 
1214
+ def _fmt_seconds(ms: float) -> str:
1215
+ """Human duration from milliseconds: 850ms, 42s, 3m20s."""
1216
+ seconds = ms / 1000
1217
+ if seconds < 1:
1218
+ return f"{int(ms)}ms"
1219
+ if seconds < 90:
1220
+ return f"{seconds:.0f}s"
1221
+ return f"{int(seconds) // 60}m{int(seconds) % 60:02d}s"
1222
+
1223
+
1224
+ def _fmt_counts(counts: dict[str, int]) -> str:
1225
+ return ", ".join(f"{k} {v}" for k, v in counts.items())
1226
+
1227
+
1228
+ @cli.command("stats")
1229
+ @click.option("--days", default=0, type=int, help="Only count the last N days (default: all).")
1230
+ @click.option("--agent", default=None, help="Only this agent.")
1231
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
1232
+ def usage_stats(days: int, agent: str | None, as_json: bool) -> None:
1233
+ """Show what dispatches actually cost: spend, durations, outcomes per agent."""
1234
+ from . import usage
1235
+
1236
+ if days < 0:
1237
+ click.echo(click.style(f"Error: --days must be >= 0 (got {days}).", fg="red"))
1238
+ raise SystemExit(1)
1239
+
1240
+ entries = usage.load(days=days, agent=agent)
1241
+ report = usage.summarize(entries)
1242
+ if as_json:
1243
+ # Always valid JSON, including the empty case — a caller piping this
1244
+ # into a parser must not get prose on a fresh install, which is
1245
+ # exactly when the journal is empty.
1246
+ click.echo(json.dumps(report, indent=2, ensure_ascii=False))
1247
+ return
1248
+ if not entries:
1249
+ window = f" in the last {days} day(s)" if days else ""
1250
+ who = f" for agent '{agent}'" if agent else ""
1251
+ click.echo(f"No dispatches recorded{who}{window}.")
1252
+ click.echo(f" Journal: {usage.journal_path()}")
1253
+ click.echo(" (Recording is off if settings.usage_log is false in agents.yaml.)")
1254
+ return
1255
+
1256
+ total = report["total"]
1257
+ window = f"last {days} day(s)" if days else "all recorded"
1258
+ click.echo(click.style(f"Usage ({window})", bold=True))
1259
+ cached = f", {total['cached_hits']} served from cache" if total["cached_hits"] else ""
1260
+ click.echo(f" dispatches: {total['dispatches']}{cached}")
1261
+ click.echo(f" spend: ${total['cost_usd']:.4f}")
1262
+ if "median_ms" in total:
1263
+ click.echo(
1264
+ f" duration: median {_fmt_seconds(total['median_ms'])}, "
1265
+ f"p90 {_fmt_seconds(total['p90_ms'])}, max {_fmt_seconds(total['max_ms'])}"
1266
+ )
1267
+ if total.get("outcomes"):
1268
+ click.echo(f" outcomes: {_fmt_counts(total['outcomes'])}")
1269
+ if total.get("errors"):
1270
+ click.echo(click.style(f" failures: {_fmt_counts(total['errors'])}", fg="yellow"))
1271
+
1272
+ click.echo()
1273
+ click.echo(click.style("Per agent", bold=True))
1274
+ for name, st in report["agents"].items():
1275
+ head = (
1276
+ f" {click.style(name, bold=True):<24} {st['dispatches']:>4} "
1277
+ f"{'run ' if st['dispatches'] == 1 else 'runs'} "
1278
+ f"${st['cost_usd']:>8.4f}"
1279
+ )
1280
+ if "median_ms" in st:
1281
+ head += (
1282
+ f" median {_fmt_seconds(st['median_ms']):>6} p90 {_fmt_seconds(st['p90_ms']):>6}"
1283
+ )
1284
+ click.echo(head)
1285
+ detail = []
1286
+ if st.get("outcomes"):
1287
+ detail.append(_fmt_counts(st["outcomes"]))
1288
+ if st.get("errors"):
1289
+ detail.append(click.style(_fmt_counts(st["errors"]), fg="yellow"))
1290
+ if st["cached_hits"]:
1291
+ detail.append(f"{st['cached_hits']} cached")
1292
+ if detail:
1293
+ click.echo(f" {' | '.join(detail)}")
1294
+
1295
+ click.echo()
1296
+ click.echo(f"Journal: {usage.journal_path()}")
1297
+
1298
+
1183
1299
  @cli.command()
1184
1300
  def serve() -> None:
1185
1301
  """Start the MCP server (stdio transport)."""
@@ -119,6 +119,13 @@ class Settings(BaseModel):
119
119
  # (parsed into DispatchResult.outcome). Off = raw `claude -p` behaviour,
120
120
  # for CLIs that predate --append-system-prompt or for A/B comparison.
121
121
  dispatch_protocol: bool = True
122
+ # Append one line per dispatch to <config dir>/usage.jsonl: agent, ok,
123
+ # cost, duration, turns, outcome, error type, caller. It is what makes
124
+ # "which agent burns money / how long does this one actually take"
125
+ # answerable (CLI `stats`, the timing block in inspect_agent, the
126
+ # timeout hint's suggested value). Owner-only, rotated at ~2 MB, and
127
+ # written with a single atomic append so concurrent servers can share it.
128
+ usage_log: bool = True
122
129
 
123
130
 
124
131
  def validate_agent_name(name: str) -> str: