harness-dispatch 0.7.9 → 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 (153) hide show
  1. package/CHANGELOG.md +1170 -1
  2. package/README.md +52 -6
  3. package/config.default.yaml +49 -5
  4. package/dist/auth.d.ts +29 -0
  5. package/dist/auth.d.ts.map +1 -1
  6. package/dist/auth.js +58 -5
  7. package/dist/auth.js.map +1 -1
  8. package/dist/billing.d.ts +24 -0
  9. package/dist/billing.d.ts.map +1 -1
  10. package/dist/billing.js +24 -0
  11. package/dist/billing.js.map +1 -1
  12. package/dist/bin.d.ts.map +1 -1
  13. package/dist/bin.js +529 -31
  14. package/dist/bin.js.map +1 -1
  15. package/dist/breaker-store.d.ts +25 -1
  16. package/dist/breaker-store.d.ts.map +1 -1
  17. package/dist/breaker-store.js +193 -25
  18. package/dist/breaker-store.js.map +1 -1
  19. package/dist/circuit-breaker.d.ts +9 -1
  20. package/dist/circuit-breaker.d.ts.map +1 -1
  21. package/dist/circuit-breaker.js +15 -1
  22. package/dist/circuit-breaker.js.map +1 -1
  23. package/dist/client-register.d.ts +157 -0
  24. package/dist/client-register.d.ts.map +1 -0
  25. package/dist/client-register.js +389 -0
  26. package/dist/client-register.js.map +1 -0
  27. package/dist/config/validation.d.ts +41 -0
  28. package/dist/config/validation.d.ts.map +1 -1
  29. package/dist/config/validation.js +215 -1
  30. package/dist/config/validation.js.map +1 -1
  31. package/dist/config.d.ts +4 -2
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/config.js +143 -6
  34. package/dist/config.js.map +1 -1
  35. package/dist/configure-yaml.d.ts +2 -0
  36. package/dist/configure-yaml.d.ts.map +1 -1
  37. package/dist/configure-yaml.js +69 -3
  38. package/dist/configure-yaml.js.map +1 -1
  39. package/dist/dispatch-log.d.ts +14 -0
  40. package/dist/dispatch-log.d.ts.map +1 -1
  41. package/dist/dispatch-log.js +3 -0
  42. package/dist/dispatch-log.js.map +1 -1
  43. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  44. package/dist/dispatchers/generic-cli.js +92 -15
  45. package/dist/dispatchers/generic-cli.js.map +1 -1
  46. package/dist/dispatchers/openai-compatible.d.ts +20 -4
  47. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  48. package/dist/dispatchers/openai-compatible.js +167 -19
  49. package/dist/dispatchers/openai-compatible.js.map +1 -1
  50. package/dist/dispatchers/shared/harness-login.d.ts +25 -0
  51. package/dist/dispatchers/shared/harness-login.d.ts.map +1 -0
  52. package/dist/dispatchers/shared/harness-login.js +68 -0
  53. package/dist/dispatchers/shared/harness-login.js.map +1 -0
  54. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  55. package/dist/dispatchers/shared/stream-subprocess.js +70 -4
  56. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  57. package/dist/file-lock.d.ts +29 -1
  58. package/dist/file-lock.d.ts.map +1 -1
  59. package/dist/file-lock.js +22 -1
  60. package/dist/file-lock.js.map +1 -1
  61. package/dist/http/answer-stream.d.ts +52 -0
  62. package/dist/http/answer-stream.d.ts.map +1 -0
  63. package/dist/http/answer-stream.js +47 -0
  64. package/dist/http/answer-stream.js.map +1 -0
  65. package/dist/http/parse.d.ts.map +1 -1
  66. package/dist/http/parse.js +41 -0
  67. package/dist/http/parse.js.map +1 -1
  68. package/dist/http/server.d.ts +2 -0
  69. package/dist/http/server.d.ts.map +1 -1
  70. package/dist/http/server.js +281 -23
  71. package/dist/http/server.js.map +1 -1
  72. package/dist/jobs/context.d.ts +0 -7
  73. package/dist/jobs/context.d.ts.map +1 -1
  74. package/dist/jobs/context.js +95 -7
  75. package/dist/jobs/context.js.map +1 -1
  76. package/dist/jobs/store.d.ts.map +1 -1
  77. package/dist/jobs/store.js +45 -4
  78. package/dist/jobs/store.js.map +1 -1
  79. package/dist/jobs/types.d.ts +8 -1
  80. package/dist/jobs/types.d.ts.map +1 -1
  81. package/dist/jobs.d.ts +39 -0
  82. package/dist/jobs.d.ts.map +1 -1
  83. package/dist/jobs.js +207 -15
  84. package/dist/jobs.js.map +1 -1
  85. package/dist/leaderboard.d.ts.map +1 -1
  86. package/dist/leaderboard.js +39 -27
  87. package/dist/leaderboard.js.map +1 -1
  88. package/dist/mcp/config-hot-reload.d.ts +14 -3
  89. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  90. package/dist/mcp/config-hot-reload.js +33 -5
  91. package/dist/mcp/config-hot-reload.js.map +1 -1
  92. package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
  93. package/dist/mcp/dispatcher-factory.js +14 -3
  94. package/dist/mcp/dispatcher-factory.js.map +1 -1
  95. package/dist/mcp/near-miss-guard.d.ts +45 -0
  96. package/dist/mcp/near-miss-guard.d.ts.map +1 -0
  97. package/dist/mcp/near-miss-guard.js +98 -0
  98. package/dist/mcp/near-miss-guard.js.map +1 -0
  99. package/dist/mcp/server.d.ts.map +1 -1
  100. package/dist/mcp/server.js +36 -0
  101. package/dist/mcp/server.js.map +1 -1
  102. package/dist/mcp/tool-schemas.d.ts +1 -1
  103. package/dist/mcp/tool-schemas.d.ts.map +1 -1
  104. package/dist/mcp/tool-schemas.js +27 -7
  105. package/dist/mcp/tool-schemas.js.map +1 -1
  106. package/dist/mcp/tools.d.ts +33 -4
  107. package/dist/mcp/tools.d.ts.map +1 -1
  108. package/dist/mcp/tools.js +85 -8
  109. package/dist/mcp/tools.js.map +1 -1
  110. package/dist/mcp-clients.d.ts +72 -0
  111. package/dist/mcp-clients.d.ts.map +1 -0
  112. package/dist/mcp-clients.js +121 -0
  113. package/dist/mcp-clients.js.map +1 -0
  114. package/dist/near-miss.d.ts +63 -0
  115. package/dist/near-miss.d.ts.map +1 -0
  116. package/dist/near-miss.js +132 -0
  117. package/dist/near-miss.js.map +1 -0
  118. package/dist/quota.d.ts +31 -1
  119. package/dist/quota.d.ts.map +1 -1
  120. package/dist/quota.js +84 -8
  121. package/dist/quota.js.map +1 -1
  122. package/dist/route-policy.d.ts +12 -1
  123. package/dist/route-policy.d.ts.map +1 -1
  124. package/dist/route-policy.js +74 -2
  125. package/dist/route-policy.js.map +1 -1
  126. package/dist/router.d.ts +61 -2
  127. package/dist/router.d.ts.map +1 -1
  128. package/dist/router.js +160 -29
  129. package/dist/router.js.map +1 -1
  130. package/dist/safety.d.ts.map +1 -1
  131. package/dist/safety.js +37 -1
  132. package/dist/safety.js.map +1 -1
  133. package/dist/state-dir.d.ts +10 -0
  134. package/dist/state-dir.d.ts.map +1 -1
  135. package/dist/state-dir.js +12 -0
  136. package/dist/state-dir.js.map +1 -1
  137. package/dist/status.d.ts +30 -21
  138. package/dist/status.d.ts.map +1 -1
  139. package/dist/status.js +152 -8
  140. package/dist/status.js.map +1 -1
  141. package/dist/types.d.ts +49 -2
  142. package/dist/types.d.ts.map +1 -1
  143. package/dist/working-dir.d.ts.map +1 -1
  144. package/dist/working-dir.js +16 -0
  145. package/dist/working-dir.js.map +1 -1
  146. package/dist/workspace-resolve.d.ts.map +1 -1
  147. package/dist/workspace-resolve.js +196 -15
  148. package/dist/workspace-resolve.js.map +1 -1
  149. package/dist/workspaces.d.ts +98 -0
  150. package/dist/workspaces.d.ts.map +1 -1
  151. package/dist/workspaces.js +609 -20
  152. package/dist/workspaces.js.map +1 -1
  153. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -2,15 +2,18 @@
2
2
  /**
3
3
  * harness-dispatch CLI entrypoint.
4
4
  */
5
- import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { promises as fs } from "node:fs";
7
7
  import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
8
9
  import { parseArgs } from "node:util";
9
10
  import { ensureHttpToken, maskToken, readHttpToken, rotateHttpToken, tokenPath } from "./auth.js";
10
11
  import { AUTO_DETECT_COMMANDS, loadConfig, resolveConfigPath } from "./config.js";
11
12
  import { LeaderboardCache } from "./leaderboard.js";
12
13
  import { VERSION } from "./version.js";
13
14
  import { commandAvailable } from "./dispatchers/shared/which-available.js";
15
+ import { codexLoginState } from "./dispatchers/shared/harness-login.js";
16
+ import { clientConfigLocations, inspectClientEntries } from "./mcp-clients.js";
14
17
  import { buildDispatchers } from "./mcp/dispatcher-factory.js";
15
18
  import { startMcpServer } from "./mcp/server.js";
16
19
  import { initObservability } from "./observability/index.js";
@@ -20,8 +23,9 @@ import { buildStatus, buildUsage, renderStatusText, renderUsageText } from "./st
20
23
  import { startHttpServer } from "./http/server.js";
21
24
  import { billingIsBlocked, buildRouteBilling } from "./billing.js";
22
25
  import { effectiveSafetyProfile } from "./safety.js";
23
- import { configToYaml } from "./configure-yaml.js";
24
- import { stateRoot } from "./state-dir.js";
26
+ import { configToYaml, isUneditedGenerated, stampGenerated } from "./configure-yaml.js";
27
+ import { desiredEntry, devLaunchCommand, launchCommand, planClientWrites, removeClientEntry, writeClientEntry, } from "./client-register.js";
28
+ import { stateRoot, userConfigPath } from "./state-dir.js";
25
29
  async function buildRuntime(configPath) {
26
30
  const config = await loadConfig(configPath);
27
31
  const dispatchers = await buildDispatchers(config);
@@ -39,6 +43,8 @@ function printUsage() {
39
43
  "Usage:",
40
44
  " harness-dispatch Start stdio MCP.",
41
45
  " harness-dispatch configure [--print] Detect and prepare harness config.",
46
+ " harness-dispatch connect Register this server with the MCP clients you have.",
47
+ " harness-dispatch connect --remove Take the entry back out again.",
42
48
  " harness-dispatch doctor [--json] Check install, config, auth, and routes.",
43
49
  " harness-dispatch doctor --live Run one routed probe when billing policy allows it.",
44
50
  " harness-dispatch doctor --live --allow-paid Run a live probe through paid/unknown routes.",
@@ -46,6 +52,7 @@ function printUsage() {
46
52
  " harness-dispatch status --watch Re-render status every --interval ms.",
47
53
  " harness-dispatch usage [--json] Show per-route call counts, quota, and billing kind.",
48
54
  " harness-dispatch serve [--port 3333] Serve MCP at /mcp and REST at /v1/*.",
55
+ ' harness-dispatch dispatch "<prompt>" Route one task and print the result.',
49
56
  " harness-dispatch auth show Print the HTTP bearer token.",
50
57
  " harness-dispatch auth rotate Rotate the HTTP bearer token.",
51
58
  "",
@@ -58,7 +65,15 @@ function printUsage() {
58
65
  " --print configure: print generated config YAML without writing it.",
59
66
  " --yes configure: write config.yaml instead of only previewing it.",
60
67
  " --force configure: overwrite an existing config file.",
68
+ " --clients <ids> connect: comma-separated client ids, instead of prompting.",
69
+ " --no-clients configure: skip the offer to register with clients.",
70
+ " --remove connect: remove the entry rather than write it.",
71
+ " --dev connect: point clients at THIS checkout's build, not the package.",
61
72
  " --allow-paid Allow doctor --live to probe paid or unknown-paid routes.",
73
+ " --service <id> dispatch: run exactly this route, no fallback to others.",
74
+ " --safety <profile> dispatch: read_only | workspace_edit | full_auto.",
75
+ " --task-type <type> dispatch: execute | plan | review | local.",
76
+ " --no-fallback dispatch: do not retry on another route if the first fails.",
62
77
  " -h, --help Show help.",
63
78
  " -v, --version Print the version and exit.",
64
79
  "",
@@ -67,7 +82,15 @@ function printUsage() {
67
82
  async function cmdConfigure(configPath, explicitConfigPath, opts) {
68
83
  // configure's --config names where it will WRITE, so a path that does not
69
84
  // exist yet is the normal first-run case, not a typo.
70
- const config = await loadConfig(configPath, { allowMissing: true });
85
+ const target = configPath ?? userConfigPath();
86
+ // A file configure wrote and nobody edited is regenerated from a fresh
87
+ // detection rather than loaded: loading it would make it authoritative and
88
+ // hide the harness installed since — the reason the user is re-running.
89
+ // Anything else on disk is the user's, loaded so its settings migrate, and
90
+ // guarded below. See stampGenerated for the natural-order story.
91
+ const existing = existsSync(target) ? await fs.readFile(target, "utf-8") : undefined;
92
+ const regenerate = existing !== undefined && isUneditedGenerated(existing);
93
+ const config = await loadConfig(regenerate ? undefined : configPath, { allowMissing: true });
71
94
  const routeCount = Object.keys(config.services).length;
72
95
  if (opts.print) {
73
96
  // Preview goes to a terminal and, routinely, into a bug report — a
@@ -87,7 +110,16 @@ async function cmdConfigure(configPath, explicitConfigPath, opts) {
87
110
  return 0;
88
111
  }
89
112
  const yamlText = configToYaml(config, { redactLiterals: false });
90
- process.stdout.write(`Detected ${routeCount} harness route${routeCount === 1 ? "" : "s"}.\n`);
113
+ // "Detected" is only true when detection ran. Over an edited file that lists
114
+ // its own routes it does not (the file is authoritative), and an acceptance
115
+ // pass measured `configure --yes --force` printing "Detected 1 harness route"
116
+ // with a second harness on PATH that never appeared in the output.
117
+ const plural = routeCount === 1 ? "" : "s";
118
+ process.stdout.write(config.detectionRan === false
119
+ ? `${routeCount} route${plural} from ${path.resolve(configPath ?? target)} — detection did not run, ` +
120
+ "because this file lists its own routes. To merge harnesses installed since, add " +
121
+ "`detect: true` to it; to start over from a fresh detection, delete it first.\n"
122
+ : `Detected ${routeCount} harness route${plural}.\n`);
91
123
  for (const [name, svc] of Object.entries(config.services)) {
92
124
  process.stdout.write(`- ${name}: harness=${svc.harness ?? name} billing=${buildRouteBilling(svc).kind} safety=${effectiveSafetyProfile(svc)} model=${svc.model ?? svc.leaderboardModel ?? "unknown"}\n`);
93
125
  }
@@ -106,7 +138,10 @@ async function cmdConfigure(configPath, explicitConfigPath, opts) {
106
138
  process.stdout.write(`- ${warning}\n`);
107
139
  }
108
140
  }
109
- const target = configPath ?? "config.yaml";
141
+ if (regenerate) {
142
+ process.stdout.write(`\n${path.resolve(target)} is unedited configure output — ` +
143
+ `${opts.yes ? "regenerating it from this detection" : "--yes will regenerate it from this detection"}.\n`);
144
+ }
110
145
  if (!opts.yes) {
111
146
  process.stdout.write(`\nNo files written. Re-run with --yes to write ${target}, or use --print to inspect YAML.\n`);
112
147
  process.stdout.write("After writing config, connect agents by adding the harness-dispatch MCP snippet to the agent you use.\n");
@@ -119,28 +154,266 @@ async function cmdConfigure(configPath, explicitConfigPath, opts) {
119
154
  // you to pass --config, which is exactly what disabled it. Overwriting a
120
155
  // hand-written config is not recoverable, so it now takes an explicit
121
156
  // --force rather than an accident of which flag you happened to use.
122
- if (existsSync(target) && !opts.force) {
157
+ if (existsSync(target) && !opts.force && !regenerate) {
123
158
  process.stderr.write(`configure: ${target} already exists and would be overwritten.\n` +
124
159
  "Use --print to inspect the generated YAML, --config <other-path> to write\n" +
125
160
  "elsewhere, or --force to overwrite it deliberately.\n");
126
161
  return 1;
127
162
  }
128
- await fs.writeFile(target, yamlText, "utf-8");
163
+ // 0600, because this file can contain a LITERAL api_key: `apiKeyForYaml`
164
+ // deliberately preserves one rather than replacing it with a `${VAR}`
165
+ // reference, so `configure` can write a real credential to disk. It was
166
+ // written with default permissions — 0644 under a typical umask — in a
167
+ // module family that is careful everywhere else (leaderboard 0600, state dir
168
+ // 0700, client-register carries the original file's mode). Found by an
169
+ // acceptance pass from reading, POSIX-only and not reproduced on Windows,
170
+ // where the mode is ignored.
171
+ //
172
+ // Applied on create only: `writeFile`'s mode does not change an existing
173
+ // file's permissions, so re-running `configure` will not silently tighten a
174
+ // file the user deliberately made group-readable.
175
+ // The default target lives in the state directory, which a first run has
176
+ // not created yet. Same mode the rest of the state dir gets.
177
+ await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
178
+ await fs.writeFile(target, stampGenerated(yamlText), { encoding: "utf-8", mode: 0o600 });
129
179
  const absoluteTarget = path.resolve(target);
130
- process.stdout.write(`Wrote ${target}.\n`);
131
- process.stdout.write("MCP snippet (uses an absolute --config path so it resolves correctly no matter what\n" +
180
+ process.stdout.write(`Wrote ${absoluteTarget}.\n`);
181
+ // The last step of setup used to be "here is some JSON, paste it somewhere".
182
+ // Nobody owned the result, and the paths in it later moved — which is how one
183
+ // machine ended up running a dead entry, a dead hook, and a third client
184
+ // reading a different config, all silently. Offer to do it instead.
185
+ if (!opts.noClients) {
186
+ process.stdout.write("\n");
187
+ return cmdConnect(configPath, {
188
+ clients: opts.clients,
189
+ remove: false,
190
+ yes: false,
191
+ force: false,
192
+ // Setup registers the installed package. A checkout entry is a
193
+ // deliberate choice made by someone who knows they have a checkout, not
194
+ // something to infer during first-run setup.
195
+ dev: false,
196
+ });
197
+ }
198
+ process.stdout.write("\nMCP snippet (uses an absolute --config path so it resolves correctly no matter what\n" +
132
199
  "directory the MCP client launches from — a relative path or none at all silently\n" +
133
200
  "falls back to the shipped defaults, ignoring every edit you make to this file):\n");
134
- process.stdout.write(JSON.stringify({
135
- mcpServers: {
136
- "harness-dispatch": {
137
- command: "harness-dispatch",
138
- args: ["--config", absoluteTarget],
139
- },
140
- },
141
- }, null, 2) + "\n");
201
+ // The SAME entry `connect` writes, built by the same function.
202
+ //
203
+ // This printed a hardcoded `harness-dispatch --config <path>` while connect
204
+ // writes whatever `launchCommand()` resolves to — `npx -y harness-dispatch
205
+ // --config <path>` without a global install. So pasting the snippet this
206
+ // command prints and then running `connect --remove` was answered "has an
207
+ // entry we did not write — left alone unless --force", and exit 1. The tool
208
+ // called its own documented output hand-edited, and blocked both update and
209
+ // removal on the manual-install path it had just recommended.
210
+ printMcpSnippet(desiredEntry(absoluteTarget, launchCommand()));
211
+ process.stdout.write("Or let `harness-dispatch connect` write it for you.\n");
142
212
  return 0;
143
213
  }
214
+ /** The entry, in the shape a client's config file wants it pasted. */
215
+ function printMcpSnippet(entry) {
216
+ if (entry === undefined)
217
+ return;
218
+ process.stdout.write(JSON.stringify({ mcpServers: { "harness-dispatch": entry } }, null, 2) + "\n");
219
+ }
220
+ /**
221
+ * Register this server with the MCP clients on this machine — the last step of
222
+ * setup, which used to be "paste this snippet somewhere".
223
+ *
224
+ * Interactive when a human is at a terminal, flag-driven otherwise. A prompt
225
+ * that blocks is right in front of a person and wrong in CI, so with no TTY
226
+ * and no `--clients` this reports what it WOULD do and writes nothing, rather
227
+ * than hanging or guessing.
228
+ */
229
+ async function cmdConnect(configPath, opts) {
230
+ const target = path.resolve(configPath ?? userConfigPath());
231
+ if (!existsSync(target) && !opts.remove) {
232
+ process.stderr.write(`connect: no config at ${target}. Run \`harness-dispatch configure --yes\` first —\n` +
233
+ "a client entry pointing at a config that does not exist is the failure this\n" +
234
+ "command exists to prevent.\n");
235
+ return 1;
236
+ }
237
+ // `import.meta.url` is this running file — dist/bin.js for an installed or
238
+ // built copy. "The build you are running now" is the only honest answer to
239
+ // which checkout --dev means, and it needs no flag value to get wrong.
240
+ const selfPath = fileURLToPath(import.meta.url);
241
+ const plans = planClientWrites(target, opts.dev ? { command: devLaunchCommand(selfPath) } : {});
242
+ if (opts.dev && !opts.remove) {
243
+ process.stdout.write(`Development entry: clients will launch ${selfPath} directly.\n` +
244
+ "That is an absolute path — it stops working, silently, if this directory is\n" +
245
+ "renamed or deleted. `harness-dispatch doctor` fails when that happens.\n\n");
246
+ }
247
+ const known = new Set(plans.map((p) => p.id));
248
+ const requested = opts.clients
249
+ ?.split(",")
250
+ .map((s) => s.trim())
251
+ .filter(Boolean);
252
+ const unknown = requested?.filter((id) => !known.has(id)) ?? [];
253
+ if (unknown.length > 0) {
254
+ process.stderr.write(`connect: unknown client${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. ` +
255
+ `Known: ${[...known].join(", ")}.\n`);
256
+ return 1;
257
+ }
258
+ const installed = plans.filter((p) => p.state !== "absent");
259
+ if (installed.length === 0) {
260
+ process.stdout.write("No MCP clients found on this machine (looked for Claude Code and Cursor).\n" +
261
+ "Nothing to register. Add this to whichever client you use, then re-run\n" +
262
+ "`harness-dispatch connect` if you install one of the two above:\n");
263
+ // Setup has to end with something you can act on. Without this, a machine
264
+ // with no client detected got a cheerful "nothing to do" and no way to
265
+ // finish wiring anything up — the snippet was the ONLY output this replaced.
266
+ printMcpSnippet(plans[0]?.desired);
267
+ return 0;
268
+ }
269
+ if (!opts.remove) {
270
+ // Stated once, up front, rather than only inside the diff for entries that
271
+ // happen to differ. What gets written should not be something you can only
272
+ // learn from a client being in a particular state.
273
+ process.stdout.write(`Entry to write: ${JSON.stringify(plans[0].desired)}\n\n`);
274
+ }
275
+ process.stdout.write(`${opts.remove ? "Removing from" : "Registering with"} clients:\n`);
276
+ for (const p of installed) {
277
+ process.stdout.write(` ${p.client.padEnd(12)} ${p.file} (${describeState(p.state, opts.remove)})\n`);
278
+ if (p.state === "differs") {
279
+ process.stdout.write(` currently: ${JSON.stringify(summariseEntry(p.current))}\n`);
280
+ }
281
+ }
282
+ const chosen = requested
283
+ ? installed.filter((p) => requested.includes(p.id))
284
+ : await chooseInteractively(installed, opts);
285
+ // Consent to replace a HAND-EDITED entry comes from ANSWERING the
286
+ // interactive prompt, which shows the difference first, or from --force.
287
+ //
288
+ // Neither `--clients` nor `--yes` counts. `--clients` says which client, not
289
+ // "overwrite whatever I put there"; `--yes` skips the question rather than
290
+ // answering it. The first version of this gate accepted "no client named" as
291
+ // consent, which let `--yes` through — the same class it was written to
292
+ // close, one flag over.
293
+ const prompted = requested === undefined && !opts.yes && process.stdin.isTTY === true;
294
+ const consented = opts.force || prompted;
295
+ if (chosen === undefined) {
296
+ process.stdout.write("Nothing written.\n");
297
+ return 0;
298
+ }
299
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
300
+ let failed = false;
301
+ let wrote = false;
302
+ for (const plan of chosen) {
303
+ const outcome = opts.remove
304
+ ? await removeClientEntry(plan, { stamp, force: opts.force })
305
+ : await writeClientEntry(plan, { stamp, consented });
306
+ if (outcome.action === "written") {
307
+ wrote = true;
308
+ process.stdout.write(`${opts.remove ? "Removed from" : "Wrote"} ${outcome.client} ` +
309
+ `(${outcome.backupPath !== undefined ? `backup: ${outcome.backupPath}` : `created ${outcome.file}`})\n`);
310
+ }
311
+ else if (outcome.action === "unchanged") {
312
+ process.stdout.write(`${outcome.client}: ${opts.remove ? "no entry of ours to remove" : "already correct"}, nothing changed.\n`);
313
+ }
314
+ else {
315
+ failed = true;
316
+ process.stderr.write(`Skipped ${outcome.client}: ${outcome.reason}\n`);
317
+ }
318
+ }
319
+ // Only when something actually changed. Telling someone to restart an
320
+ // application after a run that wrote nothing is advice with no cause.
321
+ if (wrote && !failed) {
322
+ process.stdout.write(`\nRestart the client(s) so they pick up the ${opts.remove ? "removal" : "new server"}.\n`);
323
+ }
324
+ return failed ? 1 : 0;
325
+ }
326
+ /**
327
+ * The listing describes state from the point of view of what is about to
328
+ * happen. Under `--remove`, "already registered correctly" describes the entry
329
+ * accurately and reads as nonsense under the heading "Removing from clients",
330
+ * where the same state means "this is the one that will go".
331
+ */
332
+ function describeState(state, removing) {
333
+ if (removing) {
334
+ return {
335
+ absent: "not installed",
336
+ "missing-file": "installed, no config file yet — nothing to remove",
337
+ unreadable: "config does not parse — will be left alone",
338
+ "missing-entry": "no entry of ours to remove",
339
+ matches: "our entry is here — will be removed",
340
+ differs: "has an entry we did not write — left alone unless --force",
341
+ }[state];
342
+ }
343
+ return {
344
+ absent: "not installed",
345
+ "missing-file": "installed, no config file yet — one will be created",
346
+ unreadable: "config does not parse — will be left alone",
347
+ "missing-entry": "no harness-dispatch entry yet",
348
+ matches: "already registered correctly",
349
+ differs: "has a DIFFERENT entry",
350
+ }[state];
351
+ }
352
+ /** An entry's shape without its `env`, which holds live API keys on real machines. */
353
+ function summariseEntry(entry) {
354
+ if (!entry || typeof entry !== "object")
355
+ return entry;
356
+ const { env: _hidden, ...rest } = entry;
357
+ return rest;
358
+ }
359
+ /**
360
+ * Ask, when there is someone to ask.
361
+ *
362
+ * Returns undefined for "write nothing". An entry that already differs is the
363
+ * case that most needs a human: on the machine this was written for, the
364
+ * differing entry was the WORKING one.
365
+ */
366
+ async function chooseInteractively(plans, opts) {
367
+ // What counts as actionable INVERTS under --remove.
368
+ //
369
+ // Registering: `matches` means the entry is already what we would write, so
370
+ // there is nothing to do. Removing: `matches` is exactly the entry being
371
+ // removed, so filtering it out left nothing actionable — and the command
372
+ // printed "our entry is here — will be removed", exited 0, and changed
373
+ // nothing. Reproduced byte-for-byte: same md5 before and after.
374
+ //
375
+ // Only the bare form was affected, because `--clients` bypasses this
376
+ // function entirely. That is the form README documents twice and
377
+ // OPERATIONS.md once, and no test covered this command at all.
378
+ // Stated as what IS actionable rather than what is not: the states are
379
+ // absent / unreadable / missing-entry / matches / differs, and under
380
+ // --remove only the two that actually hold an entry qualify. Written as a
381
+ // negation, `absent` (no config file at all) slipped through.
382
+ const actionable = plans.filter((p) => opts.remove === true
383
+ ? p.state === "matches" || p.state === "differs"
384
+ : p.state !== "matches" && p.state !== "unreadable");
385
+ if (actionable.length === 0)
386
+ return [];
387
+ // `--yes` skips the question; it does NOT answer it.
388
+ //
389
+ // The consent gate added for `--clients` treated "no client named" as
390
+ // "consented", and `--yes` takes that path — so an acceptance pass measured
391
+ // `connect --yes` overwriting a hand-edited entry with no prompt and no
392
+ // --force, exit 0, while the same flags on `--remove` correctly refused.
393
+ // "Do not ask me" is not the same answer as "yes, replace what I wrote".
394
+ if (opts.yes)
395
+ return actionable;
396
+ if (!process.stdin.isTTY) {
397
+ process.stdout.write(
398
+ // Names the command, not just the flags: this is reached from
399
+ // `configure --yes` too, where "or --yes" told the user to pass the flag
400
+ // they had already passed.
401
+ "\nNot a terminal, so nothing was written. Run `harness-dispatch connect --clients " +
402
+ `${actionable.map((p) => p.id).join(",")}\` (or \`connect --yes\`) to register.\n`);
403
+ return undefined;
404
+ }
405
+ const { createInterface } = await import("node:readline/promises");
406
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
407
+ try {
408
+ const answer = (await rl.question(`\nApply to ${actionable.length} client(s)? [y/N] `))
409
+ .trim()
410
+ .toLowerCase();
411
+ return answer === "y" || answer === "yes" ? actionable : undefined;
412
+ }
413
+ finally {
414
+ rl.close();
415
+ }
416
+ }
144
417
  async function cmdStatus(configPath, opts) {
145
418
  const runtime = await buildRuntime(configPath);
146
419
  const render = async () => {
@@ -230,8 +503,67 @@ async function cmdDoctor(configPath, opts) {
230
503
  {
231
504
  name: "config",
232
505
  ok: Object.keys(runtime.config.services).length > 0,
233
- detail: `${Object.keys(runtime.config.services).length} configured route(s)`,
506
+ // Names the file, because "which config is this looking at" was the
507
+ // question: `configure` run from one directory and `doctor` from
508
+ // another used to load different things and neither said so.
509
+ detail: `${Object.keys(runtime.config.services).length} configured route(s)` +
510
+ (configPath !== undefined
511
+ ? ` from ${path.resolve(configPath)}`
512
+ : " (no config file found; shipped defaults with auto-detected harnesses)"),
234
513
  },
514
+ // This one DOES fail, unlike the advisory git check below.
515
+ //
516
+ // A client entry naming a path that is not there is not a preference or a
517
+ // missing optional tool — there is no setup in which it is intended. And
518
+ // it is invisible from the client side: one that cannot spawn its server
519
+ // simply has no tools, which looks identical to never having installed
520
+ // anything. On this maintainer's machine that state survived a repo rename
521
+ // by months, silently, along with a hook pointing at the same dead
522
+ // directory.
523
+ //
524
+ // Not-configured is NOT a failure: a machine with no client entry gets
525
+ // "not registered with any client", ok. Only a broken one fails.
526
+ (() => {
527
+ const entries = inspectClientEntries();
528
+ const broken = entries.filter((e) => e.missingPaths.length > 0);
529
+ if (entries.length === 0) {
530
+ // Name the client that IS here, so "not registered" reads as the
531
+ // next step rather than as "nothing to register with".
532
+ const present = clientConfigLocations()
533
+ .filter((c) => c.commands.some((cmd) => commandAvailable(cmd)))
534
+ .map((c) => c.client);
535
+ return {
536
+ name: "mcp-clients",
537
+ ok: true,
538
+ detail: present.length > 0
539
+ ? `${present.join(", ")} installed but harness-dispatch is not registered with it — ` +
540
+ "run `harness-dispatch connect`"
541
+ : "not registered with any MCP client this tool knows how to read " +
542
+ "(Claude Code, Cursor), and none is installed — run `harness-dispatch connect` " +
543
+ "after installing one",
544
+ };
545
+ }
546
+ return {
547
+ name: "mcp-clients",
548
+ ok: broken.length === 0,
549
+ detail: broken.length === 0
550
+ ? entries.map((e) => `${e.client}: ${e.entry} resolves`).join("; ")
551
+ : broken
552
+ .map((e) =>
553
+ // "references", not "launches ... from": missingPaths
554
+ // holds any path the entry names that is not there, and
555
+ // that is routinely the `--config` argument rather than
556
+ // the launch binary. An acceptance pass found the message
557
+ // reaching the right conclusion by the wrong description —
558
+ // a missing explicit --config does make the CLI exit 1,
559
+ // but it is not where the client launches from.
560
+ `${e.client} (${e.file}) references a path that does not exist: ` +
561
+ `${e.missingPaths.join(", ")} (entry: ${e.entry}) — that client has been ` +
562
+ "getting NO tools from this server, silently. `harness-dispatch connect` " +
563
+ "rewrites the entry.")
564
+ .join(" | "),
565
+ };
566
+ })(),
235
567
  // Not required to dispatch — reported, never a hard fail.
236
568
  //
237
569
  // The `workspace` tool shells out to git for diff/apply, so without it a
@@ -267,6 +599,19 @@ async function cmdDoctor(configPath, opts) {
267
599
  ? runtime.config.configWarnings.join(" | ")
268
600
  : "no unrecognized config entries",
269
601
  },
602
+ {
603
+ // Saved state that could not be read, which is NOT a config problem and
604
+ // so never reached this list: `doctor` read `configWarnings` directly
605
+ // and stayed silent about an unreadable breaker record or usage counters
606
+ // that are not reaching disk. Two acceptance passes recorded that
607
+ // silence as an open item; `status` grew a "State problems" heading and
608
+ // `doctor` did not follow.
609
+ name: "saved-state",
610
+ ok: (status.stateWarnings?.length ?? 0) === 0,
611
+ detail: status.stateWarnings && status.stateWarnings.length > 0
612
+ ? status.stateWarnings.join(" | ")
613
+ : "readable",
614
+ },
270
615
  {
271
616
  name: "routes",
272
617
  ok: status.ready.length > 0,
@@ -297,6 +642,29 @@ async function cmdDoctor(configPath, opts) {
297
642
  },
298
643
  ];
299
644
  const blocked = status.skippedRoutes.filter((skip) => skip.code === "paid_blocked" || skip.code === "unknown_billing");
645
+ // A ready route is one whose CLI is on PATH. That said nothing about whether
646
+ // the CLI could actually make a request: an installed, never-logged-in Codex
647
+ // passed routes, billing and safety, and the first dispatch then failed with
648
+ // a raw OpenAI 401 that never mentioned `codex login`. The cold-install walk
649
+ // in acceptance/0.8.0.md is where that was seen. The CLI is asked directly
650
+ // (see harness-login.ts for why not the credential file), and only a
651
+ // definite "not logged in" fails the check.
652
+ const codexRoutes = status.routes.filter((route) => status.ready.includes(route.id) && route.harness === "codex" && route.command);
653
+ const loginStates = await Promise.all(codexRoutes.map(async (route) => ({ route, state: await codexLoginState(route.command) })));
654
+ const loggedOut = loginStates.filter((entry) => entry.state === "logged_out");
655
+ checks.push({
656
+ name: "harness-login",
657
+ ok: loggedOut.length === 0,
658
+ detail: loginStates.length === 0
659
+ ? "no ready route has a login state this tool knows how to ask for (Codex only, today)"
660
+ : loggedOut.length > 0
661
+ ? `${loggedOut.map((entry) => entry.route.id).join(", ")}: codex reports "Not logged in" — ` +
662
+ "run `codex login` (or `codex login --with-api-key`), or every dispatch to it fails " +
663
+ "with 401 Unauthorized from OpenAI"
664
+ : loginStates
665
+ .map((entry) => `${entry.route.id}: ${entry.state === "logged_in" ? "logged in" : "could not determine (codex login status gave no answer)"}`)
666
+ .join("; "),
667
+ });
300
668
  checks.push({
301
669
  name: "billing-policy",
302
670
  ok: true,
@@ -393,18 +761,50 @@ async function cmdAuth(action) {
393
761
  return 1;
394
762
  }
395
763
  }
396
- async function cmdRouteAlias(prompt, configPath) {
764
+ /**
765
+ * One dispatch from the command line — the CLI half of the `dispatch` MCP
766
+ * tool, named to match it (`route` stays as an alias).
767
+ *
768
+ * It took flags because it had none and that made it unusable for the one job
769
+ * it is most needed for. An acceptance pass has to exercise the build IN THE
770
+ * WORKING TREE; the MCP tool runs in whatever server process is already
771
+ * connected, which is a different artifact from a different moment. So the CLI
772
+ * is the honest path there — and it hardcoded taskType "execute" with two
773
+ * fallbacks, meaning a pass asking for one read-only call on one route could
774
+ * silently get an execute-profile run on up to three. The first acceptance
775
+ * pass to attempt a live dispatch wrote its own Node script against dist/
776
+ * rather than use this, which is the tell.
777
+ */
778
+ async function cmdDispatch(prompt, configPath, opts) {
397
779
  if (!prompt) {
398
- process.stderr.write('route: missing prompt. Usage: route "<prompt>"\n');
780
+ process.stderr.write('dispatch: missing prompt. Usage: dispatch [--service <id>] [--safety <profile>]\n' +
781
+ ' [--task-type <type>] [--no-fallback] [--json] "<prompt>"\n');
399
782
  return 1;
400
783
  }
401
784
  const runtime = await buildRuntime(configPath);
402
- const { result, decision } = await runtime.router.route(prompt, [], process.cwd(), {
403
- hints: { taskType: "execute" },
404
- maxFallbacks: 2,
405
- });
785
+ const hints = { taskType: opts.taskType ?? "execute" };
786
+ if (opts.safetyProfile !== undefined)
787
+ hints.safetyProfile = opts.safetyProfile;
788
+ // A named service goes through routeTo, which is what "run exactly this
789
+ // route" means — not route() with a hint, which can still fall elsewhere.
790
+ const { result, decision } = opts.service
791
+ ? await runtime.router.routeTo(opts.service, prompt, [], process.cwd(), {
792
+ ...(opts.safetyProfile !== undefined ? { safetyProfile: opts.safetyProfile } : {}),
793
+ ...(opts.taskType !== undefined ? { taskType: opts.taskType } : {}),
794
+ })
795
+ : await runtime.router.route(prompt, [], process.cwd(), {
796
+ hints,
797
+ maxFallbacks: opts.noFallback ? 0 : 2,
798
+ });
799
+ if (opts.json) {
800
+ process.stdout.write(`${JSON.stringify({ result, routing: decision ?? null }, null, 2)}\n`);
801
+ return result.success ? 0 : 1;
802
+ }
406
803
  if (decision) {
407
- process.stderr.write(`route: ${decision.service} (${decision.reason})\n`);
804
+ const beat = decision.candidates?.length
805
+ ? ` [${decision.candidates.map((c) => `${c.route} ${c.score}`).join(", ")}]`
806
+ : "";
807
+ process.stderr.write(`dispatch: ${decision.service} (${decision.reason})${beat}\n`);
408
808
  }
409
809
  process.stdout.write(result.output);
410
810
  if (!result.output.endsWith("\n"))
@@ -415,6 +815,24 @@ async function cmdRouteAlias(prompt, configPath) {
415
815
  }
416
816
  return 0;
417
817
  }
818
+ const SAFETY_PROFILES = ["read_only", "workspace_edit", "full_auto"];
819
+ const TASK_TYPES = ["execute", "plan", "review", "local"];
820
+ /**
821
+ * An enum-valued flag: rejected by name when it is not one of the listed
822
+ * values, never silently dropped to a default.
823
+ *
824
+ * `--safety read_onlyy` dropping to workspace_edit would hand a delegate MORE
825
+ * access than the caller asked for — the same failure the MCP and HTTP
826
+ * surfaces were both hardened against, and the reason `hints` is strict there.
827
+ */
828
+ function enumFlag(value, allowed, flag) {
829
+ if (value === undefined)
830
+ return undefined;
831
+ if (typeof value === "string" && allowed.includes(value)) {
832
+ return value;
833
+ }
834
+ throw new UsageError(`${flag}: invalid value ${JSON.stringify(value)}. Valid: ${allowed.join(", ")}.`);
835
+ }
418
836
  function parsePositiveInt(value, fallback) {
419
837
  if (typeof value !== "string")
420
838
  return fallback;
@@ -463,6 +881,14 @@ export async function main(argv) {
463
881
  yes: { type: "boolean" },
464
882
  force: { type: "boolean" },
465
883
  http: { type: "string" },
884
+ service: { type: "string" },
885
+ safety: { type: "string" },
886
+ "task-type": { type: "string" },
887
+ "no-fallback": { type: "boolean" },
888
+ clients: { type: "string" },
889
+ "no-clients": { type: "boolean" },
890
+ remove: { type: "boolean" },
891
+ dev: { type: "boolean" },
466
892
  },
467
893
  allowPositionals: true,
468
894
  strict: false,
@@ -475,6 +901,8 @@ export async function main(argv) {
475
901
  const knownFlags = new Set([
476
902
  "help", "version", "config", "json", "live", "allow-paid", "watch", "interval",
477
903
  "port", "host", "print", "yes", "force", "http",
904
+ "service", "safety", "task-type", "no-fallback",
905
+ "clients", "no-clients", "remove", "dev",
478
906
  ]);
479
907
  const unknownFlags = Object.keys(values).filter((k) => !knownFlags.has(k));
480
908
  if (unknownFlags.length > 0) {
@@ -531,6 +959,16 @@ export async function main(argv) {
531
959
  print: Boolean(values.print),
532
960
  yes: Boolean(values.yes),
533
961
  force: Boolean(values.force),
962
+ noClients: Boolean(values["no-clients"]),
963
+ clients: typeof values.clients === "string" ? values.clients : undefined,
964
+ });
965
+ case "connect":
966
+ return cmdConnect(configPath, {
967
+ clients: typeof values.clients === "string" ? values.clients : undefined,
968
+ remove: Boolean(values.remove),
969
+ yes: Boolean(values.yes),
970
+ force: Boolean(values.force),
971
+ dev: Boolean(values.dev),
534
972
  });
535
973
  case "doctor":
536
974
  return cmdDoctor(configPath, {
@@ -552,8 +990,21 @@ export async function main(argv) {
552
990
  return cmdServe(configPath, serveOpts(values));
553
991
  case "auth":
554
992
  return cmdAuth(rest[0]);
555
- case "route":
556
- return cmdRouteAlias(rest.join(" ").trim(), configPath);
993
+ // `route` kept as an alias: it was the name for two years of history, and
994
+ // `dispatch` matches the MCP tool that does the same thing. Same pattern
995
+ // as status/dashboard/list-services above.
996
+ case "dispatch":
997
+ case "route": {
998
+ const safety = enumFlag(values.safety, SAFETY_PROFILES, "--safety");
999
+ const taskType = enumFlag(values["task-type"], TASK_TYPES, "--task-type");
1000
+ return cmdDispatch(rest.join(" ").trim(), configPath, {
1001
+ ...(typeof values.service === "string" ? { service: values.service } : {}),
1002
+ ...(safety !== undefined ? { safetyProfile: safety } : {}),
1003
+ ...(taskType !== undefined ? { taskType } : {}),
1004
+ noFallback: Boolean(values["no-fallback"]),
1005
+ json: Boolean(values.json),
1006
+ });
1007
+ }
557
1008
  case "mcp":
558
1009
  if (values.http !== undefined) {
559
1010
  return cmdServe(configPath, serveOpts({ port: values.http, host: values.host }));
@@ -565,11 +1016,58 @@ export async function main(argv) {
565
1016
  return 1;
566
1017
  }
567
1018
  }
568
- const entrypoint = typeof process !== "undefined" && Array.isArray(process.argv) ? process.argv[1] : "";
569
- if (entrypoint && (entrypoint.endsWith("bin.ts") || entrypoint.endsWith("bin.js"))) {
1019
+ /**
1020
+ * End the process with `code`, letting the event loop drain first.
1021
+ *
1022
+ * `process.exit(code)` tears the loop down mid-flight, and on Windows that
1023
+ * aborts: `Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win * async.c` and an exit status of 127, which every shell reads as "command not
1024
+ * found". It fired AFTER a correct answer had been printed, so the work was
1025
+ * done and the report of it was a crash.
1026
+ *
1027
+ * Reproduced 3/3 across three acceptance passes, and confirmed here: two
1028
+ * endpoint routes, the first answering 200 with an unusable body and the
1029
+ * second succeeding. The fallback is what makes it two in-flight HTTP
1030
+ * connections; `--no-fallback` and a refused connection both exited 1 cleanly,
1031
+ * which is why it looked intermittent. Setting `exitCode` instead removes it,
1032
+ * measured on the same probe.
1033
+ *
1034
+ * The force-exit is the safety net `process.exit` was providing: if something
1035
+ * still holds the loop open after a grace window, leave anyway rather than
1036
+ * hanging a CLI. It is `unref`d, so it does not itself keep the process alive
1037
+ * — every command measured here exits in under a second without it firing.
1038
+ */
1039
+ const EXIT_DRAIN_GRACE_MS = 3000;
1040
+ function finish(code) {
1041
+ process.exitCode = code;
1042
+ const bail = setTimeout(() => {
1043
+ process.exit(code);
1044
+ }, EXIT_DRAIN_GRACE_MS);
1045
+ bail.unref();
1046
+ }
1047
+ // Run main() only when this file is the process entrypoint, not when a test
1048
+ // imports it. `argv[1]` is the path the user invoked, which is NOT this file
1049
+ // when npm installed the command as a symlink (`/usr/local/bin/harness-dispatch`
1050
+ // on Linux and macOS): node does not resolve it, so a name check alone
1051
+ // silently ran nothing there and exited 0 — every documented command was a
1052
+ // no-op for every non-Windows `npm install -g` user through 0.8.0. Windows
1053
+ // never hit it because npm's .cmd shim passes the real dist/bin.js path.
1054
+ const entrypoint = typeof process !== "undefined" && Array.isArray(process.argv) ? (process.argv[1] ?? "") : "";
1055
+ function isThisFile(invoked) {
1056
+ if (!invoked)
1057
+ return false;
1058
+ if (invoked.endsWith("bin.ts") || invoked.endsWith("bin.js"))
1059
+ return true;
1060
+ try {
1061
+ return realpathSync(invoked) === realpathSync(fileURLToPath(import.meta.url));
1062
+ }
1063
+ catch {
1064
+ return false;
1065
+ }
1066
+ }
1067
+ if (isThisFile(entrypoint)) {
570
1068
  void main(process.argv.slice(2))
571
1069
  .then((code) => {
572
- process.exit(code);
1070
+ finish(code);
573
1071
  })
574
1072
  .catch((err) => {
575
1073
  // A CLI user gets one actionable line, not a stack trace. Every Error is