harness-dispatch 0.4.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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +582 -0
  3. package/config.default.yaml +397 -0
  4. package/data/coding_benchmarks.json +431 -0
  5. package/dist/auth.d.ts +9 -0
  6. package/dist/auth.d.ts.map +1 -0
  7. package/dist/auth.js +73 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/billing.d.ts +7 -0
  10. package/dist/billing.d.ts.map +1 -0
  11. package/dist/billing.js +201 -0
  12. package/dist/billing.js.map +1 -0
  13. package/dist/bin.d.ts +6 -0
  14. package/dist/bin.d.ts.map +1 -0
  15. package/dist/bin.js +488 -0
  16. package/dist/bin.js.map +1 -0
  17. package/dist/breaker-store.d.ts +31 -0
  18. package/dist/breaker-store.d.ts.map +1 -0
  19. package/dist/breaker-store.js +103 -0
  20. package/dist/breaker-store.js.map +1 -0
  21. package/dist/circuit-breaker.d.ts +47 -0
  22. package/dist/circuit-breaker.d.ts.map +1 -0
  23. package/dist/circuit-breaker.js +102 -0
  24. package/dist/circuit-breaker.js.map +1 -0
  25. package/dist/config.d.ts +48 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +1248 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/dashboard/live.d.ts +49 -0
  30. package/dist/dashboard/live.d.ts.map +1 -0
  31. package/dist/dashboard/live.js +149 -0
  32. package/dist/dashboard/live.js.map +1 -0
  33. package/dist/dispatch-log.d.ts +46 -0
  34. package/dist/dispatch-log.d.ts.map +1 -0
  35. package/dist/dispatch-log.js +89 -0
  36. package/dist/dispatch-log.js.map +1 -0
  37. package/dist/dispatchers/base.d.ts +55 -0
  38. package/dist/dispatchers/base.d.ts.map +1 -0
  39. package/dist/dispatchers/base.js +80 -0
  40. package/dist/dispatchers/base.js.map +1 -0
  41. package/dist/dispatchers/generic-cli.d.ts +30 -0
  42. package/dist/dispatchers/generic-cli.d.ts.map +1 -0
  43. package/dist/dispatchers/generic-cli.js +471 -0
  44. package/dist/dispatchers/generic-cli.js.map +1 -0
  45. package/dist/dispatchers/openai-compatible.d.ts +57 -0
  46. package/dist/dispatchers/openai-compatible.d.ts.map +1 -0
  47. package/dist/dispatchers/openai-compatible.js +662 -0
  48. package/dist/dispatchers/openai-compatible.js.map +1 -0
  49. package/dist/dispatchers/shared/kill-tree.d.ts +21 -0
  50. package/dist/dispatchers/shared/kill-tree.d.ts.map +1 -0
  51. package/dist/dispatchers/shared/kill-tree.js +35 -0
  52. package/dist/dispatchers/shared/kill-tree.js.map +1 -0
  53. package/dist/dispatchers/shared/rate-limit-headers.d.ts +22 -0
  54. package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -0
  55. package/dist/dispatchers/shared/rate-limit-headers.js +124 -0
  56. package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -0
  57. package/dist/dispatchers/shared/stream-subprocess.d.ts +53 -0
  58. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -0
  59. package/dist/dispatchers/shared/stream-subprocess.js +274 -0
  60. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -0
  61. package/dist/dispatchers/shared/subprocess.d.ts +25 -0
  62. package/dist/dispatchers/shared/subprocess.d.ts.map +1 -0
  63. package/dist/dispatchers/shared/subprocess.js +110 -0
  64. package/dist/dispatchers/shared/subprocess.js.map +1 -0
  65. package/dist/dispatchers/shared/which-available.d.ts +2 -0
  66. package/dist/dispatchers/shared/which-available.d.ts.map +1 -0
  67. package/dist/dispatchers/shared/which-available.js +8 -0
  68. package/dist/dispatchers/shared/which-available.js.map +1 -0
  69. package/dist/dispatchers/shared/windows-cmd.d.ts +38 -0
  70. package/dist/dispatchers/shared/windows-cmd.d.ts.map +1 -0
  71. package/dist/dispatchers/shared/windows-cmd.js +91 -0
  72. package/dist/dispatchers/shared/windows-cmd.js.map +1 -0
  73. package/dist/http/server.d.ts +15 -0
  74. package/dist/http/server.d.ts.map +1 -0
  75. package/dist/http/server.js +495 -0
  76. package/dist/http/server.js.map +1 -0
  77. package/dist/index.d.ts +27 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +29 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/job-runner.d.ts +19 -0
  82. package/dist/job-runner.d.ts.map +1 -0
  83. package/dist/job-runner.js +42 -0
  84. package/dist/job-runner.js.map +1 -0
  85. package/dist/jobs.d.ts +116 -0
  86. package/dist/jobs.d.ts.map +1 -0
  87. package/dist/jobs.js +542 -0
  88. package/dist/jobs.js.map +1 -0
  89. package/dist/leaderboard.d.ts +62 -0
  90. package/dist/leaderboard.d.ts.map +1 -0
  91. package/dist/leaderboard.js +287 -0
  92. package/dist/leaderboard.js.map +1 -0
  93. package/dist/mcp/config-hot-reload.d.ts +64 -0
  94. package/dist/mcp/config-hot-reload.d.ts.map +1 -0
  95. package/dist/mcp/config-hot-reload.js +144 -0
  96. package/dist/mcp/config-hot-reload.js.map +1 -0
  97. package/dist/mcp/dispatcher-factory.d.ts +20 -0
  98. package/dist/mcp/dispatcher-factory.d.ts.map +1 -0
  99. package/dist/mcp/dispatcher-factory.js +30 -0
  100. package/dist/mcp/dispatcher-factory.js.map +1 -0
  101. package/dist/mcp/resources.d.ts +8 -0
  102. package/dist/mcp/resources.d.ts.map +1 -0
  103. package/dist/mcp/resources.js +45 -0
  104. package/dist/mcp/resources.js.map +1 -0
  105. package/dist/mcp/server.d.ts +36 -0
  106. package/dist/mcp/server.d.ts.map +1 -0
  107. package/dist/mcp/server.js +80 -0
  108. package/dist/mcp/server.js.map +1 -0
  109. package/dist/mcp/tools.d.ts +171 -0
  110. package/dist/mcp/tools.d.ts.map +1 -0
  111. package/dist/mcp/tools.js +658 -0
  112. package/dist/mcp/tools.js.map +1 -0
  113. package/dist/observability/index.d.ts +52 -0
  114. package/dist/observability/index.d.ts.map +1 -0
  115. package/dist/observability/index.js +111 -0
  116. package/dist/observability/index.js.map +1 -0
  117. package/dist/observability/spans.d.ts +47 -0
  118. package/dist/observability/spans.d.ts.map +1 -0
  119. package/dist/observability/spans.js +72 -0
  120. package/dist/observability/spans.js.map +1 -0
  121. package/dist/quota.d.ts +91 -0
  122. package/dist/quota.d.ts.map +1 -0
  123. package/dist/quota.js +331 -0
  124. package/dist/quota.js.map +1 -0
  125. package/dist/route-policy.d.ts +24 -0
  126. package/dist/route-policy.d.ts.map +1 -0
  127. package/dist/route-policy.js +92 -0
  128. package/dist/route-policy.js.map +1 -0
  129. package/dist/router.d.ts +162 -0
  130. package/dist/router.d.ts.map +1 -0
  131. package/dist/router.js +878 -0
  132. package/dist/router.js.map +1 -0
  133. package/dist/safety.d.ts +7 -0
  134. package/dist/safety.d.ts.map +1 -0
  135. package/dist/safety.js +33 -0
  136. package/dist/safety.js.map +1 -0
  137. package/dist/status.d.ts +100 -0
  138. package/dist/status.d.ts.map +1 -0
  139. package/dist/status.js +228 -0
  140. package/dist/status.js.map +1 -0
  141. package/dist/types.d.ts +402 -0
  142. package/dist/types.d.ts.map +1 -0
  143. package/dist/types.js +9 -0
  144. package/dist/types.js.map +1 -0
  145. package/dist/version.d.ts +2 -0
  146. package/dist/version.d.ts.map +1 -0
  147. package/dist/version.js +13 -0
  148. package/dist/version.js.map +1 -0
  149. package/dist/working-dir.d.ts +15 -0
  150. package/dist/working-dir.d.ts.map +1 -0
  151. package/dist/working-dir.js +22 -0
  152. package/dist/working-dir.js.map +1 -0
  153. package/dist/workspaces.d.ts +19 -0
  154. package/dist/workspaces.d.ts.map +1 -0
  155. package/dist/workspaces.js +352 -0
  156. package/dist/workspaces.js.map +1 -0
  157. package/package.json +96 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felix Stubner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,582 @@
1
+ # harness-dispatch
2
+
3
+ Delegate coding tasks to the agent CLIs you already pay for. harness-dispatch is a
4
+ local MCP server that turns the coding harnesses on your machine (Claude Code,
5
+ Codex, Cursor Agent, Antigravity CLI) and any local or remote API endpoint into
6
+ tools any AI can call — routing each task to the best-fit agent, spending the
7
+ flat-rate subscription quota you already own first. Routes with no billing backstop
8
+ at all — a raw metered API key, or unknown billing — stay blocked until you opt in.
9
+
10
+ Exposing harnesses as explicit tools is more reliable than telling an agent to shell
11
+ out to another CLI: models are trained on tool calling, so they actually use tools
12
+ they are given. And it routes whole tasks, not API requests — each harness keeps its
13
+ own scaffolding, test loop, and codebase context; there's no proxy in between. Long
14
+ tasks run as async jobs: start one, get an id back immediately, check for partial
15
+ output, collect the result when it finishes. Runs execute in a detached runner
16
+ process, so nothing is lost to a client timeout — or to the server itself
17
+ restarting mid-run.
18
+ (There is no Gemini CLI dispatcher; Google discontinued that CLI's backend in
19
+ mid-2026, and Antigravity CLI is its replacement.)
20
+
21
+ **What this does on your machine**, stated plainly before you install it: it spawns the
22
+ CLI subprocesses above with your prompts, which can read and write files under the
23
+ `workingDir` you pass it (this is the point — it's a coding agent router) and, depending
24
+ on the workspace/safety policy in effect, run shell commands via those CLIs. Running
25
+ `serve` additionally binds a local HTTP port (loopback by default, bearer-token gated —
26
+ see [HTTP Surface](#http-surface) before pointing `--host` anywhere else). Nothing here
27
+ is unusual for a coding-agent tool, but it's worth having in one place rather than
28
+ inferred from separate sections.
29
+
30
+ **A configured harness runs automatically** — nothing extra to switch on. If that
31
+ harness's account has paid/overage billing enabled on the *provider's* side (Cursor's
32
+ on-demand billing, Claude's usage credits, Codex's flexible credits, a raw metered API
33
+ key, etc.), harness-dispatch will use it too — it does not detect or prevent provider-side
34
+ billing state. It only blocks a route by default when there's no provider-side backstop
35
+ at all (a raw metered API key, or unknown billing), until you set `allow_paid_usage: true`
36
+ on it. See [Adding a harness](#adding-a-harness) for the config, and `status --json` /
37
+ `status`'s `note:` lines for a given route's billing classification.
38
+
39
+ ## Requirements
40
+
41
+ - Node.js `>=24.15.0`
42
+ - At least one configured harness or OpenAI-compatible endpoint
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ npm install -g harness-dispatch
48
+ harness-dispatch configure --yes
49
+ harness-dispatch doctor --live
50
+ ```
51
+
52
+ `configure --yes` detects installed harnesses and writes `config.yaml`.
53
+ Without `--yes` it only previews what it would detect and writes nothing —
54
+ useful to check first, but not a substitute for the real run above. `doctor`
55
+ then verifies the install end-to-end: binary + config load, harness
56
+ detection, auth/billing classification, and route readiness. `--live` goes one
57
+ step further and routes a single tiny prompt through the best eligible route so
58
+ you see a real completion before wiring the server into your agent. The live
59
+ probe respects billing policy — it never touches paid or unknown-billing routes
60
+ unless you pass `--allow-paid`.
61
+
62
+ Your Claude Code / Codex / Cursor subscriptions run by default, no opt-in needed
63
+ — `configure`'s output tells you if anything's blocked and why. See
64
+ [Adding a harness](#adding-a-harness) below for the config and the paid-usage note.
65
+
66
+ > This project was renamed from `harness-router` to `harness-dispatch` — the npm
67
+ > package, CLI command, env var prefix (`HARNESS_DISPATCH_*`), and MCP resource URIs
68
+ > (`harness-dispatch://status`) all changed together. If you have an older install,
69
+ > `npm uninstall -g harness-router && npm install -g harness-dispatch` and update any
70
+ > `mcpServers`/`claude_desktop_config.json` entry to invoke `harness-dispatch`, not the
71
+ > old command. Two older packages predate this rename and are no longer maintained:
72
+ > `harness-router` (`0.3.2` on the registry) and the separately-published
73
+ > `harness-router-mcp` (`0.2.0`) — both lack `usage`, `/v1/models`, `/v1/usage`,
74
+ > Antigravity support, and every fix described in this README. Note that `npx -y
75
+ > harness-dispatch` (used by the plugin's fallback launch path, see
76
+ > [plugin/README.md](./plugin/README.md)) resolves to whatever is currently on the npm
77
+ > registry, which can lag behind a local clone's `dist/`. Run `npm ls -g harness-dispatch`
78
+ > to check which version is actually installed.
79
+
80
+ You can also run without a global install:
81
+
82
+ ```bash
83
+ npx harness-dispatch configure
84
+ ```
85
+
86
+ ### Plugin install (Claude Code / Claude Desktop / Codex)
87
+
88
+ The `plugin/` directory packages the MCP server plus a delegation skill and
89
+ `/route` + `/jobs` commands for one-step installs — see
90
+ [plugin/README.md](./plugin/README.md). Claude Code:
91
+ `/plugin marketplace add <repo path or URL>` then
92
+ `/plugin install harness-dispatch@harness-dispatch`. Codex:
93
+ `node plugin/scripts/install-codex.mjs`.
94
+
95
+ ## Adding a harness
96
+
97
+ `config.yaml` is entirely optional. There is no separate hidden defaults format —
98
+ harness-dispatch ships with its own [`config.default.yaml`](config.default.yaml), the
99
+ same shape you'd write yourself, and reads it as its built-in config. With no
100
+ `config.yaml` of your own, the shipped one is filtered down to whichever of `claude`,
101
+ `codex`, `agy` (Antigravity), and `cursor-agent` are on your PATH:
102
+
103
+ ```yaml
104
+ # config.yaml can be empty, or not exist at all.
105
+ ```
106
+
107
+ Adding a harness that isn't auto-detected — a second Codex route pinned to a specific
108
+ model, or a local/hosted OpenAI-compatible endpoint — is a few lines:
109
+
110
+ ```yaml
111
+ clis:
112
+ - name: codex_sol
113
+ harness: codex # picks the dispatcher: claude_code | codex | cursor | antigravity_cli | generic
114
+ model: gpt-5.6-sol
115
+ tier: 1
116
+
117
+ endpoints:
118
+ - name: ollama
119
+ base_url: http://localhost:11434/v1
120
+ model: qwen2.5-coder
121
+ tier: 3
122
+ ```
123
+
124
+ See the shipped [`config.default.yaml`](config.default.yaml) for the full field
125
+ reference (capability weights, tiers, escalation, workspace policy, and more) — copy
126
+ it to your own `config.yaml` and edit, or run `harness-dispatch configure` to generate
127
+ a starting point.
128
+
129
+ **A wholly new CLI harness — one of the 4 built in isn't it — needs no new code
130
+ either.** `harness: generic` takes a `protocol:` block instead of reusing one of the
131
+ 4 built-in harnesses' flag/output conventions. `protocol.args` is a literal
132
+ command-line argument list, written the same way you'd type it by hand — a handful of
133
+ reserved `{{name}}` tokens are substituted (or expanded to zero or more real tokens) at
134
+ dispatch time; everything else passes through verbatim:
135
+
136
+ ```yaml
137
+ clis:
138
+ - name: my_custom_cli
139
+ harness: generic
140
+ command: my-cli # the binary, resolved on PATH like any other
141
+ tier: 3
142
+ protocol:
143
+ args: ["-p", "{{prompt}}", "{{working_dir}}", "{{model}}", "{{safety}}", "--json"]
144
+ working_dir: { flag: "--cd" } # omit {{working_dir}}/this to rely on process cwd alone
145
+ model: { flag: "--model" } # omit {{model}}/this if the CLI has no model override
146
+ safety: # args per requested safety profile, via {{safety}}
147
+ read_only: ["--mode", "plan"]
148
+ workspace_edit: ["--mode", "accept-edits"]
149
+ full_auto: ["--dangerously-skip-permissions"]
150
+ output:
151
+ mode: json_field # text | json_field | jsonl_stream
152
+ fields: [result, output, text] # checked in order; dotted paths work ("message.content")
153
+ ```
154
+
155
+ The full token reference:
156
+
157
+ | Token | Expands to |
158
+ | --- | --- |
159
+ | `{{prompt}}` | the prompt text (one token) — omitted entirely if `stdin: true` |
160
+ | `{{model}}` | `[model.flag, value]` if a model is set, else nothing |
161
+ | `{{safety}}` | `safety[requested profile]` — zero or more tokens |
162
+ | `{{working_dir}}` | `[working_dir.flag, dir, ...working_dir.extra_args_when_set]` if set, else nothing |
163
+ | `{{file_dirs}}` | `[file_dirs.flag, dir]` repeated once per included file's directory |
164
+ | `{{native_args}}` | `endpoint_native_args[endpoint_provider]`, only under `endpoint_mode: harness_native_endpoint` |
165
+
166
+ **Protocols are named and selectable, not just inline.** `claude_code`, `codex`,
167
+ `cursor`, and `antigravity_cli` are registered presets — every entry's `harness:` value
168
+ in the shipped [`config.default.yaml`](config.default.yaml)'s `clis:` list is
169
+ automatically selectable as a preset name. Reference one by name instead of retyping it:
170
+
171
+ ```yaml
172
+ clis:
173
+ - name: my_cursor_fork
174
+ harness: generic
175
+ command: my-cursor-fork-cli # a different binary that happens to share Cursor's CLI shape
176
+ protocol: cursor
177
+ ```
178
+
179
+ Or start from a preset and override just what differs, for the common "95% the
180
+ same, one flag different" case — `safety` merges per-profile (overriding just
181
+ `full_auto` doesn't erase `read_only`/`workspace_edit` from the preset):
182
+
183
+ ```yaml
184
+ clis:
185
+ - name: my_codex_fork
186
+ harness: generic
187
+ command: my-codex-fork-cli
188
+ protocol:
189
+ extends: codex
190
+ model: { flag: "--llm-model" } # only this differs from the codex preset
191
+ safety:
192
+ full_auto: ["--yolo"] # only this profile's args are replaced
193
+ ```
194
+
195
+ A built-in route's own `protocol:` (under `overrides.claude_code_cli`, etc.) accepts a
196
+ preset name or `extends:` too — it's parsed through the exact same code path as any
197
+ other route.
198
+
199
+ The `harness: claude_code | codex | cursor | antigravity_cli` routes aren't special
200
+ either — there is no per-harness dispatcher class or hardcoded TypeScript data for any
201
+ of them in this codebase. All 4 are ordinary `clis:` entries in the shipped
202
+ [`config.default.yaml`](config.default.yaml) — not a separate "defaults registry" in
203
+ some other format, loaded through the exact same parser as your own `config.yaml`,
204
+ covering each CLI's real flags including Codex's mid-run tool_use/thinking/usage
205
+ streaming events via `event_rules` (see below). Every CLI-type route — built-in or
206
+ user-added — runs through the one `GenericCliDispatcher` interpreter. Copy an entry
207
+ from the shipped file into your own `config.yaml` and edit it directly (or add a
208
+ `protocol:` block under `overrides.claude_code_cli`, etc.) and it replaces the default
209
+ entirely — nothing about the 4 built-ins is more hardcoded than a route you add
210
+ yourself.
211
+
212
+ For a CLI whose events don't fit `text`/`json_field`'s single-parse-at-exit model —
213
+ mid-run tool_use/thinking surfacing, token-usage aggregation across lines — use
214
+ `output.mode: jsonl_stream` with `output.event_rules`:
215
+
216
+ ```yaml
217
+ output:
218
+ mode: jsonl_stream
219
+ event_rules:
220
+ - when: { type: "message" } # every listed field must match this line
221
+ emit: text
222
+ text_field: message.content # dotted paths work
223
+ - when: { "item.type": "tool_use" }
224
+ emit: tool_use
225
+ name_field: item.name
226
+ input_field: item.input
227
+ - when: { type: "thinking" }
228
+ emit: thinking
229
+ chunk_field: item.text
230
+ - when: {} # omit `when` (or leave it empty) to match every line
231
+ emit: usage
232
+ input_token_fields: [usage.input_tokens, usage.prompt_tokens] # first present wins
233
+ output_token_fields: [usage.output_tokens, usage.completion_tokens]
234
+ ```
235
+
236
+ Other fields worth knowing: `file_dirs: { flag: ... }` (paired with `{{file_dirs}}`)
237
+ repeats a flag once per unique file directory (Antigravity's `--add-dir`);
238
+ `api_key_env_var` injects `api_key` under a named env var for the child process (and
239
+ clears it if ambient but unconfigured, so a stray key never leaks into a
240
+ subscription-auth call); `success_requires_output: false` switches from the default
241
+ strict contract (exit 0 AND a non-empty parsed field) to the lenient one Claude
242
+ Code/Codex/Antigravity use (exit 0 alone, falling back to raw stdout/stderr text when
243
+ parsing yields nothing). Billing for a `generic` route defaults to `unknown` (blocked
244
+ until you classify it — there's no way to know an arbitrary CLI's real billing model) —
245
+ set `billing_kind:` / `paid_usage_possible:` explicitly once you know it.
246
+
247
+ ## CLI
248
+
249
+ ```bash
250
+ harness-dispatch # stdio MCP
251
+ harness-dispatch configure # detect harnesses and prepare config
252
+ harness-dispatch configure --print # inspect generated config YAML
253
+ harness-dispatch doctor # validate install, auth, config, and routes
254
+ harness-dispatch doctor --live # run one eligible live routed probe
255
+ harness-dispatch doctor --live --allow-paid
256
+ harness-dispatch status # readable route readiness
257
+ harness-dispatch status --json # structured route metadata
258
+ harness-dispatch status --watch # live status refresh
259
+ harness-dispatch usage # per-route call counts, quota, billing kind
260
+ harness-dispatch usage --json # structured usage metadata
261
+ harness-dispatch serve --port 3333 # /mcp and /v1/* over local HTTP
262
+ harness-dispatch auth show # print HTTP bearer token
263
+ harness-dispatch auth rotate # rotate HTTP bearer token
264
+ ```
265
+
266
+ Hidden compatibility aliases currently map old alpha commands to the new surface:
267
+ `dashboard` and `list-services` map to `status`, and `mcp --http <port>` maps to `serve`.
268
+ They are not part of the public v0.4.0 vocabulary.
269
+
270
+ ## MCP Surface
271
+
272
+ `tools/list` returns three tools:
273
+
274
+ | Tool | Purpose |
275
+ | --- | --- |
276
+ | `dispatch` | Always starts new routed coding work — one task to the best-fit harness, or a fanout to several for independent opinions. Every call runs as a background job from the first moment: a fast task returns its full result inline (`completed: true`), a slow one returns `completed: false` plus a `jobId` to check on. Nothing is ever lost to a timeout — including the MCP call's own. |
277
+ | `job_status` | Checks work started by `dispatch`. Pass the `jobId` it returned to get a `partialOutput` tail while running and the full `result` once done; omit `jobId` to list recent background dispatches (compact, newest first). |
278
+ | `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. |
279
+
280
+ `workingDir` is effectively required when starting work: if you omit it, the task runs
281
+ in the router server's own process directory instead of your project, and the response
282
+ carries a `warning` field saying so.
283
+
284
+ **How the grace window works.** `dispatch` starts the task as a background job
285
+ immediately, then waits up to `graceSeconds` (default 25) for it to finish. Within the
286
+ window you get the complete result inline, exactly as if the call had blocked. Past it
287
+ you get the `jobId` — call `job_status` with that `jobId` to see a `partialOutput` tail
288
+ while it runs and the full `result` once `completed`. Because the run never depends on
289
+ the MCP call staying open, a client-side timeout costs you the inline reply, never the
290
+ work. Background runs default to a generous 60-minute ceiling meant only to catch a
291
+ genuinely hung process (stuck waiting on input, a stalled network call), not to cap
292
+ normal work — raise it per call with `hints.timeoutMs` (milliseconds), or set a
293
+ permanent per-route default with `timeout_ms:` in that service's config entry.
294
+ Precedence is `hints.timeoutMs` > the service's `timeout_ms` > the 60-minute default.
295
+
296
+ Starting a task:
297
+
298
+ ```json
299
+ {
300
+ "prompt": "Review this package for release blockers.",
301
+ "files": [],
302
+ "workingDir": "/path/to/project",
303
+ "workspacePolicy": "shared_locked",
304
+ "hints": {
305
+ "model": "gpt-5.4",
306
+ "taskType": "review",
307
+ "preferLargeContext": false,
308
+ "safetyProfile": "workspace_edit"
309
+ }
310
+ }
311
+ ```
312
+
313
+ For fanout (each route that outlives the grace window returns its own `jobId`):
314
+
315
+ ```json
316
+ {
317
+ "mode": "fanout",
318
+ "prompt": "Compare the maintainability tradeoffs in this refactor.",
319
+ "models": ["claude-opus-4-6", "gpt-5.4"],
320
+ "workspacePolicy": "copy",
321
+ "hints": {
322
+ "taskType": "plan",
323
+ "safetyProfile": "workspace_edit"
324
+ }
325
+ }
326
+ ```
327
+
328
+ Checking and listing (`job_status`): `{"jobId": "job-..."}` returns status plus
329
+ `partialOutput` or the final `result`; `{}` (no `jobId`) returns every known background
330
+ dispatch. On `dispatch`, force pure async with `"graceSeconds": 0`, or force a specific
331
+ backend with a top-level `"service"` (single mode only). Nothing is lost by checking
332
+ late — everything persists under `~/.harness-dispatch/jobs/<jobId>/`.
333
+
334
+ Status is exposed as resources:
335
+
336
+ - `harness-dispatch://status`
337
+ - `harness-dispatch://status.json`
338
+
339
+ ## HTTP Surface
340
+
341
+ `harness-dispatch serve` starts an authenticated local server on `127.0.0.1`.
342
+
343
+ Endpoints:
344
+
345
+ - `POST /mcp` for streamable HTTP MCP
346
+ - `GET /v1/status` — full route/quota/billing/breaker detail (same shape as
347
+ `harness-dispatch://status.json`)
348
+ - `GET /v1/usage` — per-route call counts, quota, billing kind, and breaker state only
349
+ - `GET /v1/models` — OpenAI-style model list; each entry's `id` is a route id you can
350
+ pass as `model` in `/v1/chat/completions`
351
+ - `POST /v1/chat/completions`
352
+
353
+ HTTP uses the bearer token from `harness-dispatch auth show`. The same token protects
354
+ MCP-over-HTTP and `/v1/*`.
355
+
356
+ `--host <host>` overrides the default `127.0.0.1` bind address if you need to reach the
357
+ server from another machine. Only pass a non-loopback host if you actually mean to —
358
+ this exposes a bearer-token-gated server, and everything the dispatched harness can do
359
+ (spawn CLIs, read/write files in `workingDir`), to your network. `serve` prints a
360
+ warning to stderr when it detects this so it isn't silent.
361
+
362
+ Example:
363
+
364
+ ```bash
365
+ TOKEN="$(harness-dispatch auth show)"
366
+
367
+ curl http://127.0.0.1:3333/v1/chat/completions \
368
+ -H "authorization: Bearer $TOKEN" \
369
+ -H "content-type: application/json" \
370
+ -d '{
371
+ "model": "gpt-5.4",
372
+ "messages": [{"role": "user", "content": "Fix the failing tests."}],
373
+ "workingDir": "/path/to/project",
374
+ "safetyProfile": "workspace_edit",
375
+ "workspacePolicy": "copy"
376
+ }'
377
+ ```
378
+
379
+ The REST surface is OpenAI-compatible enough for local clients that can speak
380
+ `/v1/chat/completions`. The `model` field is treated as a routing/model hint.
381
+
382
+ ## Endpoint Modes
383
+
384
+ Harness Router supports two local/custom endpoint patterns:
385
+
386
+ - `direct_openai_compatible`: Harness Router calls an OpenAI-compatible
387
+ `/v1/chat/completions` endpoint directly. This is the right mode for Ollama,
388
+ LM Studio, vLLM, LiteLLM, and private local HTTP model servers.
389
+ - `harness_native_endpoint`: a downstream CLI keeps its agent scaffold but is
390
+ pointed at a supported local provider. Codex currently supports this for
391
+ `ollama` and `lmstudio` through `--oss --local-provider`.
392
+
393
+ Example direct local route:
394
+
395
+ ```yaml
396
+ endpoints:
397
+ - name: ollama
398
+ base_url: http://localhost:11434/v1
399
+ model: qwen2.5-coder
400
+ endpoint_mode: direct_openai_compatible
401
+ endpoint_provider: ollama
402
+ wire_protocol: openai_chat_completions
403
+ ```
404
+
405
+ Example Codex harness-native local route:
406
+
407
+ ```yaml
408
+ services:
409
+ codex_ollama:
410
+ enabled: true
411
+ type: cli
412
+ harness: codex
413
+ command: codex
414
+ model: qwen3-coder:latest
415
+ endpoint_mode: harness_native_endpoint
416
+ endpoint_provider: ollama
417
+ wire_protocol: openai_chat_completions
418
+ billing_kind: local_compute
419
+ paid_usage_possible: false
420
+ tier: 3
421
+ weight: 0.75
422
+ cli_capability: 1.0
423
+ timeout_ms: 900000 # optional; overrides the dispatcher's default (10 min for CLIs)
424
+ capabilities:
425
+ execute: 0.8
426
+ plan: 0.7
427
+ review: 0.7
428
+ ```
429
+
430
+ ## Configure
431
+
432
+ `configure` is the main setup flow:
433
+
434
+ 1. Detect installed harnesses.
435
+ 2. Verify configured routes without spending quota where possible.
436
+ 3. Classify auth and billing so paid or unknown-paid routes are not selected by accident.
437
+ 4. Choose routed harnesses, model priority, and safety profile.
438
+ 5. Write v0.4 config YAML.
439
+ 6. Connect selected MCP agents or print snippets.
440
+
441
+ The current command is conservative: it prints detected routes by default and writes
442
+ only when explicitly asked with `--yes`.
443
+
444
+ ## Status Model
445
+
446
+ `status --json`, `/v1/status`, and `harness-dispatch://status.json` share the same
447
+ shape. Each route includes:
448
+
449
+ - route id and harness
450
+ - billing provider, surface, auth source, billing kind, paid-use flags, and confidence
451
+ - configured and effective safety profile
452
+ - effective workspace policy
453
+ - availability
454
+ - tier and model metadata
455
+ - quota score and local call count
456
+ - circuit breaker state — a tripped route's remaining cooldown is persisted to disk
457
+ (`~/.harness-dispatch/breaker_state.json`), so a server restart mid-cooldown still
458
+ excludes that route instead of retrying an exhausted one with a clean slate
459
+ - skip reason when a route is disabled, unavailable, paid-blocked, unknown-billing,
460
+ safety-incompatible, or circuit-broken
461
+ - token limits when known
462
+
463
+ Safety profiles:
464
+
465
+ - `read_only`: inspect-only routes.
466
+ - `workspace_edit`: default; routes may edit files in the workspace without broad shell access.
467
+ - `full_auto`: permits routes that require shell/write automation beyond workspace-edit mode.
468
+
469
+ Workspace policy:
470
+
471
+ - `shared`: run directly in the caller's `workingDir`.
472
+ - `shared_locked`: run directly in `workingDir`, but serialize write-capable
473
+ dispatches for the same directory inside one router process.
474
+ - `copy`: copy the project into `.harness-dispatch/workspaces/...`, run the agent
475
+ there, and return the isolated workspace path plus changed-file metadata.
476
+ - `git_worktree`: create a detached git worktree for the route and return the
477
+ worktree path plus changed-file metadata. This starts from `HEAD`, so
478
+ uncommitted source-workspace changes are not copied.
479
+
480
+ Write-capable fanout is allowed only with `workspacePolicy: "copy"` or
481
+ `workspacePolicy: "git_worktree"`. These modes isolate project state and process
482
+ cwd. They are not hardened OS sandboxes: a route with broad shell permission can
483
+ still access the host unless the downstream harness or operating system enforces
484
+ that boundary.
485
+
486
+ Provider notes:
487
+
488
+ - Claude Code `claude -p` is treated date-aware: before June 15, 2026 it is classified
489
+ as plan usage; from June 15, 2026 it is classified as Agent SDK credits with possible
490
+ overage.
491
+ - Codex CLI/SDK uses the official Codex product surface unless a route is explicitly
492
+ configured with an API key, in which case it is API billing.
493
+ - Cursor Agent CLI is classified as included usage with possible on-demand continuation.
494
+ - OpenAI-compatible `api.openai.com` routes are metered; known local runtimes are local;
495
+ unknown loopback/custom endpoints require explicit billing metadata.
496
+
497
+ ## Observability & Privacy
498
+
499
+ harness-dispatch contains **no phone-home telemetry** — nothing is ever sent to
500
+ the author or any third party. OpenTelemetry tracing is available for your own
501
+ use, but **it is off by default** — nothing OpenTelemetry-related initializes
502
+ unless you opt in:
503
+
504
+ - Enable it with `telemetry: { enabled: true }` in `config.yaml`, or the
505
+ `HARNESS_DISPATCH_TELEMETRY=1` env var.
506
+ - Once enabled, traces export via OTLP/HTTP to `http://localhost:4318` (the
507
+ standard local collector port) by default. If nothing is listening there,
508
+ spans are simply dropped — no data leaves your machine.
509
+ - Traces only go somewhere else if *you* set `OTEL_EXPORTER_OTLP_ENDPOINT` to
510
+ a remote collector.
511
+ - `OTEL_SDK_DISABLED=true` forces initialization off even if `telemetry:` is
512
+ enabled in config.
513
+
514
+ Every dispatch also appends one JSONL line to a local
515
+ dispatch log at `~/.harness-dispatch/logs/dispatches.jsonl` (override the
516
+ directory with `HARNESS_DISPATCH_LOG_DIR`) — route, success, duration, token
517
+ counts, and a capped error string, for post-hoc debugging. It's local-only,
518
+ size-capped via single-file rotation, and never sent anywhere. Job artifacts
519
+ (prompt, snapshotted files, stdout/stderr, result) live under
520
+ `~/.harness-dispatch/jobs/<jobId>/` and are pruned after 7 days of inactivity by
521
+ default — set `retention: { jobs_days: N }` in `config.yaml` (or
522
+ `HARNESS_DISPATCH_JOB_MAX_AGE_MS` for a millisecond override) to change that
523
+ window.
524
+
525
+ Prompts and outputs otherwise flow only to the harnesses/endpoints you
526
+ configured. The router's only network call by default is the leaderboard
527
+ refresh — a GET of public Arena ELO benchmark data from `api.wulong.dev` used
528
+ for route scoring; it sends nothing about you or your prompts.
529
+
530
+ ## Development
531
+
532
+ ```bash
533
+ npm ci
534
+ npm run typecheck
535
+ npm test
536
+ npm run build
537
+ npm run smoke
538
+ npm audit --omit=dev
539
+ npm pack --dry-run
540
+ ```
541
+
542
+ Live agent workflow smoke tests are opt-in because they call real harnesses and
543
+ can consume quota or product-plan usage. They create a disposable tiny Node
544
+ project under `.harness-dispatch/smoke-workspaces`, write the detailed task into a
545
+ workspace-local `.harness-dispatch/agent-task.md`, send the harness a short prompt
546
+ pointing at that brief, then verify `node test.mjs` passes.
547
+
548
+ ```powershell
549
+ $env:HARNESS_DISPATCH_LIVE_AGENT_SMOKE = '1'
550
+ npm run build
551
+ npm run smoke:agents -- --config config.yaml
552
+ ```
553
+
554
+ To temporarily include routes that can incur paid usage:
555
+
556
+ ```powershell
557
+ $env:HARNESS_DISPATCH_LIVE_AGENT_SMOKE = '1'
558
+ npm run smoke:agents -- --config config.yaml --allow-paid
559
+ ```
560
+
561
+ To include Cursor's full-auto print-mode route:
562
+
563
+ ```powershell
564
+ $env:HARNESS_DISPATCH_LIVE_AGENT_SMOKE = '1'
565
+ npm run smoke:agents -- --config config.yaml --allow-paid --safety full_auto
566
+ ```
567
+
568
+ Release gates:
569
+
570
+ ```bash
571
+ npm run check
572
+ npm run build
573
+ npm run test:coverage
574
+ npm run smoke
575
+ npm audit --omit=dev
576
+ npm pack --dry-run
577
+ ```
578
+
579
+ Before publishing, also run `smoke:agents` with the installed harnesses you want
580
+ to claim as validated, and record which routes passed, failed, or were skipped.
581
+ Set `HARNESS_DISPATCH_AGENT_SMOKE_ROOT` only when you need the disposable
582
+ workspaces somewhere other than the repo-local shared smoke cache.