agent-dispatch 0.5.0__tar.gz → 0.6.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 (29) hide show
  1. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/CHANGELOG.md +58 -1
  2. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/PKG-INFO +30 -5
  3. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/README.md +29 -4
  4. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/pyproject.toml +1 -1
  5. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/__init__.py +1 -1
  6. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/cli.py +12 -2
  7. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/jobs.py +21 -0
  8. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/models.py +7 -0
  9. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/runner.py +186 -27
  10. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/server.py +112 -16
  11. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_cli.py +34 -0
  12. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_jobs.py +42 -0
  13. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_runner.py +358 -0
  14. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_server.py +392 -15
  15. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/.github/dependabot.yml +0 -0
  16. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/.github/workflows/ci.yml +0 -0
  17. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/.github/workflows/publish.yml +0 -0
  18. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/.gitignore +0 -0
  19. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/LICENSE +0 -0
  20. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/SECURITY.md +0 -0
  21. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/agents.example.yaml +0 -0
  22. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/assets/mascot.png +0 -0
  23. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/cache.py +0 -0
  24. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/src/agent_dispatch/config.py +0 -0
  25. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/__init__.py +0 -0
  26. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/conftest.py +0 -0
  27. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_cache.py +0 -0
  28. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_config.py +0 -0
  29. {agent_dispatch-0.5.0 → agent_dispatch-0.6.0}/tests/test_models.py +0 -0
@@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-06-04
11
+
12
+ Reliability release: timeouts stop being fatal, permission-blocked "successes"
13
+ become visible, async jobs show live progress.
14
+
15
+ ### Fixed
16
+ - **`dispatch_stream` was broken on current claude CLIs** — they reject
17
+ `--print --output-format stream-json` without `--verbose` ("requires
18
+ --verbose"), so every stream dispatch (and CLI `test --stream`) failed
19
+ immediately. The runner now passes `--verbose`. Caught by live verification
20
+ against the real CLI before this release; without it the async-worker
21
+ switch to streaming (below) would have broken all `dispatch_async` jobs.
22
+
23
+ ### Added
24
+ - **Per-call timeout override.** `dispatch`, `dispatch_session`,
25
+ `dispatch_stream`, and `dispatch_async` accept `timeout_seconds` (0 = agent
26
+ default, clamped to 10–7200); `dispatch_parallel` accepts it per item. Use
27
+ it for known-long tasks instead of editing the agent config. CLI:
28
+ `agent-dispatch test <name> --timeout N`.
29
+ - **Resumable timeouts.** Fresh dispatches pre-assign a session UUID via
30
+ `--session-id`, so a timed-out dispatch still returns a `session_id` — the
31
+ partial transcript survives the kill. The timeout error now spells out the
32
+ recovery options: resume via `dispatch_session(..., session_id=...)`, retry
33
+ with `timeout_seconds`, or go async.
34
+ - **Denied-tools visibility.** The claude CLI's `permission_denials` output is
35
+ parsed into `DispatchResult.denied_tools`. A dispatch that "succeeds" while
36
+ tools were blocked (the agent answers "I need permission for X") now carries
37
+ `denied_tools` + a `hint` that the result may be incomplete and how to grant
38
+ access. On `is_error` results, non-empty denials force
39
+ `error_type="permission"` even when the error text has no permission
40
+ keywords. CLI `test` prints the hint as a yellow note.
41
+ - **Async job progress.** Async workers now run with streaming: the job file
42
+ keeps a rolling tail (last 20 lines, throttled to ~1 write/sec) of assistant
43
+ text and tool-use events. `dispatch_status` returns it as `progress` while
44
+ running (kept afterwards as a post-mortem trace); `dispatch_jobs` shows
45
+ `last_progress` for running jobs. New `JobStore.update_progress` (refuses
46
+ terminal jobs, so a trailing write can't resurrect a finished job).
47
+
48
+ ### Changed
49
+ - Timeout error messages are actionable (mention `timeout_seconds`,
50
+ `dispatch_async`, `agent-dispatch update --timeout`, and the resumable
51
+ session) instead of just "increase timeout in agents.yaml".
52
+ - Plain-text fallback successes now carry the generated `session_id`; the
53
+ stream "no result line" fallback does too (a crash mid-stream stays
54
+ resumable).
55
+ - **Old-CLI self-healing**: if the installed claude CLI predates
56
+ `--session-id`, dispatch detects the "unknown option" rejection and retries
57
+ once without the flag (logged warning; timed-out dispatches lose
58
+ resumability) instead of failing every dispatch.
59
+ - `dispatch_parallel` validates per-item `timeout_seconds` / `summary_chars`
60
+ numerically **up front** — a bad value rejects the whole call before any
61
+ dispatch runs, consistent with the structural validation contract.
62
+ - `denied_tools` parsing is bounded (10 entries, 100 chars per name) — the
63
+ field comes from the dispatched subprocess's output, which is untrusted;
64
+ unbounded lists could inflate job files and `return_ref` payloads.
65
+
10
66
  ## [0.5.0] - 2026-06-01
11
67
 
12
68
  Security-hardening release. A multi-agent audit of the codebase surfaced
@@ -214,7 +270,8 @@ cache bounding, and stale-job recovery.
214
270
  - Dependabot for `pip` + `github-actions`, GitHub Actions pinned to
215
271
  commit SHAs for supply-chain integrity.
216
272
 
217
- [Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.5.0...HEAD
273
+ [Unreleased]: https://github.com/ginkida/agent-dispatch/compare/v0.6.0...HEAD
274
+ [0.6.0]: https://github.com/ginkida/agent-dispatch/compare/v0.5.0...v0.6.0
218
275
  [0.5.0]: https://github.com/ginkida/agent-dispatch/compare/v0.4.0...v0.5.0
219
276
  [0.4.0]: https://github.com/ginkida/agent-dispatch/compare/v0.3.0...v0.4.0
220
277
  [0.3.0]: https://github.com/ginkida/agent-dispatch/compare/v0.2.2...v0.3.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-dispatch
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -130,6 +130,7 @@ One-shot task delegation. Results are cached — identical requests within TTL r
130
130
  | `response_format` | string | no | `"json"` to request a single JSON value; the parsed result lands in `parsed_result`. Empty = free-form text. |
131
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
132
  | `summary_chars` | int | no | Max chars of result text to include in the ref response (default 500). |
133
+ | `timeout_seconds` | int | no | One-off timeout override for this call (0 = agent's configured timeout; clamped to 10–7200). No config edit needed for known-long tasks. |
133
134
 
134
135
  ```json
135
136
  // Response (success)
@@ -155,6 +156,21 @@ One-shot task delegation. Results are cached — identical requests within TTL r
155
156
 
156
157
  **`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.
157
158
 
159
+ **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`.
160
+
161
+ **Denied-tools visibility:** in non-interactive mode the claude CLI auto-denies tools the agent isn't allowed to use — the agent then often "succeeds" with an answer like *"I need your permission for one read-only query"*. When that happens the response carries the deterministic signal: `denied_tools` (parsed from the CLI's `permission_denials`) plus a `hint` explaining the result may be incomplete and how to grant access. `success` stays `true` — it's a soft signal, not a failure.
162
+
163
+ ```json
164
+ // Response (success, but a tool was blocked)
165
+ {
166
+ "agent": "analysis",
167
+ "success": true,
168
+ "result": "Here is the offline mapping. To finish I'd need to run one read-only query...",
169
+ "denied_tools": ["Bash"],
170
+ "hint": "1 tool call(s) were denied by permissions: Bash. The result may be incomplete..."
171
+ }
172
+ ```
173
+
158
174
  **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
175
 
160
176
  ```json
@@ -195,6 +211,9 @@ Multi-turn: continue a conversation with an agent. First call starts a session,
195
211
  | `context` | string | no | Extra context |
196
212
  | `caller` | string | no | Who is dispatching |
197
213
  | `goal` | string | no | Broader objective |
214
+ | `timeout_seconds` | int | no | One-off timeout override (0 = agent default; clamped to 10–7200) |
215
+
216
+ `dispatch_session` is also the **timeout recovery path**: a timed-out `dispatch` returns a `session_id` — pass it here with `task="Continue where you left off"` to salvage the partial work instead of restarting.
198
217
 
199
218
  ```
200
219
  Turn 1: dispatch_session("infra", "List running containers")
@@ -210,7 +229,7 @@ Run multiple tasks concurrently. Much faster than sequential `dispatch` calls.
210
229
 
211
230
  | Parameter | Type | Required | Description |
212
231
  |-----------|------|----------|-------------|
213
- | `dispatches` | string (JSON) | yes | JSON array of `{"agent", "task", "context?", "caller?", "goal?"}` |
232
+ | `dispatches` | string (JSON) | yes | JSON array of `{"agent", "task", "context?", "caller?", "goal?", "response_format?", "return_ref?", "summary_chars?", "timeout_seconds?"}` |
214
233
  | `aggregate` | string | no | Agent name to synthesize all results into one answer |
215
234
 
216
235
  **Important:** `dispatches` is a JSON string, not a list.
@@ -250,7 +269,7 @@ Run multiple tasks concurrently. Much faster than sequential `dispatch` calls.
250
269
 
251
270
  Same as `dispatch` but shows live progress while the agent works. Use for long-running tasks. Not cached.
252
271
 
253
- Parameters are identical to `dispatch`.
272
+ Parameters are the same as `dispatch` except `return_ref`/`summary_chars` (streaming is incompatible with ref-mode).
254
273
 
255
274
  ### `dispatch_dialogue`
256
275
 
@@ -344,13 +363,15 @@ Refs reuse the same storage as `dispatch_async` jobs (under `~/.config/agent-dis
344
363
  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
364
 
346
365
  ```
347
- // 1. fire and forget
366
+ // 1. fire and forget (timeout_seconds= works here too for known-long tasks)
348
367
  dispatch_async(agent="infra", task="audit every container log for OOM kills today")
349
368
  -> {"job_id": "8f3a...e1", "status": "pending", "agent": "infra"}
350
369
 
351
370
  // 2. do other work, then check progress (non-blocking)
371
+ // `progress` is a rolling tail of what the agent is doing right now
352
372
  dispatch_status(job_id="8f3a...e1")
353
- -> {"id": "8f3a...e1", "status": "running", "started_at": 1730000123.4, ...}
373
+ -> {"id": "8f3a...e1", "status": "running", "started_at": 1730000123.4,
374
+ "progress": ["Using tool: Bash", "Scanning container logs for OOM events..."], ...}
354
375
 
355
376
  // 3. or block until done (with a timeout cap)
356
377
  dispatch_wait(job_id="8f3a...e1", timeout_seconds=120)
@@ -362,6 +383,8 @@ dispatch_wait(job_id="8f3a...e1", timeout_seconds=120)
362
383
 
363
384
  `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
385
 
386
+ Async workers run with streaming under the hood: the job file keeps a rolling tail (last 20 lines, ~1 write/sec) of assistant text and tool-use events. `dispatch_status` shows it as `progress` while the job runs and keeps it afterwards as a post-mortem trace; `dispatch_jobs` shows `last_progress` for running jobs.
387
+
365
388
  `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
389
 
367
390
  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`.
@@ -392,6 +415,8 @@ All tools return errors as:
392
415
  | Need a combined summary from multiple agents | `dispatch_parallel` with `aggregate` |
393
416
  | Long task — don't block your tool slot | `dispatch_async` + `dispatch_wait` |
394
417
  | Check progress without blocking | `dispatch_status` |
418
+ | Known-long task, one-off | any dispatch tool with `timeout_seconds=...` |
419
+ | A dispatch timed out | `dispatch_session` with the `session_id` from the error |
395
420
 
396
421
  ## Configuration
397
422
 
@@ -100,6 +100,7 @@ One-shot task delegation. Results are cached — identical requests within TTL r
100
100
  | `response_format` | string | no | `"json"` to request a single JSON value; the parsed result lands in `parsed_result`. Empty = free-form text. |
101
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
102
  | `summary_chars` | int | no | Max chars of result text to include in the ref response (default 500). |
103
+ | `timeout_seconds` | int | no | One-off timeout override for this call (0 = agent's configured timeout; clamped to 10–7200). No config edit needed for known-long tasks. |
103
104
 
104
105
  ```json
105
106
  // Response (success)
@@ -125,6 +126,21 @@ One-shot task delegation. Results are cached — identical requests within TTL r
125
126
 
126
127
  **`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.
127
128
 
129
+ **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`.
130
+
131
+ **Denied-tools visibility:** in non-interactive mode the claude CLI auto-denies tools the agent isn't allowed to use — the agent then often "succeeds" with an answer like *"I need your permission for one read-only query"*. When that happens the response carries the deterministic signal: `denied_tools` (parsed from the CLI's `permission_denials`) plus a `hint` explaining the result may be incomplete and how to grant access. `success` stays `true` — it's a soft signal, not a failure.
132
+
133
+ ```json
134
+ // Response (success, but a tool was blocked)
135
+ {
136
+ "agent": "analysis",
137
+ "success": true,
138
+ "result": "Here is the offline mapping. To finish I'd need to run one read-only query...",
139
+ "denied_tools": ["Bash"],
140
+ "hint": "1 tool call(s) were denied by permissions: Bash. The result may be incomplete..."
141
+ }
142
+ ```
143
+
128
144
  **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
145
 
130
146
  ```json
@@ -165,6 +181,9 @@ Multi-turn: continue a conversation with an agent. First call starts a session,
165
181
  | `context` | string | no | Extra context |
166
182
  | `caller` | string | no | Who is dispatching |
167
183
  | `goal` | string | no | Broader objective |
184
+ | `timeout_seconds` | int | no | One-off timeout override (0 = agent default; clamped to 10–7200) |
185
+
186
+ `dispatch_session` is also the **timeout recovery path**: a timed-out `dispatch` returns a `session_id` — pass it here with `task="Continue where you left off"` to salvage the partial work instead of restarting.
168
187
 
169
188
  ```
170
189
  Turn 1: dispatch_session("infra", "List running containers")
@@ -180,7 +199,7 @@ Run multiple tasks concurrently. Much faster than sequential `dispatch` calls.
180
199
 
181
200
  | Parameter | Type | Required | Description |
182
201
  |-----------|------|----------|-------------|
183
- | `dispatches` | string (JSON) | yes | JSON array of `{"agent", "task", "context?", "caller?", "goal?"}` |
202
+ | `dispatches` | string (JSON) | yes | JSON array of `{"agent", "task", "context?", "caller?", "goal?", "response_format?", "return_ref?", "summary_chars?", "timeout_seconds?"}` |
184
203
  | `aggregate` | string | no | Agent name to synthesize all results into one answer |
185
204
 
186
205
  **Important:** `dispatches` is a JSON string, not a list.
@@ -220,7 +239,7 @@ Run multiple tasks concurrently. Much faster than sequential `dispatch` calls.
220
239
 
221
240
  Same as `dispatch` but shows live progress while the agent works. Use for long-running tasks. Not cached.
222
241
 
223
- Parameters are identical to `dispatch`.
242
+ Parameters are the same as `dispatch` except `return_ref`/`summary_chars` (streaming is incompatible with ref-mode).
224
243
 
225
244
  ### `dispatch_dialogue`
226
245
 
@@ -314,13 +333,15 @@ Refs reuse the same storage as `dispatch_async` jobs (under `~/.config/agent-dis
314
333
  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
334
 
316
335
  ```
317
- // 1. fire and forget
336
+ // 1. fire and forget (timeout_seconds= works here too for known-long tasks)
318
337
  dispatch_async(agent="infra", task="audit every container log for OOM kills today")
319
338
  -> {"job_id": "8f3a...e1", "status": "pending", "agent": "infra"}
320
339
 
321
340
  // 2. do other work, then check progress (non-blocking)
341
+ // `progress` is a rolling tail of what the agent is doing right now
322
342
  dispatch_status(job_id="8f3a...e1")
323
- -> {"id": "8f3a...e1", "status": "running", "started_at": 1730000123.4, ...}
343
+ -> {"id": "8f3a...e1", "status": "running", "started_at": 1730000123.4,
344
+ "progress": ["Using tool: Bash", "Scanning container logs for OOM events..."], ...}
324
345
 
325
346
  // 3. or block until done (with a timeout cap)
326
347
  dispatch_wait(job_id="8f3a...e1", timeout_seconds=120)
@@ -332,6 +353,8 @@ dispatch_wait(job_id="8f3a...e1", timeout_seconds=120)
332
353
 
333
354
  `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
355
 
356
+ Async workers run with streaming under the hood: the job file keeps a rolling tail (last 20 lines, ~1 write/sec) of assistant text and tool-use events. `dispatch_status` shows it as `progress` while the job runs and keeps it afterwards as a post-mortem trace; `dispatch_jobs` shows `last_progress` for running jobs.
357
+
335
358
  `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
359
 
337
360
  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`.
@@ -362,6 +385,8 @@ All tools return errors as:
362
385
  | Need a combined summary from multiple agents | `dispatch_parallel` with `aggregate` |
363
386
  | Long task — don't block your tool slot | `dispatch_async` + `dispatch_wait` |
364
387
  | Check progress without blocking | `dispatch_status` |
388
+ | Known-long task, one-off | any dispatch tool with `timeout_seconds=...` |
389
+ | A dispatch timed out | `dispatch_session` with the `session_id` from the error |
365
390
 
366
391
  ## Configuration
367
392
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-dispatch"
3
- version = "0.5.0"
3
+ version = "0.6.0"
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.5.0"
3
+ __version__ = "0.6.0"
@@ -303,7 +303,11 @@ def update(
303
303
  "--stream", "stream", is_flag=True,
304
304
  help="Show live progress (assistant text + tool use) while the agent works.",
305
305
  )
306
- def test(name: str, task: str, stream: bool) -> None:
306
+ @click.option(
307
+ "--timeout", "timeout", default=None, type=int,
308
+ help="One-off timeout override in seconds (does not change the agent config).",
309
+ )
310
+ def test(name: str, task: str, stream: bool, timeout: int | None) -> None:
307
311
  """Test an agent by dispatching a task."""
308
312
  config = _load_or_exit()
309
313
  if name not in config.agents:
@@ -311,6 +315,8 @@ def test(name: str, task: str, stream: bool) -> None:
311
315
  raise SystemExit(1)
312
316
 
313
317
  agent = config.agents[name]
318
+ if timeout is not None and timeout > 0:
319
+ agent = agent.model_copy(update={"timeout": timeout})
314
320
  click.echo(f"Dispatching to '{name}' ({agent.directory})...")
315
321
  click.echo(f"Task: {task}")
316
322
  click.echo("---")
@@ -330,6 +336,9 @@ def test(name: str, task: str, stream: bool) -> None:
330
336
 
331
337
  if result.success:
332
338
  click.echo(result.result)
339
+ if result.hint:
340
+ click.echo()
341
+ click.echo(click.style(f"Note: {result.hint}", fg="yellow"))
333
342
  if result.cost_usd is not None:
334
343
  click.echo(f"\n--- Cost: ${result.cost_usd:.4f} | Turns: {result.num_turns}")
335
344
  else:
@@ -343,7 +352,8 @@ def test(name: str, task: str, stream: bool) -> None:
343
352
  elif result.error_type == "timeout":
344
353
  click.echo()
345
354
  click.echo(click.style("Diagnosis: timeout", fg="yellow"))
346
- click.echo(f" agent-dispatch update {name} --timeout 600")
355
+ click.echo(f" agent-dispatch test {name} --timeout 600 # one-off")
356
+ click.echo(f" agent-dispatch update {name} --timeout 600 # permanent")
347
357
  raise SystemExit(1)
348
358
 
349
359
 
@@ -59,6 +59,11 @@ class Job(BaseModel):
59
59
  completed_at: float | None = None
60
60
  result: DispatchResult | None = None
61
61
  error: str | None = None
62
+ # Rolling tail of progress lines (assistant text / tool-use events) from
63
+ # the worker's streaming dispatch. None until the first event arrives.
64
+ # Kept after completion as a post-mortem trace of what the agent did.
65
+ progress: list[str] | None = None
66
+ progress_updated_at: float | None = None
62
67
 
63
68
  def is_terminal(self) -> bool:
64
69
  return self.status in _TERMINAL_STATUSES
@@ -194,6 +199,22 @@ class JobStore:
194
199
  self._write(job)
195
200
  return job
196
201
 
202
+ def update_progress(self, job_id: str, lines: list[str]) -> Job | None:
203
+ """Replace a running job's progress tail. No-op for terminal jobs.
204
+
205
+ Returns the updated job, or None if missing/terminal (a worker's
206
+ trailing progress write can race a concurrent cancel/finish — refusing
207
+ to touch terminal jobs keeps their final state authoritative).
208
+ """
209
+ with self._lock:
210
+ job = self.get(job_id)
211
+ if job is None or job.is_terminal():
212
+ return None
213
+ job.progress = lines
214
+ job.progress_updated_at = time.time()
215
+ self._write(job)
216
+ return job
217
+
197
218
  def cancel(self, job_id: str) -> tuple[Job | None, str]:
198
219
  """Attempt to cancel a job.
199
220
 
@@ -113,3 +113,10 @@ class DispatchResult(BaseModel):
113
113
  # Set when response_format="json" was requested AND the agent's result
114
114
  # parsed cleanly. None means: not requested, or requested but unparseable.
115
115
  parsed_result: Any | None = None
116
+ # Tools the claude CLI refused to run (from `permission_denials` in its
117
+ # JSON output). Non-empty even on success=True — the agent may have
118
+ # completed with an incomplete answer because a tool was blocked.
119
+ denied_tools: list[str] | None = None
120
+ # Advisory, non-fatal guidance (e.g. "result may be incomplete, grant X").
121
+ # Errors stay in `error`; hint is for successful-but-degraded results.
122
+ hint: str | None = None