harness-dispatch 0.8.0 → 0.9.0

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 (110) hide show
  1. package/CHANGELOG.md +514 -1
  2. package/README.md +29 -4
  3. package/config.default.yaml +19 -3
  4. package/dist/billing.d.ts +24 -0
  5. package/dist/billing.d.ts.map +1 -1
  6. package/dist/billing.js +24 -0
  7. package/dist/billing.js.map +1 -1
  8. package/dist/bin.d.ts.map +1 -1
  9. package/dist/bin.js +136 -22
  10. package/dist/bin.js.map +1 -1
  11. package/dist/circuit-breaker.d.ts +9 -1
  12. package/dist/circuit-breaker.d.ts.map +1 -1
  13. package/dist/circuit-breaker.js +15 -1
  14. package/dist/circuit-breaker.js.map +1 -1
  15. package/dist/client-register.d.ts +11 -1
  16. package/dist/client-register.d.ts.map +1 -1
  17. package/dist/client-register.js +15 -6
  18. package/dist/client-register.js.map +1 -1
  19. package/dist/config/validation.d.ts +12 -0
  20. package/dist/config/validation.d.ts.map +1 -1
  21. package/dist/config/validation.js +133 -7
  22. package/dist/config/validation.js.map +1 -1
  23. package/dist/config.d.ts +4 -2
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/config.js +88 -4
  26. package/dist/config.js.map +1 -1
  27. package/dist/configure-yaml.d.ts +2 -0
  28. package/dist/configure-yaml.d.ts.map +1 -1
  29. package/dist/configure-yaml.js +69 -3
  30. package/dist/configure-yaml.js.map +1 -1
  31. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  32. package/dist/dispatchers/generic-cli.js +92 -15
  33. package/dist/dispatchers/generic-cli.js.map +1 -1
  34. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  35. package/dist/dispatchers/openai-compatible.js +22 -2
  36. package/dist/dispatchers/openai-compatible.js.map +1 -1
  37. package/dist/dispatchers/shared/harness-login.d.ts +25 -0
  38. package/dist/dispatchers/shared/harness-login.d.ts.map +1 -0
  39. package/dist/dispatchers/shared/harness-login.js +68 -0
  40. package/dist/dispatchers/shared/harness-login.js.map +1 -0
  41. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  42. package/dist/dispatchers/shared/stream-subprocess.js +70 -4
  43. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  44. package/dist/file-lock.d.ts +29 -1
  45. package/dist/file-lock.d.ts.map +1 -1
  46. package/dist/file-lock.js +22 -1
  47. package/dist/file-lock.js.map +1 -1
  48. package/dist/http/server.d.ts +2 -0
  49. package/dist/http/server.d.ts.map +1 -1
  50. package/dist/http/server.js +179 -14
  51. package/dist/http/server.js.map +1 -1
  52. package/dist/jobs/context.d.ts +0 -7
  53. package/dist/jobs/context.d.ts.map +1 -1
  54. package/dist/jobs/context.js +95 -7
  55. package/dist/jobs/context.js.map +1 -1
  56. package/dist/jobs/types.d.ts +8 -1
  57. package/dist/jobs/types.d.ts.map +1 -1
  58. package/dist/jobs.d.ts +15 -0
  59. package/dist/jobs.d.ts.map +1 -1
  60. package/dist/jobs.js +106 -14
  61. package/dist/jobs.js.map +1 -1
  62. package/dist/leaderboard.d.ts.map +1 -1
  63. package/dist/leaderboard.js +39 -27
  64. package/dist/leaderboard.js.map +1 -1
  65. package/dist/mcp/config-hot-reload.d.ts +14 -3
  66. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  67. package/dist/mcp/config-hot-reload.js +23 -5
  68. package/dist/mcp/config-hot-reload.js.map +1 -1
  69. package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
  70. package/dist/mcp/dispatcher-factory.js +14 -3
  71. package/dist/mcp/dispatcher-factory.js.map +1 -1
  72. package/dist/mcp/tool-schemas.d.ts +1 -1
  73. package/dist/mcp/tool-schemas.d.ts.map +1 -1
  74. package/dist/mcp/tool-schemas.js +6 -3
  75. package/dist/mcp/tool-schemas.js.map +1 -1
  76. package/dist/mcp/tools.d.ts +11 -4
  77. package/dist/mcp/tools.d.ts.map +1 -1
  78. package/dist/mcp/tools.js +47 -8
  79. package/dist/mcp/tools.js.map +1 -1
  80. package/dist/mcp-clients.d.ts +9 -0
  81. package/dist/mcp-clients.d.ts.map +1 -1
  82. package/dist/mcp-clients.js +4 -0
  83. package/dist/mcp-clients.js.map +1 -1
  84. package/dist/quota.d.ts.map +1 -1
  85. package/dist/quota.js +16 -2
  86. package/dist/quota.js.map +1 -1
  87. package/dist/route-policy.d.ts +2 -1
  88. package/dist/route-policy.d.ts.map +1 -1
  89. package/dist/route-policy.js +54 -0
  90. package/dist/route-policy.js.map +1 -1
  91. package/dist/router.d.ts.map +1 -1
  92. package/dist/router.js +4 -0
  93. package/dist/router.js.map +1 -1
  94. package/dist/safety.d.ts.map +1 -1
  95. package/dist/safety.js +7 -1
  96. package/dist/safety.js.map +1 -1
  97. package/dist/state-dir.d.ts +10 -0
  98. package/dist/state-dir.d.ts.map +1 -1
  99. package/dist/state-dir.js +12 -0
  100. package/dist/state-dir.js.map +1 -1
  101. package/dist/types.d.ts +24 -1
  102. package/dist/types.d.ts.map +1 -1
  103. package/dist/workspace-resolve.d.ts.map +1 -1
  104. package/dist/workspace-resolve.js +77 -0
  105. package/dist/workspace-resolve.js.map +1 -1
  106. package/dist/workspaces.d.ts +15 -0
  107. package/dist/workspaces.d.ts.map +1 -1
  108. package/dist/workspaces.js +329 -13
  109. package/dist/workspaces.js.map +1 -1
  110. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,518 @@ pre-1.0, so minor versions can carry behaviour changes.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0] — 2026-09-02
10
+
11
+ ### Changed (breaking)
12
+
13
+ - **A config file that defines routes is now authoritative about them.**
14
+ Auto-detection used to run unconditionally, so a file with `clis:` or
15
+ `endpoints:` entries ADDED to the installed harnesses rather than replacing
16
+ them, and only `disabled:` — naming every route, including ones you might
17
+ not know existed — subtracted.
18
+
19
+ That default caught three acceptance passes in a row despite explicit
20
+ warnings, and caught this project's own test suite: one test dispatched to
21
+ the real Claude Code CLI on every `npm test` and every CI run, under a
22
+ comment asserting it could not reach a route. It also decayed — adding
23
+ support for a new harness would auto-add it to every existing config, so a
24
+ `disabled:` list written today quietly stopped isolating tomorrow.
25
+
26
+ The legacy `services:` format has always behaved this way, so the two shapes
27
+ now agree rather than this being a new rule.
28
+
29
+ **What changes for you.** A config that has a `clis:` or `endpoints:` key
30
+ gets exactly the routes listed under it — including `clis: []`, which means
31
+ no CLI routes. Add `detect: true` to keep the old behaviour. A config
32
+ mentioning NEITHER key — one carrying only `overrides:`, `disabled:` or
33
+ settings — still auto-detects, because a file cannot be authoritative about
34
+ routes it does not describe; it now says so in a warning. `detect: false`
35
+ turns detection off outright.
36
+
37
+ Note that `disabled:` and `overrides:` tune AUTO-DETECTION, so they do
38
+ nothing in a config that lists its own routes — remove the entry instead.
39
+ The config the README tells you to copy said the opposite of this until it
40
+ was corrected in the same release; check yours if you wrote it from that.
41
+
42
+ ### Fixed
43
+
44
+ - **Re-running `configure` after installing a harness no longer needs
45
+ `--force`.** The natural first-run order — install this tool, configure,
46
+ find out a harness is needed, install one, configure again — was refused
47
+ at the last step: "already exists ... --force". And had the first run found
48
+ anything, the re-run would have loaded that file as authoritative and not
49
+ detected the new harness at all. `configure` now stamps what it writes
50
+ with a fingerprint; a re-run that finds its own unedited output regenerates
51
+ it from a fresh detection, and says so. A file that has been edited, or
52
+ that configure did not write, is still loaded (so its settings migrate)
53
+ and still refused without `--force` — and since such a file lists its own
54
+ routes, `--force` regenerates it from the file, not from a detection. It
55
+ used to print "Detected N harness routes" there anyway; an acceptance pass
56
+ measured that line with a second harness on PATH that never appeared. It
57
+ now says detection did not run and how to merge (`detect: true`). The
58
+ non-interactive hint at the end of `configure --yes` also told the user to
59
+ pass `--yes`, which they just had; it now names `connect`. Seen on the
60
+ cold-install walk.
61
+
62
+ - **`connect` now registers with a Claude Code that has never been opened.**
63
+ A client counted as installed only if its config file existed, and Claude
64
+ Code writes `~/.claude.json` on its first interactive launch — so a user who
65
+ installed Claude Code and this tool together was told "No MCP clients found
66
+ on this machine" with `claude` on PATH, handed JSON to paste by hand, and
67
+ `claude mcp list` stayed empty. A client whose command is on PATH now counts
68
+ as installed; when its file is missing, `connect` creates it holding only
69
+ our entry (0600), which Claude Code accepts as a user-scope registration.
70
+ Doctor's `mcp-clients` line names the installed-but-unregistered client
71
+ instead of a generic "not registered". Seen on the cold-install walk.
72
+
73
+ - **`configure` writes its config where every later command can find it.**
74
+ It wrote `./config.yaml` into whatever directory it was run from, and
75
+ lookup stopped at the current directory, so a config written from `~` was
76
+ invisible to `doctor` or `dispatch` run inside a project — "0 configured
77
+ route(s)", no hint why — while the MCP client, handed the absolute path,
78
+ saw it fine. The default target is now the state directory
79
+ (`~/.harness-dispatch/config.yaml`, or under `HARNESS_DISPATCH_STATE_DIR`),
80
+ lookup falls back to that file after `./config.yaml` (a per-project file
81
+ still wins), `connect` looks in the same place, and doctor's `config` line
82
+ names the file it loaded. `--config` and `HARNESS_DISPATCH_CONFIG` are
83
+ unchanged. Seen on the cold-install walk, where a run from `/` produced
84
+ `/config.yaml`.
85
+
86
+ - **`doctor` now notices a Codex that is installed but not logged in.** A
87
+ ready route meant "the CLI is on PATH", so a never-logged-in Codex passed
88
+ routes, billing and safety, and the first dispatch failed with a raw OpenAI
89
+ `401 Unauthorized ... Missing bearer` that never mentioned `codex login`.
90
+ A new `harness-login` check asks `codex login status` and fails with the
91
+ command to run when it answers "Not logged in". The CLI is asked rather
92
+ than its credential file read, because Codex accepts a ChatGPT login or an
93
+ API key and honours its own home directory, and only its own answer is
94
+ right in every case. Any answer other than a definite "Not logged in" —
95
+ an older Codex without the subcommand, a spawn failure — is reported as
96
+ undetermined and does not fail the install. Codex only: the other
97
+ harnesses have no equivalent subcommand this tool has verified.
98
+
99
+ - **The installed command did nothing on Linux and macOS.** `npm install -g`
100
+ puts `harness-dispatch` on PATH as a symlink to `dist/bin.js`, and node
101
+ hands that unresolved link path to the program, so a run-if-entrypoint guard
102
+ that only looked for a filename ending in `bin.js` decided it was being
103
+ imported, ran nothing, and exited 0. Every documented command — `configure`,
104
+ `doctor`, `connect`, `dispatch`, even `--help` — printed nothing and reported
105
+ success on every non-Windows `npm install -g` or `npx` since the first
106
+ release. Windows was unaffected only because npm's `.cmd` shim passes the
107
+ real file path. The guard now also accepts an invoked path that resolves to
108
+ this file. Found by walking the README's three install steps in a clean
109
+ container, which nothing before had done: every prior walk ran `node
110
+ dist/bin.js` on this Windows machine.
111
+
112
+ - **SECURITY: workspace paths are now built and verified rather than checked
113
+ and trusted.** Four consecutive releases patched a guard that inspected a
114
+ path STRING and then let every later write re-resolve it, and each patch was
115
+ found incomplete by the next review: it followed the link it was checking,
116
+ then covered one of the two isolation policies, then only the last path
117
+ segment, then stopped one directory short of the one the release notes named
118
+ — and was silently inert altogether if the workspaces setting had a trailing
119
+ slash or a `..` in it. Validating once also left the rest of the dispatch
120
+ trusting the result, so swapping a directory for a link mid-copy redirected
121
+ it.
122
+
123
+ Every directory from the anchor down is now created by this tool with a
124
+ non-recursive `mkdir`, which cannot traverse a link it did not make, and the
125
+ verified path is returned and used instead of being re-derived. Destructive
126
+ operations re-verify immediately before acting. The symlink refusal now
127
+ applies on Windows too, where junctions need no privileges and were being
128
+ followed.
129
+
130
+ Verified as two separate users on Linux across every shape reported: a link
131
+ at the workspace root, at the base, at the base's parent, and with the
132
+ setting spelled with a trailing slash or `..` — all refused, with nothing
133
+ written into the attacker's directory. Legitimate use is unaffected,
134
+ including a symlinked temp directory, which is normal on macOS.
135
+
136
+ - **A delegated "run the tests" task no longer blocks its own route.** The
137
+ rate-limit phrase check ran against the whole transcript with no filter, so
138
+ this project's own test output — which names rate limits in test titles —
139
+ flagged as a real limiter, tripping the breaker for 300 seconds and
140
+ recording a rate limit that never happened. It is now checked per line and
141
+ past the same filter the numeric check uses.
142
+
143
+ - **Real limiter messages that were being missed now register**, including
144
+ Anthropic's `rate_limit_error` and OpenAI's "You exceeded your current
145
+ quota" wording. A missed limit is the worse direction: the router keeps
146
+ hammering a route that has already said stop.
147
+
148
+ - A metered route that declares it cannot bill you is no longer told to allow
149
+ paid usage. The previous release put that advice in a branch such a route
150
+ never reaches, so it worked for local routes only.
151
+
152
+ - The claims checker now also scans MCP resource descriptions, every
153
+ route-refusal message, the rendered status body and the CLI's own help text
154
+ — surfaces a user or agent reads that it could not see.
155
+
156
+ - **SECURITY: the workspace symlink guard covered one of the two isolation
157
+ policies.** The previous release applied the `lstat` refusal, the
158
+ guard-before-prune ordering and the sweep's name check to `copy` only.
159
+ `git_worktree` kept all three defects, so the identical attack still
160
+ destroyed data — reproduced as two real users: `copy` refused, `git_worktree`
161
+ deleted the victim's directory and raised the guard's error afterwards. The
162
+ entry claiming the class was closed was true for one policy and false for the
163
+ other. The test shipped with it passed `policy: "copy"` only, so the suite
164
+ stayed green over a live vulnerability; it now runs against both.
165
+
166
+ - **The same guard only checked the last path segment.** `<tmp>/harness-dispatch`
167
+ and `<tmp>/harness-dispatch/workspaces` are fixed names under a
168
+ world-writable directory and were never examined, so a link planted one or
169
+ two levels up redirected the whole copy into an attacker's tree. Every
170
+ segment from the workspaces base down is now checked for links and ownership.
171
+
172
+ - **A rate-limit regression from the previous release.** The filter added to
173
+ stop false positives discarded any line containing "should", which threw away
174
+ real limiter messages — a missed 429 means the router keeps hammering an
175
+ exhausted route. Two other patterns in that filter could never match at all.
176
+
177
+ - **A free local route is no longer told it can bill you, with a fix that does
178
+ nothing.** A route with a declared kind and `paid_usage_possible: false` is
179
+ blocked when its `billing_confidence` is `unknown` — deliberate — but was
180
+ shown "route can incur paid usage" beside `paid=no` and advised to set a
181
+ field it already had. It now names the real cause and a remedy that works.
182
+
183
+ - The `usage` tool description and README told agents that `service` and
184
+ `models` are unvalidated. Both throw on an unknown route id. The same wrong
185
+ sentence was corrected in the plugin skill one release earlier and missed
186
+ here — `scripts/check-claims.mjs` could not see tool descriptions at all, and
187
+ now does.
188
+
189
+ - `scripts/fetch_benchmarks.py` no longer replaces good benchmark data with its
190
+ bundled fallback when the network fails. It swallowed every exception,
191
+ returned an empty set, wrote it out and exited 0 — turning a transient outage
192
+ into a permanent downgrade of the file that ships in the package.
193
+
194
+ - An env-var reference embedded in a larger value (`base_url:
195
+ https://${HOST}/v1`) is now stripped from spawned CLI environments. The
196
+ sanitizer anchored to whole-string matches, so a key inside a longer string
197
+ stayed visible — the exact leak it was written to close, one string shape
198
+ over.
199
+
200
+ - `OPERATIONS.md` described streamed fanout wrongly (its arms DO create jobs,
201
+ and are not aborted on disconnect), `safety.ts` claimed every shipped harness
202
+ defines all three safety profiles (`cursor_cli` declares one),
203
+ `ux-walkthrough.md` called `discard` the only destructive operation (the
204
+ retention sweep is the one that deleted files), and two module headers said
205
+ three tools where six are registered.
206
+
207
+ - **SECURITY: a `copy` dispatch could delete files outside its workspace.**
208
+ The ownership guard used `stat`, which follows symlinks, so it compared the
209
+ uid of whatever a link POINTED AT rather than the link's own — and on a
210
+ shared machine the workspace path is predictable, so another local user can
211
+ plant one. Worse, the retention sweep that deletes aged workspaces ran
212
+ BEFORE the guard and had no ownership, marker or name check of its own.
213
+ Reproduced end to end in a container: a victim's directory chmod'd, the
214
+ project copied into it, and their files removed. All three now fixed —
215
+ `lstat` with symlinks refused outright, the guard runs before anything
216
+ destructive, and the sweep only deletes directories matching the name shape
217
+ this tool generates. The previous release's entry claiming a foreign-owned
218
+ root was "refused outright" was false against exactly this attack.
219
+
220
+ - **Non-ASCII output was being corrupted.** Every stdout chunk was decoded on
221
+ its own, so a character split across two reads became replacement
222
+ characters — accented text, CJK, emoji, box-drawing. This is the path every
223
+ job uses, so it reached partial output, saved logs and delivered results,
224
+ and was invisible to anyone working in English.
225
+
226
+ - **An aborted streaming request now stops the run.** Nothing connected the
227
+ client disconnect to the dispatch, so an abandoned stream ran to completion,
228
+ spending quota — and being the one path with no job record, it could not be
229
+ cancelled either. On a CLI route that meant an agent still editing files for
230
+ a caller that had gone.
231
+
232
+ - **HTTP fanout with no eligible route now refuses** instead of answering 200
233
+ with an empty list. CI and cron read 200 as success; the MCP surface already
234
+ refused the identical request by name.
235
+
236
+ - A configured `timeout_ms` now covers reading an endpoint's response body,
237
+ not just its headers — the same defect as the leaderboard timeout fixed last
238
+ release, in the path the router uses for its primary route.
239
+
240
+ - Endpoint credentials can no longer leak through streaming error text: undici
241
+ embeds the request URL, and this branch returned it unscrubbed while its
242
+ sibling scrubbed it deliberately.
243
+
244
+ - A rate-limit false positive no longer trips the breaker on text ABOUT a 429
245
+ — a line number, or a test assertion the delegate ran. One flag blocks a
246
+ route for 300 seconds and records a limit that never happened.
247
+
248
+ - Config hot-reload now notices a config whose timestamp moves BACKWARDS
249
+ (restored from a backup, `cp -p`, extracted from an archive), and documents
250
+ the two cases where it does nothing at all.
251
+
252
+ - The MCP snippet `configure` prints is now the entry `connect` actually
253
+ writes. Pasting the documented snippet then running `connect` was answered
254
+ "has an entry we did not write" — the tool called its own output
255
+ hand-edited and refused to update or remove it.
256
+
257
+ - The chained-context omission notice named the wrong jobs when a job id
258
+ repeated, listing ones whose output was directly above it.
259
+
260
+ - **An existing `copy` workspace directory is now actually secured.** The
261
+ previous release set 0700 with `mkdir`'s `mode` option, which applies only to
262
+ directories it CREATES — so everyone who had used `copy` before kept a
263
+ world-readable root holding a full copy of their source, and the entry
264
+ claiming otherwise was wrong. Measured on Linux: 0755 before, 0755 after. An
265
+ explicit chmod fixes them. A root owned by ANOTHER user is now refused
266
+ outright rather than copied into: the path is deterministic inside a shared
267
+ temp directory, so on the multi-user machine this guard exists for, someone
268
+ else can create it first.
269
+
270
+ - **A half-dead leaderboard endpoint no longer hangs every dispatch.** The 8s
271
+ timeout was cancelled as soon as response headers arrived, leaving the body
272
+ read unbounded — measured still pending after 15s. Quality scores are awaited
273
+ per routing candidate, so this stalled routing indefinitely. Off by default,
274
+ which is why it was not worse.
275
+
276
+ - **The HTTP server no longer leaks an MCP server per rejected request.** A
277
+ request carrying an unknown session id is answered 400 without initialising,
278
+ so nothing ever closed the server built for it in advance — five such
279
+ requests left five alive until shutdown. Sessions are also expired after 30
280
+ minutes idle: nothing removed them before except an explicit HTTP DELETE,
281
+ which the standard client never sends, so a clean shutdown left its session
282
+ resident forever.
283
+
284
+ - **`retry_job` on an abandoned job no longer races the original.** Its guard
285
+ read the derived status while the claim path reads the raw status file, so a
286
+ supervisor could pick up the original while the retry ran — the exact outcome
287
+ the command's own error text says it prevents. The original is now marked
288
+ cancelled.
289
+
290
+ - **Chained context says what it left out.** Jobs past the character budget
291
+ were dropped with no header and no note, contradicting the contract stated in
292
+ the same file. The docblock also claimed oldest entries truncate first, which
293
+ was never true — the last ones go.
294
+
295
+ - The `copy` size refusal named the parent of your project rather than the
296
+ workspace holding the work, and the `force` flag's description still promised
297
+ an uncommitted-changes refusal that only exists inside a git repository.
298
+
299
+ - **`configure` no longer deletes `detect: false`.** That key is the only
300
+ setting that isolates a machine from its installed paid CLIs, and it had no
301
+ field on the internal config object, so regenerating a file silently dropped
302
+ it. Measured: `detect: false` plus `max_concurrent_runs: 2` came back as the
303
+ latter alone, and reloading it with all four harness CLIs present produced
304
+ four routes on real subscriptions — under a "Wrote" message. A bare
305
+ `detect: false` regenerated as an empty document, where even the
306
+ "this config defines no routes" warning is suppressed, because its trigger
307
+ requires a non-empty file. `detect` now round-trips, and is written only when
308
+ the file stated it — carrying the resolved value would stamp `detect: true`
309
+ into every config that merely omitted it.
310
+
311
+ - **`connect --remove` now removes the entry.** Without `--clients` it printed
312
+ "our entry is here — will be removed", exited 0, and left the file
313
+ byte-identical. The chooser filtered out plans whose entry already matches —
314
+ correct when registering, and exactly backwards when removing, where a
315
+ matching entry is the one being removed. `--clients` bypassed that function
316
+ and always worked; the broken form is the one documented in README and
317
+ OPERATIONS.md. A hand-edited entry is still refused without `--force`, and
318
+ now exits non-zero rather than reporting success for work it did not do.
319
+ This command had no test coverage at all; it does now.
320
+
321
+ - **The test suite can no longer reach a real route.** Test setup already
322
+ sandboxed the log, state and jobs directories — where the suite WRITES — but
323
+ not config, which is what it DISCOVERS. A test that loaded config without
324
+ naming a file or stubbing detection picked up whatever the developer's
325
+ machine offers. Measured while closing this: on the maintainer's machine
326
+ that is the repo's own `config.yaml`, yielding four API routes with real
327
+ keys; on a machine with the CLIs installed it is the harness fleet, on real
328
+ subscriptions. One boundary test really did dispatch to Claude Code on every
329
+ CI run once. Setup now pins `HARNESS_DISPATCH_CONFIG` at a `detect: false`
330
+ sandbox, so an un-stubbed load gets an empty route table; a test wanting
331
+ routes passes its own path, which still wins.
332
+
333
+ - **An orphaned job can be cancelled again — when it is still able to run.**
334
+ There are two kinds: one written to disk when the server exits before a
335
+ queued job starts (genuinely terminal), and one DERIVED from a stale
336
+ heartbeat while the file still says `queued`. The second is not inert — a
337
+ supervisor reclaims it once the dead owner's claim ages out — and `cancel`
338
+ answered "had already finished; nothing to cancel" about work that could
339
+ still start, leaving no way to stop it. It now settles the status itself,
340
+ since an orphaned job has no runner to notice a marker. The written kind
341
+ still reports nothing to cancel.
342
+
343
+ - **A `copy` workspace of many files can no longer build an unbounded patch.**
344
+ The per-file diff was already bounded; the concatenation of those files was
345
+ not, so files each under the limit still summed past it. Both limits now
346
+ explain themselves — the worktree path used to surface the cap as
347
+ `stdout maxBuffer length exceeded`, which says nothing about patches or
348
+ about the work still being safe on disk.
349
+
350
+ - **Isolated workspaces are created 0700**, like the state and job
351
+ directories. A `copy` workspace holds a full copy of your source and the
352
+ default base is in the shared temp directory, so on a multi-user POSIX
353
+ machine it was readable by everyone. No effect on Windows.
354
+
355
+ - **Chained context says which project it came from.** `contextJobs` inlines
356
+ any job from the machine-wide store with no working-directory scoping, so a
357
+ job from one project could be pulled into a dispatch for another with
358
+ nothing indicating it. Disclosed rather than blocked: chaining across
359
+ projects is legitimate and the caller passes the id explicitly — what was
360
+ missing is that nobody could see it happen.
361
+
362
+ - **Dead supervisor heartbeats are swept.** A killed supervisor's file stopped
363
+ being counted but stayed forever, and the liveness check reads every file on
364
+ every drain. The sweep is limited to heartbeats: the crash log beside them
365
+ exists to explain the very supervisor that died.
366
+
367
+ - `workspace apply`'s description no longer claims a refusal it cannot always
368
+ make: the uncommitted-changes check needs a git repository. Outside one,
369
+ only the per-file check applies — a file the patch touches is still
370
+ refused, unrelated edits cannot be seen.
371
+
372
+ - A `timeout_ms` or token-cap warning no longer claims routing reads the
373
+ field. Routing does not; the range branch was fixed for this and the
374
+ not-a-number branch beside it was not.
375
+
376
+ - **Usage counters are no longer silently lost when two dispatches finish at
377
+ once.** `withFileLock` runs its critical section unlocked after a 2s
378
+ timeout — deliberate, and still right for the circuit breaker, which has
379
+ nothing to fall back on. It was wrong for the quota counters, which keep a
380
+ pending delta and clear it only on success: an unserialised write landed,
381
+ the delta was cleared as though serialised, and the process actually holding
382
+ the lock then overwrote the file with a value computed before that write
383
+ existed. Reproduced against the built artifact: five recorded calls gone,
384
+ no error. The counters now defer instead, and the delta lands on the next
385
+ result. A busy moment is no longer reported as "counters not reaching
386
+ disk" — they are delayed, not lost, and saying otherwise makes a working
387
+ system look broken.
388
+
389
+ - **A route knocked out by a 429 no longer reports `tripped: true,
390
+ failures: 0`.** `trip()` set the cooldown without counting the failure, so
391
+ `usage` — the surface an orchestrator is told to consult before delegating
392
+ — showed a contradiction that reads as a bookkeeping bug rather than a real
393
+ trip.
394
+
395
+ - **Discarding a workspace whose directory is already gone now clears the
396
+ registration git is still holding.** The early return skipped the block that
397
+ removes a worktree through git, so a workspace pruned by retention or
398
+ deleted by hand left `.git/worktrees/<name>` in the user's repo
399
+ permanently — the exact outcome `discardWorkspace`'s own documentation says
400
+ it exists to prevent. Reproduced: `git worktree list` still showing the path
401
+ as `prunable` after discard reported success.
402
+
403
+ - **An orphaned or cancelled job now hands back its partial work through the
404
+ tool an orchestrator actually calls.** The progress was on disk and
405
+ `getAsyncJob` read it, but `job_status` answered `output: ""`: orphaned and
406
+ cancelled count as terminal, and the poll response attached `partialOutput`
407
+ only on the NOT-terminal branch. So a commit titled "an orphaned job hands
408
+ back its progress" was correct in the module it edited and had no effect at
409
+ the surface a caller touches — verified at the function, not at the tool.
410
+ The crash path (a `failed` status with no result) was losing its output the
411
+ same way. The HTTP surface already salvaged this; MCP now matches it, and
412
+ the answer says the output is PARTIAL so salvage is not read as a result.
413
+
414
+ - **A gitignored file the agent wrote is no longer reported as applied while
415
+ being left behind.** Under `git_worktree`, the patch came from `git add -A
416
+ -N`, which obeys `.gitignore`, while the changed-file list came from a
417
+ filesystem fingerprint, which does not. A job that wrote a `.env` or any
418
+ ignored file got `applied: true` naming it, with the file absent from the
419
+ patch and from the project. It compounded: the already-applied guard needs
420
+ every recorded change present, so it never fired, and the next apply refused
421
+ with "changed since dispatch" — blaming the caller for the first apply's own
422
+ writes, the misleading refusal an earlier fix had removed. The paths already
423
+ recorded as changed are now force-added, which also makes `git_worktree`
424
+ agree with `copy`, whose per-file patch always carried them.
425
+
426
+ - **`clis: []` now isolates a config, which is what the previous release said
427
+ it did.** Authoritativeness keyed off a NON-EMPTY list, so the most explicit
428
+ way to write "no CLI routes" still loaded every harness on the machine —
429
+ the exact failure the change was written for, described in its own comment
430
+ in the past tense while remaining true. Presence of `clis:`/`endpoints:` is
431
+ now what makes a config authoritative, empty or not. A config that mentions
432
+ neither still auto-detects, unchanged.
433
+
434
+ - **An infinite or NaN route field is no longer accepted, and the warning
435
+ about it is no longer false.** The new range check covered negative values
436
+ but not `.inf`, `.nan`, or `1e999` (which YAML types as a string, and
437
+ `Number()` overflows to `Infinity`). Two paths were wrong: `1e999` produced
438
+ no warning at all, and `tier: -.inf, weight: .inf` loaded as
439
+ `-Infinity`/`Infinity` — ahead of every tier, above every score —
440
+ underneath a warning reading "IGNORED, and the built-in default applies
441
+ instead". A message asserting the opposite of what happened is worse than
442
+ the silence it replaced. Warnings also now name the value the operator
443
+ actually wrote (`JSON.stringify(Infinity)` is `null`, so they reported
444
+ `tier is null` for a file saying `-.inf`), and explain the right mechanism
445
+ per field rather than describing routing for `timeout_ms`.
446
+
447
+ - **`configure` no longer generates a config that fails its own `doctor`.**
448
+ It carried `disabled:` forward alongside the `clis:` it generates; in an
449
+ authoritative config that control does nothing, so `doctor` reported it had
450
+ no effect and exited 1. The disabled route is already absent from the
451
+ generated list, so the name was saying nothing. It is still emitted for a
452
+ generated config that lists no routes, where it does the work.
453
+
454
+ - **A `clis:` or `endpoints:` written as a mapping instead of a list now
455
+ says so.** The entries vanished, the config counted as defining no routes,
456
+ detection ran, and someone trying to name their own routes silently got
457
+ every installed paid harness instead — under a warning claiming their
458
+ config defined no routes, which contradicted the file in front of them.
459
+
460
+ - Legacy `services:` configs now get the same top-level key warnings as the
461
+ modern shape. That path returned before the check ran, so `policy: copy`
462
+ warned twice in one format and not at all in the other.
463
+
464
+ - **A negative `tier`, `weight` or `cli_capability` no longer hands a route
465
+ every dispatch.** These were type-checked but never range-checked, and
466
+ routing multiplies `weight` and `cli_capability` into the score while
467
+ ordering `tier` ascending — so a negative pair does not demote a route, it
468
+ promotes it past every legitimate one. An acceptance pass measured
469
+ `tier: -5, weight: -100, cli_capability: -3` scoring 299.8 against a normal
470
+ route's 0.88, from a tier sorting ahead of them all, with no warning
471
+ anywhere. Out-of-range values are now reported and ignored, so the built-in
472
+ default applies — the same outcome an unreadable value already got. Upper
473
+ bounds were deliberately not added: `cli_capability: 1.1` ships in this
474
+ repo's own default config as real tuning, and the defect is sign, not
475
+ magnitude.
476
+
477
+ - Top-level `policy:` and `workspace_policy:` now say they have no effect.
478
+ Both were allow-listed and read nowhere — and both ARE valid per-route keys,
479
+ which is what makes the top-level spelling easy to write: it looks like a
480
+ global default for the per-route setting, and there is no such default. An
481
+ isolation control that silently does nothing is the failure the config
482
+ validator exists to prevent. The per-route keys are unaffected.
483
+
484
+ - An `execute` task can no longer be routed to an HTTP endpoint. An endpoint
485
+ has no agent loop, no file access and no shell — PRODUCT.md states this as
486
+ design rather than gap — but an undeclared capability defaults to 1.0 and no
487
+ endpoint example declares any, so endpoints scored PERFECT for execute. An
488
+ acceptance pass measured a `--task-type execute` dispatch routed to an
489
+ endpoint, returning prose with exit 0: execution reported as succeeded when
490
+ none happened. It surfaced exactly when the CLI routes were busy or tripped,
491
+ which is the case a caller is least able to check.
492
+
493
+ Refused outright rather than scored low, on both counts deliberately: a score
494
+ of 0 still leaves a route selectable when it is the only candidate — the
495
+ failing case itself — and a declared capability must not override it, the
496
+ same rule as the safety-flag check. Endpoints remain full members of the mix
497
+ for plan, review and second opinions.
498
+
499
+ - An orphaned job now hands back the progress it saved. When a supervisor dies
500
+ there is no result, but its output is on disk in `stdout.partial.log` — and
501
+ the orphan branch returned above the code that reads it, so the response was
502
+ empty while the trail sat there. Chaining had the same gap, reporting "no
503
+ result available" for a job whose partial output was recoverable; it now
504
+ carries that output, labelled INCOMPLETE. PRODUCT.md names losing this trail
505
+ as the defining failure: "a wasted attempt with no trail".
506
+
507
+ - HTTP fanout arms are now job-backed, like the MCP surface has always been.
508
+ They called `routeTo` directly, so an arm's work existed only inside the
509
+ request — no job directory, no manifest, no partial log. Killing the client
510
+ or the server mid-fanout lost every arm's output with nothing on disk to
511
+ salvage, which is the failure PRODUCT.md names as defining, on one of two
512
+ surfaces.
513
+
514
+ The response shape is unchanged: arms are awaited and the same rows are
515
+ returned, so an OpenAI-compatible client sees exactly what it saw before.
516
+ Each row additionally carries the arm's `jobId`, which is what makes salvage
517
+ possible. This was briefly recorded here as deferred "because it changes what
518
+ a client receives" — that was wrong, and re-reading the MCP path showed why:
519
+ durability and the response contract are independent.
520
+
9
521
  ## [0.8.0] — 2026-08-31
10
522
 
11
523
  ### Added
@@ -1444,7 +1956,8 @@ the MCP surface to three tools: `dispatch`, `job_status`, `usage`.
1444
1956
  Known issues in this release, fixed in 0.5.0: `configure` writes resolved API keys into
1445
1957
  its output, and `configure --yes --force` can delete user-added harnesses.
1446
1958
 
1447
- [Unreleased]: https://github.com/fstubner/harness-dispatch/compare/v0.8.0...HEAD
1959
+ [Unreleased]: https://github.com/fstubner/harness-dispatch/compare/v0.9.0...HEAD
1960
+ [0.9.0]: https://github.com/fstubner/harness-dispatch/compare/v0.8.0...v0.9.0
1448
1961
  [0.8.0]: https://github.com/fstubner/harness-dispatch/compare/v0.7.9...v0.8.0
1449
1962
  [0.7.9]: https://github.com/fstubner/harness-dispatch/compare/v0.7.8...v0.7.9
1450
1963
  [0.7.8]: https://github.com/fstubner/harness-dispatch/compare/v0.7.7...v0.7.8
package/README.md CHANGED
@@ -66,15 +66,23 @@ harness-dispatch configure --yes
66
66
  harness-dispatch doctor --live
67
67
  ```
68
68
 
69
- `configure --yes` detects installed harnesses, writes `config.yaml`, and then
69
+ `configure --yes` detects installed harnesses, writes `config.yaml` into the
70
+ tool's own state directory (`~/.harness-dispatch/`, or `HARNESS_DISPATCH_STATE_DIR`),
71
+ and then
70
72
  offers to register this server with each MCP client it finds (Claude Code,
71
73
  Cursor) — showing you what it would write, and what is already there, before
72
74
  changing anything. `--no-clients` skips the offer and prints a snippet to paste
73
75
  instead; `harness-dispatch connect` does the same registration later on its own,
74
76
  and `connect --remove` undoes it. Without
75
- `--yes` configure previews and writes nothing. `doctor` then checks the whole chain:
77
+ `--yes` configure previews and writes nothing. Re-running it regenerates a file it
78
+ wrote and you have not edited, so installing a harness later is just `configure --yes`
79
+ again; a file you have changed is refused without `--force`, and because such a file
80
+ lists its own routes, even `--force` regenerates it from the file rather than from a
81
+ fresh detection (it says so; add `detect: true` to the file to merge new harnesses). `doctor` then checks the whole chain:
76
82
  binary, config load, harness detection, auth and billing classification, route
77
- readiness. `--live` goes further and routes one tiny real prompt, so you see a
83
+ readiness, and for a Codex route asks `codex login status` whether the CLI is
84
+ logged in (the other harnesses have no equivalent this tool has verified, so
85
+ their login state is not checked). `--live` goes further and routes one tiny real prompt, so you see a
78
86
  completion before wiring anything into your agent. The live probe never touches paid or
79
87
  unknown-billing routes unless you pass `--allow-paid`.
80
88
 
@@ -211,6 +219,8 @@ Adding a harness that isn't auto-detected — a second Codex route pinned to a s
211
219
  model, or a local/hosted OpenAI-compatible endpoint — is a few lines:
212
220
 
213
221
  ```yaml
222
+ detect: true # keep auto-detected harnesses as well; see below
223
+
214
224
  clis:
215
225
  - name: codex_sol
216
226
  harness: codex # picks the dispatcher: claude_code | codex | cursor | antigravity_cli | generic
@@ -224,6 +234,21 @@ endpoints:
224
234
  tier: 3
225
235
  ```
226
236
 
237
+ **`detect: true` is doing real work there.** A config that lists any `clis:` or
238
+ `endpoints:` is authoritative: it gets exactly the routes it names, and nothing is
239
+ auto-detected alongside them. Without that line, the snippet above does not *add*
240
+ `codex_sol` and `ollama` to your installed harnesses — it replaces them, and
241
+ `claude_code_cli`, `codex_cli`, `cursor_cli` and `antigravity_cli` are gone.
242
+
243
+ Which one you want depends on the goal:
244
+
245
+ | You want | Write |
246
+ |---|---|
247
+ | My routes *plus* whatever is installed | `detect: true` alongside your entries |
248
+ | Exactly the routes I list, nothing else | just the entries (the default) |
249
+ | Nothing but auto-detection, minus a route | no `clis:`/`endpoints:`, plus `disabled: [name]` |
250
+ | No routes at all | `detect: false` |
251
+
227
252
  See the shipped [`config.default.yaml`](config.default.yaml) for the full field
228
253
  reference (capability weights, tiers, escalation, workspace policy, and more) — copy
229
254
  it to your own `config.yaml` and edit, or run `harness-dispatch configure` to generate
@@ -387,7 +412,7 @@ version bump.
387
412
  | `cancel_job` | Stops work started by `dispatch` — a wrong turn, a wrong directory, a superseded run. A job still waiting for a slot stops outright; a running one tears down within about a second (poll `job_status` to see it land), killing the agent CLI and its children. Files it already changed are **not** reverted, and a cancelled run is not counted as a route failure. |
388
413
  | `retry_job` | Re-runs a finished job's task from its own record — same prompt (as the delegate saw it), files, working directory, hints and workspace policy. Pass `service` to send the retry to a different route, which is the usual reason to retry: the task was fine and the route was not — the original's model is left behind when the new route does not declare it, reported as `droppedModel`. Returns a new jobId; the original is untouched. |
389
414
  | `workspace` | For a job that ran with `workspacePolicy: "copy"` or `"git_worktree"`, the agent's changes live in an isolated workspace and were **never** applied to your project. `action: "diff"` returns the real patch; `"apply"` applies it (refusing when your project has uncommitted changes, since the patch was built against a clean base — `force: true` overrides); `"discard"` deletes the workspace. The full patch is always written to the job directory, so `git apply` by hand is available either way. |
390
- | `usage` | Per-route call counts, quota, billing kind, and breaker state — check this before passing an unfamiliar `hints.model`/`service`/`models` value, since those are not validated. Pass `listModels: <route id>` to fetch that `openai_compatible` route's live `GET /models` catalog instead of (or alongside) the summary. |
415
+ | `usage` | Per-route call counts, quota, billing kind, and breaker state — check this before passing an unfamiliar `hints.model`/`service`/`models` value. `service` and `models` are validated — an unknown route id is rejected, naming the valid ones — while `hints.model` is forwarded to the picked harness as-is, so a wrong model name fails at the harness instead. Pass `listModels: <route id>` to fetch that `openai_compatible` route's live `GET /models` catalog instead of (or alongside) the summary. |
391
416
 
392
417
  `workingDir` is effectively required when starting work: if you omit it, the task runs
393
418
  in the router server's own process directory instead of your project, and the response
@@ -11,7 +11,15 @@
11
11
  # actually installed. To customize, copy this file to config.yaml in your
12
12
  # own project and edit — or run `harness-dispatch configure` to generate a
13
13
  # starting point instead. Same schema, same parser, no special cases either
14
- # way. Add a new entry and it's a route; delete one and auto-detection may re-add it — use `disabled:` to suppress a route for good.
14
+ # way. Add a new entry and it's a route.
15
+ #
16
+ # A config that lists any `clis:` or `endpoints:` is AUTHORITATIVE: it gets
17
+ # exactly the routes it names, and nothing is auto-detected alongside them.
18
+ # So deleting an entry from your own config.yaml removes that route, full
19
+ # stop — `disabled:` is not needed for it, and has no effect in such a file.
20
+ # `disabled:` applies only to a config that names no routes of its own and is
21
+ # therefore still relying on auto-detection. Add `detect: true` if you want
22
+ # both: your entries AND whatever is installed.
15
23
  #
16
24
  # Route selection scores each entry by task fit:
17
25
  # capabilities[task_type] x leaderboard ELO x cli_capability x quota x weight
@@ -197,8 +205,16 @@ clis:
197
205
  surface: cursor_agent_cli
198
206
  auth_source: product_login
199
207
  billing_kind: included_usage_then_on_demand
200
- # Cursor hard-stops at the plan's included pool unless on-demand billing is
201
- # enabled in Settings -> Billing -> Spending set true here if you did.
208
+ # Cursor stops at the plan's included pool unless you have turned on
209
+ # on-demand usage in Settings -> Billing -> Spending. Set true here if you
210
+ # did — and note the second half, which the earlier wording ("hard-stops")
211
+ # missed: once on-demand IS on, overage is billed in ARREARS at the end of
212
+ # the cycle, and Cursor's own docs recommend setting a spend limit because
213
+ # without one there is no cap. So this route is the one subscription in the
214
+ # shipped set whose overage is not prepaid, which is why the `true` here is
215
+ # a bigger statement than it looks. Checked against Cursor's 2026 pricing
216
+ # documentation on 2026-08-31; NOT live-verified, because doing so means
217
+ # exhausting a real included pool.
202
218
  paid_usage_possible: false
203
219
  # Capability floor, per requested profile. cursor-agent's capability really
204
220
  # does differ by mode, so a single value cannot describe it: