harness-dispatch 0.8.0 → 0.10.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 (174) hide show
  1. package/CHANGELOG.md +1074 -2
  2. package/README.md +92 -36
  3. package/config.default.yaml +19 -3
  4. package/dist/auth.d.ts.map +1 -1
  5. package/dist/auth.js.map +1 -1
  6. package/dist/billing.d.ts +24 -0
  7. package/dist/billing.d.ts.map +1 -1
  8. package/dist/billing.js +24 -0
  9. package/dist/billing.js.map +1 -1
  10. package/dist/bin.d.ts.map +1 -1
  11. package/dist/bin.js +299 -48
  12. package/dist/bin.js.map +1 -1
  13. package/dist/circuit-breaker.d.ts +9 -1
  14. package/dist/circuit-breaker.d.ts.map +1 -1
  15. package/dist/circuit-breaker.js +15 -1
  16. package/dist/circuit-breaker.js.map +1 -1
  17. package/dist/client-register.d.ts +11 -1
  18. package/dist/client-register.d.ts.map +1 -1
  19. package/dist/client-register.js +15 -6
  20. package/dist/client-register.js.map +1 -1
  21. package/dist/config/coercions.d.ts +5 -0
  22. package/dist/config/coercions.d.ts.map +1 -1
  23. package/dist/config/coercions.js +8 -0
  24. package/dist/config/coercions.js.map +1 -1
  25. package/dist/config/protocol.js.map +1 -1
  26. package/dist/config/validation.d.ts +12 -0
  27. package/dist/config/validation.d.ts.map +1 -1
  28. package/dist/config/validation.js +133 -7
  29. package/dist/config/validation.js.map +1 -1
  30. package/dist/config.d.ts +15 -5
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/config.js +199 -152
  33. package/dist/config.js.map +1 -1
  34. package/dist/configure-yaml.d.ts +2 -0
  35. package/dist/configure-yaml.d.ts.map +1 -1
  36. package/dist/configure-yaml.js +114 -4
  37. package/dist/configure-yaml.js.map +1 -1
  38. package/dist/dispatch-log.d.ts.map +1 -1
  39. package/dist/dispatch-log.js +3 -1
  40. package/dist/dispatch-log.js.map +1 -1
  41. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  42. package/dist/dispatchers/generic-cli.js +281 -112
  43. package/dist/dispatchers/generic-cli.js.map +1 -1
  44. package/dist/dispatchers/openai-compatible.d.ts +0 -5
  45. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  46. package/dist/dispatchers/openai-compatible.js +150 -83
  47. package/dist/dispatchers/openai-compatible.js.map +1 -1
  48. package/dist/dispatchers/shared/harness-login.d.ts +25 -0
  49. package/dist/dispatchers/shared/harness-login.d.ts.map +1 -0
  50. package/dist/dispatchers/shared/harness-login.js +73 -0
  51. package/dist/dispatchers/shared/harness-login.js.map +1 -0
  52. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  53. package/dist/dispatchers/shared/stream-subprocess.js +71 -58
  54. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  55. package/dist/file-lock.d.ts +29 -1
  56. package/dist/file-lock.d.ts.map +1 -1
  57. package/dist/file-lock.js +53 -20
  58. package/dist/file-lock.js.map +1 -1
  59. package/dist/harness-presets.d.ts +51 -0
  60. package/dist/harness-presets.d.ts.map +1 -0
  61. package/dist/harness-presets.js +154 -0
  62. package/dist/harness-presets.js.map +1 -0
  63. package/dist/http/parse.d.ts +8 -1
  64. package/dist/http/parse.d.ts.map +1 -1
  65. package/dist/http/parse.js +10 -3
  66. package/dist/http/parse.js.map +1 -1
  67. package/dist/http/server.d.ts +2 -0
  68. package/dist/http/server.d.ts.map +1 -1
  69. package/dist/http/server.js +188 -16
  70. package/dist/http/server.js.map +1 -1
  71. package/dist/job-runner.js +6 -0
  72. package/dist/job-runner.js.map +1 -1
  73. package/dist/jobs/context.d.ts +0 -7
  74. package/dist/jobs/context.d.ts.map +1 -1
  75. package/dist/jobs/context.js +95 -7
  76. package/dist/jobs/context.js.map +1 -1
  77. package/dist/jobs/lifecycle.d.ts +100 -0
  78. package/dist/jobs/lifecycle.d.ts.map +1 -0
  79. package/dist/jobs/lifecycle.js +263 -0
  80. package/dist/jobs/lifecycle.js.map +1 -0
  81. package/dist/jobs/read.d.ts +17 -0
  82. package/dist/jobs/read.d.ts.map +1 -0
  83. package/dist/jobs/read.js +103 -0
  84. package/dist/jobs/read.js.map +1 -0
  85. package/dist/jobs/run.d.ts +45 -0
  86. package/dist/jobs/run.d.ts.map +1 -0
  87. package/dist/jobs/run.js +351 -0
  88. package/dist/jobs/run.js.map +1 -0
  89. package/dist/jobs/start.d.ts +9 -0
  90. package/dist/jobs/start.d.ts.map +1 -0
  91. package/dist/jobs/start.js +126 -0
  92. package/dist/jobs/start.js.map +1 -0
  93. package/dist/jobs/store.d.ts.map +1 -1
  94. package/dist/jobs/store.js +13 -3
  95. package/dist/jobs/store.js.map +1 -1
  96. package/dist/jobs/supervisor.d.ts +151 -0
  97. package/dist/jobs/supervisor.d.ts.map +1 -0
  98. package/dist/jobs/supervisor.js +679 -0
  99. package/dist/jobs/supervisor.js.map +1 -0
  100. package/dist/jobs/types.d.ts +8 -1
  101. package/dist/jobs/types.d.ts.map +1 -1
  102. package/dist/jobs.d.ts +15 -218
  103. package/dist/jobs.d.ts.map +1 -1
  104. package/dist/jobs.js +14 -1317
  105. package/dist/jobs.js.map +1 -1
  106. package/dist/leaderboard.d.ts.map +1 -1
  107. package/dist/leaderboard.js +44 -28
  108. package/dist/leaderboard.js.map +1 -1
  109. package/dist/mcp/config-hot-reload.d.ts +14 -3
  110. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  111. package/dist/mcp/config-hot-reload.js +27 -6
  112. package/dist/mcp/config-hot-reload.js.map +1 -1
  113. package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
  114. package/dist/mcp/dispatcher-factory.js +15 -4
  115. package/dist/mcp/dispatcher-factory.js.map +1 -1
  116. package/dist/mcp/resources.d.ts.map +1 -1
  117. package/dist/mcp/resources.js +3 -2
  118. package/dist/mcp/resources.js.map +1 -1
  119. package/dist/mcp/tool-schemas.d.ts +1 -1
  120. package/dist/mcp/tool-schemas.d.ts.map +1 -1
  121. package/dist/mcp/tool-schemas.js +6 -3
  122. package/dist/mcp/tool-schemas.js.map +1 -1
  123. package/dist/mcp/tools.d.ts +20 -6
  124. package/dist/mcp/tools.d.ts.map +1 -1
  125. package/dist/mcp/tools.js +92 -14
  126. package/dist/mcp/tools.js.map +1 -1
  127. package/dist/mcp-clients.d.ts +9 -0
  128. package/dist/mcp-clients.d.ts.map +1 -1
  129. package/dist/mcp-clients.js +4 -0
  130. package/dist/mcp-clients.js.map +1 -1
  131. package/dist/observability/spans.d.ts.map +1 -1
  132. package/dist/observability/spans.js +10 -2
  133. package/dist/observability/spans.js.map +1 -1
  134. package/dist/quota.d.ts.map +1 -1
  135. package/dist/quota.js +16 -2
  136. package/dist/quota.js.map +1 -1
  137. package/dist/redaction.d.ts +90 -0
  138. package/dist/redaction.d.ts.map +1 -0
  139. package/dist/redaction.js +221 -0
  140. package/dist/redaction.js.map +1 -0
  141. package/dist/route-policy.d.ts +2 -1
  142. package/dist/route-policy.d.ts.map +1 -1
  143. package/dist/route-policy.js +54 -0
  144. package/dist/route-policy.js.map +1 -1
  145. package/dist/router.d.ts +12 -23
  146. package/dist/router.d.ts.map +1 -1
  147. package/dist/router.js +86 -223
  148. package/dist/router.js.map +1 -1
  149. package/dist/safety.d.ts.map +1 -1
  150. package/dist/safety.js +7 -1
  151. package/dist/safety.js.map +1 -1
  152. package/dist/state-dir.d.ts +10 -0
  153. package/dist/state-dir.d.ts.map +1 -1
  154. package/dist/state-dir.js +27 -1
  155. package/dist/state-dir.js.map +1 -1
  156. package/dist/status.d.ts +14 -3
  157. package/dist/status.d.ts.map +1 -1
  158. package/dist/status.js +36 -3
  159. package/dist/status.js.map +1 -1
  160. package/dist/types.d.ts +24 -1
  161. package/dist/types.d.ts.map +1 -1
  162. package/dist/workspace-lock.d.ts +20 -0
  163. package/dist/workspace-lock.d.ts.map +1 -1
  164. package/dist/workspace-lock.js +63 -0
  165. package/dist/workspace-lock.js.map +1 -1
  166. package/dist/workspace-resolve.d.ts +0 -9
  167. package/dist/workspace-resolve.d.ts.map +1 -1
  168. package/dist/workspace-resolve.js +115 -183
  169. package/dist/workspace-resolve.js.map +1 -1
  170. package/dist/workspaces.d.ts +15 -0
  171. package/dist/workspaces.d.ts.map +1 -1
  172. package/dist/workspaces.js +330 -90
  173. package/dist/workspaces.js.map +1 -1
  174. package/package.json +5 -4
package/dist/bin.js CHANGED
@@ -2,7 +2,8 @@
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
+ import { installOutputRedaction } from "./redaction.js";
6
7
  import { promises as fs } from "node:fs";
7
8
  import path from "node:path";
8
9
  import { fileURLToPath } from "node:url";
@@ -12,9 +13,11 @@ import { AUTO_DETECT_COMMANDS, loadConfig, resolveConfigPath } from "./config.js
12
13
  import { LeaderboardCache } from "./leaderboard.js";
13
14
  import { VERSION } from "./version.js";
14
15
  import { commandAvailable } from "./dispatchers/shared/which-available.js";
15
- import { inspectClientEntries } from "./mcp-clients.js";
16
+ import { codexLoginState } from "./dispatchers/shared/harness-login.js";
17
+ import { clientConfigLocations, inspectClientEntries } from "./mcp-clients.js";
16
18
  import { buildDispatchers } from "./mcp/dispatcher-factory.js";
17
19
  import { startMcpServer } from "./mcp/server.js";
20
+ import { resolveRunnerPath } from "./jobs.js";
18
21
  import { initObservability } from "./observability/index.js";
19
22
  import { QuotaCache } from "./quota.js";
20
23
  import { Router } from "./router.js";
@@ -22,9 +25,9 @@ import { buildStatus, buildUsage, renderStatusText, renderUsageText } from "./st
22
25
  import { startHttpServer } from "./http/server.js";
23
26
  import { billingIsBlocked, buildRouteBilling } from "./billing.js";
24
27
  import { effectiveSafetyProfile } from "./safety.js";
25
- import { configToYaml } from "./configure-yaml.js";
26
- import { devLaunchCommand, planClientWrites, removeClientEntry, writeClientEntry, } from "./client-register.js";
27
- import { stateRoot } from "./state-dir.js";
28
+ import { configToYaml, isUneditedGenerated, stampGenerated } from "./configure-yaml.js";
29
+ import { desiredEntry, devLaunchCommand, launchCommand, planClientWrites, removeClientEntry, writeClientEntry, } from "./client-register.js";
30
+ import { stateRoot, userConfigPath } from "./state-dir.js";
28
31
  async function buildRuntime(configPath) {
29
32
  const config = await loadConfig(configPath);
30
33
  const dispatchers = await buildDispatchers(config);
@@ -35,8 +38,21 @@ async function buildRuntime(configPath) {
35
38
  const router = new Router(config, quota, dispatchers, leaderboard);
36
39
  return { config, dispatchers, quota, leaderboard, router };
37
40
  }
38
- function printUsage() {
39
- process.stdout.write([
41
+ /**
42
+ * Did this invocation ask for machine-readable output?
43
+ *
44
+ * Read from raw argv rather than the parsed flags because the two callers are
45
+ * the unknown-command branch and the top-level error handler, both of which
46
+ * run where parsing has either not happened or already failed. `--json=true`
47
+ * counts: an acceptance pass measured that spelling getting JSON on success
48
+ * and plain text on failure, which is the inconsistency the envelope exists to
49
+ * remove.
50
+ */
51
+ function wantsJsonOutput() {
52
+ return process.argv.slice(2).some((a) => a === "--json" || a.startsWith("--json="));
53
+ }
54
+ function printUsage(stream = process.stdout) {
55
+ stream.write([
40
56
  "harness-dispatch",
41
57
  "",
42
58
  "Usage:",
@@ -78,30 +94,68 @@ function printUsage() {
78
94
  "",
79
95
  ].join("\n"));
80
96
  }
81
- async function cmdConfigure(configPath, explicitConfigPath, opts) {
97
+ async function cmdConfigure(configPath, opts) {
82
98
  // configure's --config names where it will WRITE, so a path that does not
83
99
  // exist yet is the normal first-run case, not a typo.
84
- const config = await loadConfig(configPath, { allowMissing: true });
100
+ const target = configPath ?? userConfigPath();
101
+ // A file configure wrote and nobody edited is regenerated from a fresh
102
+ // detection rather than loaded: loading it would make it authoritative and
103
+ // hide the harness installed since — the reason the user is re-running.
104
+ // Anything else on disk is the user's, loaded so its settings migrate, and
105
+ // guarded below. See stampGenerated for the natural-order story.
106
+ const existing = existsSync(target) ? await fs.readFile(target, "utf-8") : undefined;
107
+ const regenerate = existing !== undefined && isUneditedGenerated(existing);
108
+ const config = await loadConfig(regenerate ? undefined : configPath, { allowMissing: true });
85
109
  const routeCount = Object.keys(config.services).length;
86
110
  if (opts.print) {
87
111
  // Preview goes to a terminal and, routinely, into a bug report — a
88
112
  // literal key with no ${VAR} to restore is redacted rather than echoed.
89
113
  const preview = configToYaml(config, { redactLiterals: true });
90
114
  process.stdout.write(preview);
91
- const redacted = Object.values(config.services).some((svc) => svc.apiKey !== undefined &&
115
+ const keyRedacted = Object.values(config.services).some((svc) => svc.apiKey !== undefined &&
92
116
  svc.apiKey !== "" &&
93
117
  config.envRefs?.get(svc.apiKey) === undefined &&
94
118
  config.apiKeyRefs?.get(svc.name) === undefined);
95
- if (redacted) {
96
- process.stderr.write("note: one or more api_key values are literals in the source config and were " +
97
- "replaced with ${ENV_VAR} placeholders in this preview. Move them to " +
98
- "environment variables this output is not a drop-in replacement for that file " +
99
- "until you do.\n");
119
+ // The note used to speak only for the api_key while the base_url beside it
120
+ // was printed whole, so a preview containing a URL password carried a
121
+ // sentence implying it had been sanitised. It now names what it did.
122
+ const urlRedacted = Object.values(config.services).some((svc) => {
123
+ if (svc.baseUrl === undefined || svc.baseUrl === "")
124
+ return false;
125
+ if (config.envRefs?.get(svc.baseUrl) !== undefined)
126
+ return false;
127
+ try {
128
+ const url = new URL(svc.baseUrl);
129
+ return (url.password !== "" || url.username !== "" || [...url.searchParams.keys()].length > 0);
130
+ }
131
+ catch {
132
+ return true;
133
+ }
134
+ });
135
+ if (keyRedacted || urlRedacted) {
136
+ const what = [
137
+ keyRedacted ? "api_key values" : undefined,
138
+ urlRedacted ? "credential-bearing parts of base_url" : undefined,
139
+ ]
140
+ .filter(Boolean)
141
+ .join(" and ");
142
+ process.stderr.write(`note: ${what} are literals in the source config and were redacted in this ` +
143
+ "preview. Move them to environment variables — this output is not a drop-in " +
144
+ "replacement for that file until you do.\n");
100
145
  }
101
146
  return 0;
102
147
  }
103
148
  const yamlText = configToYaml(config, { redactLiterals: false });
104
- process.stdout.write(`Detected ${routeCount} harness route${routeCount === 1 ? "" : "s"}.\n`);
149
+ // "Detected" is only true when detection ran. Over an edited file that lists
150
+ // its own routes it does not (the file is authoritative), and an acceptance
151
+ // pass measured `configure --yes --force` printing "Detected 1 harness route"
152
+ // with a second harness on PATH that never appeared in the output.
153
+ const plural = routeCount === 1 ? "" : "s";
154
+ process.stdout.write(config.detectionRan === false
155
+ ? `${routeCount} route${plural} from ${path.resolve(configPath ?? target)} — detection did not run, ` +
156
+ "because this file lists its own routes. To merge harnesses installed since, add " +
157
+ "`detect: true` to it; to start over from a fresh detection, delete it first.\n"
158
+ : `Detected ${routeCount} harness route${plural}.\n`);
105
159
  for (const [name, svc] of Object.entries(config.services)) {
106
160
  process.stdout.write(`- ${name}: harness=${svc.harness ?? name} billing=${buildRouteBilling(svc).kind} safety=${effectiveSafetyProfile(svc)} model=${svc.model ?? svc.leaderboardModel ?? "unknown"}\n`);
107
161
  }
@@ -120,7 +174,10 @@ async function cmdConfigure(configPath, explicitConfigPath, opts) {
120
174
  process.stdout.write(`- ${warning}\n`);
121
175
  }
122
176
  }
123
- const target = configPath ?? "config.yaml";
177
+ if (regenerate) {
178
+ process.stdout.write(`\n${path.resolve(target)} is unedited configure output — ` +
179
+ `${opts.yes ? "regenerating it from this detection" : "--yes will regenerate it from this detection"}.\n`);
180
+ }
124
181
  if (!opts.yes) {
125
182
  process.stdout.write(`\nNo files written. Re-run with --yes to write ${target}, or use --print to inspect YAML.\n`);
126
183
  process.stdout.write("After writing config, connect agents by adding the harness-dispatch MCP snippet to the agent you use.\n");
@@ -133,7 +190,7 @@ async function cmdConfigure(configPath, explicitConfigPath, opts) {
133
190
  // you to pass --config, which is exactly what disabled it. Overwriting a
134
191
  // hand-written config is not recoverable, so it now takes an explicit
135
192
  // --force rather than an accident of which flag you happened to use.
136
- if (existsSync(target) && !opts.force) {
193
+ if (existsSync(target) && !opts.force && !regenerate) {
137
194
  process.stderr.write(`configure: ${target} already exists and would be overwritten.\n` +
138
195
  "Use --print to inspect the generated YAML, --config <other-path> to write\n" +
139
196
  "elsewhere, or --force to overwrite it deliberately.\n");
@@ -151,9 +208,12 @@ async function cmdConfigure(configPath, explicitConfigPath, opts) {
151
208
  // Applied on create only: `writeFile`'s mode does not change an existing
152
209
  // file's permissions, so re-running `configure` will not silently tighten a
153
210
  // file the user deliberately made group-readable.
154
- await fs.writeFile(target, yamlText, { encoding: "utf-8", mode: 0o600 });
211
+ // The default target lives in the state directory, which a first run has
212
+ // not created yet. Same mode the rest of the state dir gets.
213
+ await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
214
+ await fs.writeFile(target, stampGenerated(yamlText), { encoding: "utf-8", mode: 0o600 });
155
215
  const absoluteTarget = path.resolve(target);
156
- process.stdout.write(`Wrote ${target}.\n`);
216
+ process.stdout.write(`Wrote ${absoluteTarget}.\n`);
157
217
  // The last step of setup used to be "here is some JSON, paste it somewhere".
158
218
  // Nobody owned the result, and the paths in it later moved — which is how one
159
219
  // machine ended up running a dead entry, a dead hook, and a third client
@@ -174,7 +234,16 @@ async function cmdConfigure(configPath, explicitConfigPath, opts) {
174
234
  process.stdout.write("\nMCP snippet (uses an absolute --config path so it resolves correctly no matter what\n" +
175
235
  "directory the MCP client launches from — a relative path or none at all silently\n" +
176
236
  "falls back to the shipped defaults, ignoring every edit you make to this file):\n");
177
- printMcpSnippet({ command: "harness-dispatch", args: ["--config", absoluteTarget] });
237
+ // The SAME entry `connect` writes, built by the same function.
238
+ //
239
+ // This printed a hardcoded `harness-dispatch --config <path>` while connect
240
+ // writes whatever `launchCommand()` resolves to — `npx -y harness-dispatch
241
+ // --config <path>` without a global install. So pasting the snippet this
242
+ // command prints and then running `connect --remove` was answered "has an
243
+ // entry we did not write — left alone unless --force", and exit 1. The tool
244
+ // called its own documented output hand-edited, and blocked both update and
245
+ // removal on the manual-install path it had just recommended.
246
+ printMcpSnippet(desiredEntry(absoluteTarget, launchCommand()));
178
247
  process.stdout.write("Or let `harness-dispatch connect` write it for you.\n");
179
248
  return 0;
180
249
  }
@@ -194,7 +263,7 @@ function printMcpSnippet(entry) {
194
263
  * than hanging or guessing.
195
264
  */
196
265
  async function cmdConnect(configPath, opts) {
197
- const target = path.resolve(configPath ?? "config.yaml");
266
+ const target = path.resolve(configPath ?? userConfigPath());
198
267
  if (!existsSync(target) && !opts.remove) {
199
268
  process.stderr.write(`connect: no config at ${target}. Run \`harness-dispatch configure --yes\` first —\n` +
200
269
  "a client entry pointing at a config that does not exist is the failure this\n" +
@@ -272,7 +341,8 @@ async function cmdConnect(configPath, opts) {
272
341
  : await writeClientEntry(plan, { stamp, consented });
273
342
  if (outcome.action === "written") {
274
343
  wrote = true;
275
- process.stdout.write(`${opts.remove ? "Removed from" : "Wrote"} ${outcome.client} (backup: ${outcome.backupPath})\n`);
344
+ process.stdout.write(`${opts.remove ? "Removed from" : "Wrote"} ${outcome.client} ` +
345
+ `(${outcome.backupPath !== undefined ? `backup: ${outcome.backupPath}` : `created ${outcome.file}`})\n`);
276
346
  }
277
347
  else if (outcome.action === "unchanged") {
278
348
  process.stdout.write(`${outcome.client}: ${opts.remove ? "no entry of ours to remove" : "already correct"}, nothing changed.\n`);
@@ -299,6 +369,7 @@ function describeState(state, removing) {
299
369
  if (removing) {
300
370
  return {
301
371
  absent: "not installed",
372
+ "missing-file": "installed, no config file yet — nothing to remove",
302
373
  unreadable: "config does not parse — will be left alone",
303
374
  "missing-entry": "no entry of ours to remove",
304
375
  matches: "our entry is here — will be removed",
@@ -307,6 +378,7 @@ function describeState(state, removing) {
307
378
  }
308
379
  return {
309
380
  absent: "not installed",
381
+ "missing-file": "installed, no config file yet — one will be created",
310
382
  unreadable: "config does not parse — will be left alone",
311
383
  "missing-entry": "no harness-dispatch entry yet",
312
384
  matches: "already registered correctly",
@@ -328,7 +400,24 @@ function summariseEntry(entry) {
328
400
  * differing entry was the WORKING one.
329
401
  */
330
402
  async function chooseInteractively(plans, opts) {
331
- const actionable = plans.filter((p) => p.state !== "matches" && p.state !== "unreadable");
403
+ // What counts as actionable INVERTS under --remove.
404
+ //
405
+ // Registering: `matches` means the entry is already what we would write, so
406
+ // there is nothing to do. Removing: `matches` is exactly the entry being
407
+ // removed, so filtering it out left nothing actionable — and the command
408
+ // printed "our entry is here — will be removed", exited 0, and changed
409
+ // nothing. Reproduced byte-for-byte: same md5 before and after.
410
+ //
411
+ // Only the bare form was affected, because `--clients` bypasses this
412
+ // function entirely. That is the form README documents twice and
413
+ // OPERATIONS.md once, and no test covered this command at all.
414
+ // Stated as what IS actionable rather than what is not: the states are
415
+ // absent / unreadable / missing-entry / matches / differs, and under
416
+ // --remove only the two that actually hold an entry qualify. Written as a
417
+ // negation, `absent` (no config file at all) slipped through.
418
+ const actionable = plans.filter((p) => opts.remove === true
419
+ ? p.state === "matches" || p.state === "differs"
420
+ : p.state !== "matches" && p.state !== "unreadable");
332
421
  if (actionable.length === 0)
333
422
  return [];
334
423
  // `--yes` skips the question; it does NOT answer it.
@@ -341,8 +430,12 @@ async function chooseInteractively(plans, opts) {
341
430
  if (opts.yes)
342
431
  return actionable;
343
432
  if (!process.stdin.isTTY) {
344
- process.stdout.write("\nNot a terminal, so nothing was written. Re-run with --clients " +
345
- `${actionable.map((p) => p.id).join(",")} (or --yes) to apply.\n`);
433
+ process.stdout.write(
434
+ // Names the command, not just the flags: this is reached from
435
+ // `configure --yes` too, where "or --yes" told the user to pass the flag
436
+ // they had already passed.
437
+ "\nNot a terminal, so nothing was written. Run `harness-dispatch connect --clients " +
438
+ `${actionable.map((p) => p.id).join(",")}\` (or \`connect --yes\`) to register.\n`);
346
439
  return undefined;
347
440
  }
348
441
  const { createInterface } = await import("node:readline/promises");
@@ -435,6 +528,13 @@ async function cmdDoctor(configPath, opts) {
435
528
  const nodeOk = (nodeMajor ?? 0) > 22 ||
436
529
  ((nodeMajor ?? 0) === 22 &&
437
530
  ((nodeMinor ?? 0) > 22 || ((nodeMinor ?? 0) === 22 && (nodePatch ?? 0) >= 2)));
531
+ const configuredCommands = new Set(status.routes
532
+ .map((route) => route.command)
533
+ .filter((command) => typeof command === "string")
534
+ .map((command) => path.basename(command).replace(/\.(cmd|exe)$/i, "")));
535
+ const unconfiguredHarnesses = runtime.config.detectionRan === false
536
+ ? Object.values(AUTO_DETECT_COMMANDS).filter((command) => commandAvailable(command) && !configuredCommands.has(command))
537
+ : [];
438
538
  const checks = [
439
539
  {
440
540
  name: "node",
@@ -446,7 +546,21 @@ async function cmdDoctor(configPath, opts) {
446
546
  {
447
547
  name: "config",
448
548
  ok: Object.keys(runtime.config.services).length > 0,
449
- detail: `${Object.keys(runtime.config.services).length} configured route(s)`,
549
+ // Names the file, because "which config is this looking at" was the
550
+ // question: `configure` run from one directory and `doctor` from
551
+ // another used to load different things and neither said so.
552
+ detail: `${Object.keys(runtime.config.services).length} configured route(s)` +
553
+ (configPath === undefined
554
+ ? " (no config file found; shipped defaults with auto-detected harnesses)"
555
+ : runtime.config.detectionRan === false
556
+ ? ` from ${path.resolve(configPath)}`
557
+ : // Detection ran. Two different reasons, and saying the wrong one
558
+ // is confusing: a file may define no routes at all, or it may
559
+ // define some AND ask for detection with `detect: true`. The
560
+ // first version reported the former for both.
561
+ (runtime.config.detect === true
562
+ ? ` from ${path.resolve(configPath)} plus auto-detected harnesses (detect: true)`
563
+ : ` auto-detected — ${path.resolve(configPath)} defines no routes of its own`)),
450
564
  },
451
565
  // This one DOES fail, unlike the advisory git check below.
452
566
  //
@@ -464,11 +578,20 @@ async function cmdDoctor(configPath, opts) {
464
578
  const entries = inspectClientEntries();
465
579
  const broken = entries.filter((e) => e.missingPaths.length > 0);
466
580
  if (entries.length === 0) {
581
+ // Name the client that IS here, so "not registered" reads as the
582
+ // next step rather than as "nothing to register with".
583
+ const present = clientConfigLocations()
584
+ .filter((c) => c.commands.some((cmd) => commandAvailable(cmd)))
585
+ .map((c) => c.client);
467
586
  return {
468
587
  name: "mcp-clients",
469
588
  ok: true,
470
- detail: "not registered with any MCP client this tool knows how to read " +
471
- "(Claude Code, Cursor) run `harness-dispatch connect` to register it",
589
+ detail: present.length > 0
590
+ ? `${present.join(", ")} installed but harness-dispatch is not registered with it — ` +
591
+ "run `harness-dispatch connect`"
592
+ : "not registered with any MCP client this tool knows how to read " +
593
+ "(Claude Code, Cursor), and none is installed — run `harness-dispatch connect` " +
594
+ "after installing one",
472
595
  };
473
596
  }
474
597
  return {
@@ -546,11 +669,22 @@ async function cmdDoctor(configPath, opts) {
546
669
  // When nothing is ready, say what was looked for. "0 ready route(s)" on
547
670
  // its own leaves a new user with no idea whether the tool is broken or
548
671
  // simply has nothing to route to, and no hint what to install.
549
- detail: status.ready.length > 0
550
- ? `${status.ready.length} ready route(s)`
672
+ detail: (status.ready.length > 0
673
+ ? // Trailing period so this reads as a sentence when the
674
+ // installed-but-unconfigured note is appended after it.
675
+ `${status.ready.length} ready route(s).`
551
676
  : `0 ready route(s). Looked for these harness CLIs on PATH: ` +
552
677
  `${Object.values(AUTO_DETECT_COMMANDS).join(", ")}. ` +
553
- `Install one, or add a route to config.yaml (endpoints: need no CLI).`,
678
+ `Install one, or add a route to config.yaml (endpoints: need no CLI).`) +
679
+ // A config that lists its own routes is authoritative, so a harness
680
+ // installed later is simply absent — and this line said "1 ready
681
+ // route(s)" with a second CLI on PATH and no hint. The hint about PATH
682
+ // above only fired at zero routes.
683
+ (unconfiguredHarnesses.length > 0
684
+ ? ` Installed but not in this config: ${unconfiguredHarnesses.join(", ")} — add ` +
685
+ `\`detect: true\` to ${configPath !== undefined ? path.resolve(configPath) : "the config"} ` +
686
+ `to merge them, or a clis: entry for each.`
687
+ : ""),
554
688
  },
555
689
  {
556
690
  // Nothing checked this, so an unwritable state directory surfaced only
@@ -558,8 +692,27 @@ async function cmdDoctor(configPath, opts) {
558
692
  // run finished" — a false cause, 90s after the work had actually
559
693
  // succeeded.
560
694
  name: "state-dir",
561
- ok: stateDirWritable().ok,
562
- detail: stateDirWritable().detail,
695
+ // Called ONCE: each call creates and deletes a probe file, and this
696
+ // asked the same question twice to fill two fields.
697
+ ...stateDirWritable(),
698
+ },
699
+ {
700
+ // Whether dispatches will actually be detached.
701
+ //
702
+ // `resolveRunnerPath()` returning undefined is not an error — it is the
703
+ // signal to run the job IN-PROCESS, which is right for an unbuilt
704
+ // checkout and wrong everywhere else: the concurrency cap is enforced by
705
+ // the supervisor pool, so in-process mode silently removes the bound
706
+ // that exists because of a measured OOM. It prints one line on stderr at
707
+ // dispatch time and nothing checked it, so "am I actually capped?" had
708
+ // no answer. An audit noticed; this is that answer.
709
+ name: "job-runner",
710
+ ok: resolveRunnerPath() !== undefined,
711
+ detail: resolveRunnerPath() !== undefined
712
+ ? "found; jobs run detached and the concurrency cap applies"
713
+ : "dist/job-runner.js not found — jobs will run IN-PROCESS, which " +
714
+ "removes the max_concurrent_runs cap and does not survive a server " +
715
+ "restart. Run `npm run build`, or reinstall the package.",
563
716
  },
564
717
  {
565
718
  name: "http-auth",
@@ -570,6 +723,63 @@ async function cmdDoctor(configPath, opts) {
570
723
  },
571
724
  ];
572
725
  const blocked = status.skippedRoutes.filter((skip) => skip.code === "paid_blocked" || skip.code === "unknown_billing");
726
+ // A ready route is one whose CLI is on PATH. That said nothing about whether
727
+ // the CLI could actually make a request: an installed, never-logged-in Codex
728
+ // passed routes, billing and safety, and the first dispatch then failed with
729
+ // a raw OpenAI 401 that never mentioned `codex login`. The cold-install walk
730
+ // in acceptance/0.8.0.md is where that was seen. The CLI is asked directly
731
+ // (see harness-login.ts for why not the credential file), and only a
732
+ // definite "not logged in" fails the check.
733
+ const codexRoutes = status.routes.filter((route) => status.ready.includes(route.id) && route.harness === "codex" && route.command);
734
+ const loginStates = await Promise.all(codexRoutes.map(async (route) => ({ route, state: await codexLoginState(route.command) })));
735
+ const loggedOut = loginStates.filter((entry) => entry.state === "logged_out");
736
+ // A route that has NEVER succeeded is worth saying out loud.
737
+ //
738
+ // The breaker is about recent failure and forgets after its cooldown, so a
739
+ // route that is simply dead — a host that no longer resolves, a key that was
740
+ // revoked — keeps being selected, failing, and falling back, forever. On the
741
+ // maintainer's own machine a local endpoint sat at 8 calls and 0 successes
742
+ // while being tier-3-preferred for `review`, so every review dispatch paid
743
+ // for one doomed attempt before falling back. Nothing reported it: `usage`
744
+ // showed the counts and nobody reads `usage` when things merely feel slow.
745
+ //
746
+ // Advisory, never a failure: a fresh install has no calls at all, and a
747
+ // route can legitimately fail its first few (a laptop that was asleep). The
748
+ // threshold is about having enough evidence to be worth mentioning, not
749
+ // about being sure.
750
+ const NEVER_SUCCEEDED_MIN_CALLS = 5;
751
+ const deadRoutes = status.routes
752
+ .filter((route) => status.ready.includes(route.id))
753
+ .map((route) => ({
754
+ id: route.id,
755
+ calls: route.quota.localCallCount ?? 0,
756
+ successes: route.quota.localSuccessCount ?? 0,
757
+ }))
758
+ .filter((r) => r.calls >= NEVER_SUCCEEDED_MIN_CALLS && r.successes === 0);
759
+ checks.push({
760
+ name: "route-health",
761
+ ok: true,
762
+ detail: deadRoutes.length === 0
763
+ ? "no ready route has failed every call it has been given"
764
+ : deadRoutes
765
+ .map((r) => `${r.id} has never succeeded (${r.calls} calls, 0 successes) — it is still ` +
766
+ `being selected and failing, so every dispatch it wins costs an attempt ` +
767
+ `before falling back. Check the endpoint or credential, or disable it.`)
768
+ .join(" | "),
769
+ });
770
+ checks.push({
771
+ name: "harness-login",
772
+ ok: loggedOut.length === 0,
773
+ detail: loginStates.length === 0
774
+ ? "no ready route has a login state this tool knows how to ask for (Codex only, today)"
775
+ : loggedOut.length > 0
776
+ ? `${loggedOut.map((entry) => entry.route.id).join(", ")}: codex reports "Not logged in" — ` +
777
+ "run `codex login` (or `codex login --with-api-key`), or every dispatch to it fails " +
778
+ "with 401 Unauthorized from OpenAI"
779
+ : loginStates
780
+ .map((entry) => `${entry.route.id}: ${entry.state === "logged_in" ? "logged in" : "could not determine (codex login status gave no answer)"}`)
781
+ .join("; "),
782
+ });
573
783
  checks.push({
574
784
  name: "billing-policy",
575
785
  ok: true,
@@ -662,8 +872,10 @@ async function cmdAuth(action) {
662
872
  return 0;
663
873
  }
664
874
  default:
665
- process.stderr.write("auth: expected show or rotate\n");
666
- return 1;
875
+ // Thrown, not written: the top-level handler is the one place that
876
+ // knows whether --json was asked for. Written here it bypassed the
877
+ // envelope, so `auth --json` reported failure as a bare sentence.
878
+ throw new UsageError("auth: expected show or rotate");
667
879
  }
668
880
  }
669
881
  /**
@@ -682,9 +894,8 @@ async function cmdAuth(action) {
682
894
  */
683
895
  async function cmdDispatch(prompt, configPath, opts) {
684
896
  if (!prompt) {
685
- process.stderr.write('dispatch: missing prompt. Usage: dispatch [--service <id>] [--safety <profile>]\n' +
686
- ' [--task-type <type>] [--no-fallback] [--json] "<prompt>"\n');
687
- return 1;
897
+ throw new UsageError('dispatch: missing prompt. Usage: dispatch [--service <id>] [--safety <profile>] ' +
898
+ '[--task-type <type>] [--no-fallback] [--json] "<prompt>"');
688
899
  }
689
900
  const runtime = await buildRuntime(configPath);
690
901
  const hints = { taskType: opts.taskType ?? "execute" };
@@ -769,6 +980,9 @@ function serveOpts(values) {
769
980
  return out;
770
981
  }
771
982
  export async function main(argv) {
983
+ // Terminal output is a sink; see src/redaction.ts. Installed before any
984
+ // config is loaded, which is fine — the registry is consulted per write.
985
+ installOutputRedaction();
772
986
  const { values, positionals } = parseArgs({
773
987
  args: argv,
774
988
  options: {
@@ -834,7 +1048,10 @@ export async function main(argv) {
834
1048
  const [command, ...rest] = positionals;
835
1049
  // `--config` with no value: parseArgs yields boolean true, which reached
836
1050
  // path.join and threw ERR_INVALID_ARG_TYPE as a raw Node stack trace.
837
- if (values.config !== undefined && typeof values.config !== "string") {
1051
+ // `--config=` (empty) is the same mistake with a string type: it resolved to
1052
+ // "", loadConfig treated it as no path, and doctor reported the auto-detected
1053
+ // routes as loaded "from" the current directory.
1054
+ if (values.config !== undefined && (typeof values.config !== "string" || values.config === "")) {
838
1055
  throw new UsageError("--config needs a path, e.g. --config ./config.yaml");
839
1056
  }
840
1057
  const explicitConfigPath = values.config;
@@ -860,7 +1077,7 @@ export async function main(argv) {
860
1077
  }
861
1078
  switch (command) {
862
1079
  case "configure":
863
- return cmdConfigure(configPath, explicitConfigPath, {
1080
+ return cmdConfigure(configPath, {
864
1081
  print: Boolean(values.print),
865
1082
  yes: Boolean(values.yes),
866
1083
  force: Boolean(values.force),
@@ -916,9 +1133,16 @@ export async function main(argv) {
916
1133
  }
917
1134
  return main(configPath !== undefined ? ["--config", configPath] : []);
918
1135
  default:
919
- process.stderr.write(`unknown command: ${command}\n`);
920
- printUsage();
921
- return 1;
1136
+ // Usage goes to STDERR here, not stdout. An unknown command is an
1137
+ // error, and printing the help block on stdout meant
1138
+ // `harness-dispatch frobnicate --json | jq` got the usage text as
1139
+ // its input — the exact pipe the --json envelope keeps parseable.
1140
+ // Suppressed under --json: the caller asked for machine-readable
1141
+ // output, and a help block ahead of the envelope makes stderr
1142
+ // unparseable in the same way stdout was.
1143
+ if (!wantsJsonOutput())
1144
+ printUsage(process.stderr);
1145
+ throw new UsageError(`unknown command: ${command}`);
922
1146
  }
923
1147
  }
924
1148
  /**
@@ -949,8 +1173,27 @@ function finish(code) {
949
1173
  }, EXIT_DRAIN_GRACE_MS);
950
1174
  bail.unref();
951
1175
  }
952
- const entrypoint = typeof process !== "undefined" && Array.isArray(process.argv) ? process.argv[1] : "";
953
- if (entrypoint && (entrypoint.endsWith("bin.ts") || entrypoint.endsWith("bin.js"))) {
1176
+ // Run main() only when this file is the process entrypoint, not when a test
1177
+ // imports it. `argv[1]` is the path the user invoked, which is NOT this file
1178
+ // when npm installed the command as a symlink (`/usr/local/bin/harness-dispatch`
1179
+ // on Linux and macOS): node does not resolve it, so a name check alone
1180
+ // silently ran nothing there and exited 0 — every documented command was a
1181
+ // no-op for every non-Windows `npm install -g` user through 0.8.0. Windows
1182
+ // never hit it because npm's .cmd shim passes the real dist/bin.js path.
1183
+ const entrypoint = typeof process !== "undefined" && Array.isArray(process.argv) ? (process.argv[1] ?? "") : "";
1184
+ function isThisFile(invoked) {
1185
+ if (!invoked)
1186
+ return false;
1187
+ if (invoked.endsWith("bin.ts") || invoked.endsWith("bin.js"))
1188
+ return true;
1189
+ try {
1190
+ return realpathSync(invoked) === realpathSync(fileURLToPath(import.meta.url));
1191
+ }
1192
+ catch {
1193
+ return false;
1194
+ }
1195
+ }
1196
+ if (isThisFile(entrypoint)) {
954
1197
  void main(process.argv.slice(2))
955
1198
  .then((code) => {
956
1199
  finish(code);
@@ -963,8 +1206,16 @@ if (entrypoint && (entrypoint.endsWith("bin.ts") || entrypoint.endsWith("bin.js"
963
1206
  // reliable way to tell "bug" from "bad input" by class here. Only a
964
1207
  // non-Error throw (a genuine programming error) keeps its stack.
965
1208
  if (err instanceof UsageError || err instanceof Error) {
966
- process.stderr.write(`harness-dispatch: ${err.message}
967
- `);
1209
+ // `--json` is a promise about the SHAPE of this command's output, and
1210
+ // it was kept only on the success path: a bad --config made
1211
+ // `doctor --json` print a sentence, so anything parsing the output got
1212
+ // a parse error instead of the reason. The message is the same; only
1213
+ // the envelope follows what was asked for. Errors still go to stderr,
1214
+ // so a caller reading stdout for results is unaffected either way.
1215
+ const wantsJson = wantsJsonOutput();
1216
+ process.stderr.write(wantsJson
1217
+ ? `${JSON.stringify({ ok: false, error: err.message }, null, 2)}\n`
1218
+ : `harness-dispatch: ${err.message}\n`);
968
1219
  process.exit(1);
969
1220
  }
970
1221
  throw err;