harness-dispatch 0.9.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.
- package/CHANGELOG.md +560 -1
- package/README.md +74 -43
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js.map +1 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +166 -29
- package/dist/bin.js.map +1 -1
- package/dist/config/coercions.d.ts +5 -0
- package/dist/config/coercions.d.ts.map +1 -1
- package/dist/config/coercions.js +8 -0
- package/dist/config/coercions.js.map +1 -1
- package/dist/config/protocol.js.map +1 -1
- package/dist/config/validation.d.ts.map +1 -1
- package/dist/config/validation.js.map +1 -1
- package/dist/config.d.ts +11 -3
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +111 -148
- package/dist/config.js.map +1 -1
- package/dist/configure-yaml.d.ts.map +1 -1
- package/dist/configure-yaml.js +57 -13
- package/dist/configure-yaml.js.map +1 -1
- package/dist/dispatch-log.d.ts.map +1 -1
- package/dist/dispatch-log.js +3 -1
- package/dist/dispatch-log.js.map +1 -1
- package/dist/dispatchers/generic-cli.d.ts.map +1 -1
- package/dist/dispatchers/generic-cli.js +189 -97
- package/dist/dispatchers/generic-cli.js.map +1 -1
- package/dist/dispatchers/openai-compatible.d.ts +0 -5
- package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
- package/dist/dispatchers/openai-compatible.js +129 -82
- package/dist/dispatchers/openai-compatible.js.map +1 -1
- package/dist/dispatchers/shared/harness-login.d.ts.map +1 -1
- package/dist/dispatchers/shared/harness-login.js +7 -2
- package/dist/dispatchers/shared/harness-login.js.map +1 -1
- package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
- package/dist/dispatchers/shared/stream-subprocess.js +7 -60
- package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +31 -19
- package/dist/file-lock.js.map +1 -1
- package/dist/harness-presets.d.ts +51 -0
- package/dist/harness-presets.d.ts.map +1 -0
- package/dist/harness-presets.js +154 -0
- package/dist/harness-presets.js.map +1 -0
- package/dist/http/parse.d.ts +8 -1
- package/dist/http/parse.d.ts.map +1 -1
- package/dist/http/parse.js +10 -3
- package/dist/http/parse.js.map +1 -1
- package/dist/http/server.d.ts.map +1 -1
- package/dist/http/server.js +9 -2
- package/dist/http/server.js.map +1 -1
- package/dist/job-runner.js +6 -0
- package/dist/job-runner.js.map +1 -1
- package/dist/jobs/lifecycle.d.ts +100 -0
- package/dist/jobs/lifecycle.d.ts.map +1 -0
- package/dist/jobs/lifecycle.js +263 -0
- package/dist/jobs/lifecycle.js.map +1 -0
- package/dist/jobs/read.d.ts +17 -0
- package/dist/jobs/read.d.ts.map +1 -0
- package/dist/jobs/read.js +103 -0
- package/dist/jobs/read.js.map +1 -0
- package/dist/jobs/run.d.ts +45 -0
- package/dist/jobs/run.d.ts.map +1 -0
- package/dist/jobs/run.js +351 -0
- package/dist/jobs/run.js.map +1 -0
- package/dist/jobs/start.d.ts +9 -0
- package/dist/jobs/start.d.ts.map +1 -0
- package/dist/jobs/start.js +126 -0
- package/dist/jobs/start.js.map +1 -0
- package/dist/jobs/store.d.ts.map +1 -1
- package/dist/jobs/store.js +13 -3
- package/dist/jobs/store.js.map +1 -1
- package/dist/jobs/supervisor.d.ts +151 -0
- package/dist/jobs/supervisor.d.ts.map +1 -0
- package/dist/jobs/supervisor.js +679 -0
- package/dist/jobs/supervisor.js.map +1 -0
- package/dist/jobs.d.ts +15 -233
- package/dist/jobs.d.ts.map +1 -1
- package/dist/jobs.js +14 -1409
- package/dist/jobs.js.map +1 -1
- package/dist/leaderboard.d.ts.map +1 -1
- package/dist/leaderboard.js +5 -1
- package/dist/leaderboard.js.map +1 -1
- package/dist/mcp/config-hot-reload.d.ts.map +1 -1
- package/dist/mcp/config-hot-reload.js +4 -1
- package/dist/mcp/config-hot-reload.js.map +1 -1
- package/dist/mcp/dispatcher-factory.js +1 -1
- package/dist/mcp/dispatcher-factory.js.map +1 -1
- package/dist/mcp/resources.d.ts.map +1 -1
- package/dist/mcp/resources.js +3 -2
- package/dist/mcp/resources.js.map +1 -1
- package/dist/mcp/tools.d.ts +9 -2
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +45 -6
- package/dist/mcp/tools.js.map +1 -1
- package/dist/observability/spans.d.ts.map +1 -1
- package/dist/observability/spans.js +10 -2
- package/dist/observability/spans.js.map +1 -1
- package/dist/redaction.d.ts +90 -0
- package/dist/redaction.d.ts.map +1 -0
- package/dist/redaction.js +221 -0
- package/dist/redaction.js.map +1 -0
- package/dist/router.d.ts +12 -23
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +83 -224
- package/dist/router.js.map +1 -1
- package/dist/state-dir.d.ts.map +1 -1
- package/dist/state-dir.js +15 -1
- package/dist/state-dir.js.map +1 -1
- package/dist/status.d.ts +14 -3
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +36 -3
- package/dist/status.js.map +1 -1
- package/dist/workspace-lock.d.ts +20 -0
- package/dist/workspace-lock.d.ts.map +1 -1
- package/dist/workspace-lock.js +63 -0
- package/dist/workspace-lock.js.map +1 -1
- package/dist/workspace-resolve.d.ts +0 -9
- package/dist/workspace-resolve.d.ts.map +1 -1
- package/dist/workspace-resolve.js +38 -183
- package/dist/workspace-resolve.js.map +1 -1
- package/dist/workspaces.d.ts.map +1 -1
- package/dist/workspaces.js +63 -139
- package/dist/workspaces.js.map +1 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -63,28 +63,47 @@ found one.
|
|
|
63
63
|
```bash
|
|
64
64
|
npm install -g harness-dispatch
|
|
65
65
|
harness-dispatch configure --yes
|
|
66
|
-
harness-dispatch doctor
|
|
66
|
+
harness-dispatch doctor
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
`doctor` checks your install, config, auth and routes without contacting any
|
|
70
|
+
provider. Add `--live` when you want it to prove a dispatch really works end to
|
|
71
|
+
end — that one sends a real request through an eligible route and spends
|
|
72
|
+
whatever quota that route bills against, so it is a deliberate step rather than
|
|
73
|
+
part of setup. `configure` is optional too: the tool auto-detects installed
|
|
74
|
+
harnesses and runs without a `config.yaml` at all. Write one when you want to
|
|
75
|
+
pin routes, add an endpoint, or change a default.
|
|
76
|
+
|
|
69
77
|
`configure --yes` detects installed harnesses, writes `config.yaml` into the
|
|
70
|
-
tool's own state directory (`~/.harness-dispatch/`, or `HARNESS_DISPATCH_STATE_DIR`)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
78
|
+
tool's own state directory (`~/.harness-dispatch/`, or `HARNESS_DISPATCH_STATE_DIR`)
|
|
79
|
+
— unless a `config.yaml` already exists in the current directory or
|
|
80
|
+
`HARNESS_DISPATCH_CONFIG` is set, in which case that file is the target.
|
|
81
|
+
|
|
82
|
+
Without `--yes` it previews and writes nothing.
|
|
83
|
+
|
|
84
|
+
**Registering with your MCP clients.** After writing, `configure` offers to
|
|
85
|
+
register this server with each client it finds (Claude Code, Cursor), showing
|
|
86
|
+
what it would write and what is already there before changing anything.
|
|
87
|
+
`--no-clients` skips the offer and prints a snippet to paste instead.
|
|
88
|
+
`harness-dispatch connect` does the same registration later on its own, and
|
|
89
|
+
`connect --remove` undoes it.
|
|
90
|
+
|
|
91
|
+
**Re-running it.** A file `configure` wrote and you have not edited is
|
|
92
|
+
regenerated, so installing a harness later is just `configure --yes` again. A
|
|
93
|
+
file you have changed is refused without `--force` — and because such a file
|
|
94
|
+
lists its own routes, even `--force` regenerates it from the file rather than
|
|
95
|
+
from a fresh detection. It says so when that happens; add `detect: true` to the
|
|
96
|
+
file to merge newly installed harnesses in.
|
|
97
|
+
|
|
98
|
+
**What `doctor` checks.** The whole chain: binary, config load, harness
|
|
99
|
+
detection, auth and billing classification, route readiness, whether
|
|
100
|
+
`dist/job-runner.js` is present (without it jobs run in-process and the
|
|
101
|
+
concurrency cap does not apply), and for a Codex route it asks `codex login
|
|
102
|
+
status` whether the CLI is logged in. The other harnesses have no equivalent
|
|
103
|
+
this tool has verified, so their login state is not checked. `--live` goes
|
|
104
|
+
further and routes one tiny real prompt through an eligible route, so you see a
|
|
105
|
+
completion before wiring anything into your agent — that one spends quota, and
|
|
106
|
+
it never touches paid or unknown-billing routes unless you pass `--allow-paid`.
|
|
88
107
|
|
|
89
108
|
Your Claude Code / Codex / Cursor subscriptions run by default with no opt-in;
|
|
90
109
|
`configure` tells you if anything is blocked and why.
|
|
@@ -390,14 +409,16 @@ harness-dispatch status --json # structured route metadata
|
|
|
390
409
|
harness-dispatch status --watch # live status refresh
|
|
391
410
|
harness-dispatch usage # per-route call counts, quota, billing kind
|
|
392
411
|
harness-dispatch usage --json # structured usage metadata
|
|
412
|
+
harness-dispatch dispatch "<prompt>" # route one task and print the result
|
|
413
|
+
harness-dispatch dispatch "<prompt>" --service codex_cli --safety read_only --task-type review --no-fallback --json
|
|
393
414
|
harness-dispatch serve --port 3333 # /mcp and /v1/* over local HTTP
|
|
394
415
|
harness-dispatch auth show # print HTTP bearer token
|
|
395
416
|
harness-dispatch auth rotate # rotate HTTP bearer token
|
|
396
417
|
```
|
|
397
418
|
|
|
398
419
|
Hidden compatibility aliases currently map old alpha commands to the new surface:
|
|
399
|
-
`dashboard` and `list-services` map to `status`, `route <prompt>`
|
|
400
|
-
|
|
420
|
+
`dashboard` and `list-services` map to `status`, `route <prompt>` is an alias of
|
|
421
|
+
`dispatch`, and `mcp --http <port>` maps to `serve`.
|
|
401
422
|
They are not part of the public vocabulary and may be removed without a major
|
|
402
423
|
version bump.
|
|
403
424
|
|
|
@@ -412,7 +433,7 @@ version bump.
|
|
|
412
433
|
| `cancel_job` | Stops work started by `dispatch` — a wrong turn, a wrong directory, a superseded run. A job still waiting for a slot stops outright; a running one tears down within about a second (poll `job_status` to see it land), killing the agent CLI and its children. Files it already changed are **not** reverted, and a cancelled run is not counted as a route failure. |
|
|
413
434
|
| `retry_job` | Re-runs a finished job's task from its own record — same prompt (as the delegate saw it), files, working directory, hints and workspace policy. Pass `service` to send the retry to a different route, which is the usual reason to retry: the task was fine and the route was not — the original's model is left behind when the new route does not declare it, reported as `droppedModel`. Returns a new jobId; the original is untouched. |
|
|
414
435
|
| `workspace` | For a job that ran with `workspacePolicy: "copy"` or `"git_worktree"`, the agent's changes live in an isolated workspace and were **never** applied to your project. `action: "diff"` returns the real patch; `"apply"` applies it (refusing when your project has uncommitted changes, since the patch was built against a clean base — `force: true` overrides); `"discard"` deletes the workspace. The full patch is always written to the job directory, so `git apply` by hand is available either way. |
|
|
415
|
-
| `usage` | Per-route call counts, quota, billing kind, and breaker state — check this before passing an unfamiliar `hints.model`/`service`/`models` value. `service` and `models` are validated — an unknown route id is rejected, naming the valid ones — while `hints.model` is forwarded to the picked harness as-is, so a wrong model name fails at the harness instead. Pass `listModels: <route id>` to
|
|
436
|
+
| `usage` | Per-route call counts, quota, billing kind, and breaker state — check this before passing an unfamiliar `hints.model`/`service`/`models` value. `service` and `models` are validated — an unknown route id is rejected, naming the valid ones — while `hints.model` is forwarded to the picked harness as-is, so a wrong model name fails at the harness instead. Pass `listModels: <route id>` to get that `openai_compatible` route's model catalog instead of (or alongside) the summary: the route's declared `models:` list when it has one, otherwise a live `GET /models` from the endpoint. |
|
|
416
437
|
|
|
417
438
|
`workingDir` is effectively required when starting work: if you omit it, the task runs
|
|
418
439
|
in the router server's own process directory instead of your project, and the response
|
|
@@ -467,8 +488,8 @@ For fanout (each route that outlives the grace window returns its own `jobId`):
|
|
|
467
488
|
```
|
|
468
489
|
|
|
469
490
|
Checking and listing (`job_status`): `{"jobId": "job-..."}` returns status plus
|
|
470
|
-
`partialOutput` or the final `result`; `{}` (no `jobId`) returns
|
|
471
|
-
|
|
491
|
+
`partialOutput` or the final `result`; `{}` (no `jobId`) returns the 20 most recent
|
|
492
|
+
background dispatches, newest first, plus an `omitted` count when there are more. On `dispatch`, force pure async with `"graceSeconds": 0`, or force a specific
|
|
472
493
|
backend with a top-level `"service"` (single mode only). Nothing is lost by checking
|
|
473
494
|
late — everything persists under `~/.harness-dispatch/jobs/<jobId>/`.
|
|
474
495
|
|
|
@@ -501,7 +522,6 @@ Endpoints:
|
|
|
501
522
|
- `GET /v1/usage` — per-route call counts, quota, billing kind, and breaker state only
|
|
502
523
|
- `GET /v1/models` — OpenAI-style model list; each entry's `id` is a route id you can
|
|
503
524
|
pass as `model` in `/v1/chat/completions`
|
|
504
|
-
- `POST /v1/chat/completions`
|
|
505
525
|
|
|
506
526
|
HTTP uses the bearer token from `harness-dispatch auth show`. The same token protects
|
|
507
527
|
MCP-over-HTTP and `/v1/*`.
|
|
@@ -562,15 +582,13 @@ endpoints:
|
|
|
562
582
|
wire_protocol: openai_chat_completions
|
|
563
583
|
```
|
|
564
584
|
|
|
565
|
-
Example Codex harness-native local route
|
|
585
|
+
Example Codex harness-native local route, as a `clis:` entry (the same shape
|
|
586
|
+
`configure` writes; everything not set here comes from the shipped `codex` defaults):
|
|
566
587
|
|
|
567
588
|
```yaml
|
|
568
|
-
|
|
569
|
-
codex_ollama
|
|
570
|
-
enabled: true
|
|
571
|
-
type: cli
|
|
589
|
+
clis:
|
|
590
|
+
- name: codex_ollama
|
|
572
591
|
harness: codex
|
|
573
|
-
command: codex
|
|
574
592
|
model: qwen3-coder:latest
|
|
575
593
|
endpoint_mode: harness_native_endpoint
|
|
576
594
|
endpoint_provider: ollama
|
|
@@ -580,23 +598,32 @@ services:
|
|
|
580
598
|
tier: 3
|
|
581
599
|
weight: 0.75
|
|
582
600
|
cli_capability: 1.0
|
|
583
|
-
timeout_ms: 900000 # optional; overrides the
|
|
601
|
+
timeout_ms: 900000 # optional; overrides the 60-minute job default (10 min applies only to the CLI `dispatch` command and `doctor --live`)
|
|
584
602
|
capabilities:
|
|
585
603
|
execute: 0.8
|
|
586
604
|
plan: 0.7
|
|
587
605
|
review: 0.7
|
|
588
606
|
```
|
|
589
607
|
|
|
608
|
+
The older top-level `services:` format still loads, but it is a separate format:
|
|
609
|
+
a file that uses it has its `clis:`/`endpoints:` ignored with a warning, so do not
|
|
610
|
+
mix the two.
|
|
611
|
+
|
|
590
612
|
## Configure
|
|
591
613
|
|
|
592
|
-
`configure` is the main setup flow
|
|
614
|
+
`configure` is the main setup flow. It does four things, in order, and prompts for
|
|
615
|
+
nothing except the final registration:
|
|
616
|
+
|
|
617
|
+
1. Detect installed harness CLIs on PATH (or load the existing config, when there
|
|
618
|
+
is one it did not write itself — see the note on re-runs under Install).
|
|
619
|
+
2. Print every route with its billing classification and effective safety
|
|
620
|
+
profile, and say which routes are blocked until you opt in to paid usage.
|
|
621
|
+
3. Write the config YAML (`--yes`; without it, nothing is written).
|
|
622
|
+
4. Offer to register with each MCP client it finds, or print the snippet
|
|
623
|
+
(`--no-clients`).
|
|
593
624
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
3. Classify auth and billing so paid or unknown-paid routes are not selected by accident.
|
|
597
|
-
4. Choose routed harnesses, model priority, and safety profile.
|
|
598
|
-
5. Write config YAML.
|
|
599
|
-
6. Connect selected MCP agents or print snippets.
|
|
625
|
+
There is no interactive choice of harnesses, model priority or safety profile:
|
|
626
|
+
edit the written file for those.
|
|
600
627
|
|
|
601
628
|
The current command is conservative: it prints detected routes by default and writes
|
|
602
629
|
only when explicitly asked with `--yes`.
|
|
@@ -653,9 +680,9 @@ that boundary.
|
|
|
653
680
|
|
|
654
681
|
Provider notes:
|
|
655
682
|
|
|
656
|
-
- Claude Code `claude -p` is
|
|
657
|
-
|
|
658
|
-
|
|
683
|
+
- Claude Code `claude -p` is classified as included plan usage. Anthropic announced a
|
|
684
|
+
separate Agent SDK credit pool for it (2026-06-15) and paused that change before it
|
|
685
|
+
took effect; the classification will move only if the split actually ships.
|
|
659
686
|
- Codex CLI/SDK uses the official Codex product surface unless a route is explicitly
|
|
660
687
|
configured with an API key, in which case it is API billing.
|
|
661
688
|
- Cursor Agent CLI is classified as included usage with possible on-demand continuation.
|
|
@@ -696,7 +723,11 @@ wait in `queued` and start as slots free — you still get a `jobId` back
|
|
|
696
723
|
immediately and nothing is rejected or lost, only delayed. The bound exists
|
|
697
724
|
because agent CLIs are heavyweight processes, not fan-outable HTTP calls: a
|
|
698
725
|
measured burst of 13 concurrent runs exhausted memory and failed half of them.
|
|
699
|
-
Change it with `max_concurrent_runs: N` in `config.yaml
|
|
726
|
+
Change it with `max_concurrent_runs: N` in `config.yaml`. `0` lifts the cap —
|
|
727
|
+
jobs no longer queue for a slot — while still running them through the
|
|
728
|
+
supervisor pool, so runner processes stay bounded at 4 however many jobs are in
|
|
729
|
+
flight. Memory then scales with the harnesses you actually launch rather than
|
|
730
|
+
with a per-job wrapper.
|
|
700
731
|
|
|
701
732
|
Prompts and outputs flow only to the harnesses/endpoints you configured. **The
|
|
702
733
|
router makes no other network call by default.**
|
package/dist/auth.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAQA,wBAAgB,OAAO,IAAI,MAAM,CAEhC;AAED,wBAAgB,SAAS,IAAI,MAAM,CAElC;AAED,wBAAgB,iBAAiB,IAAI,MAAM,CAE1C;AAED,wBAAsB,aAAa,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAS5D;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,GAAG,IAAI,CASjD;AAED,sEAAsE;AACtE,wBAAgB,gBAAgB,IAAI,MAAM,CAMzC;AAED,wBAAsB,eAAe,IAAI,OAAO,CAAC,MAAM,CAAC,CAOvD;AAED,wBAAsB,eAAe,IAAI,OAAO,CAAC,MAAM,CAAC,CAKvD;AAyBD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAC1B,mBAAmB,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,EAClD,KAAK,EAAE,MAAM,GAAG,IAAI,GACnB,OAAO,CAOT;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAG/C"}
|
package/dist/auth.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC3D,OAAO,EAAE,QAAQ,IAAI,EAAE,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"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"}
|
package/dist/bin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../src/bin.ts"],"names":[],"mappings":";AACA;;GAEG;
|
|
1
|
+
{"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../src/bin.ts"],"names":[],"mappings":";AACA;;GAEG;AA4qCH,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA4K1D"}
|
package/dist/bin.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* harness-dispatch CLI entrypoint.
|
|
4
4
|
*/
|
|
5
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";
|
|
@@ -16,6 +17,7 @@ import { codexLoginState } from "./dispatchers/shared/harness-login.js";
|
|
|
16
17
|
import { clientConfigLocations, inspectClientEntries } from "./mcp-clients.js";
|
|
17
18
|
import { buildDispatchers } from "./mcp/dispatcher-factory.js";
|
|
18
19
|
import { startMcpServer } from "./mcp/server.js";
|
|
20
|
+
import { resolveRunnerPath } from "./jobs.js";
|
|
19
21
|
import { initObservability } from "./observability/index.js";
|
|
20
22
|
import { QuotaCache } from "./quota.js";
|
|
21
23
|
import { Router } from "./router.js";
|
|
@@ -36,8 +38,21 @@ async function buildRuntime(configPath) {
|
|
|
36
38
|
const router = new Router(config, quota, dispatchers, leaderboard);
|
|
37
39
|
return { config, dispatchers, quota, leaderboard, router };
|
|
38
40
|
}
|
|
39
|
-
|
|
40
|
-
|
|
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([
|
|
41
56
|
"harness-dispatch",
|
|
42
57
|
"",
|
|
43
58
|
"Usage:",
|
|
@@ -79,7 +94,7 @@ function printUsage() {
|
|
|
79
94
|
"",
|
|
80
95
|
].join("\n"));
|
|
81
96
|
}
|
|
82
|
-
async function cmdConfigure(configPath,
|
|
97
|
+
async function cmdConfigure(configPath, opts) {
|
|
83
98
|
// configure's --config names where it will WRITE, so a path that does not
|
|
84
99
|
// exist yet is the normal first-run case, not a typo.
|
|
85
100
|
const target = configPath ?? userConfigPath();
|
|
@@ -97,15 +112,36 @@ async function cmdConfigure(configPath, explicitConfigPath, opts) {
|
|
|
97
112
|
// literal key with no ${VAR} to restore is redacted rather than echoed.
|
|
98
113
|
const preview = configToYaml(config, { redactLiterals: true });
|
|
99
114
|
process.stdout.write(preview);
|
|
100
|
-
const
|
|
115
|
+
const keyRedacted = Object.values(config.services).some((svc) => svc.apiKey !== undefined &&
|
|
101
116
|
svc.apiKey !== "" &&
|
|
102
117
|
config.envRefs?.get(svc.apiKey) === undefined &&
|
|
103
118
|
config.apiKeyRefs?.get(svc.name) === undefined);
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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");
|
|
109
145
|
}
|
|
110
146
|
return 0;
|
|
111
147
|
}
|
|
@@ -492,6 +528,13 @@ async function cmdDoctor(configPath, opts) {
|
|
|
492
528
|
const nodeOk = (nodeMajor ?? 0) > 22 ||
|
|
493
529
|
((nodeMajor ?? 0) === 22 &&
|
|
494
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
|
+
: [];
|
|
495
538
|
const checks = [
|
|
496
539
|
{
|
|
497
540
|
name: "node",
|
|
@@ -507,9 +550,17 @@ async function cmdDoctor(configPath, opts) {
|
|
|
507
550
|
// question: `configure` run from one directory and `doctor` from
|
|
508
551
|
// another used to load different things and neither said so.
|
|
509
552
|
detail: `${Object.keys(runtime.config.services).length} configured route(s)` +
|
|
510
|
-
(configPath
|
|
511
|
-
?
|
|
512
|
-
:
|
|
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`)),
|
|
513
564
|
},
|
|
514
565
|
// This one DOES fail, unlike the advisory git check below.
|
|
515
566
|
//
|
|
@@ -618,11 +669,22 @@ async function cmdDoctor(configPath, opts) {
|
|
|
618
669
|
// When nothing is ready, say what was looked for. "0 ready route(s)" on
|
|
619
670
|
// its own leaves a new user with no idea whether the tool is broken or
|
|
620
671
|
// simply has nothing to route to, and no hint what to install.
|
|
621
|
-
detail: status.ready.length > 0
|
|
622
|
-
?
|
|
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).`
|
|
623
676
|
: `0 ready route(s). Looked for these harness CLIs on PATH: ` +
|
|
624
677
|
`${Object.values(AUTO_DETECT_COMMANDS).join(", ")}. ` +
|
|
625
|
-
`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
|
+
: ""),
|
|
626
688
|
},
|
|
627
689
|
{
|
|
628
690
|
// Nothing checked this, so an unwritable state directory surfaced only
|
|
@@ -630,8 +692,27 @@ async function cmdDoctor(configPath, opts) {
|
|
|
630
692
|
// run finished" — a false cause, 90s after the work had actually
|
|
631
693
|
// succeeded.
|
|
632
694
|
name: "state-dir",
|
|
633
|
-
|
|
634
|
-
|
|
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.",
|
|
635
716
|
},
|
|
636
717
|
{
|
|
637
718
|
name: "http-auth",
|
|
@@ -652,6 +733,40 @@ async function cmdDoctor(configPath, opts) {
|
|
|
652
733
|
const codexRoutes = status.routes.filter((route) => status.ready.includes(route.id) && route.harness === "codex" && route.command);
|
|
653
734
|
const loginStates = await Promise.all(codexRoutes.map(async (route) => ({ route, state: await codexLoginState(route.command) })));
|
|
654
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
|
+
});
|
|
655
770
|
checks.push({
|
|
656
771
|
name: "harness-login",
|
|
657
772
|
ok: loggedOut.length === 0,
|
|
@@ -757,8 +872,10 @@ async function cmdAuth(action) {
|
|
|
757
872
|
return 0;
|
|
758
873
|
}
|
|
759
874
|
default:
|
|
760
|
-
|
|
761
|
-
|
|
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");
|
|
762
879
|
}
|
|
763
880
|
}
|
|
764
881
|
/**
|
|
@@ -777,9 +894,8 @@ async function cmdAuth(action) {
|
|
|
777
894
|
*/
|
|
778
895
|
async function cmdDispatch(prompt, configPath, opts) {
|
|
779
896
|
if (!prompt) {
|
|
780
|
-
|
|
781
|
-
'
|
|
782
|
-
return 1;
|
|
897
|
+
throw new UsageError('dispatch: missing prompt. Usage: dispatch [--service <id>] [--safety <profile>] ' +
|
|
898
|
+
'[--task-type <type>] [--no-fallback] [--json] "<prompt>"');
|
|
783
899
|
}
|
|
784
900
|
const runtime = await buildRuntime(configPath);
|
|
785
901
|
const hints = { taskType: opts.taskType ?? "execute" };
|
|
@@ -864,6 +980,9 @@ function serveOpts(values) {
|
|
|
864
980
|
return out;
|
|
865
981
|
}
|
|
866
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();
|
|
867
986
|
const { values, positionals } = parseArgs({
|
|
868
987
|
args: argv,
|
|
869
988
|
options: {
|
|
@@ -929,7 +1048,10 @@ export async function main(argv) {
|
|
|
929
1048
|
const [command, ...rest] = positionals;
|
|
930
1049
|
// `--config` with no value: parseArgs yields boolean true, which reached
|
|
931
1050
|
// path.join and threw ERR_INVALID_ARG_TYPE as a raw Node stack trace.
|
|
932
|
-
|
|
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 === "")) {
|
|
933
1055
|
throw new UsageError("--config needs a path, e.g. --config ./config.yaml");
|
|
934
1056
|
}
|
|
935
1057
|
const explicitConfigPath = values.config;
|
|
@@ -955,7 +1077,7 @@ export async function main(argv) {
|
|
|
955
1077
|
}
|
|
956
1078
|
switch (command) {
|
|
957
1079
|
case "configure":
|
|
958
|
-
return cmdConfigure(configPath,
|
|
1080
|
+
return cmdConfigure(configPath, {
|
|
959
1081
|
print: Boolean(values.print),
|
|
960
1082
|
yes: Boolean(values.yes),
|
|
961
1083
|
force: Boolean(values.force),
|
|
@@ -1011,9 +1133,16 @@ export async function main(argv) {
|
|
|
1011
1133
|
}
|
|
1012
1134
|
return main(configPath !== undefined ? ["--config", configPath] : []);
|
|
1013
1135
|
default:
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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}`);
|
|
1017
1146
|
}
|
|
1018
1147
|
}
|
|
1019
1148
|
/**
|
|
@@ -1077,8 +1206,16 @@ if (isThisFile(entrypoint)) {
|
|
|
1077
1206
|
// reliable way to tell "bug" from "bad input" by class here. Only a
|
|
1078
1207
|
// non-Error throw (a genuine programming error) keeps its stack.
|
|
1079
1208
|
if (err instanceof UsageError || err instanceof Error) {
|
|
1080
|
-
|
|
1081
|
-
|
|
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`);
|
|
1082
1219
|
process.exit(1);
|
|
1083
1220
|
}
|
|
1084
1221
|
throw err;
|