harness-dispatch 0.7.8 → 0.8.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.
- package/CHANGELOG.md +706 -1
- package/README.md +35 -5
- package/config.default.yaml +30 -2
- package/dist/auth.d.ts +29 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +58 -5
- package/dist/auth.js.map +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +449 -21
- package/dist/bin.js.map +1 -1
- package/dist/breaker-store.d.ts +25 -1
- package/dist/breaker-store.d.ts.map +1 -1
- package/dist/breaker-store.js +193 -25
- package/dist/breaker-store.js.map +1 -1
- package/dist/client-register.d.ts +147 -0
- package/dist/client-register.d.ts.map +1 -0
- package/dist/client-register.js +380 -0
- package/dist/client-register.js.map +1 -0
- package/dist/config/validation.d.ts +29 -0
- package/dist/config/validation.d.ts.map +1 -1
- package/dist/config/validation.js +88 -0
- package/dist/config/validation.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +55 -2
- package/dist/config.js.map +1 -1
- package/dist/dispatch-log.d.ts +14 -0
- package/dist/dispatch-log.d.ts.map +1 -1
- package/dist/dispatch-log.js +3 -0
- package/dist/dispatch-log.js.map +1 -1
- package/dist/dispatchers/generic-cli.d.ts.map +1 -1
- package/dist/dispatchers/generic-cli.js +35 -0
- package/dist/dispatchers/generic-cli.js.map +1 -1
- package/dist/dispatchers/openai-compatible.d.ts +20 -4
- package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
- package/dist/dispatchers/openai-compatible.js +145 -17
- package/dist/dispatchers/openai-compatible.js.map +1 -1
- package/dist/dispatchers/shared/rate-limit-headers.d.ts +4 -2
- package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -1
- package/dist/dispatchers/shared/rate-limit-headers.js +4 -2
- package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -1
- package/dist/http/answer-stream.d.ts +52 -0
- package/dist/http/answer-stream.d.ts.map +1 -0
- package/dist/http/answer-stream.js +47 -0
- package/dist/http/answer-stream.js.map +1 -0
- package/dist/http/parse.d.ts.map +1 -1
- package/dist/http/parse.js +41 -0
- package/dist/http/parse.js.map +1 -1
- package/dist/http/server.d.ts.map +1 -1
- package/dist/http/server.js +102 -9
- package/dist/http/server.js.map +1 -1
- package/dist/jobs/store.d.ts.map +1 -1
- package/dist/jobs/store.js +45 -4
- package/dist/jobs/store.js.map +1 -1
- package/dist/jobs.d.ts +24 -0
- package/dist/jobs.d.ts.map +1 -1
- package/dist/jobs.js +101 -1
- package/dist/jobs.js.map +1 -1
- package/dist/mcp/config-hot-reload.d.ts.map +1 -1
- package/dist/mcp/config-hot-reload.js +10 -0
- package/dist/mcp/config-hot-reload.js.map +1 -1
- package/dist/mcp/near-miss-guard.d.ts +45 -0
- package/dist/mcp/near-miss-guard.d.ts.map +1 -0
- package/dist/mcp/near-miss-guard.js +98 -0
- package/dist/mcp/near-miss-guard.js.map +1 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +36 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-schemas.d.ts.map +1 -1
- package/dist/mcp/tool-schemas.js +21 -4
- package/dist/mcp/tool-schemas.js.map +1 -1
- package/dist/mcp/tools.d.ts +32 -0
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +41 -0
- package/dist/mcp/tools.js.map +1 -1
- package/dist/mcp-clients.d.ts +63 -0
- package/dist/mcp-clients.d.ts.map +1 -0
- package/dist/mcp-clients.js +117 -0
- package/dist/mcp-clients.js.map +1 -0
- package/dist/near-miss.d.ts +63 -0
- package/dist/near-miss.d.ts.map +1 -0
- package/dist/near-miss.js +132 -0
- package/dist/near-miss.js.map +1 -0
- package/dist/quota.d.ts +31 -1
- package/dist/quota.d.ts.map +1 -1
- package/dist/quota.js +68 -6
- package/dist/quota.js.map +1 -1
- package/dist/route-policy.d.ts +10 -0
- package/dist/route-policy.d.ts.map +1 -1
- package/dist/route-policy.js +20 -2
- package/dist/route-policy.js.map +1 -1
- package/dist/router.d.ts +61 -2
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +165 -29
- package/dist/router.js.map +1 -1
- package/dist/safety.d.ts.map +1 -1
- package/dist/safety.js +31 -1
- package/dist/safety.js.map +1 -1
- package/dist/status.d.ts +30 -21
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +152 -8
- package/dist/status.js.map +1 -1
- package/dist/types.d.ts +43 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/working-dir.d.ts.map +1 -1
- package/dist/working-dir.js +16 -0
- package/dist/working-dir.js.map +1 -1
- package/dist/workspace-resolve.d.ts.map +1 -1
- package/dist/workspace-resolve.js +119 -15
- package/dist/workspace-resolve.js.map +1 -1
- package/dist/workspaces.d.ts +83 -0
- package/dist/workspaces.d.ts.map +1 -1
- package/dist/workspaces.js +288 -15
- package/dist/workspaces.js.map +1 -1
- package/package.json +4 -3
package/dist/bin.js
CHANGED
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
import { existsSync, mkdirSync, 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";
|
|
13
|
+
import { VERSION } from "./version.js";
|
|
14
|
+
import { commandAvailable } from "./dispatchers/shared/which-available.js";
|
|
15
|
+
import { inspectClientEntries } from "./mcp-clients.js";
|
|
12
16
|
import { buildDispatchers } from "./mcp/dispatcher-factory.js";
|
|
13
17
|
import { startMcpServer } from "./mcp/server.js";
|
|
14
18
|
import { initObservability } from "./observability/index.js";
|
|
@@ -19,6 +23,7 @@ import { startHttpServer } from "./http/server.js";
|
|
|
19
23
|
import { billingIsBlocked, buildRouteBilling } from "./billing.js";
|
|
20
24
|
import { effectiveSafetyProfile } from "./safety.js";
|
|
21
25
|
import { configToYaml } from "./configure-yaml.js";
|
|
26
|
+
import { devLaunchCommand, planClientWrites, removeClientEntry, writeClientEntry, } from "./client-register.js";
|
|
22
27
|
import { stateRoot } from "./state-dir.js";
|
|
23
28
|
async function buildRuntime(configPath) {
|
|
24
29
|
const config = await loadConfig(configPath);
|
|
@@ -37,6 +42,8 @@ function printUsage() {
|
|
|
37
42
|
"Usage:",
|
|
38
43
|
" harness-dispatch Start stdio MCP.",
|
|
39
44
|
" harness-dispatch configure [--print] Detect and prepare harness config.",
|
|
45
|
+
" harness-dispatch connect Register this server with the MCP clients you have.",
|
|
46
|
+
" harness-dispatch connect --remove Take the entry back out again.",
|
|
40
47
|
" harness-dispatch doctor [--json] Check install, config, auth, and routes.",
|
|
41
48
|
" harness-dispatch doctor --live Run one routed probe when billing policy allows it.",
|
|
42
49
|
" harness-dispatch doctor --live --allow-paid Run a live probe through paid/unknown routes.",
|
|
@@ -44,6 +51,7 @@ function printUsage() {
|
|
|
44
51
|
" harness-dispatch status --watch Re-render status every --interval ms.",
|
|
45
52
|
" harness-dispatch usage [--json] Show per-route call counts, quota, and billing kind.",
|
|
46
53
|
" harness-dispatch serve [--port 3333] Serve MCP at /mcp and REST at /v1/*.",
|
|
54
|
+
' harness-dispatch dispatch "<prompt>" Route one task and print the result.',
|
|
47
55
|
" harness-dispatch auth show Print the HTTP bearer token.",
|
|
48
56
|
" harness-dispatch auth rotate Rotate the HTTP bearer token.",
|
|
49
57
|
"",
|
|
@@ -56,8 +64,17 @@ function printUsage() {
|
|
|
56
64
|
" --print configure: print generated config YAML without writing it.",
|
|
57
65
|
" --yes configure: write config.yaml instead of only previewing it.",
|
|
58
66
|
" --force configure: overwrite an existing config file.",
|
|
67
|
+
" --clients <ids> connect: comma-separated client ids, instead of prompting.",
|
|
68
|
+
" --no-clients configure: skip the offer to register with clients.",
|
|
69
|
+
" --remove connect: remove the entry rather than write it.",
|
|
70
|
+
" --dev connect: point clients at THIS checkout's build, not the package.",
|
|
59
71
|
" --allow-paid Allow doctor --live to probe paid or unknown-paid routes.",
|
|
72
|
+
" --service <id> dispatch: run exactly this route, no fallback to others.",
|
|
73
|
+
" --safety <profile> dispatch: read_only | workspace_edit | full_auto.",
|
|
74
|
+
" --task-type <type> dispatch: execute | plan | review | local.",
|
|
75
|
+
" --no-fallback dispatch: do not retry on another route if the first fails.",
|
|
60
76
|
" -h, --help Show help.",
|
|
77
|
+
" -v, --version Print the version and exit.",
|
|
61
78
|
"",
|
|
62
79
|
].join("\n"));
|
|
63
80
|
}
|
|
@@ -122,22 +139,224 @@ async function cmdConfigure(configPath, explicitConfigPath, opts) {
|
|
|
122
139
|
"elsewhere, or --force to overwrite it deliberately.\n");
|
|
123
140
|
return 1;
|
|
124
141
|
}
|
|
125
|
-
|
|
142
|
+
// 0600, because this file can contain a LITERAL api_key: `apiKeyForYaml`
|
|
143
|
+
// deliberately preserves one rather than replacing it with a `${VAR}`
|
|
144
|
+
// reference, so `configure` can write a real credential to disk. It was
|
|
145
|
+
// written with default permissions — 0644 under a typical umask — in a
|
|
146
|
+
// module family that is careful everywhere else (leaderboard 0600, state dir
|
|
147
|
+
// 0700, client-register carries the original file's mode). Found by an
|
|
148
|
+
// acceptance pass from reading, POSIX-only and not reproduced on Windows,
|
|
149
|
+
// where the mode is ignored.
|
|
150
|
+
//
|
|
151
|
+
// Applied on create only: `writeFile`'s mode does not change an existing
|
|
152
|
+
// file's permissions, so re-running `configure` will not silently tighten a
|
|
153
|
+
// file the user deliberately made group-readable.
|
|
154
|
+
await fs.writeFile(target, yamlText, { encoding: "utf-8", mode: 0o600 });
|
|
126
155
|
const absoluteTarget = path.resolve(target);
|
|
127
156
|
process.stdout.write(`Wrote ${target}.\n`);
|
|
128
|
-
|
|
157
|
+
// The last step of setup used to be "here is some JSON, paste it somewhere".
|
|
158
|
+
// Nobody owned the result, and the paths in it later moved — which is how one
|
|
159
|
+
// machine ended up running a dead entry, a dead hook, and a third client
|
|
160
|
+
// reading a different config, all silently. Offer to do it instead.
|
|
161
|
+
if (!opts.noClients) {
|
|
162
|
+
process.stdout.write("\n");
|
|
163
|
+
return cmdConnect(configPath, {
|
|
164
|
+
clients: opts.clients,
|
|
165
|
+
remove: false,
|
|
166
|
+
yes: false,
|
|
167
|
+
force: false,
|
|
168
|
+
// Setup registers the installed package. A checkout entry is a
|
|
169
|
+
// deliberate choice made by someone who knows they have a checkout, not
|
|
170
|
+
// something to infer during first-run setup.
|
|
171
|
+
dev: false,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
process.stdout.write("\nMCP snippet (uses an absolute --config path so it resolves correctly no matter what\n" +
|
|
129
175
|
"directory the MCP client launches from — a relative path or none at all silently\n" +
|
|
130
176
|
"falls back to the shipped defaults, ignoring every edit you make to this file):\n");
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
"harness-dispatch": {
|
|
134
|
-
command: "harness-dispatch",
|
|
135
|
-
args: ["--config", absoluteTarget],
|
|
136
|
-
},
|
|
137
|
-
},
|
|
138
|
-
}, null, 2) + "\n");
|
|
177
|
+
printMcpSnippet({ command: "harness-dispatch", args: ["--config", absoluteTarget] });
|
|
178
|
+
process.stdout.write("Or let `harness-dispatch connect` write it for you.\n");
|
|
139
179
|
return 0;
|
|
140
180
|
}
|
|
181
|
+
/** The entry, in the shape a client's config file wants it pasted. */
|
|
182
|
+
function printMcpSnippet(entry) {
|
|
183
|
+
if (entry === undefined)
|
|
184
|
+
return;
|
|
185
|
+
process.stdout.write(JSON.stringify({ mcpServers: { "harness-dispatch": entry } }, null, 2) + "\n");
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Register this server with the MCP clients on this machine — the last step of
|
|
189
|
+
* setup, which used to be "paste this snippet somewhere".
|
|
190
|
+
*
|
|
191
|
+
* Interactive when a human is at a terminal, flag-driven otherwise. A prompt
|
|
192
|
+
* that blocks is right in front of a person and wrong in CI, so with no TTY
|
|
193
|
+
* and no `--clients` this reports what it WOULD do and writes nothing, rather
|
|
194
|
+
* than hanging or guessing.
|
|
195
|
+
*/
|
|
196
|
+
async function cmdConnect(configPath, opts) {
|
|
197
|
+
const target = path.resolve(configPath ?? "config.yaml");
|
|
198
|
+
if (!existsSync(target) && !opts.remove) {
|
|
199
|
+
process.stderr.write(`connect: no config at ${target}. Run \`harness-dispatch configure --yes\` first —\n` +
|
|
200
|
+
"a client entry pointing at a config that does not exist is the failure this\n" +
|
|
201
|
+
"command exists to prevent.\n");
|
|
202
|
+
return 1;
|
|
203
|
+
}
|
|
204
|
+
// `import.meta.url` is this running file — dist/bin.js for an installed or
|
|
205
|
+
// built copy. "The build you are running now" is the only honest answer to
|
|
206
|
+
// which checkout --dev means, and it needs no flag value to get wrong.
|
|
207
|
+
const selfPath = fileURLToPath(import.meta.url);
|
|
208
|
+
const plans = planClientWrites(target, opts.dev ? { command: devLaunchCommand(selfPath) } : {});
|
|
209
|
+
if (opts.dev && !opts.remove) {
|
|
210
|
+
process.stdout.write(`Development entry: clients will launch ${selfPath} directly.\n` +
|
|
211
|
+
"That is an absolute path — it stops working, silently, if this directory is\n" +
|
|
212
|
+
"renamed or deleted. `harness-dispatch doctor` fails when that happens.\n\n");
|
|
213
|
+
}
|
|
214
|
+
const known = new Set(plans.map((p) => p.id));
|
|
215
|
+
const requested = opts.clients
|
|
216
|
+
?.split(",")
|
|
217
|
+
.map((s) => s.trim())
|
|
218
|
+
.filter(Boolean);
|
|
219
|
+
const unknown = requested?.filter((id) => !known.has(id)) ?? [];
|
|
220
|
+
if (unknown.length > 0) {
|
|
221
|
+
process.stderr.write(`connect: unknown client${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. ` +
|
|
222
|
+
`Known: ${[...known].join(", ")}.\n`);
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
const installed = plans.filter((p) => p.state !== "absent");
|
|
226
|
+
if (installed.length === 0) {
|
|
227
|
+
process.stdout.write("No MCP clients found on this machine (looked for Claude Code and Cursor).\n" +
|
|
228
|
+
"Nothing to register. Add this to whichever client you use, then re-run\n" +
|
|
229
|
+
"`harness-dispatch connect` if you install one of the two above:\n");
|
|
230
|
+
// Setup has to end with something you can act on. Without this, a machine
|
|
231
|
+
// with no client detected got a cheerful "nothing to do" and no way to
|
|
232
|
+
// finish wiring anything up — the snippet was the ONLY output this replaced.
|
|
233
|
+
printMcpSnippet(plans[0]?.desired);
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
if (!opts.remove) {
|
|
237
|
+
// Stated once, up front, rather than only inside the diff for entries that
|
|
238
|
+
// happen to differ. What gets written should not be something you can only
|
|
239
|
+
// learn from a client being in a particular state.
|
|
240
|
+
process.stdout.write(`Entry to write: ${JSON.stringify(plans[0].desired)}\n\n`);
|
|
241
|
+
}
|
|
242
|
+
process.stdout.write(`${opts.remove ? "Removing from" : "Registering with"} clients:\n`);
|
|
243
|
+
for (const p of installed) {
|
|
244
|
+
process.stdout.write(` ${p.client.padEnd(12)} ${p.file} (${describeState(p.state, opts.remove)})\n`);
|
|
245
|
+
if (p.state === "differs") {
|
|
246
|
+
process.stdout.write(` currently: ${JSON.stringify(summariseEntry(p.current))}\n`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const chosen = requested
|
|
250
|
+
? installed.filter((p) => requested.includes(p.id))
|
|
251
|
+
: await chooseInteractively(installed, opts);
|
|
252
|
+
// Consent to replace a HAND-EDITED entry comes from ANSWERING the
|
|
253
|
+
// interactive prompt, which shows the difference first, or from --force.
|
|
254
|
+
//
|
|
255
|
+
// Neither `--clients` nor `--yes` counts. `--clients` says which client, not
|
|
256
|
+
// "overwrite whatever I put there"; `--yes` skips the question rather than
|
|
257
|
+
// answering it. The first version of this gate accepted "no client named" as
|
|
258
|
+
// consent, which let `--yes` through — the same class it was written to
|
|
259
|
+
// close, one flag over.
|
|
260
|
+
const prompted = requested === undefined && !opts.yes && process.stdin.isTTY === true;
|
|
261
|
+
const consented = opts.force || prompted;
|
|
262
|
+
if (chosen === undefined) {
|
|
263
|
+
process.stdout.write("Nothing written.\n");
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
266
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
267
|
+
let failed = false;
|
|
268
|
+
let wrote = false;
|
|
269
|
+
for (const plan of chosen) {
|
|
270
|
+
const outcome = opts.remove
|
|
271
|
+
? await removeClientEntry(plan, { stamp, force: opts.force })
|
|
272
|
+
: await writeClientEntry(plan, { stamp, consented });
|
|
273
|
+
if (outcome.action === "written") {
|
|
274
|
+
wrote = true;
|
|
275
|
+
process.stdout.write(`${opts.remove ? "Removed from" : "Wrote"} ${outcome.client} (backup: ${outcome.backupPath})\n`);
|
|
276
|
+
}
|
|
277
|
+
else if (outcome.action === "unchanged") {
|
|
278
|
+
process.stdout.write(`${outcome.client}: ${opts.remove ? "no entry of ours to remove" : "already correct"}, nothing changed.\n`);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
failed = true;
|
|
282
|
+
process.stderr.write(`Skipped ${outcome.client}: ${outcome.reason}\n`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Only when something actually changed. Telling someone to restart an
|
|
286
|
+
// application after a run that wrote nothing is advice with no cause.
|
|
287
|
+
if (wrote && !failed) {
|
|
288
|
+
process.stdout.write(`\nRestart the client(s) so they pick up the ${opts.remove ? "removal" : "new server"}.\n`);
|
|
289
|
+
}
|
|
290
|
+
return failed ? 1 : 0;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The listing describes state from the point of view of what is about to
|
|
294
|
+
* happen. Under `--remove`, "already registered correctly" describes the entry
|
|
295
|
+
* accurately and reads as nonsense under the heading "Removing from clients",
|
|
296
|
+
* where the same state means "this is the one that will go".
|
|
297
|
+
*/
|
|
298
|
+
function describeState(state, removing) {
|
|
299
|
+
if (removing) {
|
|
300
|
+
return {
|
|
301
|
+
absent: "not installed",
|
|
302
|
+
unreadable: "config does not parse — will be left alone",
|
|
303
|
+
"missing-entry": "no entry of ours to remove",
|
|
304
|
+
matches: "our entry is here — will be removed",
|
|
305
|
+
differs: "has an entry we did not write — left alone unless --force",
|
|
306
|
+
}[state];
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
absent: "not installed",
|
|
310
|
+
unreadable: "config does not parse — will be left alone",
|
|
311
|
+
"missing-entry": "no harness-dispatch entry yet",
|
|
312
|
+
matches: "already registered correctly",
|
|
313
|
+
differs: "has a DIFFERENT entry",
|
|
314
|
+
}[state];
|
|
315
|
+
}
|
|
316
|
+
/** An entry's shape without its `env`, which holds live API keys on real machines. */
|
|
317
|
+
function summariseEntry(entry) {
|
|
318
|
+
if (!entry || typeof entry !== "object")
|
|
319
|
+
return entry;
|
|
320
|
+
const { env: _hidden, ...rest } = entry;
|
|
321
|
+
return rest;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Ask, when there is someone to ask.
|
|
325
|
+
*
|
|
326
|
+
* Returns undefined for "write nothing". An entry that already differs is the
|
|
327
|
+
* case that most needs a human: on the machine this was written for, the
|
|
328
|
+
* differing entry was the WORKING one.
|
|
329
|
+
*/
|
|
330
|
+
async function chooseInteractively(plans, opts) {
|
|
331
|
+
const actionable = plans.filter((p) => p.state !== "matches" && p.state !== "unreadable");
|
|
332
|
+
if (actionable.length === 0)
|
|
333
|
+
return [];
|
|
334
|
+
// `--yes` skips the question; it does NOT answer it.
|
|
335
|
+
//
|
|
336
|
+
// The consent gate added for `--clients` treated "no client named" as
|
|
337
|
+
// "consented", and `--yes` takes that path — so an acceptance pass measured
|
|
338
|
+
// `connect --yes` overwriting a hand-edited entry with no prompt and no
|
|
339
|
+
// --force, exit 0, while the same flags on `--remove` correctly refused.
|
|
340
|
+
// "Do not ask me" is not the same answer as "yes, replace what I wrote".
|
|
341
|
+
if (opts.yes)
|
|
342
|
+
return actionable;
|
|
343
|
+
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`);
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
const { createInterface } = await import("node:readline/promises");
|
|
349
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
350
|
+
try {
|
|
351
|
+
const answer = (await rl.question(`\nApply to ${actionable.length} client(s)? [y/N] `))
|
|
352
|
+
.trim()
|
|
353
|
+
.toLowerCase();
|
|
354
|
+
return answer === "y" || answer === "yes" ? actionable : undefined;
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
rl.close();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
141
360
|
async function cmdStatus(configPath, opts) {
|
|
142
361
|
const runtime = await buildRuntime(configPath);
|
|
143
362
|
const render = async () => {
|
|
@@ -229,6 +448,78 @@ async function cmdDoctor(configPath, opts) {
|
|
|
229
448
|
ok: Object.keys(runtime.config.services).length > 0,
|
|
230
449
|
detail: `${Object.keys(runtime.config.services).length} configured route(s)`,
|
|
231
450
|
},
|
|
451
|
+
// This one DOES fail, unlike the advisory git check below.
|
|
452
|
+
//
|
|
453
|
+
// A client entry naming a path that is not there is not a preference or a
|
|
454
|
+
// missing optional tool — there is no setup in which it is intended. And
|
|
455
|
+
// it is invisible from the client side: one that cannot spawn its server
|
|
456
|
+
// simply has no tools, which looks identical to never having installed
|
|
457
|
+
// anything. On this maintainer's machine that state survived a repo rename
|
|
458
|
+
// by months, silently, along with a hook pointing at the same dead
|
|
459
|
+
// directory.
|
|
460
|
+
//
|
|
461
|
+
// Not-configured is NOT a failure: a machine with no client entry gets
|
|
462
|
+
// "not registered with any client", ok. Only a broken one fails.
|
|
463
|
+
(() => {
|
|
464
|
+
const entries = inspectClientEntries();
|
|
465
|
+
const broken = entries.filter((e) => e.missingPaths.length > 0);
|
|
466
|
+
if (entries.length === 0) {
|
|
467
|
+
return {
|
|
468
|
+
name: "mcp-clients",
|
|
469
|
+
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",
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
name: "mcp-clients",
|
|
476
|
+
ok: broken.length === 0,
|
|
477
|
+
detail: broken.length === 0
|
|
478
|
+
? entries.map((e) => `${e.client}: ${e.entry} resolves`).join("; ")
|
|
479
|
+
: broken
|
|
480
|
+
.map((e) =>
|
|
481
|
+
// "references", not "launches ... from": missingPaths
|
|
482
|
+
// holds any path the entry names that is not there, and
|
|
483
|
+
// that is routinely the `--config` argument rather than
|
|
484
|
+
// the launch binary. An acceptance pass found the message
|
|
485
|
+
// reaching the right conclusion by the wrong description —
|
|
486
|
+
// a missing explicit --config does make the CLI exit 1,
|
|
487
|
+
// but it is not where the client launches from.
|
|
488
|
+
`${e.client} (${e.file}) references a path that does not exist: ` +
|
|
489
|
+
`${e.missingPaths.join(", ")} (entry: ${e.entry}) — that client has been ` +
|
|
490
|
+
"getting NO tools from this server, silently. `harness-dispatch connect` " +
|
|
491
|
+
"rewrites the entry.")
|
|
492
|
+
.join(" | "),
|
|
493
|
+
};
|
|
494
|
+
})(),
|
|
495
|
+
// Not required to dispatch — reported, never a hard fail.
|
|
496
|
+
//
|
|
497
|
+
// The `workspace` tool shells out to git for diff/apply, so without it a
|
|
498
|
+
// delegate's work COMPLETES in an isolated workspace and then the tool
|
|
499
|
+
// that retrieves it dies with `spawn git ENOENT` wrapped as "could not
|
|
500
|
+
// diff <file> for this workspace" — a message about a program the user was
|
|
501
|
+
// never told they needed. README lists the requirements as Node plus a
|
|
502
|
+
// harness. The changes are recoverable by hand via workspaceRoot in the
|
|
503
|
+
// response, which is why this warns here instead of failing the install.
|
|
504
|
+
//
|
|
505
|
+
// `ok: true` UNCONDITIONALLY, matching http-auth / billing-policy /
|
|
506
|
+
// safety-policy below: doctor's exit code is the sum of every check, so a
|
|
507
|
+
// false here made `doctor` exit 1 on a machine with no git — a
|
|
508
|
+
// configuration the README calls supported. Any install script or CI step
|
|
509
|
+
// gating on that exit code would fail a working install, and the code
|
|
510
|
+
// would stop distinguishing "your install is broken" from "an optional
|
|
511
|
+
// tool is missing". The advice belongs in `detail`, which is where the
|
|
512
|
+
// other advisory checks put theirs.
|
|
513
|
+
{
|
|
514
|
+
name: "git",
|
|
515
|
+
ok: true,
|
|
516
|
+
detail: commandAvailable("git")
|
|
517
|
+
? "available — workspace diff/apply and git_worktree isolation can run"
|
|
518
|
+
: "NOT FOUND — optional. Dispatch still works, but the `workspace` tool " +
|
|
519
|
+
"cannot diff or apply an isolated run's changes, and the git_worktree " +
|
|
520
|
+
"policy is unavailable. Retrieve changes by hand from the " +
|
|
521
|
+
"workspaceRoot in the dispatch response.",
|
|
522
|
+
},
|
|
232
523
|
{
|
|
233
524
|
name: "config-warnings",
|
|
234
525
|
ok: (runtime.config.configWarnings?.length ?? 0) === 0,
|
|
@@ -236,6 +527,19 @@ async function cmdDoctor(configPath, opts) {
|
|
|
236
527
|
? runtime.config.configWarnings.join(" | ")
|
|
237
528
|
: "no unrecognized config entries",
|
|
238
529
|
},
|
|
530
|
+
{
|
|
531
|
+
// Saved state that could not be read, which is NOT a config problem and
|
|
532
|
+
// so never reached this list: `doctor` read `configWarnings` directly
|
|
533
|
+
// and stayed silent about an unreadable breaker record or usage counters
|
|
534
|
+
// that are not reaching disk. Two acceptance passes recorded that
|
|
535
|
+
// silence as an open item; `status` grew a "State problems" heading and
|
|
536
|
+
// `doctor` did not follow.
|
|
537
|
+
name: "saved-state",
|
|
538
|
+
ok: (status.stateWarnings?.length ?? 0) === 0,
|
|
539
|
+
detail: status.stateWarnings && status.stateWarnings.length > 0
|
|
540
|
+
? status.stateWarnings.join(" | ")
|
|
541
|
+
: "readable",
|
|
542
|
+
},
|
|
239
543
|
{
|
|
240
544
|
name: "routes",
|
|
241
545
|
ok: status.ready.length > 0,
|
|
@@ -362,18 +666,50 @@ async function cmdAuth(action) {
|
|
|
362
666
|
return 1;
|
|
363
667
|
}
|
|
364
668
|
}
|
|
365
|
-
|
|
669
|
+
/**
|
|
670
|
+
* One dispatch from the command line — the CLI half of the `dispatch` MCP
|
|
671
|
+
* tool, named to match it (`route` stays as an alias).
|
|
672
|
+
*
|
|
673
|
+
* It took flags because it had none and that made it unusable for the one job
|
|
674
|
+
* it is most needed for. An acceptance pass has to exercise the build IN THE
|
|
675
|
+
* WORKING TREE; the MCP tool runs in whatever server process is already
|
|
676
|
+
* connected, which is a different artifact from a different moment. So the CLI
|
|
677
|
+
* is the honest path there — and it hardcoded taskType "execute" with two
|
|
678
|
+
* fallbacks, meaning a pass asking for one read-only call on one route could
|
|
679
|
+
* silently get an execute-profile run on up to three. The first acceptance
|
|
680
|
+
* pass to attempt a live dispatch wrote its own Node script against dist/
|
|
681
|
+
* rather than use this, which is the tell.
|
|
682
|
+
*/
|
|
683
|
+
async function cmdDispatch(prompt, configPath, opts) {
|
|
366
684
|
if (!prompt) {
|
|
367
|
-
process.stderr.write('
|
|
685
|
+
process.stderr.write('dispatch: missing prompt. Usage: dispatch [--service <id>] [--safety <profile>]\n' +
|
|
686
|
+
' [--task-type <type>] [--no-fallback] [--json] "<prompt>"\n');
|
|
368
687
|
return 1;
|
|
369
688
|
}
|
|
370
689
|
const runtime = await buildRuntime(configPath);
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
690
|
+
const hints = { taskType: opts.taskType ?? "execute" };
|
|
691
|
+
if (opts.safetyProfile !== undefined)
|
|
692
|
+
hints.safetyProfile = opts.safetyProfile;
|
|
693
|
+
// A named service goes through routeTo, which is what "run exactly this
|
|
694
|
+
// route" means — not route() with a hint, which can still fall elsewhere.
|
|
695
|
+
const { result, decision } = opts.service
|
|
696
|
+
? await runtime.router.routeTo(opts.service, prompt, [], process.cwd(), {
|
|
697
|
+
...(opts.safetyProfile !== undefined ? { safetyProfile: opts.safetyProfile } : {}),
|
|
698
|
+
...(opts.taskType !== undefined ? { taskType: opts.taskType } : {}),
|
|
699
|
+
})
|
|
700
|
+
: await runtime.router.route(prompt, [], process.cwd(), {
|
|
701
|
+
hints,
|
|
702
|
+
maxFallbacks: opts.noFallback ? 0 : 2,
|
|
703
|
+
});
|
|
704
|
+
if (opts.json) {
|
|
705
|
+
process.stdout.write(`${JSON.stringify({ result, routing: decision ?? null }, null, 2)}\n`);
|
|
706
|
+
return result.success ? 0 : 1;
|
|
707
|
+
}
|
|
375
708
|
if (decision) {
|
|
376
|
-
|
|
709
|
+
const beat = decision.candidates?.length
|
|
710
|
+
? ` [${decision.candidates.map((c) => `${c.route} ${c.score}`).join(", ")}]`
|
|
711
|
+
: "";
|
|
712
|
+
process.stderr.write(`dispatch: ${decision.service} (${decision.reason})${beat}\n`);
|
|
377
713
|
}
|
|
378
714
|
process.stdout.write(result.output);
|
|
379
715
|
if (!result.output.endsWith("\n"))
|
|
@@ -384,6 +720,24 @@ async function cmdRouteAlias(prompt, configPath) {
|
|
|
384
720
|
}
|
|
385
721
|
return 0;
|
|
386
722
|
}
|
|
723
|
+
const SAFETY_PROFILES = ["read_only", "workspace_edit", "full_auto"];
|
|
724
|
+
const TASK_TYPES = ["execute", "plan", "review", "local"];
|
|
725
|
+
/**
|
|
726
|
+
* An enum-valued flag: rejected by name when it is not one of the listed
|
|
727
|
+
* values, never silently dropped to a default.
|
|
728
|
+
*
|
|
729
|
+
* `--safety read_onlyy` dropping to workspace_edit would hand a delegate MORE
|
|
730
|
+
* access than the caller asked for — the same failure the MCP and HTTP
|
|
731
|
+
* surfaces were both hardened against, and the reason `hints` is strict there.
|
|
732
|
+
*/
|
|
733
|
+
function enumFlag(value, allowed, flag) {
|
|
734
|
+
if (value === undefined)
|
|
735
|
+
return undefined;
|
|
736
|
+
if (typeof value === "string" && allowed.includes(value)) {
|
|
737
|
+
return value;
|
|
738
|
+
}
|
|
739
|
+
throw new UsageError(`${flag}: invalid value ${JSON.stringify(value)}. Valid: ${allowed.join(", ")}.`);
|
|
740
|
+
}
|
|
387
741
|
function parsePositiveInt(value, fallback) {
|
|
388
742
|
if (typeof value !== "string")
|
|
389
743
|
return fallback;
|
|
@@ -420,6 +774,7 @@ export async function main(argv) {
|
|
|
420
774
|
options: {
|
|
421
775
|
config: { type: "string" },
|
|
422
776
|
help: { type: "boolean", short: "h" },
|
|
777
|
+
version: { type: "boolean", short: "v" },
|
|
423
778
|
json: { type: "boolean" },
|
|
424
779
|
live: { type: "boolean" },
|
|
425
780
|
"allow-paid": { type: "boolean" },
|
|
@@ -431,6 +786,14 @@ export async function main(argv) {
|
|
|
431
786
|
yes: { type: "boolean" },
|
|
432
787
|
force: { type: "boolean" },
|
|
433
788
|
http: { type: "string" },
|
|
789
|
+
service: { type: "string" },
|
|
790
|
+
safety: { type: "string" },
|
|
791
|
+
"task-type": { type: "string" },
|
|
792
|
+
"no-fallback": { type: "boolean" },
|
|
793
|
+
clients: { type: "string" },
|
|
794
|
+
"no-clients": { type: "boolean" },
|
|
795
|
+
remove: { type: "boolean" },
|
|
796
|
+
dev: { type: "boolean" },
|
|
434
797
|
},
|
|
435
798
|
allowPositionals: true,
|
|
436
799
|
strict: false,
|
|
@@ -441,14 +804,28 @@ export async function main(argv) {
|
|
|
441
804
|
// got garbage AND a success code. PRODUCT.md names automation as a user, and
|
|
442
805
|
// a wrong exit code is the one thing automation cannot recover from.
|
|
443
806
|
const knownFlags = new Set([
|
|
444
|
-
"help", "config", "json", "live", "allow-paid", "watch", "interval",
|
|
807
|
+
"help", "version", "config", "json", "live", "allow-paid", "watch", "interval",
|
|
445
808
|
"port", "host", "print", "yes", "force", "http",
|
|
809
|
+
"service", "safety", "task-type", "no-fallback",
|
|
810
|
+
"clients", "no-clients", "remove", "dev",
|
|
446
811
|
]);
|
|
447
812
|
const unknownFlags = Object.keys(values).filter((k) => !knownFlags.has(k));
|
|
448
813
|
if (unknownFlags.length > 0) {
|
|
449
814
|
throw new UsageError(`unknown option${unknownFlags.length > 1 ? "s" : ""}: ` +
|
|
450
815
|
`${unknownFlags.map((f) => `--${f}`).join(", ")}. Run --help for the list.`);
|
|
451
816
|
}
|
|
817
|
+
// Before --help, and before anything that can fail: a version is what you
|
|
818
|
+
// ask for when something is already wrong, so it must not depend on config
|
|
819
|
+
// loading, a readable jobs root, or any route being reachable.
|
|
820
|
+
//
|
|
821
|
+
// The MCP handshake has always reported this in serverInfo, so an agent
|
|
822
|
+
// consumer could see it. A human diagnosing an install had no way to ask —
|
|
823
|
+
// `--version` exited 1 with "unknown option", which reads like the binary is
|
|
824
|
+
// broken rather than like the flag is missing.
|
|
825
|
+
if (values.version) {
|
|
826
|
+
process.stdout.write(`${VERSION}\n`);
|
|
827
|
+
return 0;
|
|
828
|
+
}
|
|
452
829
|
if (values.help) {
|
|
453
830
|
printUsage();
|
|
454
831
|
return 0;
|
|
@@ -487,6 +864,16 @@ export async function main(argv) {
|
|
|
487
864
|
print: Boolean(values.print),
|
|
488
865
|
yes: Boolean(values.yes),
|
|
489
866
|
force: Boolean(values.force),
|
|
867
|
+
noClients: Boolean(values["no-clients"]),
|
|
868
|
+
clients: typeof values.clients === "string" ? values.clients : undefined,
|
|
869
|
+
});
|
|
870
|
+
case "connect":
|
|
871
|
+
return cmdConnect(configPath, {
|
|
872
|
+
clients: typeof values.clients === "string" ? values.clients : undefined,
|
|
873
|
+
remove: Boolean(values.remove),
|
|
874
|
+
yes: Boolean(values.yes),
|
|
875
|
+
force: Boolean(values.force),
|
|
876
|
+
dev: Boolean(values.dev),
|
|
490
877
|
});
|
|
491
878
|
case "doctor":
|
|
492
879
|
return cmdDoctor(configPath, {
|
|
@@ -508,8 +895,21 @@ export async function main(argv) {
|
|
|
508
895
|
return cmdServe(configPath, serveOpts(values));
|
|
509
896
|
case "auth":
|
|
510
897
|
return cmdAuth(rest[0]);
|
|
511
|
-
|
|
512
|
-
|
|
898
|
+
// `route` kept as an alias: it was the name for two years of history, and
|
|
899
|
+
// `dispatch` matches the MCP tool that does the same thing. Same pattern
|
|
900
|
+
// as status/dashboard/list-services above.
|
|
901
|
+
case "dispatch":
|
|
902
|
+
case "route": {
|
|
903
|
+
const safety = enumFlag(values.safety, SAFETY_PROFILES, "--safety");
|
|
904
|
+
const taskType = enumFlag(values["task-type"], TASK_TYPES, "--task-type");
|
|
905
|
+
return cmdDispatch(rest.join(" ").trim(), configPath, {
|
|
906
|
+
...(typeof values.service === "string" ? { service: values.service } : {}),
|
|
907
|
+
...(safety !== undefined ? { safetyProfile: safety } : {}),
|
|
908
|
+
...(taskType !== undefined ? { taskType } : {}),
|
|
909
|
+
noFallback: Boolean(values["no-fallback"]),
|
|
910
|
+
json: Boolean(values.json),
|
|
911
|
+
});
|
|
912
|
+
}
|
|
513
913
|
case "mcp":
|
|
514
914
|
if (values.http !== undefined) {
|
|
515
915
|
return cmdServe(configPath, serveOpts({ port: values.http, host: values.host }));
|
|
@@ -521,11 +921,39 @@ export async function main(argv) {
|
|
|
521
921
|
return 1;
|
|
522
922
|
}
|
|
523
923
|
}
|
|
924
|
+
/**
|
|
925
|
+
* End the process with `code`, letting the event loop drain first.
|
|
926
|
+
*
|
|
927
|
+
* `process.exit(code)` tears the loop down mid-flight, and on Windows that
|
|
928
|
+
* 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
|
|
929
|
+
* found". It fired AFTER a correct answer had been printed, so the work was
|
|
930
|
+
* done and the report of it was a crash.
|
|
931
|
+
*
|
|
932
|
+
* Reproduced 3/3 across three acceptance passes, and confirmed here: two
|
|
933
|
+
* endpoint routes, the first answering 200 with an unusable body and the
|
|
934
|
+
* second succeeding. The fallback is what makes it two in-flight HTTP
|
|
935
|
+
* connections; `--no-fallback` and a refused connection both exited 1 cleanly,
|
|
936
|
+
* which is why it looked intermittent. Setting `exitCode` instead removes it,
|
|
937
|
+
* measured on the same probe.
|
|
938
|
+
*
|
|
939
|
+
* The force-exit is the safety net `process.exit` was providing: if something
|
|
940
|
+
* still holds the loop open after a grace window, leave anyway rather than
|
|
941
|
+
* hanging a CLI. It is `unref`d, so it does not itself keep the process alive
|
|
942
|
+
* — every command measured here exits in under a second without it firing.
|
|
943
|
+
*/
|
|
944
|
+
const EXIT_DRAIN_GRACE_MS = 3000;
|
|
945
|
+
function finish(code) {
|
|
946
|
+
process.exitCode = code;
|
|
947
|
+
const bail = setTimeout(() => {
|
|
948
|
+
process.exit(code);
|
|
949
|
+
}, EXIT_DRAIN_GRACE_MS);
|
|
950
|
+
bail.unref();
|
|
951
|
+
}
|
|
524
952
|
const entrypoint = typeof process !== "undefined" && Array.isArray(process.argv) ? process.argv[1] : "";
|
|
525
953
|
if (entrypoint && (entrypoint.endsWith("bin.ts") || entrypoint.endsWith("bin.js"))) {
|
|
526
954
|
void main(process.argv.slice(2))
|
|
527
955
|
.then((code) => {
|
|
528
|
-
|
|
956
|
+
finish(code);
|
|
529
957
|
})
|
|
530
958
|
.catch((err) => {
|
|
531
959
|
// A CLI user gets one actionable line, not a stack trace. Every Error is
|