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.
- agent_dispatch-0.15.1/.coverage +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/AGENTS.md +11 -2
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/CHANGELOG.md +79 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/PKG-INFO +61 -6
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/README.md +60 -5
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/agents.example.yaml +6 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/pyproject.toml +1 -1
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/__init__.py +1 -1
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/cli.py +116 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/models.py +7 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/runner.py +124 -10
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/server.py +37 -1
- agent_dispatch-0.15.1/src/agent_dispatch/servers.py +156 -0
- agent_dispatch-0.15.1/src/agent_dispatch/usage.py +365 -0
- agent_dispatch-0.15.1/tests/conftest.py +68 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/test_cli.py +129 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/test_runner.py +188 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/test_server.py +117 -1
- agent_dispatch-0.15.1/tests/test_servers.py +157 -0
- agent_dispatch-0.15.1/tests/test_usage.py +334 -0
- agent_dispatch-0.14.0/tests/conftest.py +0 -41
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/.github/dependabot.yml +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/.github/workflows/ci.yml +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/.github/workflows/publish.yml +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/.gitignore +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/LICENSE +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/SECURITY.md +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/assets/mascot.png +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/cache.py +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/config.py +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/src/agent_dispatch/jobs.py +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/__init__.py +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/test_cache.py +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/test_config.py +0 -0
- {agent_dispatch-0.14.0 → agent_dispatch-0.15.1}/tests/test_jobs.py +0 -0
- {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 #
|
|
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/`,
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
@@ -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:
|