agent-dispatch 0.12.1__tar.gz → 0.14.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 (30) hide show
  1. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/.github/workflows/publish.yml +8 -1
  2. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/AGENTS.md +15 -4
  3. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/CHANGELOG.md +132 -0
  4. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/PKG-INFO +66 -7
  5. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/README.md +64 -5
  6. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/agents.example.yaml +19 -0
  7. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/pyproject.toml +1 -1
  8. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/__init__.py +1 -1
  9. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/cache.py +6 -0
  10. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/cli.py +77 -2
  11. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/config.py +31 -2
  12. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/jobs.py +40 -8
  13. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/models.py +27 -0
  14. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/runner.py +201 -42
  15. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/src/agent_dispatch/server.py +279 -137
  16. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_cache.py +15 -0
  17. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_cli.py +158 -18
  18. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_config.py +73 -0
  19. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_jobs.py +33 -0
  20. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_models.py +12 -3
  21. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_runner.py +202 -4
  22. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/test_server.py +381 -0
  23. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/.github/dependabot.yml +0 -0
  24. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/.github/workflows/ci.yml +0 -0
  25. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/.gitignore +0 -0
  26. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/LICENSE +0 -0
  27. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/SECURITY.md +0 -0
  28. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/assets/mascot.png +0 -0
  29. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/__init__.py +0 -0
  30. {agent_dispatch-0.12.1 → agent_dispatch-0.14.0}/tests/conftest.py +0 -0
@@ -26,5 +26,12 @@ jobs:
26
26
  pip install build
27
27
  python -m build
28
28
 
29
+ # Keep this current with the build backend. The pinned action bundles its
30
+ # own twine, and twine rejects a Metadata-Version it does not know: the
31
+ # v0.13.0 publish failed because hatchling 1.32 emits `Metadata-Version:
32
+ # 2.5` while the previous pin (Feb 2026) shipped twine 6.1.0 / packaging
33
+ # 25.0. `python -m build` resolves the newest hatchling at build time, so
34
+ # this drifts on its own with no repo change — see the release checklist's
35
+ # `twine check` step, which catches it before a tag is cut.
29
36
  - name: Publish to PyPI
30
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
37
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
@@ -28,7 +28,7 @@ pip install -e ".[dev]"
28
28
 
29
29
  ```bash
30
30
  ruff check src/ tests/
31
- python3 -m pytest tests/ -v # 578 tests, ~5s
31
+ python3 -m pytest tests/ -v # 637 tests, ~15s
32
32
  ```
33
33
 
34
34
  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.
@@ -52,16 +52,27 @@ Tests must **never** invoke the real `claude` CLI. Runner tests mock `shutil.whi
52
52
  - **Never `close()` a pipe another thread may still be reading.** `close()` waits on the reader's buffer lock with *no timeout*, so it would hang the dispatch forever — the bounded `join()` before it buys nothing. `dispatch_stream` skips the stderr close while the drain thread is alive and lets the daemon reader + Popen finalizer release the fd. This is not hypothetical: a stdio MCP server inherits the child's `stderr`, so the pipe often has no EOF even after `claude` exits cleanly.
53
53
  - **No `await` inside `config_lock()`.** `ProcessLock`'s in-process guard is a `threading.RLock` — re-entrant per *thread* — and every MCP tool coroutine runs on the one event-loop thread. Suspending in the critical section lets a second coroutine re-enter the "held" lock and interleave its own load/mutate/save. Collect warnings as data, emit them after the `with` block (`test_no_await_inside_the_config_lock` enforces this by AST).
54
54
  - Cross-process locks are acquired with a **bounded** wait, never a blocking `flock`: the server takes them on its event-loop thread, so a wedged holder would freeze every tool. After the deadline it proceeds unlocked and logs — a possible lost update beats a permanent freeze.
55
- - `recover_stale` sweeps `pending` on a **much longer** threshold than `running`: the jobs directory is shared by every `agent-dispatch serve`, so an hours-old pending job may still be queued behind another live server's semaphore.
55
+ - `recover_stale` sweeps `pending` on a **much longer** threshold than `running`: the jobs directory is shared by every `agent-dispatch serve`, so an hours-old pending job may still be queued behind another live server's semaphore. For a *running* job, `started_at` alone does **not** prove abandonment — a dispatch may legitimately run to the 7200s timeout ceiling, so the file's own mtime is checked too (a live worker rewrites it on every progress flush). That check can only ever *skip* a recovery, never add one.
56
+ - Deleting job records is **opt-in** (`settings.job_retention_days`, default `0` = off) and only ever happens at server start, never inside a tool. They are the user's dispatch history and the deletion is irreversible, so an unreadable config is treated as "do nothing" rather than falling back to a default retention.
57
+ - `max_concurrency` must bound *subprocesses*, not coroutines. Cancelling a coroutine does not stop the thread behind `asyncio.to_thread`, so `async with sem:` gave the slot away while `claude` kept running and billing. Dispatches go through `_dispatch_guarded`, which releases from the future's done-callback and `shield`s the await. Never "simplify" it back to `async with`.
58
+ - Tool responses are serialized through `server._dumps`, never bare `json.dumps`: the stdlib default (`ensure_ascii=True`) turns every non-ASCII character into a `\uXXXX` escape, tripling the bytes and tokenizing badly, for no gain — the stdio transport emits raw UTF-8 via pydantic anyway. A real `list_groups()` carried 8520 escapes and weighed 59 KB instead of 25 KB.
59
+ - `agents.yaml` is parsed with libyaml's `CSafeLoader` when available (`config._YamlLoader`), falling back to the pure-Python **safe** loader — never `yaml.Loader`. The config is re-read on every tool call, so this parse is on the hot path of all 21 tools *and* blocks the event-loop thread: 9.80 ms → 0.76 ms on a real 38 KB config.
56
60
  - Pydantic does **not** validate on assignment. `Field(ge=...)` guards only the *load* path; every mutation surface (CLI `add`/`update`, MCP `add_agent`/`update_agent`) needs its own boundary check, or the bound escapes as a raw `ValidationError`.
57
61
  - 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.
58
62
  - Anything that changes an agent's config must call `_invalidate_agent_cache` — the cache key holds the agent *name*, not its directory or permissions.
59
- - Only *clean* successes are cached: `cache.put` refuses failures, `denied_tools` results, and `budget_exceeded` results, so the documented "grant access, then re-dispatch" recovery is never short-circuited.
63
+ - 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.
64
+ - **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
+ - **`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.
60
66
  - 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.
67
+ - The config error sets are declared **once** and in two halves: `config.CONFIG_LOAD_ERRORS` (read) and `config.CONFIG_SAVE_ERRORS` (write — `yaml.dump`'s `RepresenterError` is a `yaml.YAMLError`, therefore neither `OSError` nor `ConfigLoadError`, and used to escape both the MCP guard and the CLI's `_save_or_exit`). Two halves, not one set, because the remediations differ: a failed write is atomic so the old config survives, while a failed read needs the YAML fixed.
61
68
  - MCP tools that load config carry `@_config_guard` under `@mcp.tool()` so a broken `agents.yaml` — or a failed *write* — returns the `{"error": ...}` envelope instead of a raw traceback. The set of load errors lives in one place (`config.CONFIG_LOAD_ERRORS`) because three surfaces handle it: **`UnicodeDecodeError` is a `ValueError`, not an `OSError`**, and listing types per-site is exactly how a cp1251 config slipped past all three.
62
69
 
63
70
  - Tests must not touch anything outside `tmp_path`. `test_server.py`'s autouse `_reset_globals` and `test_cli.py`'s `_isolated_config` redirect **both** `AGENT_DISPATCH_CONFIG` and `AGENT_DISPATCH_JOBS_DIR`: a mutation tool that bails out early (unknown agent) still takes `config_lock()` first, which would otherwise create a lock file beside the developer's real config.
64
71
 
72
+ - `mcp` is pinned **`>=1.2.0,<2`** deliberately: 2.0 removed `mcp.server.fastmcp`, which `server.py` imports, so an unbounded range gives every fresh install a dead `agent-dispatch serve`. Lifting the cap means porting to `mcp.server.mcpserver.MCPServer` — it is not a dependency bump.
73
+ - Verify packaging in a **clean venv**, never the dev machine: build the wheel, install it fresh, import the server. A stale pin in local site-packages hides exactly the failure a new user hits first.
74
+ - Run **`twine check dist/*`** before cutting the tag, with a *current* twine. The clean-venv check does not cover this: it proves the wheel installs, not that PyPI's uploader will accept its metadata. `python -m build` pulls the newest hatchling at build time, so the emitted `Metadata-Version` climbs on its own, and `pypa/gh-action-pypi-publish` is pinned to a SHA that bundles a fixed twine — v0.13.0's publish failed on exactly that mismatch (hatchling 1.32 → metadata 2.5; pinned twine 6.1.0 → "not a valid metadata version"). When it happens, bump the action pin rather than pinning the backend down.
75
+
65
76
  ## Deliberately not built
66
77
 
67
78
  These were considered — some fully implemented — and cut on purpose: an agent router / auto-dispatch (`recommend_agent` / `dispatch_auto`, removed before 0.8.0 — a keyword scorer adds little over the calling LLM at a handful of agents, and auto-dispatch can spend money or mutate a repo on a guess); groups as an execution engine (they are a descriptive layer — no routing, no per-group settings); an agent-dispatch-side budget ledger across dispatches (the CLI's own `--max-budget-usd` covers a single run; anything cumulative would need state we deliberately don't keep). Please open an issue with the use case before adding any of them.
@@ -76,4 +87,4 @@ Python ≥ 3.10 · `from __future__ import annotations` everywhere · Pydantic v
76
87
 
77
88
  ## More detail
78
89
 
79
- [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/`, 578 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`, ...).
90
+ [README.md](README.md) documents every MCP tool with parameter tables, response shapes, and the error-recovery map — it doubles as the behavioral spec. The test suite (`tests/`, 637 tests) encodes the exact expected behavior of every layer: when in doubt, read the tests for the module you're touching (`test_runner.py`, `test_server.py`, `test_cli.py`, ...).
@@ -7,6 +7,138 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.0] - 2026-09-08
11
+
12
+ The delegation round: what a dispatched agent is *told*, and what it tells
13
+ back. Until now `claude -p` was launched with the task and nothing else — it
14
+ did not know it was being driven by another agent, that nobody would answer a
15
+ question, how long it had, or how to report an unfinished job. The failure
16
+ mode was concrete and billed: an ambiguous task ended in *"Could you clarify
17
+ which service you mean?"*, `success: true`, cached for the whole TTL.
18
+
19
+ ### Added
20
+ - **The dispatch protocol.** Every dispatch now appends a short system prompt
21
+ (`--append-system-prompt`, never mixed into the task text) telling the agent
22
+ that it is non-interactive and dispatched by `caller`, that it must state
23
+ assumptions instead of asking, that a denied tool is something to report and
24
+ work around rather than stop on, what its time budget (and spend cap) is, to
25
+ lead with the outcome — which is what makes a `return_ref` summary, the
26
+ *head* of the text, worth reading — and to end with one line:
27
+ `STATUS: done | partial | blocked`. Passed on `--resume` too. Verified live
28
+ against claude 2.1.263 (`agent-dispatch test <agent> --stream`).
29
+ - **`DispatchResult.outcome`** — that trailing line, lifted out of `result`
30
+ into a field: `done`, `partial` or `blocked`, or absent when the agent did
31
+ not report one. It never flips `success`. `partial`/`blocked` come with a
32
+ `hint` carrying the exact `dispatch_session(..., session_id=...)` call to
33
+ continue, ride the `return_ref` payload and `dispatch_jobs` summaries, are
34
+ shown by `agent-dispatch test` / `job <id>`, are labelled for the
35
+ `dispatch_parallel` aggregator so a blocked member is not synthesized as a
36
+ finished one — and are **not cached**: whatever the agent was missing is not
37
+ in the cache key, so a retry after fixing it must run fresh.
38
+ - **Per-agent `instructions`** — standing orders appended after the protocol
39
+ on every dispatch ("read-only SQL only", "never restart a stack unless the
40
+ task says so"). `add_agent`/`update_agent` (`"none"` clears), CLI `add` /
41
+ `update --instructions` (`none` clears), shown by `inspect_agent` and
42
+ `describe`, pruned from YAML when empty. Changing them invalidates the
43
+ agent's cache entries like any other config change.
44
+ - **`settings.dispatch_protocol`** (default `true`) — set to `false` for a raw
45
+ `claude -p` (a CLI that predates `--append-system-prompt`, or an A/B). The
46
+ protocol and the instructions share one flag, so instructions still go
47
+ through when set. `doctor` now probes `claude --help` for the flag and warns
48
+ with the exact remediation when it is missing.
49
+
50
+ ### Changed
51
+ - In `response_format="json"` mode the protocol omits the "lead with the
52
+ outcome" and STATUS bullets — the JSON footer governs the reply shape. A
53
+ STATUS line that arrives anyway is still stripped before parsing, so
54
+ `parsed_result` survives it.
55
+ - `dispatch` and `dispatch_stream` build their success result through one
56
+ `_build_success_result`, the twin of `_build_error_result`.
57
+
58
+
59
+ ## [0.13.0] - 2026-08-13
60
+
61
+ An efficiency round, measured against a real 38 KB config (4 agents, 6 groups),
62
+ a 294-record jobs directory and 14 concurrently running servers — not against
63
+ synthetic fixtures. Findings came from a five-axis audit (caller context,
64
+ latency/IO, spend, CPU, concurrency) whose every claim was re-measured
65
+ adversarially before implementation; most were confirmed as real but too small
66
+ to matter next to a 10–120 s subprocess and were deliberately left alone.
67
+
68
+ ### Changed
69
+ - **Tool responses no longer escape non-ASCII, cutting discovery payloads
70
+ roughly in half.** `json.dumps` defaults to `ensure_ascii=True`, so every
71
+ Cyrillic character left as a `\uXXXX` escape — 6 bytes for what UTF-8 stores
72
+ in 2, and a far worse token sequence. One `list_groups()` carried **8520**
73
+ such escapes: 59 KB where 25 KB was enough. Measured over a full discovery
74
+ pass (`list_agents` + `list_groups` + `inspect_group` + `inspect_agent`):
75
+ 104 KB → 51 KB, about **13 000 tokens saved per pass**, on every call, in
76
+ every session. Nothing is lost: the stdio transport serializes the JSON-RPC
77
+ envelope with pydantic's `model_dump_json`, which already emits raw UTF-8 —
78
+ which is also why the `dispatch` family never had this problem while every
79
+ `json.dumps` tool did. All 57 call sites now go through one `_dumps` helper
80
+ so the two agree. `_dumps` probes encodability and falls back to the escaped
81
+ form for the one payload that needs it — a **lone surrogate**, which an agent
82
+ produces by printing a literal `\udXXX` escape in its JSON output (`json.loads`
83
+ manufactures it from perfectly ASCII input, so the subprocess-level
84
+ `errors="replace"` cannot help). Unescaped it would raise `UnicodeEncodeError`
85
+ inside the stdio transport *after* the tool returned — past every guard.
86
+ The saving is proportional to how much non-ASCII your config and results
87
+ carry: on an all-ASCII setup it is exactly zero.
88
+ - **`agents.yaml` parses ~13x faster.** The config is re-read on every single
89
+ tool call (deliberately — that is how a new agent is picked up without a
90
+ restart), so the parse sits on the hot path of all 21 tools *and* runs on the
91
+ event-loop thread, where it blocks every other tool. Switching to libyaml's
92
+ `CSafeLoader` takes the real config from **9.80 ms to 0.76 ms** per call.
93
+ Falls back to the pure-Python safe loader when PyYAML was built without
94
+ libyaml; both are the *safe* loader, so a config still cannot construct
95
+ arbitrary objects.
96
+
97
+ ### Added
98
+ - **`settings.job_retention_days`** — when > 0, terminal job records older than
99
+ N days are deleted at server start. Every async dispatch and every
100
+ `return_ref` dispatch leaves a job file that nothing removed on its own, so
101
+ the directory grew forever while `dispatch_jobs` and stale-job recovery
102
+ parsed every file in it. **Defaults to `0` (off)**: those records are the
103
+ user's own dispatch history and deleting them is irreversible, so it is an
104
+ explicit opt-in rather than something a version bump starts doing to an
105
+ existing install. An unreadable config is treated as `0`, never guessed.
106
+
107
+ ### Fixed
108
+ - **`max_concurrency` bounds live subprocesses again, not live coroutines.**
109
+ `async with sem:` released the slot when the *coroutine* unwound, but
110
+ cancelling a coroutine does not stop the thread behind `asyncio.to_thread` —
111
+ so an interrupted turn or a closed session handed the slot to the next
112
+ dispatch while the `claude` subprocess kept running and kept being billed. N
113
+ cancellations meant up to N extra concurrent subprocesses, and each abandoned
114
+ worker also held a thread out of the default executor (`min(32, cpu_count+4)`,
115
+ which `to_thread` needs for *every* dispatch) for the rest of its timeout —
116
+ up to 7200s. The slot is now tied to the worker's real lifetime via
117
+ `_dispatch_guarded`; cancellation still reaches the caller immediately.
118
+ - **A legitimately long dispatch is no longer flipped to `failed` by an
119
+ unrelated server starting up.** `recover_stale` judged abandonment from
120
+ `started_at` alone, but a dispatch may legitimately run up to the 7200s
121
+ timeout ceiling — so any of the (routinely 14+) other servers booting would
122
+ mark a live 2-hour job `failed`, after which `finish()` refuses the real,
123
+ already-paid-for result. A *running* job whose file was modified within the
124
+ threshold is now left alone: a live worker rewrites it on every progress
125
+ flush, so only the file's own age proves abandonment. This can only ever skip
126
+ a recovery — a genuinely abandoned job is picked up on a later start.
127
+ - **A config that cannot be *rendered* now returns an error envelope instead of
128
+ a traceback.** `_get_config` wraps read-side YAML failures into
129
+ `ConfigLoadError`, but `save_config` → `yaml.dump` raises `RepresenterError` —
130
+ a `yaml.YAMLError`, so neither an `OSError` nor a `ConfigLoadError`. It
131
+ therefore slipped past *both* arms of the MCP guard on
132
+ `add_agent`/`update_agent`/`remove_agent` **and** past the CLI's
133
+ `_save_or_exit`. Unreachable today (only JSON-native types are ever dumped)
134
+ and closed now because it is the same "an exception type escapes the handler
135
+ meant to catch it" class that produced the 0.12.0 and 0.12.1 rounds. The set
136
+ is declared once as `config.CONFIG_SAVE_ERRORS`, next to its read-side twin,
137
+ so the two surfaces cannot drift.
138
+ - **Stale-job recovery scans the jobs directory once instead of twice.** It
139
+ called `list()` per status, and `list()` reads and parses every file in the
140
+ directory (~85 ms at 294 records) — at the start of every server process.
141
+
10
142
  ## [0.12.1] - 2026-07-30
11
143
 
12
144
  Two holes in 0.12.0's own "tools always return a clean error" fix.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: agent-dispatch
3
- Version: 0.12.1
3
+ Version: 0.14.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
@@ -220,7 +220,8 @@ dispatch(
220
220
  "session_id": "sess-abc-123",
221
221
  "cost_usd": 0.02,
222
222
  "duration_ms": 5000,
223
- "num_turns": 2
223
+ "num_turns": 2,
224
+ "outcome": "done"
224
225
  }
225
226
 
226
227
  // Response (failure — error_type helps you handle programmatically)
@@ -278,6 +279,30 @@ Error: TypeError at scheduler.py:42
278
279
  Check container logs for recent errors related to the scheduler service
279
280
  ```
280
281
 
282
+ **The dispatch protocol and `outcome`.** `claude -p` on its own does not know it is being driven by another agent: given an ambiguous task it will happily end with *"Could you clarify which service you mean?"* — a billed run that answered nothing, and one that a plain cache would then serve again for the whole TTL. So every dispatch also appends a short **dispatch protocol** to the agent's system prompt (`--append-system-prompt`, never mixed into your task text):
283
+
284
+ - it is running non-interactively, dispatched by `caller`, and nobody will answer a question or approve an action — state the assumption and proceed, take the safer reading when two differ;
285
+ - a denied tool or a missing thing is something to report, not something to stop on — finish everything else that is possible;
286
+ - its time budget is about the agent's timeout (and its spend cap, if one is set) — scope the work to fit; a complete partial answer beats an unfinished perfect one;
287
+ - lead with the outcome, then the evidence; list what could not be done and why (this is what makes a `return_ref` summary — the **head** of the text — worth reading);
288
+ - end with one line, `STATUS: done`, `STATUS: partial` or `STATUS: blocked`.
289
+
290
+ That last line is lifted out of `result` into **`outcome`** — the agent's own verdict, deterministic to check: `"done"` means complete; `"partial"` / `"blocked"` mean the agent says the work is unfinished, the result names what is missing, and a `hint` spells out the continuation (usually `dispatch_session(..., session_id=...)`). Read `outcome` before reading the text. It never flips `success`, `partial` / `blocked` results are **not cached**, and a `dispatch_parallel(..., aggregate=...)` labels such members for the aggregator so a blocked report is not synthesized as a finished one. Absent when the agent did not report one — the protocol is off (`settings.dispatch_protocol: false`), `response_format="json"` was requested (the JSON footer governs the reply shape there), or the agent simply skipped it.
291
+
292
+ **Standing orders.** Per-agent `instructions` (set via `add_agent` / `update_agent` or `agent-dispatch update <name> --instructions "..."`) follow the protocol in the same system prompt on **every** dispatch — "read-only SQL only", "never restart a stack unless the task says so", "answer with exact log lines". Unlike `context`, which is per call, and unlike the project's own `CLAUDE.md`, which is written for an interactive session, these are the rules for *being dispatched*.
293
+
294
+ ```json
295
+ // Response (success, but the agent says it did not finish)
296
+ {
297
+ "agent": "infra",
298
+ "success": true,
299
+ "result": "Restarted horizon. Could not verify the queue drained: the redis container is not reachable from here.",
300
+ "session_id": "sess-abc-123",
301
+ "outcome": "partial",
302
+ "hint": "The agent reports its work is PARTIAL — read the result for what is missing, then continue in the same session via dispatch_session(agent='infra', task='Continue where you left off', session_id='sess-abc-123') or re-dispatch with what it needed."
303
+ }
304
+ ```
305
+
281
306
  ### `dispatch_session`
282
307
 
283
308
  Multi-turn: continue a conversation with an agent. First call starts a session, pass `session_id` back to continue. Never cached.
@@ -395,6 +420,7 @@ Register a new project directory as an agent. Description is auto-generated from
395
420
  | `disallowed_tools` | string | no | Comma-separated disallowed tools |
396
421
  | `capabilities` | string | no | Comma-separated capability labels (e.g. `"docker_logs,deploy_debug"`) |
397
422
  | `risky_capabilities` | string | no | Comma-separated high-risk labels (e.g. `"restart_services"`) |
423
+ | `instructions` | string | no | Standing orders appended to the agent's system prompt on every dispatch (see [the dispatch protocol](#dispatch)) |
398
424
 
399
425
  ### `update_agent`
400
426
 
@@ -412,6 +438,7 @@ Update an existing agent's configuration. Only non-empty fields are changed. Pas
412
438
  | `disallowed_tools` | string | no | Comma-separated. `"none"` to clear |
413
439
  | `capabilities` | string | no | Comma-separated. `"none"` to clear |
414
440
  | `risky_capabilities` | string | no | Comma-separated. `"none"` to clear |
441
+ | `instructions` | string | no | Standing orders for every dispatch (replaces the text). `"none"` to clear |
415
442
 
416
443
  Changing an agent's config drops that agent's cached results — the cache key holds the agent *name*, so a re-pointed or re-permissioned agent would otherwise keep answering from the previous config for the rest of the TTL. The same applies to `add_agent` and `remove_agent`.
417
444
 
@@ -515,6 +542,7 @@ Failures are deterministic: check `success`, then branch on `error_type`.
515
542
  Three soft signals that arrive with `success: true`:
516
543
 
517
544
  - **`denied_tools` + `hint`** — the agent finished but some tool calls were blocked; the result may be incomplete. Grant access (see the `permission` row) and re-dispatch.
545
+ - **`outcome: "partial"` / `"blocked"`** — the agent's own verdict that it did not finish; the result names what is missing and the `hint` carries the `dispatch_session(...)` call to continue in the same session. Not cached, so a re-dispatch after fixing the cause runs fresh.
518
546
  - **`parsed_result: null` with `response_format="json"`** — the reply wasn't valid JSON; the raw text is still in `result`. Caveat: an agent that *can't* comply returns `{"error": "<reason>"}` — which parses successfully — so also check `parsed_result` for an `"error"` key.
519
547
  - **`budget_exceeded: true`** — `cost_usd` came in over the agent's `max_budget_usd` (or the settings default) without the CLI stopping the run (the final turn can overshoot the cap). The dispatch is not failed — the money is already spent — but a runaway agent is now visible. Tighten the task, pick a cheaper model, or raise the budget. A run the CLI *did* stop fails with `error_type: "budget"` instead.
520
548
 
@@ -539,6 +567,9 @@ agents:
539
567
  - deploy_debug
540
568
  risky_capabilities: # high-risk labels, surfaced for visibility
541
569
  - restart_services
570
+ # instructions: | # standing orders, appended to the system prompt on every dispatch
571
+ # Read-only: never restart or redeploy unless the task says so.
572
+ # Quote exact log lines with timestamps.
542
573
  # model: sonnet # optional model override
543
574
  # max_budget_usd: 1.0 # cost limit per dispatch
544
575
  # permission_mode: bypassPermissions # one of: default | plan | bypassPermissions
@@ -574,6 +605,9 @@ settings:
574
605
  # - Edit
575
606
  max_dispatch_depth: 3 # recursion protection
576
607
  max_concurrency: 5 # max parallel claude -p processes (per dispatch path)
608
+ # dispatch_protocol: true # send every agent the dispatch protocol (non-interactive,
609
+ # # time budget, STATUS line → `outcome`). false = raw claude -p.
610
+ # job_retention_days: 30 # 0 (default) = never prune. See "Job retention" below.
577
611
  cache:
578
612
  enabled: true
579
613
  ttl: 300 # seconds
@@ -582,6 +616,30 @@ settings:
582
616
 
583
617
  Config is reloaded on every tool call — add agents without restarting.
584
618
 
619
+ ### Job retention
620
+
621
+ Every `dispatch_async` **and** every `dispatch(..., return_ref=True)` writes a
622
+ record to `~/.config/agent-dispatch/jobs/`, and nothing deletes it on its own —
623
+ `dispatch_gc` has to be run by hand. The directory therefore grows without
624
+ bound, and `dispatch_jobs` plus the stale-job recovery that runs at every server
625
+ start read and parse *every* file in it.
626
+
627
+ Set `job_retention_days` to prune terminal (done/failed/cancelled) records older
628
+ than N days when a server starts:
629
+
630
+ ```yaml
631
+ settings:
632
+ job_retention_days: 30
633
+ ```
634
+
635
+ It defaults to `0` — **off** — because those records are your own history of
636
+ past dispatches and deleting them cannot be undone. Pending and running jobs are
637
+ never touched.
638
+
639
+ `agent-dispatch gc --days N` and the `dispatch_gc` tool apply the same rule as a
640
+ one-off. Both *delete* immediately and report the count; neither previews, so
641
+ check what is there first with `agent-dispatch jobs`.
642
+
585
643
  ### Auto-Description
586
644
 
587
645
  `agent-dispatch add` without `--description` generates one from:
@@ -621,8 +679,9 @@ agent-dispatch MCP server
621
679
  ▼
622
680
  New Claude Code session in ~/projects/infra/
623
681
  ├─ Inherits: CLAUDE.md, .mcp.json, project tools
682
+ ├─ System prompt += dispatch protocol + the agent's standing `instructions`
624
683
  ├─ Receives structured prompt with goal/caller/context/task
625
- └─ Returns result → cached for future identical requests
684
+ └─ Returns result (+ its own STATUS → `outcome`) → cached when complete
626
685
  ```
627
686
 
628
687
  ## Safety
@@ -634,7 +693,7 @@ agent-dispatch MCP server
634
693
  - **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.
635
694
  - **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`.
636
695
  - **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.
637
- - **Caching** — identical `(agent, task, context, caller, goal, response_format)` requests return cached results, bounded by `cache.max_size` (oldest entry evicted first). Only clean successes are cached: failures, results with `denied_tools`, and results flagged `budget_exceeded` are not, so the documented "grant access / raise the cap, then re-dispatch" recovery is never served a stale crippled answer. Changing an agent's config invalidates its entries. Sessions and dialogues are never cached. A `group=` dispatch folds the group's `shared_context` into `context`, so different groups cache separately and a plain dispatch is unaffected.
696
+ - **Caching** — identical `(agent, task, context, caller, goal, response_format)` requests return cached results, bounded by `cache.max_size` (oldest entry evicted first). Only clean successes are cached: failures, results with `denied_tools`, results flagged `budget_exceeded`, and results the agent itself reported as `partial` / `blocked` are not, so the documented "grant access / raise the cap, then re-dispatch" recovery is never served a stale crippled answer. Changing an agent's config invalidates its entries. Sessions and dialogues are never cached. A `group=` dispatch folds the group's `shared_context` into `context`, so different groups cache separately and a plain dispatch is unaffected.
638
697
  - **Durable config** — `agents.yaml` is written atomically (temp file + rename), so an interrupted write can never truncate it. Every mutation path (CLI and MCP server alike) also takes a cross-process advisory lock, so concurrent edits don't drop one another's agents. The lock is best-effort by design: after waiting 10 seconds it logs a warning and proceeds anyway, because a wedged lock holder must not freeze the MCP server — so on a heavily contended config a lost update is possible, while a truncated one is not.
639
698
 
640
699
  See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassPermissions` escalation risk and on-disk job files).
@@ -645,13 +704,13 @@ See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassP
645
704
  |---------|-------------|
646
705
  | `agent-dispatch init` | Create config + register MCP server with Claude Code |
647
706
  | `agent-dispatch add <name> <dir>` | Add an agent (auto-generates description) |
648
- | `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
707
+ | `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, `--instructions`, etc.) |
649
708
  | `agent-dispatch remove <name>` | Remove an agent |
650
709
  | `agent-dispatch list` | List agents with health status and permissions |
651
710
  | `agent-dispatch group <add\|list\|inspect\|update\|remove>` | Manage [groups](#groups) — cross-project working sets of agents |
652
711
  | `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
653
712
  | `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
654
- | `agent-dispatch doctor` | Diagnose installation: Claude CLI, MCP registration, agent health, and group membership |
713
+ | `agent-dispatch doctor` | Diagnose installation: Claude CLI (incl. `--append-system-prompt` support), MCP registration, agent health, and group membership |
655
714
  | `agent-dispatch jobs [--status --limit]` | List async dispatch jobs (most recent first) |
656
715
  | `agent-dispatch job <id>` | Show one job: status, progress tail, result preview |
657
716
  | `agent-dispatch cancel <id>` | Cancel a pending job (running jobs: use the `dispatch_cancel` MCP tool) |
@@ -190,7 +190,8 @@ dispatch(
190
190
  "session_id": "sess-abc-123",
191
191
  "cost_usd": 0.02,
192
192
  "duration_ms": 5000,
193
- "num_turns": 2
193
+ "num_turns": 2,
194
+ "outcome": "done"
194
195
  }
195
196
 
196
197
  // Response (failure — error_type helps you handle programmatically)
@@ -248,6 +249,30 @@ Error: TypeError at scheduler.py:42
248
249
  Check container logs for recent errors related to the scheduler service
249
250
  ```
250
251
 
252
+ **The dispatch protocol and `outcome`.** `claude -p` on its own does not know it is being driven by another agent: given an ambiguous task it will happily end with *"Could you clarify which service you mean?"* — a billed run that answered nothing, and one that a plain cache would then serve again for the whole TTL. So every dispatch also appends a short **dispatch protocol** to the agent's system prompt (`--append-system-prompt`, never mixed into your task text):
253
+
254
+ - it is running non-interactively, dispatched by `caller`, and nobody will answer a question or approve an action — state the assumption and proceed, take the safer reading when two differ;
255
+ - a denied tool or a missing thing is something to report, not something to stop on — finish everything else that is possible;
256
+ - its time budget is about the agent's timeout (and its spend cap, if one is set) — scope the work to fit; a complete partial answer beats an unfinished perfect one;
257
+ - lead with the outcome, then the evidence; list what could not be done and why (this is what makes a `return_ref` summary — the **head** of the text — worth reading);
258
+ - end with one line, `STATUS: done`, `STATUS: partial` or `STATUS: blocked`.
259
+
260
+ That last line is lifted out of `result` into **`outcome`** — the agent's own verdict, deterministic to check: `"done"` means complete; `"partial"` / `"blocked"` mean the agent says the work is unfinished, the result names what is missing, and a `hint` spells out the continuation (usually `dispatch_session(..., session_id=...)`). Read `outcome` before reading the text. It never flips `success`, `partial` / `blocked` results are **not cached**, and a `dispatch_parallel(..., aggregate=...)` labels such members for the aggregator so a blocked report is not synthesized as a finished one. Absent when the agent did not report one — the protocol is off (`settings.dispatch_protocol: false`), `response_format="json"` was requested (the JSON footer governs the reply shape there), or the agent simply skipped it.
261
+
262
+ **Standing orders.** Per-agent `instructions` (set via `add_agent` / `update_agent` or `agent-dispatch update <name> --instructions "..."`) follow the protocol in the same system prompt on **every** dispatch — "read-only SQL only", "never restart a stack unless the task says so", "answer with exact log lines". Unlike `context`, which is per call, and unlike the project's own `CLAUDE.md`, which is written for an interactive session, these are the rules for *being dispatched*.
263
+
264
+ ```json
265
+ // Response (success, but the agent says it did not finish)
266
+ {
267
+ "agent": "infra",
268
+ "success": true,
269
+ "result": "Restarted horizon. Could not verify the queue drained: the redis container is not reachable from here.",
270
+ "session_id": "sess-abc-123",
271
+ "outcome": "partial",
272
+ "hint": "The agent reports its work is PARTIAL — read the result for what is missing, then continue in the same session via dispatch_session(agent='infra', task='Continue where you left off', session_id='sess-abc-123') or re-dispatch with what it needed."
273
+ }
274
+ ```
275
+
251
276
  ### `dispatch_session`
252
277
 
253
278
  Multi-turn: continue a conversation with an agent. First call starts a session, pass `session_id` back to continue. Never cached.
@@ -365,6 +390,7 @@ Register a new project directory as an agent. Description is auto-generated from
365
390
  | `disallowed_tools` | string | no | Comma-separated disallowed tools |
366
391
  | `capabilities` | string | no | Comma-separated capability labels (e.g. `"docker_logs,deploy_debug"`) |
367
392
  | `risky_capabilities` | string | no | Comma-separated high-risk labels (e.g. `"restart_services"`) |
393
+ | `instructions` | string | no | Standing orders appended to the agent's system prompt on every dispatch (see [the dispatch protocol](#dispatch)) |
368
394
 
369
395
  ### `update_agent`
370
396
 
@@ -382,6 +408,7 @@ Update an existing agent's configuration. Only non-empty fields are changed. Pas
382
408
  | `disallowed_tools` | string | no | Comma-separated. `"none"` to clear |
383
409
  | `capabilities` | string | no | Comma-separated. `"none"` to clear |
384
410
  | `risky_capabilities` | string | no | Comma-separated. `"none"` to clear |
411
+ | `instructions` | string | no | Standing orders for every dispatch (replaces the text). `"none"` to clear |
385
412
 
386
413
  Changing an agent's config drops that agent's cached results — the cache key holds the agent *name*, so a re-pointed or re-permissioned agent would otherwise keep answering from the previous config for the rest of the TTL. The same applies to `add_agent` and `remove_agent`.
387
414
 
@@ -485,6 +512,7 @@ Failures are deterministic: check `success`, then branch on `error_type`.
485
512
  Three soft signals that arrive with `success: true`:
486
513
 
487
514
  - **`denied_tools` + `hint`** — the agent finished but some tool calls were blocked; the result may be incomplete. Grant access (see the `permission` row) and re-dispatch.
515
+ - **`outcome: "partial"` / `"blocked"`** — the agent's own verdict that it did not finish; the result names what is missing and the `hint` carries the `dispatch_session(...)` call to continue in the same session. Not cached, so a re-dispatch after fixing the cause runs fresh.
488
516
  - **`parsed_result: null` with `response_format="json"`** — the reply wasn't valid JSON; the raw text is still in `result`. Caveat: an agent that *can't* comply returns `{"error": "<reason>"}` — which parses successfully — so also check `parsed_result` for an `"error"` key.
489
517
  - **`budget_exceeded: true`** — `cost_usd` came in over the agent's `max_budget_usd` (or the settings default) without the CLI stopping the run (the final turn can overshoot the cap). The dispatch is not failed — the money is already spent — but a runaway agent is now visible. Tighten the task, pick a cheaper model, or raise the budget. A run the CLI *did* stop fails with `error_type: "budget"` instead.
490
518
 
@@ -509,6 +537,9 @@ agents:
509
537
  - deploy_debug
510
538
  risky_capabilities: # high-risk labels, surfaced for visibility
511
539
  - restart_services
540
+ # instructions: | # standing orders, appended to the system prompt on every dispatch
541
+ # Read-only: never restart or redeploy unless the task says so.
542
+ # Quote exact log lines with timestamps.
512
543
  # model: sonnet # optional model override
513
544
  # max_budget_usd: 1.0 # cost limit per dispatch
514
545
  # permission_mode: bypassPermissions # one of: default | plan | bypassPermissions
@@ -544,6 +575,9 @@ settings:
544
575
  # - Edit
545
576
  max_dispatch_depth: 3 # recursion protection
546
577
  max_concurrency: 5 # max parallel claude -p processes (per dispatch path)
578
+ # dispatch_protocol: true # send every agent the dispatch protocol (non-interactive,
579
+ # # time budget, STATUS line → `outcome`). false = raw claude -p.
580
+ # job_retention_days: 30 # 0 (default) = never prune. See "Job retention" below.
547
581
  cache:
548
582
  enabled: true
549
583
  ttl: 300 # seconds
@@ -552,6 +586,30 @@ settings:
552
586
 
553
587
  Config is reloaded on every tool call — add agents without restarting.
554
588
 
589
+ ### Job retention
590
+
591
+ Every `dispatch_async` **and** every `dispatch(..., return_ref=True)` writes a
592
+ record to `~/.config/agent-dispatch/jobs/`, and nothing deletes it on its own —
593
+ `dispatch_gc` has to be run by hand. The directory therefore grows without
594
+ bound, and `dispatch_jobs` plus the stale-job recovery that runs at every server
595
+ start read and parse *every* file in it.
596
+
597
+ Set `job_retention_days` to prune terminal (done/failed/cancelled) records older
598
+ than N days when a server starts:
599
+
600
+ ```yaml
601
+ settings:
602
+ job_retention_days: 30
603
+ ```
604
+
605
+ It defaults to `0` — **off** — because those records are your own history of
606
+ past dispatches and deleting them cannot be undone. Pending and running jobs are
607
+ never touched.
608
+
609
+ `agent-dispatch gc --days N` and the `dispatch_gc` tool apply the same rule as a
610
+ one-off. Both *delete* immediately and report the count; neither previews, so
611
+ check what is there first with `agent-dispatch jobs`.
612
+
555
613
  ### Auto-Description
556
614
 
557
615
  `agent-dispatch add` without `--description` generates one from:
@@ -591,8 +649,9 @@ agent-dispatch MCP server
591
649
  ▼
592
650
  New Claude Code session in ~/projects/infra/
593
651
  ├─ Inherits: CLAUDE.md, .mcp.json, project tools
652
+ ├─ System prompt += dispatch protocol + the agent's standing `instructions`
594
653
  ├─ Receives structured prompt with goal/caller/context/task
595
- └─ Returns result → cached for future identical requests
654
+ └─ Returns result (+ its own STATUS → `outcome`) → cached when complete
596
655
  ```
597
656
 
598
657
  ## Safety
@@ -604,7 +663,7 @@ agent-dispatch MCP server
604
663
  - **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.
605
664
  - **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`.
606
665
  - **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.
607
- - **Caching** — identical `(agent, task, context, caller, goal, response_format)` requests return cached results, bounded by `cache.max_size` (oldest entry evicted first). Only clean successes are cached: failures, results with `denied_tools`, and results flagged `budget_exceeded` are not, so the documented "grant access / raise the cap, then re-dispatch" recovery is never served a stale crippled answer. Changing an agent's config invalidates its entries. Sessions and dialogues are never cached. A `group=` dispatch folds the group's `shared_context` into `context`, so different groups cache separately and a plain dispatch is unaffected.
666
+ - **Caching** — identical `(agent, task, context, caller, goal, response_format)` requests return cached results, bounded by `cache.max_size` (oldest entry evicted first). Only clean successes are cached: failures, results with `denied_tools`, results flagged `budget_exceeded`, and results the agent itself reported as `partial` / `blocked` are not, so the documented "grant access / raise the cap, then re-dispatch" recovery is never served a stale crippled answer. Changing an agent's config invalidates its entries. Sessions and dialogues are never cached. A `group=` dispatch folds the group's `shared_context` into `context`, so different groups cache separately and a plain dispatch is unaffected.
608
667
  - **Durable config** — `agents.yaml` is written atomically (temp file + rename), so an interrupted write can never truncate it. Every mutation path (CLI and MCP server alike) also takes a cross-process advisory lock, so concurrent edits don't drop one another's agents. The lock is best-effort by design: after waiting 10 seconds it logs a warning and proceeds anyway, because a wedged lock holder must not freeze the MCP server — so on a heavily contended config a lost update is possible, while a truncated one is not.
609
668
 
610
669
  See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassPermissions` escalation risk and on-disk job files).
@@ -615,13 +674,13 @@ See [SECURITY.md](SECURITY.md) for the full threat model (including the `bypassP
615
674
  |---------|-------------|
616
675
  | `agent-dispatch init` | Create config + register MCP server with Claude Code |
617
676
  | `agent-dispatch add <name> <dir>` | Add an agent (auto-generates description) |
618
- | `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, etc.) |
677
+ | `agent-dispatch update <name>` | Update agent config (permissions, timeout, model, `--instructions`, etc.) |
619
678
  | `agent-dispatch remove <name>` | Remove an agent |
620
679
  | `agent-dispatch list` | List agents with health status and permissions |
621
680
  | `agent-dispatch group <add\|list\|inspect\|update\|remove>` | Manage [groups](#groups) — cross-project working sets of agents |
622
681
  | `agent-dispatch describe <name>` | Show full configuration for one agent (tri-state tools, project files) |
623
682
  | `agent-dispatch test <name> [task] [--stream]` | Test an agent with a dispatch (`--stream` for live progress) |
624
- | `agent-dispatch doctor` | Diagnose installation: Claude CLI, MCP registration, agent health, and group membership |
683
+ | `agent-dispatch doctor` | Diagnose installation: Claude CLI (incl. `--append-system-prompt` support), MCP registration, agent health, and group membership |
625
684
  | `agent-dispatch jobs [--status --limit]` | List async dispatch jobs (most recent first) |
626
685
  | `agent-dispatch job <id>` | Show one job: status, progress tail, result preview |
627
686
  | `agent-dispatch cancel <id>` | Cancel a pending job (running jobs: use the `dispatch_cancel` MCP tool) |
@@ -9,6 +9,13 @@ agents:
9
9
  risky_capabilities:
10
10
  - restart_services
11
11
  timeout: 300
12
+ # Standing orders, appended to the agent's system prompt on EVERY dispatch
13
+ # (after the dispatch protocol). Unlike `context`, which is per call, and
14
+ # unlike the project's CLAUDE.md, which is written for an interactive
15
+ # session, these are the rules for being dispatched.
16
+ instructions: |
17
+ Read-only unless the task explicitly asks to restart or redeploy.
18
+ Quote exact log lines with timestamps; never paraphrase an error.
12
19
 
13
20
  # Analytics gateway — a browser + Yandex Metrica agent, no codebase of its own.
14
21
  # Its value is its MCP servers + access, not its source. Shared across groups.
@@ -93,6 +100,12 @@ settings:
93
100
  default_timeout: 300
94
101
  max_dispatch_depth: 3 # recursion protection: A -> B -> A
95
102
  max_concurrency: 5 # max parallel claude -p processes
103
+ # dispatch_protocol: true # Append the dispatch protocol to every agent's system
104
+ # prompt: it is non-interactive (never end with a
105
+ # question), its time/spend budget, lead with the
106
+ # outcome, end with `STATUS: done|partial|blocked`
107
+ # (→ DispatchResult.outcome). false = raw claude -p;
108
+ # needed only for CLIs without --append-system-prompt.
96
109
  # default_max_budget_usd: 1.0 # spend cap per dispatch, passed to claude as --max-budget-usd
97
110
  # (a run stopped at the cap fails with error_type: budget)
98
111
  # default_permission_mode: bypassPermissions # inherited by agents without override
@@ -100,6 +113,12 @@ settings:
100
113
  # - Bash
101
114
  # - Read
102
115
  # - Edit
116
+ # job_retention_days: 30 # OFF by default (0). When > 0, terminal job records
117
+ # older than this are deleted at server start. Every
118
+ # async and every return_ref dispatch leaves a job
119
+ # file behind and nothing removes it otherwise, so
120
+ # the directory grows forever — but the records are
121
+ # your dispatch history, so pruning is opt-in.
103
122
  cache:
104
123
  enabled: true
105
124
  ttl: 300 # seconds; identical (agent, task, context) requests are cached
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-dispatch"
3
- version = "0.12.1"
3
+ version = "0.14.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.12.1"
3
+ __version__ = "0.14.0"
@@ -102,6 +102,12 @@ class DispatchCache:
102
102
  # serve the same crippled answer back for the whole TTL and make that
103
103
  # recovery a no-op (the permission config is not part of the key).
104
104
  return
105
+ if result.outcome in ("partial", "blocked"):
106
+ # The agent itself says the work is unfinished. Whatever it was
107
+ # missing (a service that was down, a file that did not exist yet)
108
+ # is not in the key either, so a retry after fixing it would be
109
+ # served the same unfinished answer for the whole TTL.
110
+ return
105
111
  key = self._make_key(agent, task, context, caller, goal, response_format)
106
112
  with self._lock:
107
113
  # Bound memory: when at capacity and inserting a new key, evict the