agent-dispatch 0.3.0__tar.gz → 0.5.0__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 (31) hide show
  1. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/.github/workflows/ci.yml +4 -0
  2. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/.github/workflows/publish.yml +5 -1
  3. agent_dispatch-0.5.0/CHANGELOG.md +224 -0
  4. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/PKG-INFO +96 -6
  5. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/README.md +95 -5
  6. agent_dispatch-0.5.0/SECURITY.md +77 -0
  7. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/agents.example.yaml +1 -0
  8. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/pyproject.toml +23 -1
  9. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/src/agent_dispatch/__init__.py +1 -1
  10. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/src/agent_dispatch/cache.py +26 -8
  11. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/src/agent_dispatch/cli.py +7 -4
  12. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/src/agent_dispatch/config.py +61 -28
  13. agent_dispatch-0.5.0/src/agent_dispatch/jobs.py +299 -0
  14. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/src/agent_dispatch/models.py +5 -0
  15. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/src/agent_dispatch/runner.py +123 -11
  16. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/src/agent_dispatch/server.py +690 -24
  17. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/tests/test_cache.py +33 -2
  18. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/tests/test_cli.py +1 -0
  19. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/tests/test_config.py +12 -0
  20. agent_dispatch-0.5.0/tests/test_jobs.py +339 -0
  21. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/tests/test_models.py +5 -4
  22. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/tests/test_runner.py +206 -0
  23. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/tests/test_server.py +764 -8
  24. agent_dispatch-0.3.0/CHANGELOG.md +0 -98
  25. agent_dispatch-0.3.0/SECURITY.md +0 -22
  26. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/.github/dependabot.yml +0 -0
  27. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/.gitignore +0 -0
  28. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/LICENSE +0 -0
  29. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/assets/mascot.png +0 -0
  30. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/tests/__init__.py +0 -0
  31. {agent_dispatch-0.3.0 → agent_dispatch-0.5.0}/tests/conftest.py +0 -0
@@ -6,6 +6,10 @@ on:
6
6
  pull_request:
7
7
  branches: [main]
8
8
 
9
+ # Least privilege: CI only needs to read the repo.
10
+ permissions:
11
+ contents: read
12
+
9
13
  jobs:
10
14
  test:
11
15
  runs-on: ubuntu-latest
@@ -4,12 +4,16 @@ on:
4
4
  release:
5
5
  types: [published]
6
6
 
7
+ # Default to no privileges; the publish job opts into exactly what it needs.
8
+ permissions: {}
9
+
7
10
  jobs:
8
11
  publish:
9
12
  runs-on: ubuntu-latest
10
13
  environment: pypi
11
14
  permissions:
12
- id-token: write
15
+ id-token: write # OIDC token for PyPI Trusted Publisher
16
+ contents: read # checkout the tagged source
13
17
  steps:
14
18
  - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
15
19
 
@@ -0,0 +1,224 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.5.0] - 2026-06-01
11
+
12
+ Security-hardening release. A multi-agent audit of the codebase surfaced
13
+ several issues; the confirmed ones are fixed here, plus job cancellation,
14
+ cache bounding, and stale-job recovery.
15
+
16
+ ### Security
17
+ - **Path traversal in async jobs (fixed).** `dispatch_status`, `dispatch_wait`,
18
+ and `fetch_result` accept a caller-supplied `job_id`/`ref` that flowed
19
+ straight into `JobStore`'s file-path construction. A crafted value such as
20
+ `../../secret` could read any Job-shaped `.json` file outside the jobs
21
+ directory. Job ids are now validated against `^[0-9a-f]{32}$` at the tool
22
+ boundary (`_validate_ref`), in `JobStore.get`, and in `JobStore._path`
23
+ (defense in depth). Malformed ids are rejected without touching the
24
+ filesystem. New helper `jobs.is_valid_job_id`.
25
+ - **Argument/flag injection via structured CLI fields (fixed).** A
26
+ `session_id` (caller-controlled in `dispatch_session`) — or a misconfigured
27
+ `model`, `permission_mode`, or tool name — that started with `-` was placed
28
+ in the argument position after a flag (e.g. `--resume <session_id>`) and the
29
+ `claude` CLI parsed it as a *new* flag, allowing options like
30
+ `--permission-mode bypassPermissions` to be smuggled in. `_build_command`
31
+ now rejects any such value via `_reject_flaglike` (raising
32
+ `runner.ArgInjectionError`); `dispatch`/`dispatch_stream` surface it as a
33
+ clean failed result, never spawning a subprocess.
34
+ - **Tightened file permissions.** Job files are written `0o600` and the jobs
35
+ directory is created `0o700` (they hold full task/context/result payloads
36
+ that may contain secrets). `save_config` now writes `agents.yaml` `0o600`
37
+ and its parent directory `0o700`. All `chmod`s are best-effort (skipped on
38
+ platforms without POSIX modes).
39
+
40
+ ### Added
41
+ - `dispatch_cancel(job_id)` MCP tool — cancel a *pending* async job before it
42
+ starts. Running jobs are left to finish (their subprocess can't be safely
43
+ interrupted); the tool reports an `outcome` of `cancelled`, `running`,
44
+ `already_terminal`, or `not_found`. Makes the previously-unreachable
45
+ `cancelled` job status real. Backed by `JobStore.cancel`, and the
46
+ cancel/start race is closed by `mark_running` refusing a cancelled job.
47
+ - Cache size bound — `CacheSettings.max_size` (default 1000) caps the
48
+ in-memory dispatch cache, evicting the oldest entry first (FIFO by insertion
49
+ time; read access does not refresh, since the timestamp also drives TTL),
50
+ preventing unbounded memory growth from many unique requests. `cache_stats`
51
+ now reports `max_size` and `evictions`.
52
+ - Stale-job recovery — on startup the server marks jobs abandoned in
53
+ `running` (older than 1h, e.g. from a crashed prior run) as `failed` so
54
+ callers don't poll them forever (`JobStore.recover_stale`).
55
+
56
+ ### Changed
57
+ - Input bounds hardened across MCP tools: `dispatch_jobs(limit)` clamped to
58
+ `[1, 1000]`; `dispatch_gc(max_age_days)` rejects non-finite values;
59
+ `summary_chars` (in `dispatch` and per-item `dispatch_parallel`) clamped to
60
+ `[0, 100000]`; `dispatch_parallel` rejects more than
61
+ `max(100, max_concurrency * 20)` items to bound subprocess fan-out.
62
+ - Async job worker now logs lifecycle transitions (running / finished) with
63
+ the job id for easier production debugging.
64
+ - Type hints filled in (`_ref_payload`, `_run_job`, `_run_one`).
65
+ - Lint surface expanded — ruff now enforces bugbear (`B`), bandit security
66
+ (`S`), import order (`I`), and pyupgrade (`UP`) in addition to the defaults,
67
+ with documented ignores for the trusted `claude` subprocess calls.
68
+ - `SECURITY.md` rewritten: accurate supported-versions table and an expanded
69
+ threat model (bypassPermissions, on-disk job files, env inheritance,
70
+ best-effort recursion depth, argument-injection mitigation).
71
+
72
+ ## [0.4.0] - 2026-05-15
73
+
74
+ ### Added
75
+ - Result references — `dispatch(..., return_ref=True)` and per-item in
76
+ `dispatch_parallel` now return a compact `{ref, agent, success, size,
77
+ summary, summary_chars, cost_usd, ...}` payload instead of the full
78
+ result text. The full DispatchResult is persisted to disk (reusing the
79
+ async JobStore) and can be loaded on demand via the new
80
+ `fetch_result(ref, max_chars=0)` MCP tool. Saves caller context when
81
+ the result is large; the JSON parsed_result (small by nature) is still
82
+ inlined alongside the ref. fetch_result also works on any
83
+ `dispatch_async` job_id — the storage is shared.
84
+ - `JobStore.create_completed(...)` — persists an already-finished
85
+ DispatchResult as a Job in terminal state. Used by ref mode; future
86
+ iterations can use it for result archival.
87
+ - Structured JSON response support — `dispatch`, `dispatch_session`,
88
+ `dispatch_async`, `dispatch_stream`, and per-item in `dispatch_parallel`
89
+ now accept `response_format="json"`. When set, the runner appends a clear
90
+ "respond with a single JSON value, no prose, no fences" footer to the
91
+ prompt and attempts to parse the agent's response (tolerating ```json
92
+ fences). The parsed value lands in a new `DispatchResult.parsed_result`
93
+ field — `None` when not requested or unparseable (soft mode: parse
94
+ failure does NOT mark the dispatch as failed). Cache key now includes
95
+ `response_format` so JSON and text requests for the same task don't
96
+ collide.
97
+ - `list_agents` MCP tool now surfaces `mcp_servers`, `stacks`, and `dbs`
98
+ per agent (when present) — the same structured data `auto_describe`
99
+ already collects from `.mcp.json`, `Dockerfile`, `pyproject.toml`,
100
+ `package.json`, `Cargo.toml`, `go.mod`, `prisma/`, `alembic.ini`, etc.
101
+ Calling agents no longer need to dispatch a probe just to learn what
102
+ tools the target has.
103
+ - New `inspect_agent(name, preview_lines=40)` MCP tool — cheap detailed
104
+ lookup without a `claude` subprocess. Returns the agent's full config
105
+ fields (timeout, model, budget, permission_mode, tool lists), detected
106
+ MCP/stacks/DBs, plus short previews of `CLAUDE.md` and `README.md` so
107
+ the caller can confirm capabilities before spending a real dispatch.
108
+ - `config.collect_mcp_servers()`, `config.detect_stacks()`, and
109
+ `config.detect_dbs()` are now public helpers (the previous private
110
+ `_collect_mcp_servers` remains as an alias for compatibility).
111
+ - Async dispatch with a `job_id` pattern — five new MCP tools let calling
112
+ agents fire-and-forget long-running tasks without blocking their own tool
113
+ slot:
114
+ - `dispatch_async(agent, task, ...)` — start a dispatch in the background,
115
+ returns `{job_id, status: "pending", agent}` immediately.
116
+ - `dispatch_status(job_id)` — read the current state of a job without
117
+ blocking (pending / running / done / failed) including the
118
+ `DispatchResult` once complete.
119
+ - `dispatch_wait(job_id, timeout_seconds=60)` — block until terminal or
120
+ until the timeout fires (capped at 3600s). Returns the same shape as
121
+ `dispatch_status` plus `timed_out_waiting: true` on timeout — the job
122
+ keeps running and the caller can poll/wait again.
123
+ - `dispatch_jobs(status?, limit=50)` — list recent jobs as summaries,
124
+ optionally filtered by status (most recent first).
125
+ - `dispatch_gc(max_age_days=7)` — purge terminal jobs older than the
126
+ threshold. Pending and running jobs are never touched.
127
+ - Job state persists to disk as one JSON file per job under
128
+ `~/.config/agent-dispatch/jobs/` (override via `AGENT_DISPATCH_JOBS_DIR`).
129
+ Atomic writes via `os.replace()` so partial files never appear, and jobs
130
+ survive across server restarts (existing terminal jobs remain queryable,
131
+ in-flight jobs are abandoned on restart — to be addressed in a future
132
+ iteration with PID tracking).
133
+
134
+ ## [0.3.0] - 2026-05-08
135
+
136
+ ### Added
137
+ - `agent-dispatch doctor` CLI command — diagnoses installation issues:
138
+ checks `claude` CLI on PATH, `agent-dispatch` on PATH, config validity,
139
+ MCP registration with Claude Code, and per-agent directory health.
140
+ Exits non-zero if any blocking issue is found.
141
+ - `agent-dispatch describe <name>` CLI command — show one agent's full
142
+ configuration: directory, description, timeout, model, budget, permission
143
+ mode, tri-state tool fields (`(inherit defaults)` vs `(none — explicit
144
+ override)` vs explicit list), and which project files would be inherited.
145
+ - `--stream` flag for `agent-dispatch test` — surfaces live progress
146
+ (assistant text + tool use) while the agent works, useful for long
147
+ tasks where you'd otherwise see nothing until completion.
148
+
149
+ ### Fixed
150
+ - `list_agents` MCP tool no longer crashes the entire response when one
151
+ agent's directory is unreadable (`PermissionError`, network FS hiccup,
152
+ etc.). The bad agent now reports `healthy: "UNREADABLE"` and the rest
153
+ of the listing succeeds — matching the documented response shape.
154
+ - Dispatch cache key now includes `caller` and `goal`. Previously two
155
+ requests with the same `(agent, task, context)` but different framing
156
+ (e.g. `caller="frontend"` vs `caller="backend"`) would collide and the
157
+ second request would receive the cached response from the first — even
158
+ though the structured prompt sent to Claude is materially different.
159
+
160
+ ## [0.2.2] - 2026-04-17
161
+
162
+ ### Fixed
163
+ - `agent-dispatch list` now distinguishes `allowed_tools: None` (inherit
164
+ from settings defaults) from `allowed_tools: []` (explicitly no tools).
165
+ Previously both were rendered identically.
166
+
167
+ ## [0.2.1] - 2026-04-17
168
+
169
+ ### Fixed
170
+ - 13 bugs across the runner, server, CLI, config, and models:
171
+ - Runner: defensive coercion in `_classify_error` for non-string inputs;
172
+ fallback messages when `is_error=True` produces empty `result`;
173
+ correct error_type classification on plain-text stdout fallbacks;
174
+ orphan subprocess cleanup on stream exit paths.
175
+ - Server: up-front validation in `dispatch_parallel` (rejects bad items
176
+ before any dispatch runs); `dispatch_dialogue` surfaces per-turn errors;
177
+ `cache_stats` evicts expired entries before reporting.
178
+ - CLI: friendly error messages on malformed YAML / invalid schema;
179
+ `list` handles `OSError` from unreachable directories;
180
+ sentinel patterns for `update` to clear fields (`"none"` / `""`).
181
+ - Config: deduplication when collecting MCP servers from multiple paths.
182
+ - Models: tighter validation bounds (`ge=0`, `ge=1`).
183
+
184
+ ## [0.2.0] - 2026-04-16
185
+
186
+ ### Added
187
+ - Error classification — `DispatchResult.error_type` now reports
188
+ `permission`, `timeout`, `recursion`, `not_found`, or `cli_error`.
189
+ Permission errors include an actionable hint with suggested fixes.
190
+ - Permission management — agents and global settings support
191
+ `permission_mode`, `allowed_tools`, and `disallowed_tools`. Tool lists
192
+ use tri-state semantics: `None` inherits from defaults, `[]` overrides
193
+ to "no tools", a list specifies the allowed/disallowed set.
194
+ - `update_agent` MCP tool — modify an existing agent's configuration
195
+ without remove + re-add. CLI parity via `agent-dispatch update`.
196
+ - CLI tests for `init` and `test` commands.
197
+
198
+ ## [0.1.0] - 2026-04-10
199
+
200
+ ### Added
201
+ - Initial release.
202
+ - 11 MCP tools: `list_agents`, `add_agent`, `remove_agent`, `dispatch`,
203
+ `dispatch_session`, `dispatch_parallel` (with optional aggregation),
204
+ `dispatch_stream`, `dispatch_dialogue`, `cache_stats`, `cache_clear`.
205
+ - CLI: `init`, `add`, `remove`, `list`, `test`, `serve`.
206
+ - Recursion protection via `AGENT_DISPATCH_DEPTH` env var.
207
+ - In-memory TTL cache (thread-safe).
208
+ - Concurrency control via `asyncio.Semaphore` (default: 5 parallel
209
+ `claude -p` processes).
210
+ - Auto-description from `CLAUDE.md`, `README.md`, `pyproject.toml`,
211
+ `package.json`, `.mcp.json`, and stack/DB indicators.
212
+ - PyPI publishing via Trusted Publisher (OIDC).
213
+ - CI matrix on Python 3.10, 3.11, 3.12, 3.13.
214
+ - Dependabot for `pip` + `github-actions`, GitHub Actions pinned to
215
+ commit SHAs for supply-chain integrity.
216
+
217
+ [Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.5.0...HEAD
218
+ [0.5.0]: https://github.com/ginkida/agent-dispatch/compare/v0.4.0...v0.5.0
219
+ [0.4.0]: https://github.com/ginkida/agent-dispatch/compare/v0.3.0...v0.4.0
220
+ [0.3.0]: https://github.com/ginkida/agent-dispatch/compare/v0.2.2...v0.3.0
221
+ [0.2.2]: https://github.com/ginkida/agent-dispatch/compare/v0.2.1...v0.2.2
222
+ [0.2.1]: https://github.com/ginkida/agent-dispatch/compare/v0.2.0...v0.2.1
223
+ [0.2.0]: https://github.com/ginkida/agent-dispatch/compare/v0.1.0...v0.2.0
224
+ [0.1.0]: https://github.com/ginkida/agent-dispatch/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-dispatch
3
- Version: 0.3.0
3
+ Version: 0.5.0
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
@@ -85,7 +85,7 @@ Done. Every Claude Code session now has access to all dispatch tools.
85
85
  Lists all configured agents. **Call this first** to see what's available.
86
86
 
87
87
  ```json
88
- // Response (permission fields shown only when configured)
88
+ // Response (capability + permission fields shown only when populated)
89
89
  [
90
90
  {
91
91
  "name": "infra",
@@ -94,12 +94,28 @@ Lists all configured agents. **Call this first** to see what's available.
94
94
  "healthy": true,
95
95
  "has_claude_md": true,
96
96
  "has_mcp_config": true,
97
+ "mcp_servers": ["portainer", "postgres"],
98
+ "stacks": ["Python", "Docker"],
99
+ "dbs": ["Alembic"],
97
100
  "permission_mode": "bypassPermissions",
98
101
  "allowed_tools": ["Bash", "Read", "Grep"]
99
102
  }
100
103
  ]
101
104
  ```
102
105
 
106
+ `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.
107
+
108
+ ### `inspect_agent`
109
+
110
+ 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.
111
+
112
+ | Parameter | Type | Required | Description |
113
+ |-----------|------|----------|-------------|
114
+ | `name` | string | yes | Agent name from `list_agents` |
115
+ | `preview_lines` | int | no | Max lines of CLAUDE.md/README.md (default 40, max 200, 0 disables) |
116
+
117
+ Use this **before** `dispatch_async`/`dispatch` to confirm an agent has the tools and context for your task — much cheaper than a probe dispatch.
118
+
103
119
  ### `dispatch`
104
120
 
105
121
  One-shot task delegation. Results are cached — identical requests within TTL return instantly.
@@ -111,6 +127,9 @@ One-shot task delegation. Results are cached — identical requests within TTL r
111
127
  | `context` | string | no | Extra context: error messages, code snippets, stack traces |
112
128
  | `caller` | string | no | Your project/role — helps the agent understand who's asking |
113
129
  | `goal` | string | no | Broader objective — helps the agent make better trade-offs |
130
+ | `response_format` | string | no | `"json"` to request a single JSON value; the parsed result lands in `parsed_result`. Empty = free-form text. |
131
+ | `return_ref` | bool | no | When `true`, returns just a `ref` + summary preview instead of the full result text. Use `fetch_result(ref)` to load the full text on demand. |
132
+ | `summary_chars` | int | no | Max chars of result text to include in the ref response (default 500). |
114
133
 
115
134
  ```json
116
135
  // Response (success)
@@ -136,6 +155,18 @@ One-shot task delegation. Results are cached — identical requests within TTL r
136
155
 
137
156
  **`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `cli_error` (other failures). Permission errors include an actionable hint.
138
157
 
158
+ **Structured JSON output:** pass `response_format="json"` to ask the agent for a single JSON value. The runner appends an instruction footer ("respond with a single valid JSON value, no fences, no prose") and on success parses the response — the parsed value lands in `parsed_result`. The raw text is always in `result`. Parse failures leave `parsed_result=None` but don't fail the dispatch (soft mode).
159
+
160
+ ```json
161
+ // Response with response_format="json"
162
+ {
163
+ "agent": "infra",
164
+ "success": true,
165
+ "result": "{\"errors\": 3, \"first_at\": \"14:02\"}",
166
+ "parsed_result": {"errors": 3, "first_at": "14:02"}
167
+ }
168
+ ```
169
+
139
170
  **Always pass `caller` and `goal`** — the dispatched agent sees a structured prompt:
140
171
 
141
172
  ```markdown
@@ -290,6 +321,57 @@ Remove an agent from config.
290
321
 
291
322
  View cache hit rate and size, or clear all cached results.
292
323
 
324
+ ### Result references — `return_ref` + `fetch_result`
325
+
326
+ For dispatches whose result text is large (audits, log dumps, code searches), passing the full text back inflates the calling agent's context. Use `return_ref=True` to get just a small reference instead:
327
+
328
+ ```
329
+ dispatch(agent="infra", task="audit every container", return_ref=True, summary_chars=200)
330
+ -> {"ref": "8f3a...e1", "agent": "infra", "success": true,
331
+ "size": 14823, "summary_chars": 200,
332
+ "summary": "Inspected 32 containers. Found 3 OOM kills in the last hour:\n- worker-3...",
333
+ "cost_usd": 0.08, "duration_ms": 9200}
334
+
335
+ // Later, when you actually need to read the result:
336
+ fetch_result(ref="8f3a...e1") -> full DispatchResult JSON
337
+ fetch_result(ref="8f3a...e1", max_chars=2000) -> truncated, plus {"truncated": true, "full_size": 14823}
338
+ ```
339
+
340
+ Refs reuse the same storage as `dispatch_async` jobs (under `~/.config/agent-dispatch/jobs/`), so any `job_id` returned by `dispatch_async` is also a valid `ref` for `fetch_result`. `parsed_result` (when `response_format="json"` is set) is small and is always inlined directly in the ref response — no second fetch needed.
341
+
342
+ ### Async dispatch — `dispatch_async`, `dispatch_status`, `dispatch_wait`, `dispatch_cancel`, `dispatch_jobs`, `dispatch_gc`
343
+
344
+ When a dispatched task is going to take a while, you don't want to block your own tool slot for minutes. Async dispatch returns a `job_id` immediately and lets you check back when you're ready.
345
+
346
+ ```
347
+ // 1. fire and forget
348
+ dispatch_async(agent="infra", task="audit every container log for OOM kills today")
349
+ -> {"job_id": "8f3a...e1", "status": "pending", "agent": "infra"}
350
+
351
+ // 2. do other work, then check progress (non-blocking)
352
+ dispatch_status(job_id="8f3a...e1")
353
+ -> {"id": "8f3a...e1", "status": "running", "started_at": 1730000123.4, ...}
354
+
355
+ // 3. or block until done (with a timeout cap)
356
+ dispatch_wait(job_id="8f3a...e1", timeout_seconds=120)
357
+ -> {"id": "8f3a...e1", "status": "done", "result": {"agent": "infra", "success": true, ...}}
358
+
359
+ // If the timeout fires, the job keeps running:
360
+ -> {"id": "...", "status": "running", "timed_out_waiting": true}
361
+ ```
362
+
363
+ `dispatch_cancel(job_id)` cancels a job that is still **pending** (before its subprocess starts) — a running job is left to finish, since its `claude` subprocess can't be safely interrupted. The response carries an `outcome` of `cancelled`, `running`, `already_terminal`, or `not_found`.
364
+
365
+ `dispatch_jobs(status?)` lists recent jobs as summaries (filter by `pending` / `running` / `done` / `failed` / `cancelled`). `dispatch_gc(max_age_days=7)` purges terminal jobs older than the threshold — pending and running jobs are never deleted.
366
+
367
+ Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `AGENT_DISPATCH_JOBS_DIR`). One JSON file per job, written owner-only (`0o600`) with atomic writes — safe to read or `ls` while jobs are in flight. Caller-supplied `job_id`s are validated as 32-char hex before any file access (no path traversal). On startup the server marks jobs abandoned in `running` by a prior crashed instance as `failed`.
368
+
369
+ | When to use async | When to use `dispatch` |
370
+ |-------------------|------------------------|
371
+ | Long task (minutes) — you want to keep working | Short task — you need the answer right now |
372
+ | Several long tasks you'll collect later | Several short tasks → `dispatch_parallel` |
373
+ | Don't care about caching (each call is a fresh job) | Cached by default — identical requests are free |
374
+
293
375
  ### Error Responses
294
376
 
295
377
  All tools return errors as:
@@ -308,6 +390,8 @@ All tools return errors as:
308
390
  | Long task, want to see progress | `dispatch_stream` |
309
391
  | Two agents need to collaborate | `dispatch_dialogue` |
310
392
  | Need a combined summary from multiple agents | `dispatch_parallel` with `aggregate` |
393
+ | Long task — don't block your tool slot | `dispatch_async` + `dispatch_wait` |
394
+ | Check progress without blocking | `dispatch_status` |
311
395
 
312
396
  ## Configuration
313
397
 
@@ -336,10 +420,11 @@ settings:
336
420
  # - Read
337
421
  # - Edit
338
422
  max_dispatch_depth: 3 # recursion protection
339
- max_concurrency: 5 # max parallel claude -p processes
423
+ max_concurrency: 5 # max parallel claude -p processes (per dispatch path)
340
424
  cache:
341
425
  enabled: true
342
426
  ttl: 300 # seconds
427
+ max_size: 1000 # max cached entries; oldest evicted first (FIFO)
343
428
  ```
344
429
 
345
430
  Config is reloaded on every tool call — add agents without restarting.
@@ -377,11 +462,16 @@ agent-dispatch MCP server
377
462
 
378
463
  ## Safety
379
464
 
380
- - **Recursion protection** — `AGENT_DISPATCH_DEPTH` env var tracks nesting. Default limit: 3.
465
+ - **Recursion protection** — `AGENT_DISPATCH_DEPTH` env var tracks nesting. Default limit: 3. Best-effort across the subprocess boundary (see [SECURITY.md](SECURITY.md)).
466
+ - **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.
467
+ - **Path-traversal guard** — caller-supplied `job_id`/`ref` values are validated as 32-char hex before any filesystem access.
468
+ - **Owner-only state** — job files (`0o600`) and `agents.yaml` (`0o600`) are written for the owner only; their directories are `0o700`.
381
469
  - **Cost control** — `max_budget_usd` per agent or globally.
382
- - **Concurrency** — `max_concurrency` (default: 5) limits parallel `claude -p` processes.
470
+ - **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`.
383
471
  - **Timeout** — per-agent or global (default: 300s). Orphaned processes are cleaned up.
384
- - **Caching** — identical `(agent, task, context)` requests return cached results. Only successes are cached. Sessions and dialogues are never cached.
472
+ - **Caching** — identical `(agent, task, context, caller, goal, response_format)` requests return cached results, bounded by `cache.max_size` (oldest entry evicted first). Only successes are cached. Sessions and dialogues are never cached.
473
+
474
+ See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassPermissions` escalation risk and on-disk job files).
385
475
 
386
476
  ## CLI
387
477
 
@@ -55,7 +55,7 @@ Done. Every Claude Code session now has access to all dispatch tools.
55
55
  Lists all configured agents. **Call this first** to see what's available.
56
56
 
57
57
  ```json
58
- // Response (permission fields shown only when configured)
58
+ // Response (capability + permission fields shown only when populated)
59
59
  [
60
60
  {
61
61
  "name": "infra",
@@ -64,12 +64,28 @@ Lists all configured agents. **Call this first** to see what's available.
64
64
  "healthy": true,
65
65
  "has_claude_md": true,
66
66
  "has_mcp_config": true,
67
+ "mcp_servers": ["portainer", "postgres"],
68
+ "stacks": ["Python", "Docker"],
69
+ "dbs": ["Alembic"],
67
70
  "permission_mode": "bypassPermissions",
68
71
  "allowed_tools": ["Bash", "Read", "Grep"]
69
72
  }
70
73
  ]
71
74
  ```
72
75
 
76
+ `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.
77
+
78
+ ### `inspect_agent`
79
+
80
+ 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.
81
+
82
+ | Parameter | Type | Required | Description |
83
+ |-----------|------|----------|-------------|
84
+ | `name` | string | yes | Agent name from `list_agents` |
85
+ | `preview_lines` | int | no | Max lines of CLAUDE.md/README.md (default 40, max 200, 0 disables) |
86
+
87
+ Use this **before** `dispatch_async`/`dispatch` to confirm an agent has the tools and context for your task — much cheaper than a probe dispatch.
88
+
73
89
  ### `dispatch`
74
90
 
75
91
  One-shot task delegation. Results are cached — identical requests within TTL return instantly.
@@ -81,6 +97,9 @@ One-shot task delegation. Results are cached — identical requests within TTL r
81
97
  | `context` | string | no | Extra context: error messages, code snippets, stack traces |
82
98
  | `caller` | string | no | Your project/role — helps the agent understand who's asking |
83
99
  | `goal` | string | no | Broader objective — helps the agent make better trade-offs |
100
+ | `response_format` | string | no | `"json"` to request a single JSON value; the parsed result lands in `parsed_result`. Empty = free-form text. |
101
+ | `return_ref` | bool | no | When `true`, returns just a `ref` + summary preview instead of the full result text. Use `fetch_result(ref)` to load the full text on demand. |
102
+ | `summary_chars` | int | no | Max chars of result text to include in the ref response (default 500). |
84
103
 
85
104
  ```json
86
105
  // Response (success)
@@ -106,6 +125,18 @@ One-shot task delegation. Results are cached — identical requests within TTL r
106
125
 
107
126
  **`error_type` values:** `permission` (tool/action denied), `timeout`, `recursion` (dispatch depth exceeded), `not_found` (missing directory or CLI), `cli_error` (other failures). Permission errors include an actionable hint.
108
127
 
128
+ **Structured JSON output:** pass `response_format="json"` to ask the agent for a single JSON value. The runner appends an instruction footer ("respond with a single valid JSON value, no fences, no prose") and on success parses the response — the parsed value lands in `parsed_result`. The raw text is always in `result`. Parse failures leave `parsed_result=None` but don't fail the dispatch (soft mode).
129
+
130
+ ```json
131
+ // Response with response_format="json"
132
+ {
133
+ "agent": "infra",
134
+ "success": true,
135
+ "result": "{\"errors\": 3, \"first_at\": \"14:02\"}",
136
+ "parsed_result": {"errors": 3, "first_at": "14:02"}
137
+ }
138
+ ```
139
+
109
140
  **Always pass `caller` and `goal`** — the dispatched agent sees a structured prompt:
110
141
 
111
142
  ```markdown
@@ -260,6 +291,57 @@ Remove an agent from config.
260
291
 
261
292
  View cache hit rate and size, or clear all cached results.
262
293
 
294
+ ### Result references — `return_ref` + `fetch_result`
295
+
296
+ For dispatches whose result text is large (audits, log dumps, code searches), passing the full text back inflates the calling agent's context. Use `return_ref=True` to get just a small reference instead:
297
+
298
+ ```
299
+ dispatch(agent="infra", task="audit every container", return_ref=True, summary_chars=200)
300
+ -> {"ref": "8f3a...e1", "agent": "infra", "success": true,
301
+ "size": 14823, "summary_chars": 200,
302
+ "summary": "Inspected 32 containers. Found 3 OOM kills in the last hour:\n- worker-3...",
303
+ "cost_usd": 0.08, "duration_ms": 9200}
304
+
305
+ // Later, when you actually need to read the result:
306
+ fetch_result(ref="8f3a...e1") -> full DispatchResult JSON
307
+ fetch_result(ref="8f3a...e1", max_chars=2000) -> truncated, plus {"truncated": true, "full_size": 14823}
308
+ ```
309
+
310
+ Refs reuse the same storage as `dispatch_async` jobs (under `~/.config/agent-dispatch/jobs/`), so any `job_id` returned by `dispatch_async` is also a valid `ref` for `fetch_result`. `parsed_result` (when `response_format="json"` is set) is small and is always inlined directly in the ref response — no second fetch needed.
311
+
312
+ ### Async dispatch — `dispatch_async`, `dispatch_status`, `dispatch_wait`, `dispatch_cancel`, `dispatch_jobs`, `dispatch_gc`
313
+
314
+ When a dispatched task is going to take a while, you don't want to block your own tool slot for minutes. Async dispatch returns a `job_id` immediately and lets you check back when you're ready.
315
+
316
+ ```
317
+ // 1. fire and forget
318
+ dispatch_async(agent="infra", task="audit every container log for OOM kills today")
319
+ -> {"job_id": "8f3a...e1", "status": "pending", "agent": "infra"}
320
+
321
+ // 2. do other work, then check progress (non-blocking)
322
+ dispatch_status(job_id="8f3a...e1")
323
+ -> {"id": "8f3a...e1", "status": "running", "started_at": 1730000123.4, ...}
324
+
325
+ // 3. or block until done (with a timeout cap)
326
+ dispatch_wait(job_id="8f3a...e1", timeout_seconds=120)
327
+ -> {"id": "8f3a...e1", "status": "done", "result": {"agent": "infra", "success": true, ...}}
328
+
329
+ // If the timeout fires, the job keeps running:
330
+ -> {"id": "...", "status": "running", "timed_out_waiting": true}
331
+ ```
332
+
333
+ `dispatch_cancel(job_id)` cancels a job that is still **pending** (before its subprocess starts) — a running job is left to finish, since its `claude` subprocess can't be safely interrupted. The response carries an `outcome` of `cancelled`, `running`, `already_terminal`, or `not_found`.
334
+
335
+ `dispatch_jobs(status?)` lists recent jobs as summaries (filter by `pending` / `running` / `done` / `failed` / `cancelled`). `dispatch_gc(max_age_days=7)` purges terminal jobs older than the threshold — pending and running jobs are never deleted.
336
+
337
+ Job state persists to disk at `~/.config/agent-dispatch/jobs/` (override with `AGENT_DISPATCH_JOBS_DIR`). One JSON file per job, written owner-only (`0o600`) with atomic writes — safe to read or `ls` while jobs are in flight. Caller-supplied `job_id`s are validated as 32-char hex before any file access (no path traversal). On startup the server marks jobs abandoned in `running` by a prior crashed instance as `failed`.
338
+
339
+ | When to use async | When to use `dispatch` |
340
+ |-------------------|------------------------|
341
+ | Long task (minutes) — you want to keep working | Short task — you need the answer right now |
342
+ | Several long tasks you'll collect later | Several short tasks → `dispatch_parallel` |
343
+ | Don't care about caching (each call is a fresh job) | Cached by default — identical requests are free |
344
+
263
345
  ### Error Responses
264
346
 
265
347
  All tools return errors as:
@@ -278,6 +360,8 @@ All tools return errors as:
278
360
  | Long task, want to see progress | `dispatch_stream` |
279
361
  | Two agents need to collaborate | `dispatch_dialogue` |
280
362
  | Need a combined summary from multiple agents | `dispatch_parallel` with `aggregate` |
363
+ | Long task — don't block your tool slot | `dispatch_async` + `dispatch_wait` |
364
+ | Check progress without blocking | `dispatch_status` |
281
365
 
282
366
  ## Configuration
283
367
 
@@ -306,10 +390,11 @@ settings:
306
390
  # - Read
307
391
  # - Edit
308
392
  max_dispatch_depth: 3 # recursion protection
309
- max_concurrency: 5 # max parallel claude -p processes
393
+ max_concurrency: 5 # max parallel claude -p processes (per dispatch path)
310
394
  cache:
311
395
  enabled: true
312
396
  ttl: 300 # seconds
397
+ max_size: 1000 # max cached entries; oldest evicted first (FIFO)
313
398
  ```
314
399
 
315
400
  Config is reloaded on every tool call — add agents without restarting.
@@ -347,11 +432,16 @@ agent-dispatch MCP server
347
432
 
348
433
  ## Safety
349
434
 
350
- - **Recursion protection** — `AGENT_DISPATCH_DEPTH` env var tracks nesting. Default limit: 3.
435
+ - **Recursion protection** — `AGENT_DISPATCH_DEPTH` env var tracks nesting. Default limit: 3. Best-effort across the subprocess boundary (see [SECURITY.md](SECURITY.md)).
436
+ - **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.
437
+ - **Path-traversal guard** — caller-supplied `job_id`/`ref` values are validated as 32-char hex before any filesystem access.
438
+ - **Owner-only state** — job files (`0o600`) and `agents.yaml` (`0o600`) are written for the owner only; their directories are `0o700`.
351
439
  - **Cost control** — `max_budget_usd` per agent or globally.
352
- - **Concurrency** — `max_concurrency` (default: 5) limits parallel `claude -p` processes.
440
+ - **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`.
353
441
  - **Timeout** — per-agent or global (default: 300s). Orphaned processes are cleaned up.
354
- - **Caching** — identical `(agent, task, context)` requests return cached results. Only successes are cached. Sessions and dialogues are never cached.
442
+ - **Caching** — identical `(agent, task, context, caller, goal, response_format)` requests return cached results, bounded by `cache.max_size` (oldest entry evicted first). Only successes are cached. Sessions and dialogues are never cached.
443
+
444
+ See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassPermissions` escalation risk and on-disk job files).
355
445
 
356
446
  ## CLI
357
447