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.
Files changed (125) hide show
  1. package/CHANGELOG.md +560 -1
  2. package/README.md +74 -43
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js.map +1 -1
  5. package/dist/bin.d.ts.map +1 -1
  6. package/dist/bin.js +166 -29
  7. package/dist/bin.js.map +1 -1
  8. package/dist/config/coercions.d.ts +5 -0
  9. package/dist/config/coercions.d.ts.map +1 -1
  10. package/dist/config/coercions.js +8 -0
  11. package/dist/config/coercions.js.map +1 -1
  12. package/dist/config/protocol.js.map +1 -1
  13. package/dist/config/validation.d.ts.map +1 -1
  14. package/dist/config/validation.js.map +1 -1
  15. package/dist/config.d.ts +11 -3
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +111 -148
  18. package/dist/config.js.map +1 -1
  19. package/dist/configure-yaml.d.ts.map +1 -1
  20. package/dist/configure-yaml.js +57 -13
  21. package/dist/configure-yaml.js.map +1 -1
  22. package/dist/dispatch-log.d.ts.map +1 -1
  23. package/dist/dispatch-log.js +3 -1
  24. package/dist/dispatch-log.js.map +1 -1
  25. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  26. package/dist/dispatchers/generic-cli.js +189 -97
  27. package/dist/dispatchers/generic-cli.js.map +1 -1
  28. package/dist/dispatchers/openai-compatible.d.ts +0 -5
  29. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  30. package/dist/dispatchers/openai-compatible.js +129 -82
  31. package/dist/dispatchers/openai-compatible.js.map +1 -1
  32. package/dist/dispatchers/shared/harness-login.d.ts.map +1 -1
  33. package/dist/dispatchers/shared/harness-login.js +7 -2
  34. package/dist/dispatchers/shared/harness-login.js.map +1 -1
  35. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  36. package/dist/dispatchers/shared/stream-subprocess.js +7 -60
  37. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  38. package/dist/file-lock.d.ts.map +1 -1
  39. package/dist/file-lock.js +31 -19
  40. package/dist/file-lock.js.map +1 -1
  41. package/dist/harness-presets.d.ts +51 -0
  42. package/dist/harness-presets.d.ts.map +1 -0
  43. package/dist/harness-presets.js +154 -0
  44. package/dist/harness-presets.js.map +1 -0
  45. package/dist/http/parse.d.ts +8 -1
  46. package/dist/http/parse.d.ts.map +1 -1
  47. package/dist/http/parse.js +10 -3
  48. package/dist/http/parse.js.map +1 -1
  49. package/dist/http/server.d.ts.map +1 -1
  50. package/dist/http/server.js +9 -2
  51. package/dist/http/server.js.map +1 -1
  52. package/dist/job-runner.js +6 -0
  53. package/dist/job-runner.js.map +1 -1
  54. package/dist/jobs/lifecycle.d.ts +100 -0
  55. package/dist/jobs/lifecycle.d.ts.map +1 -0
  56. package/dist/jobs/lifecycle.js +263 -0
  57. package/dist/jobs/lifecycle.js.map +1 -0
  58. package/dist/jobs/read.d.ts +17 -0
  59. package/dist/jobs/read.d.ts.map +1 -0
  60. package/dist/jobs/read.js +103 -0
  61. package/dist/jobs/read.js.map +1 -0
  62. package/dist/jobs/run.d.ts +45 -0
  63. package/dist/jobs/run.d.ts.map +1 -0
  64. package/dist/jobs/run.js +351 -0
  65. package/dist/jobs/run.js.map +1 -0
  66. package/dist/jobs/start.d.ts +9 -0
  67. package/dist/jobs/start.d.ts.map +1 -0
  68. package/dist/jobs/start.js +126 -0
  69. package/dist/jobs/start.js.map +1 -0
  70. package/dist/jobs/store.d.ts.map +1 -1
  71. package/dist/jobs/store.js +13 -3
  72. package/dist/jobs/store.js.map +1 -1
  73. package/dist/jobs/supervisor.d.ts +151 -0
  74. package/dist/jobs/supervisor.d.ts.map +1 -0
  75. package/dist/jobs/supervisor.js +679 -0
  76. package/dist/jobs/supervisor.js.map +1 -0
  77. package/dist/jobs.d.ts +15 -233
  78. package/dist/jobs.d.ts.map +1 -1
  79. package/dist/jobs.js +14 -1409
  80. package/dist/jobs.js.map +1 -1
  81. package/dist/leaderboard.d.ts.map +1 -1
  82. package/dist/leaderboard.js +5 -1
  83. package/dist/leaderboard.js.map +1 -1
  84. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  85. package/dist/mcp/config-hot-reload.js +4 -1
  86. package/dist/mcp/config-hot-reload.js.map +1 -1
  87. package/dist/mcp/dispatcher-factory.js +1 -1
  88. package/dist/mcp/dispatcher-factory.js.map +1 -1
  89. package/dist/mcp/resources.d.ts.map +1 -1
  90. package/dist/mcp/resources.js +3 -2
  91. package/dist/mcp/resources.js.map +1 -1
  92. package/dist/mcp/tools.d.ts +9 -2
  93. package/dist/mcp/tools.d.ts.map +1 -1
  94. package/dist/mcp/tools.js +45 -6
  95. package/dist/mcp/tools.js.map +1 -1
  96. package/dist/observability/spans.d.ts.map +1 -1
  97. package/dist/observability/spans.js +10 -2
  98. package/dist/observability/spans.js.map +1 -1
  99. package/dist/redaction.d.ts +90 -0
  100. package/dist/redaction.d.ts.map +1 -0
  101. package/dist/redaction.js +221 -0
  102. package/dist/redaction.js.map +1 -0
  103. package/dist/router.d.ts +12 -23
  104. package/dist/router.d.ts.map +1 -1
  105. package/dist/router.js +83 -224
  106. package/dist/router.js.map +1 -1
  107. package/dist/state-dir.d.ts.map +1 -1
  108. package/dist/state-dir.js +15 -1
  109. package/dist/state-dir.js.map +1 -1
  110. package/dist/status.d.ts +14 -3
  111. package/dist/status.d.ts.map +1 -1
  112. package/dist/status.js +36 -3
  113. package/dist/status.js.map +1 -1
  114. package/dist/workspace-lock.d.ts +20 -0
  115. package/dist/workspace-lock.d.ts.map +1 -1
  116. package/dist/workspace-lock.js +63 -0
  117. package/dist/workspace-lock.js.map +1 -1
  118. package/dist/workspace-resolve.d.ts +0 -9
  119. package/dist/workspace-resolve.d.ts.map +1 -1
  120. package/dist/workspace-resolve.js +38 -183
  121. package/dist/workspace-resolve.js.map +1 -1
  122. package/dist/workspaces.d.ts.map +1 -1
  123. package/dist/workspaces.js +63 -139
  124. package/dist/workspaces.js.map +1 -1
  125. 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 --live
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
- and then
72
- offers to register this server with each MCP client it finds (Claude Code,
73
- Cursor) — showing you what it would write, and what is already there, before
74
- changing anything. `--no-clients` skips the offer and prints a snippet to paste
75
- instead; `harness-dispatch connect` does the same registration later on its own,
76
- and `connect --remove` undoes it. Without
77
- `--yes` configure previews and writes nothing. Re-running it regenerates a file it
78
- wrote and you have not edited, so installing a harness later is just `configure --yes`
79
- again; a file you have changed is refused without `--force`, and because such a file
80
- lists its own routes, even `--force` regenerates it from the file rather than from a
81
- fresh detection (it says so; add `detect: true` to the file to merge new harnesses). `doctor` then checks the whole chain:
82
- binary, config load, harness detection, auth and billing classification, route
83
- readiness, and for a Codex route asks `codex login status` whether the CLI is
84
- logged in (the other harnesses have no equivalent this tool has verified, so
85
- their login state is not checked). `--live` goes further and routes one tiny real prompt, so you see a
86
- completion before wiring anything into your agent. The live probe never touches paid or
87
- unknown-billing routes unless you pass `--allow-paid`.
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>` runs a one-off
400
- routed dispatch, and `mcp --http <port>` maps to `serve`.
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 fetch that `openai_compatible` route's live `GET /models` catalog instead of (or alongside) the summary. |
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 every known background
471
- dispatch. On `dispatch`, force pure async with `"graceSeconds": 0`, or force a specific
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
- services:
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 dispatcher's default (10 min for CLIs)
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
- 1. Detect installed harnesses.
595
- 2. Verify configured routes without spending quota where possible.
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 treated date-aware: before June 15, 2026 it is classified
657
- as plan usage; from June 15, 2026 it is classified as Agent SDK credits with possible
658
- overage.
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`; `0` removes the bound.
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.**
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../src/auth.ts"],"names":[],"mappings":"AASA,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"}
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":"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"}
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;AA+iCH,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAgK1D"}
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
- function printUsage() {
40
- 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([
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, explicitConfigPath, opts) {
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 redacted = Object.values(config.services).some((svc) => svc.apiKey !== undefined &&
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
- if (redacted) {
105
- process.stderr.write("note: one or more api_key values are literals in the source config and were " +
106
- "replaced with ${ENV_VAR} placeholders in this preview. Move them to " +
107
- "environment variables this output is not a drop-in replacement for that file " +
108
- "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");
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 !== undefined
511
- ? ` from ${path.resolve(configPath)}`
512
- : " (no config file found; shipped defaults with auto-detected harnesses)"),
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
- ? `${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).`
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
- ok: stateDirWritable().ok,
634
- 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.",
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
- process.stderr.write("auth: expected show or rotate\n");
761
- 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");
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
- process.stderr.write('dispatch: missing prompt. Usage: dispatch [--service <id>] [--safety <profile>]\n' +
781
- ' [--task-type <type>] [--no-fallback] [--json] "<prompt>"\n');
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
- 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 === "")) {
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, explicitConfigPath, {
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
- process.stderr.write(`unknown command: ${command}\n`);
1015
- printUsage();
1016
- 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}`);
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
- process.stderr.write(`harness-dispatch: ${err.message}
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;