harness-dispatch 0.4.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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +582 -0
  3. package/config.default.yaml +397 -0
  4. package/data/coding_benchmarks.json +431 -0
  5. package/dist/auth.d.ts +9 -0
  6. package/dist/auth.d.ts.map +1 -0
  7. package/dist/auth.js +73 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/billing.d.ts +7 -0
  10. package/dist/billing.d.ts.map +1 -0
  11. package/dist/billing.js +201 -0
  12. package/dist/billing.js.map +1 -0
  13. package/dist/bin.d.ts +6 -0
  14. package/dist/bin.d.ts.map +1 -0
  15. package/dist/bin.js +488 -0
  16. package/dist/bin.js.map +1 -0
  17. package/dist/breaker-store.d.ts +31 -0
  18. package/dist/breaker-store.d.ts.map +1 -0
  19. package/dist/breaker-store.js +103 -0
  20. package/dist/breaker-store.js.map +1 -0
  21. package/dist/circuit-breaker.d.ts +47 -0
  22. package/dist/circuit-breaker.d.ts.map +1 -0
  23. package/dist/circuit-breaker.js +102 -0
  24. package/dist/circuit-breaker.js.map +1 -0
  25. package/dist/config.d.ts +48 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +1248 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/dashboard/live.d.ts +49 -0
  30. package/dist/dashboard/live.d.ts.map +1 -0
  31. package/dist/dashboard/live.js +149 -0
  32. package/dist/dashboard/live.js.map +1 -0
  33. package/dist/dispatch-log.d.ts +46 -0
  34. package/dist/dispatch-log.d.ts.map +1 -0
  35. package/dist/dispatch-log.js +89 -0
  36. package/dist/dispatch-log.js.map +1 -0
  37. package/dist/dispatchers/base.d.ts +55 -0
  38. package/dist/dispatchers/base.d.ts.map +1 -0
  39. package/dist/dispatchers/base.js +80 -0
  40. package/dist/dispatchers/base.js.map +1 -0
  41. package/dist/dispatchers/generic-cli.d.ts +30 -0
  42. package/dist/dispatchers/generic-cli.d.ts.map +1 -0
  43. package/dist/dispatchers/generic-cli.js +471 -0
  44. package/dist/dispatchers/generic-cli.js.map +1 -0
  45. package/dist/dispatchers/openai-compatible.d.ts +57 -0
  46. package/dist/dispatchers/openai-compatible.d.ts.map +1 -0
  47. package/dist/dispatchers/openai-compatible.js +662 -0
  48. package/dist/dispatchers/openai-compatible.js.map +1 -0
  49. package/dist/dispatchers/shared/kill-tree.d.ts +21 -0
  50. package/dist/dispatchers/shared/kill-tree.d.ts.map +1 -0
  51. package/dist/dispatchers/shared/kill-tree.js +35 -0
  52. package/dist/dispatchers/shared/kill-tree.js.map +1 -0
  53. package/dist/dispatchers/shared/rate-limit-headers.d.ts +22 -0
  54. package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -0
  55. package/dist/dispatchers/shared/rate-limit-headers.js +124 -0
  56. package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -0
  57. package/dist/dispatchers/shared/stream-subprocess.d.ts +53 -0
  58. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -0
  59. package/dist/dispatchers/shared/stream-subprocess.js +274 -0
  60. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -0
  61. package/dist/dispatchers/shared/subprocess.d.ts +25 -0
  62. package/dist/dispatchers/shared/subprocess.d.ts.map +1 -0
  63. package/dist/dispatchers/shared/subprocess.js +110 -0
  64. package/dist/dispatchers/shared/subprocess.js.map +1 -0
  65. package/dist/dispatchers/shared/which-available.d.ts +2 -0
  66. package/dist/dispatchers/shared/which-available.d.ts.map +1 -0
  67. package/dist/dispatchers/shared/which-available.js +8 -0
  68. package/dist/dispatchers/shared/which-available.js.map +1 -0
  69. package/dist/dispatchers/shared/windows-cmd.d.ts +38 -0
  70. package/dist/dispatchers/shared/windows-cmd.d.ts.map +1 -0
  71. package/dist/dispatchers/shared/windows-cmd.js +91 -0
  72. package/dist/dispatchers/shared/windows-cmd.js.map +1 -0
  73. package/dist/http/server.d.ts +15 -0
  74. package/dist/http/server.d.ts.map +1 -0
  75. package/dist/http/server.js +495 -0
  76. package/dist/http/server.js.map +1 -0
  77. package/dist/index.d.ts +27 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +29 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/job-runner.d.ts +19 -0
  82. package/dist/job-runner.d.ts.map +1 -0
  83. package/dist/job-runner.js +42 -0
  84. package/dist/job-runner.js.map +1 -0
  85. package/dist/jobs.d.ts +116 -0
  86. package/dist/jobs.d.ts.map +1 -0
  87. package/dist/jobs.js +542 -0
  88. package/dist/jobs.js.map +1 -0
  89. package/dist/leaderboard.d.ts +62 -0
  90. package/dist/leaderboard.d.ts.map +1 -0
  91. package/dist/leaderboard.js +287 -0
  92. package/dist/leaderboard.js.map +1 -0
  93. package/dist/mcp/config-hot-reload.d.ts +64 -0
  94. package/dist/mcp/config-hot-reload.d.ts.map +1 -0
  95. package/dist/mcp/config-hot-reload.js +144 -0
  96. package/dist/mcp/config-hot-reload.js.map +1 -0
  97. package/dist/mcp/dispatcher-factory.d.ts +20 -0
  98. package/dist/mcp/dispatcher-factory.d.ts.map +1 -0
  99. package/dist/mcp/dispatcher-factory.js +30 -0
  100. package/dist/mcp/dispatcher-factory.js.map +1 -0
  101. package/dist/mcp/resources.d.ts +8 -0
  102. package/dist/mcp/resources.d.ts.map +1 -0
  103. package/dist/mcp/resources.js +45 -0
  104. package/dist/mcp/resources.js.map +1 -0
  105. package/dist/mcp/server.d.ts +36 -0
  106. package/dist/mcp/server.d.ts.map +1 -0
  107. package/dist/mcp/server.js +80 -0
  108. package/dist/mcp/server.js.map +1 -0
  109. package/dist/mcp/tools.d.ts +171 -0
  110. package/dist/mcp/tools.d.ts.map +1 -0
  111. package/dist/mcp/tools.js +658 -0
  112. package/dist/mcp/tools.js.map +1 -0
  113. package/dist/observability/index.d.ts +52 -0
  114. package/dist/observability/index.d.ts.map +1 -0
  115. package/dist/observability/index.js +111 -0
  116. package/dist/observability/index.js.map +1 -0
  117. package/dist/observability/spans.d.ts +47 -0
  118. package/dist/observability/spans.d.ts.map +1 -0
  119. package/dist/observability/spans.js +72 -0
  120. package/dist/observability/spans.js.map +1 -0
  121. package/dist/quota.d.ts +91 -0
  122. package/dist/quota.d.ts.map +1 -0
  123. package/dist/quota.js +331 -0
  124. package/dist/quota.js.map +1 -0
  125. package/dist/route-policy.d.ts +24 -0
  126. package/dist/route-policy.d.ts.map +1 -0
  127. package/dist/route-policy.js +92 -0
  128. package/dist/route-policy.js.map +1 -0
  129. package/dist/router.d.ts +162 -0
  130. package/dist/router.d.ts.map +1 -0
  131. package/dist/router.js +878 -0
  132. package/dist/router.js.map +1 -0
  133. package/dist/safety.d.ts +7 -0
  134. package/dist/safety.d.ts.map +1 -0
  135. package/dist/safety.js +33 -0
  136. package/dist/safety.js.map +1 -0
  137. package/dist/status.d.ts +100 -0
  138. package/dist/status.d.ts.map +1 -0
  139. package/dist/status.js +228 -0
  140. package/dist/status.js.map +1 -0
  141. package/dist/types.d.ts +402 -0
  142. package/dist/types.d.ts.map +1 -0
  143. package/dist/types.js +9 -0
  144. package/dist/types.js.map +1 -0
  145. package/dist/version.d.ts +2 -0
  146. package/dist/version.d.ts.map +1 -0
  147. package/dist/version.js +13 -0
  148. package/dist/version.js.map +1 -0
  149. package/dist/working-dir.d.ts +15 -0
  150. package/dist/working-dir.d.ts.map +1 -0
  151. package/dist/working-dir.js +22 -0
  152. package/dist/working-dir.js.map +1 -0
  153. package/dist/workspaces.d.ts +19 -0
  154. package/dist/workspaces.d.ts.map +1 -0
  155. package/dist/workspaces.js +352 -0
  156. package/dist/workspaces.js.map +1 -0
  157. package/package.json +96 -0
@@ -0,0 +1,658 @@
1
+ /**
2
+ * MCP tool registry for harness-dispatch.
3
+ *
4
+ * The public MCP surface is three tools, each doing exactly one thing:
5
+ * `dispatch` starts routed work (single or fanout) and only ever starts —
6
+ * `job_status` checks or lists it, `usage` reads route/quota state. Every
7
+ * dispatch is job-backed from the first moment — dispatch races an inline
8
+ * grace window against the background run, so a fast task returns its full
9
+ * result in-call and a slow one degrades to a pollable jobId with NOTHING
10
+ * lost to a timeout; job_status is how that jobId gets checked on later.
11
+ * Status is also exposed as resources so clients can inspect state without
12
+ * a tool call.
13
+ */
14
+ import { z } from "zod";
15
+ import { setTimeout as delay } from "node:timers/promises";
16
+ import { withMcpToolSpan } from "../observability/spans.js";
17
+ import { evaluateRoutePolicy } from "../route-policy.js";
18
+ import { getAsyncJob, listAsyncJobs, startAsyncJobTracked } from "../jobs.js";
19
+ import { isIsolatedWorkspacePolicy } from "../workspaces.js";
20
+ import { buildStatus, buildUsage } from "../status.js";
21
+ import { endpointUrl } from "../dispatchers/openai-compatible.js";
22
+ const taskTypeSchema = z.enum(["execute", "plan", "review", "local"]);
23
+ const safetyProfileSchema = z.enum(["read_only", "workspace_edit", "full_auto"]);
24
+ const workspacePolicySchema = z.enum(["shared", "shared_locked", "copy", "git_worktree"]);
25
+ const routePolicySchema = z.enum(["standard", "local_only", "approval_required", "blocked"]);
26
+ const publicHintsSchema = z
27
+ .object({
28
+ model: z
29
+ .string()
30
+ .optional()
31
+ .describe("Preferred route or model name (e.g. a route id like 'codex' or a model like " +
32
+ "'gpt-5.6-sol'). Routes that statically declare this model get a scoring " +
33
+ "boost; the model is ALWAYS passed to the harness as an override either way, " +
34
+ "even on a route that doesn't recognize it — NOT validated, so an unfamiliar " +
35
+ "or misspelled name can still fail at dispatch time if the harness doesn't " +
36
+ "support it. Check the response's routing.modelHintMatched: true means the " +
37
+ "picked route actually declares this model; false means it was forwarded " +
38
+ "blind and you should treat the result with more suspicion (or check why). " +
39
+ "Call the `usage` tool first to see valid route ids, their default models, " +
40
+ "and a modelHint per route pointing to where that harness's real model " +
41
+ "catalog is documented (or how to list it) — use it to pick correctly up " +
42
+ "front or self-correct after an unfamiliar-model failure. In fanout mode " +
43
+ "this field is ignored entirely — use `models` (top-level, not under " +
44
+ "hints) to select fanout candidates instead."),
45
+ taskType: taskTypeSchema
46
+ .optional()
47
+ .describe("Kind of work: 'execute' (write/modify code, run commands), 'plan' " +
48
+ "(architecture/design, no edits), 'review' (critique code, no edits), 'local' " +
49
+ "(trivial/mechanical — prefers free local endpoints). ALWAYS set this: when " +
50
+ "omitted, per-task capability weighting and model escalation are disabled and " +
51
+ "routing quality degrades."),
52
+ preferLargeContext: z
53
+ .boolean()
54
+ .optional()
55
+ .describe("Boost routes with very large context windows (for huge-codebase reads)."),
56
+ safetyProfile: safetyProfileSchema
57
+ .optional()
58
+ .describe("Maximum permission the routed harness may use: 'read_only' (inspect only — use " +
59
+ "for review/plan), 'workspace_edit' (default; may edit files in workingDir), " +
60
+ "'full_auto' (unrestricted shell — only when explicitly needed). Routes that " +
61
+ "cannot honor the requested profile are skipped."),
62
+ workspacePolicy: workspacePolicySchema.optional().describe("Workspace execution policy."),
63
+ routePolicy: routePolicySchema
64
+ .optional()
65
+ .describe("Operational routing policy: 'standard' (default), 'local_only' (never leave the " +
66
+ "machine), 'approval_required' (BLOCKS non-local routes — it is a restriction, " +
67
+ "not an approval grant), 'blocked' (dry-run: block everything)."),
68
+ timeoutMs: z
69
+ .number()
70
+ .int()
71
+ .positive()
72
+ .optional()
73
+ .describe("Override the background run's hard ceiling (milliseconds). Every dispatch " +
74
+ "runs as a background job with a generous 60-minute default meant to catch a " +
75
+ "genuinely hung process, not to cap a slow-but-healthy run — raise this for " +
76
+ "a task you expect to run past an hour. This changes when the harness itself " +
77
+ "gives up, not how long the inline grace window waits (that's `graceSeconds`)."),
78
+ })
79
+ .describe("Public routing hints.");
80
+ const workingDirDescription = "Absolute path to the project the task is about. EFFECTIVELY REQUIRED: when omitted, " +
81
+ "the task runs in the router server's own working directory — almost never the " +
82
+ "project you mean. Always pass the caller's project root.";
83
+ /** Inline grace window: how long `dispatch` waits for the background run before returning a pollable jobId instead of the full result. */
84
+ const DEFAULT_GRACE_SECONDS = 25;
85
+ const dispatchInputShape = {
86
+ prompt: z
87
+ .string()
88
+ .describe("The coding task or question. Every dispatch starts as a background job " +
89
+ "immediately; if it finishes within the grace window you get the full result " +
90
+ "inline, otherwise you get a jobId — check on it with the `job_status` tool. " +
91
+ "Either way nothing is ever lost to a timeout."),
92
+ mode: z
93
+ .enum(["single", "fanout"])
94
+ .optional()
95
+ .default("single")
96
+ .describe("'single' routes to the one best-fit harness. 'fanout' runs the prompt on " +
97
+ "MULTIPLE routes in parallel for independent perspectives — without `models` it " +
98
+ "hits every eligible route and consumes quota on each; prefer passing an " +
99
+ "explicit `models` list. Write-capable fanout requires workspacePolicy 'copy' " +
100
+ "or 'git_worktree'. Fanout results that outlive the grace window each return " +
101
+ "their own jobId to poll individually."),
102
+ files: z
103
+ .array(z.string())
104
+ .optional()
105
+ .describe("Absolute file paths to snapshot and include as context."),
106
+ workingDir: z.string().optional().describe(workingDirDescription),
107
+ workspacePolicy: workspacePolicySchema.optional().describe("Workspace execution policy."),
108
+ hints: publicHintsSchema.optional(),
109
+ models: z
110
+ .array(z.string())
111
+ .optional()
112
+ .describe("Route ids or model names to fan out to (fanout mode only). This is the ONLY " +
113
+ "field that narrows which routes fanout hits — `hints.model` is ignored " +
114
+ "entirely in fanout mode (not used for selection, not forwarded to any " +
115
+ "dispatch); it only does anything in single mode. Get valid ids from the " +
116
+ "`usage` tool."),
117
+ service: z
118
+ .string()
119
+ .optional()
120
+ .describe("Optional explicit route id to run (e.g. 'codex', 'cursor', 'local_inference' — " +
121
+ "see the `usage` tool for valid ids). Omit to let the router pick. Single " +
122
+ "mode only — incompatible with mode='fanout' (use `models` there)."),
123
+ graceSeconds: z
124
+ .number()
125
+ .int()
126
+ .min(0)
127
+ .max(600)
128
+ .optional()
129
+ .describe(`Seconds to wait for the run inline before returning a pollable jobId (default ` +
130
+ `${DEFAULT_GRACE_SECONDS}). 0 returns the jobId immediately (pure async). ` +
131
+ `Raising it past your MCP client's own request timeout buys nothing — the run ` +
132
+ `continues in the background either way and the result stays collectible via ` +
133
+ `\`job_status\`, so a client timeout on this call loses nothing but the inline reply.`),
134
+ };
135
+ const jobStatusInputShape = {
136
+ jobId: z
137
+ .string()
138
+ .optional()
139
+ .describe("Check a previously started dispatch: returns partialOutput while running and " +
140
+ "the full result once completed or failed. Omit to list every known background " +
141
+ "dispatch instead."),
142
+ };
143
+ export const TOOL_NAMES = ["dispatch", "job_status", "usage"];
144
+ function jsonText(value) {
145
+ return {
146
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
147
+ };
148
+ }
149
+ function toHints(h) {
150
+ if (!h)
151
+ return {};
152
+ const out = {};
153
+ if (h.model !== undefined)
154
+ out.model = h.model;
155
+ if (h.taskType !== undefined)
156
+ out.taskType = h.taskType;
157
+ if (h.preferLargeContext !== undefined)
158
+ out.preferLargeContext = h.preferLargeContext;
159
+ if (h.safetyProfile !== undefined)
160
+ out.safetyProfile = h.safetyProfile;
161
+ if (h.workspacePolicy !== undefined)
162
+ out.workspacePolicy = h.workspacePolicy;
163
+ if (h.routePolicy !== undefined)
164
+ out.routePolicy = h.routePolicy;
165
+ if (h.timeoutMs !== undefined)
166
+ out.timeoutMs = h.timeoutMs;
167
+ return out;
168
+ }
169
+ function workspacePolicyFromInput(input) {
170
+ return input.workspacePolicy ?? input.hints?.workspacePolicy;
171
+ }
172
+ async function ensureFreshConfig(reloader) {
173
+ if (reloader)
174
+ await reloader.maybeReload();
175
+ }
176
+ async function emitProgress(extra, progressToken, counter, event, route) {
177
+ if (!extra?.sendNotification || progressToken === undefined)
178
+ return;
179
+ counter.value += 1;
180
+ try {
181
+ await extra.sendNotification({
182
+ method: "notifications/progress",
183
+ params: {
184
+ progressToken,
185
+ progress: counter.value,
186
+ message: summarizeEvent(event, route),
187
+ _meta: { event, route },
188
+ },
189
+ });
190
+ }
191
+ catch {
192
+ // Best effort only. A progress delivery failure should not fail the tool.
193
+ }
194
+ }
195
+ function summarizeEvent(event, route) {
196
+ const prefix = route ? `[${route}] ` : "";
197
+ switch (event.type) {
198
+ case "stdout":
199
+ return `${prefix}stdout: ${truncate(event.chunk, 60)}`;
200
+ case "stderr":
201
+ return `${prefix}stderr: ${truncate(event.chunk, 60)}`;
202
+ case "tool_use":
203
+ return `${prefix}tool_use: ${event.name}`;
204
+ case "thinking":
205
+ return `${prefix}thinking: ${truncate(event.chunk, 60)}`;
206
+ case "completion":
207
+ return `${prefix}completion: ${event.result.success ? "ok" : "fail"}`;
208
+ case "error":
209
+ return `${prefix}error: ${event.error}`;
210
+ }
211
+ }
212
+ function truncate(s, n) {
213
+ return s.length <= n ? s : `${s.slice(0, n - 3)}...`;
214
+ }
215
+ function routeResponse(result, decision, workingDirWarningMessage) {
216
+ const response = {
217
+ success: result.success,
218
+ output: result.output,
219
+ route: result.service,
220
+ };
221
+ if (workingDirWarningMessage !== undefined)
222
+ response.warning = workingDirWarningMessage;
223
+ if (result.error !== undefined)
224
+ response.error = result.error;
225
+ if (result.durationMs !== undefined)
226
+ response.durationMs = result.durationMs;
227
+ if (result.tokensUsed !== undefined)
228
+ response.tokensUsed = result.tokensUsed;
229
+ if (result.skippedRoutes !== undefined)
230
+ response.skippedRoutes = result.skippedRoutes;
231
+ if (result.workspace !== undefined)
232
+ response.workspace = result.workspace;
233
+ if (decision) {
234
+ if (decision.model !== undefined)
235
+ response.model = decision.model;
236
+ response.routing = {
237
+ tier: decision.tier,
238
+ quotaScore: decision.quotaScore,
239
+ qualityScore: decision.qualityScore,
240
+ cliCapability: decision.cliCapability,
241
+ capabilityScore: decision.capabilityScore,
242
+ taskType: decision.taskType,
243
+ finalScore: decision.finalScore,
244
+ reason: decision.reason,
245
+ };
246
+ if (decision.elo !== undefined)
247
+ response.routing.elo = decision.elo;
248
+ if (decision.modelHintMatched !== undefined) {
249
+ response.routing.modelHintMatched = decision.modelHintMatched;
250
+ }
251
+ if (decision.skippedRoutes !== undefined && decision.skippedRoutes.length > 0) {
252
+ response.skippedRoutes = decision.skippedRoutes;
253
+ }
254
+ }
255
+ return response;
256
+ }
257
+ /**
258
+ * Wait up to `graceMs` for the background run, then report disk truth. The
259
+ * race only decides when we STOP waiting — completion state is always read
260
+ * back from the job artifacts, so the inline path and a later poll can
261
+ * never disagree about the same run. The grace timer is unref'd so a
262
+ * short-lived process (tests, one-shot CLI) never hangs on it.
263
+ */
264
+ async function waitGrace(completion, graceMs) {
265
+ if (graceMs <= 0)
266
+ return;
267
+ await Promise.race([completion, delay(graceMs, undefined, { ref: false })]);
268
+ }
269
+ function jobCompleted(job) {
270
+ return (job.result !== undefined ||
271
+ job.status.status === "completed" ||
272
+ job.status.status === "failed" ||
273
+ // Orphaned (owner process died mid-run) is terminal: it will never
274
+ // complete, so callers must stop polling and re-dispatch.
275
+ job.status.status === "orphaned");
276
+ }
277
+ /**
278
+ * Build the RouteResponse view of a finished job. Covers the crash path
279
+ * too: a runJob failure writes a final "failed" status but no result.json,
280
+ * which must still surface as a completed-and-failed dispatch, not as
281
+ * something to keep polling.
282
+ */
283
+ function jobRouteResponse(job) {
284
+ if (job.result) {
285
+ return routeResponse(job.result.result, job.result.decision, job.status.warning);
286
+ }
287
+ const response = {
288
+ success: false,
289
+ output: "",
290
+ route: job.status.route ?? job.status.service ?? "none",
291
+ };
292
+ if (job.status.error !== undefined)
293
+ response.error = job.status.error;
294
+ if (job.status.warning !== undefined)
295
+ response.warning = job.status.warning;
296
+ if (job.status.durationMs !== undefined)
297
+ response.durationMs = job.status.durationMs;
298
+ return response;
299
+ }
300
+ /** Progress forwarder for the inline grace window — goes quiet once the MCP call has returned. */
301
+ function makeProgressTap(extra, live, counter, route) {
302
+ const progressToken = extra?._meta?.progressToken;
303
+ if (progressToken === undefined || !extra?.sendNotification)
304
+ return undefined;
305
+ return (event) => {
306
+ if (!live.value)
307
+ return;
308
+ void emitProgress(extra, progressToken, counter, event, route);
309
+ };
310
+ }
311
+ async function startSingle(deps, input, extra) {
312
+ await ensureFreshConfig(deps.reloader);
313
+ const hints = toHints(input.hints);
314
+ const workspacePolicy = workspacePolicyFromInput(input);
315
+ if (workspacePolicy !== undefined)
316
+ hints.workspacePolicy = workspacePolicy;
317
+ const live = { value: true };
318
+ const counter = { value: 0 };
319
+ const onEvent = makeProgressTap(extra, live, counter, input.service);
320
+ const { status, completion } = await startAsyncJobTracked({ holder: deps.holder }, {
321
+ prompt: input.prompt,
322
+ files: input.files ?? [],
323
+ ...(input.workingDir !== undefined ? { workingDir: input.workingDir } : {}),
324
+ hints,
325
+ ...(input.workspacePolicy !== undefined ? { workspacePolicy: input.workspacePolicy } : {}),
326
+ ...(input.service !== undefined ? { service: input.service } : {}),
327
+ ...(onEvent !== undefined ? { onEvent } : {}),
328
+ });
329
+ const graceMs = (input.graceSeconds ?? DEFAULT_GRACE_SECONDS) * 1000;
330
+ await waitGrace(completion, graceMs);
331
+ live.value = false;
332
+ const job = await getAsyncJob(status.jobId);
333
+ if (jobCompleted(job)) {
334
+ return { mode: "single", jobId: status.jobId, completed: true, ...jobRouteResponse(job) };
335
+ }
336
+ const pending = {
337
+ mode: "single",
338
+ jobId: status.jobId,
339
+ completed: false,
340
+ };
341
+ if (job.partialOutput !== undefined)
342
+ pending.partialOutput = job.partialOutput;
343
+ if (job.status.nextPollSeconds !== undefined)
344
+ pending.nextPollSeconds = job.status.nextPollSeconds;
345
+ if (job.status.instructions !== undefined)
346
+ pending.instructions = job.status.instructions;
347
+ if (job.status.warning !== undefined)
348
+ pending.warning = job.status.warning;
349
+ return pending;
350
+ }
351
+ function matchesRequestedModel(routeName, svc, requested) {
352
+ if (requested.size === 0)
353
+ return true;
354
+ const lower = new Set([...requested].map((s) => s.toLowerCase()));
355
+ return [routeName, svc.model, svc.leaderboardModel]
356
+ .filter((v) => typeof v === "string" && v.length > 0)
357
+ .some((v) => lower.has(v.toLowerCase()));
358
+ }
359
+ async function startFanout(deps, input, extra) {
360
+ await ensureFreshConfig(deps.reloader);
361
+ const state = deps.holder.state;
362
+ const hints = toHints(input.hints);
363
+ const workspacePolicy = workspacePolicyFromInput(input);
364
+ if (workspacePolicy !== undefined)
365
+ hints.workspacePolicy = workspacePolicy;
366
+ const fanoutSafetyProfile = hints.safetyProfile ?? "read_only";
367
+ if (fanoutSafetyProfile !== "read_only" &&
368
+ (hints.workspacePolicy === undefined || !isIsolatedWorkspacePolicy(hints.workspacePolicy))) {
369
+ return {
370
+ mode: "fanout",
371
+ completed: true,
372
+ results: [],
373
+ skippedRoutes: [
374
+ {
375
+ route: "fanout",
376
+ code: "workspace_isolation_required",
377
+ message: "write-capable fanout requires workspacePolicy=copy or workspacePolicy=git_worktree; use read_only fanout or run single-route workspace_edit",
378
+ },
379
+ ],
380
+ };
381
+ }
382
+ hints.safetyProfile = fanoutSafetyProfile;
383
+ // Contract: hints.model is ignored entirely in fanout mode — `models` is
384
+ // the only selection mechanism. The job runner would forward it to every
385
+ // arm otherwise.
386
+ delete hints.model;
387
+ const taskType = hints.taskType ?? "plan";
388
+ const requested = new Set(input.models ?? []);
389
+ const counter = { value: 0 };
390
+ const skippedRoutes = [];
391
+ const candidates = [];
392
+ for (const [routeName, svc] of Object.entries(state.config.services)) {
393
+ if (!matchesRequestedModel(routeName, svc, requested))
394
+ continue;
395
+ const breaker = state.router.getBreaker(routeName);
396
+ const dispatcher = state.dispatchers[routeName];
397
+ const policy = evaluateRoutePolicy(routeName, svc, {
398
+ ...(dispatcher !== undefined ? { dispatcher } : {}),
399
+ circuitBroken: Boolean(breaker?.isTripped),
400
+ ...(hints.safetyProfile !== undefined ? { requestedSafetyProfile: hints.safetyProfile } : {}),
401
+ ...(hints.routePolicy !== undefined ? { routePolicy: hints.routePolicy } : {}),
402
+ });
403
+ if (policy.skipped)
404
+ skippedRoutes.push(policy.skipped);
405
+ if (policy.blocked)
406
+ continue;
407
+ candidates.push(routeName);
408
+ }
409
+ const prompt = input.prompt;
410
+ const files = input.files ?? [];
411
+ const live = { value: true };
412
+ // Start every candidate as its own background job up front, then wait ONE
413
+ // shared grace window for all of them — a route that beats the deadline
414
+ // reports inline, the rest hand back their jobIds. Per-route job dirs also
415
+ // give each fanout arm its own artifacts, which the blocking version never
416
+ // had.
417
+ const started = await Promise.all(candidates.map(async (routeName) => {
418
+ const svc = state.config.services[routeName];
419
+ const cap = svc.capabilities[taskType] ?? 1.0;
420
+ const quality = await state.leaderboard.getQualityScore(svc.leaderboardModel, svc.thinkingLevel);
421
+ const onEvent = makeProgressTap(extra, live, counter, routeName);
422
+ const job = await startAsyncJobTracked({ holder: deps.holder }, {
423
+ prompt,
424
+ files,
425
+ ...(input.workingDir !== undefined ? { workingDir: input.workingDir } : {}),
426
+ hints,
427
+ service: routeName,
428
+ ...(onEvent !== undefined ? { onEvent } : {}),
429
+ });
430
+ return { routeName, cap, quality, job };
431
+ }));
432
+ const graceMs = (input.graceSeconds ?? DEFAULT_GRACE_SECONDS) * 1000;
433
+ await waitGrace(Promise.all(started.map((s) => s.job.completion)).then(() => undefined), graceMs);
434
+ live.value = false;
435
+ let warning;
436
+ const results = await Promise.all(started.map(async ({ routeName, cap, quality, job }) => {
437
+ const current = await getAsyncJob(job.status.jobId);
438
+ if (current.status.warning !== undefined)
439
+ warning = current.status.warning;
440
+ const item = {
441
+ route: routeName,
442
+ jobId: job.status.jobId,
443
+ completed: jobCompleted(current),
444
+ capabilityScore: cap,
445
+ qualityScore: quality.qualityScore,
446
+ };
447
+ if (quality.elo !== null)
448
+ item.elo = quality.elo;
449
+ if (item.completed) {
450
+ const response = jobRouteResponse(current);
451
+ item.success = response.success;
452
+ item.output = response.output;
453
+ if (response.error !== undefined)
454
+ item.error = response.error;
455
+ if (response.durationMs !== undefined)
456
+ item.durationMs = response.durationMs;
457
+ if (response.workspace !== undefined)
458
+ item.workspace = response.workspace;
459
+ }
460
+ else if (current.partialOutput !== undefined) {
461
+ item.partialOutput = current.partialOutput;
462
+ }
463
+ return item;
464
+ }));
465
+ results.sort((a, b) => {
466
+ if (a.completed !== b.completed)
467
+ return a.completed ? -1 : 1;
468
+ if (a.success !== b.success)
469
+ return a.success ? -1 : 1;
470
+ return b.capabilityScore - a.capabilityScore;
471
+ });
472
+ const response = {
473
+ mode: "fanout",
474
+ completed: results.every((r) => r.completed),
475
+ results,
476
+ };
477
+ if (skippedRoutes.length > 0)
478
+ response.skippedRoutes = skippedRoutes;
479
+ if (warning !== undefined)
480
+ response.warning = warning;
481
+ return response;
482
+ }
483
+ async function pollDispatch(jobId) {
484
+ const job = await getAsyncJob(jobId);
485
+ const completed = jobCompleted(job);
486
+ const response = { jobId, completed, status: job.status };
487
+ if (completed) {
488
+ response.result = jobRouteResponse(job);
489
+ }
490
+ else if (job.partialOutput !== undefined) {
491
+ response.partialOutput = job.partialOutput;
492
+ }
493
+ return response;
494
+ }
495
+ export async function handleDispatch(deps, input, extra) {
496
+ return withMcpToolSpan({ "tool.name": "dispatch" }, async () => {
497
+ if ((input.mode ?? "single") === "fanout") {
498
+ if (input.service !== undefined) {
499
+ throw new Error("dispatch: `service` forces a single route and is incompatible with mode='fanout' — use `models` to select fanout routes");
500
+ }
501
+ return startFanout(deps, input, extra);
502
+ }
503
+ return startSingle(deps, input, extra);
504
+ });
505
+ }
506
+ /** Most-recent jobs returned by the list view. */
507
+ const LIST_LIMIT = 20;
508
+ export async function handleJobStatus(input) {
509
+ return withMcpToolSpan({ "tool.name": "job_status" }, async () => {
510
+ if (input.jobId !== undefined)
511
+ return pollDispatch(input.jobId);
512
+ // Compact list: full detail (jobDir, warnings, instructions, errors)
513
+ // lives behind a per-job check — dumping it for every job ever was a
514
+ // several-KB token tax on each list call.
515
+ const all = await listAsyncJobs();
516
+ const jobs = all.slice(0, LIST_LIMIT).map((j) => ({
517
+ jobId: j.jobId,
518
+ status: j.status,
519
+ createdAt: j.createdAt,
520
+ ...(j.route !== undefined ? { route: j.route } : {}),
521
+ ...(j.service !== undefined && j.service !== j.route ? { service: j.service } : {}),
522
+ ...(j.success !== undefined ? { success: j.success } : {}),
523
+ ...(j.durationMs !== undefined ? { durationMs: j.durationMs } : {}),
524
+ }));
525
+ return {
526
+ jobs,
527
+ ...(all.length > LIST_LIMIT ? { omitted: all.length - LIST_LIMIT } : {}),
528
+ };
529
+ });
530
+ }
531
+ const usageInputShape = {
532
+ listModels: z
533
+ .string()
534
+ .optional()
535
+ .describe("Route id of an OpenAI-compatible endpoint (e.g. an entry from `endpoints:` " +
536
+ "like nvidia_nim or ollama). If the route declares a `models:` list in " +
537
+ "config, that operator-curated list is returned as-is — declaring it is " +
538
+ "how you override live discovery (e.g. to pin specific ids, or the " +
539
+ "endpoint's /models listing is noisy/untrustworthy). Otherwise fetches the " +
540
+ "endpoint's live GET /models catalog server-side — the API key never " +
541
+ "leaves the router. Either way, results come back under `liveModels`. CLI " +
542
+ "harness routes don't support this; use their modelHint instead."),
543
+ };
544
+ /**
545
+ * Model catalog for one OpenAI-compatible route. An operator-declared
546
+ * `models:` list is an override, not a supplement — if present it's
547
+ * returned as-is with NO network call, so a curated/pinned list stays
548
+ * authoritative even if the endpoint's live /models changes or is noisy.
549
+ * `source` distinguishes the two so a caller can tell which happened.
550
+ */
551
+ async function fetchEndpointModels(route, svc) {
552
+ if (!svc) {
553
+ return { route, error: `unknown route '${route}' — call usage without listModels to see valid route ids` };
554
+ }
555
+ if (svc.models && svc.models.length > 0) {
556
+ return { route, models: svc.models, source: "declared" };
557
+ }
558
+ if (svc.type !== "openai_compatible" || !svc.baseUrl) {
559
+ return {
560
+ route,
561
+ error: "listModels only works for openai_compatible endpoint routes (or any route " +
562
+ "with a declared models: list) — CLI harnesses publish their catalog via " +
563
+ "this route's modelHint instead",
564
+ };
565
+ }
566
+ const url = endpointUrl(svc.baseUrl, "/models");
567
+ const headers = {};
568
+ if (svc.apiKey)
569
+ headers["Authorization"] = `Bearer ${svc.apiKey}`;
570
+ try {
571
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(10_000) });
572
+ if (!res.ok) {
573
+ return { route, error: `GET ${url} -> HTTP ${res.status}` };
574
+ }
575
+ const body = (await res.json());
576
+ const models = (body.data ?? [])
577
+ .map((m) => m.id)
578
+ .filter((id) => typeof id === "string");
579
+ return { route, models, source: "live" };
580
+ }
581
+ catch (err) {
582
+ return { route, error: `GET ${url} failed: ${err instanceof Error ? err.message : String(err)}` };
583
+ }
584
+ }
585
+ async function handleUsage(deps, args = {}) {
586
+ await ensureFreshConfig(deps.reloader);
587
+ const state = deps.holder.state;
588
+ const status = await buildStatus(state.config, state.dispatchers, state.quota, state.router, state.leaderboard);
589
+ const usage = buildUsage(status);
590
+ if (args.listModels) {
591
+ const svc = state.config.services[args.listModels];
592
+ return { ...usage, liveModels: await fetchEndpointModels(args.listModels, svc) };
593
+ }
594
+ return usage;
595
+ }
596
+ export function registerTools(server, deps) {
597
+ server.registerTool("dispatch", {
598
+ title: "Dispatch a coding task",
599
+ description: "Delegate a bounded coding task (implement, fix, review, plan, or investigate — " +
600
+ "not general Q&A) to the best-fit harness (Claude Code, Codex, Cursor, " +
601
+ "Antigravity, or a configured endpoint), or fan out to several for independent " +
602
+ "opinions. Always starts new work — every dispatch runs as a background job " +
603
+ "from the first moment: if it finishes within the grace window (default " +
604
+ `${DEFAULT_GRACE_SECONDS}s, see graceSeconds) you get the full result inline ` +
605
+ "(completed: true); otherwise you get completed: false plus a jobId — check on " +
606
+ "it with the `job_status` tool, which returns partialOutput while it runs and " +
607
+ "the full result once done. NOTHING is ever lost to a timeout, including this " +
608
+ "MCP call's own: the run executes in a detached process that survives even a " +
609
+ "server restart, and results " +
610
+ "persist on disk — if THIS call times out client-side, the jobId was lost with " +
611
+ "the reply, so call `job_status` with no arguments and pick the newest running " +
612
+ "entry (it is yours). Keep graceSeconds under your MCP client's own request " +
613
+ "timeout, or skip the inline wait entirely with graceSeconds: 0. Always pass " +
614
+ "`workingDir` (the caller's project root) and `hints.taskType`.",
615
+ inputSchema: dispatchInputShape,
616
+ }, async (args, extra) => jsonText(await handleDispatch(deps, args, extra)));
617
+ server.registerTool("job_status", {
618
+ title: "Check or list background dispatches",
619
+ description: "Check on work started by `dispatch`. Pass the `jobId` it returned: while " +
620
+ "running you get `partialOutput` (a live tail), and once `completed` is true " +
621
+ "you get the full `result` — same shape as an inline dispatch reply. Omit " +
622
+ "`jobId` to list recent background dispatches instead (compact, newest first). "
623
+ + "Nothing is lost by " +
624
+ "checking late; results persist on disk.",
625
+ inputSchema: jobStatusInputShape,
626
+ }, async (args) => jsonText(await handleJobStatus(args)));
627
+ server.registerTool("usage", {
628
+ title: "Check current usage",
629
+ description: "Per-route call counts (success/failure), quota remaining, billing kind, and " +
630
+ "circuit-breaker state for this session. Call this before using an unfamiliar " +
631
+ "`hints.model`/`service`/`models` value to see valid route ids and their " +
632
+ "current models — those fields are not validated and silently ignore unknown names. " +
633
+ "Each route also includes modelHint (where that harness's real model catalog " +
634
+ "is documented or listed live) and, when the operator declared one, models: " +
635
+ "a list of known-good ids. For OpenAI-compatible endpoint routes, pass " +
636
+ "listModels: <route id> to fetch the endpoint's live GET /models catalog " +
637
+ "server-side and get the real ids back under liveModels — use these to pick " +
638
+ "a real model up front or self-correct after a dispatch failure caused by an " +
639
+ "unsupported model name.",
640
+ inputSchema: usageInputShape,
641
+ }, async (args) => jsonText(await handleUsage(deps, args)));
642
+ }
643
+ export async function invokeTool(name, args, deps) {
644
+ if (name === "dispatch") {
645
+ const parsed = z.object(dispatchInputShape).parse(args);
646
+ return { kind: "json", data: await handleDispatch(deps, parsed) };
647
+ }
648
+ if (name === "job_status") {
649
+ const parsed = z.object(jobStatusInputShape).parse(args ?? {});
650
+ return { kind: "json", data: await handleJobStatus(parsed) };
651
+ }
652
+ if (name === "usage") {
653
+ const parsed = z.object(usageInputShape).parse(args ?? {});
654
+ return { kind: "json", data: await handleUsage(deps, parsed) };
655
+ }
656
+ throw new Error(`Unknown tool: ${name} (valid: ${TOOL_NAMES.join(", ")})`);
657
+ }
658
+ //# sourceMappingURL=tools.js.map