llm-relay 0.82.2 → 0.83.2

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.
@@ -1,9 +1,10 @@
1
- import { DEFAULT_MCP_MAX_WAIT_MS } from "../config-types.js";
1
+ import { DEFAULT_MCP_BLOCKING_WAIT_MS, DEFAULT_MCP_MAX_WAIT_MS, EFFORT_LEVELS } from "../config-types.js";
2
2
  import { isToolUseBlock } from "../anthropic.js";
3
3
  import { formatAttemptBudget, formatLaneStats, isPassThroughSpec, LANE_UNRELIABLE_STREAK, mcpPassThroughReason } from "../dispatch.js";
4
4
  import { laneOfRung } from "../lane-manifest.js";
5
+ import { defaultTreeSnapshot, newestChangeMs, renderTreeDelta, sameTree, } from "./tree-delta.js";
5
6
  import { estimateTokensFromCharacters } from "../metadata.js";
6
- import { DEFAULT_LANE_TIMEOUT_MS, DEFAULT_MAX_DEPTH, DEPTH_ENV, EMPTY_OUTPUT_REASON, LaneJobStore, SKIPPED_LANE_STATUS, attemptWasTried, classifyDispatchedResult, classifyLaneAttempt, checkCwd, currentDepth, defaultAnswerFetch, defaultLaneSpawner, isContentEmpty, readRelayAnnouncements, relayLoopbackUrl, } from "./lane-runner.js";
7
+ import { DEFAULT_LANE_TIMEOUT_MS, DEFAULT_MAX_DEPTH, DEPTH_ENV, EMPTY_OUTPUT_REASON, LaneJobStore, SKIPPED_LANE_STATUS, agyWorkingDirInvoke, attemptWasTried, classifyDispatchedResult, classifyLaneAttempt, checkCwd, currentDepth, defaultAnswerFetch, defaultLaneSpawner, expandEnvReferences, isContentEmpty, readRelayAnnouncements, relayLoopbackUrl, } from "./lane-runner.js";
7
8
  import { readOnlyInvoke, readOnlyVerdict } from "./readonly-boundary.js";
8
9
  import { agyQuotaStatement } from "./agy-quota-log.js";
9
10
  import { nullJobJournal } from "./job-journal.js";
@@ -11,6 +12,7 @@ import { nullJobArchive } from "./job-archive.js";
11
12
  import { RPC_INTERNAL_ERROR, RPC_INVALID_PARAMS, RPC_METHOD_NOT_FOUND, RPC_PARSE_ERROR, encodeMessage, errorResponse, isJsonRpcNotification, isJsonRpcRequest, logStderr, negotiateProtocolVersion, resultResponse, splitMessages, } from "./protocol.js";
12
13
  export const MCP_SERVER_NAME = "llm-relay";
13
14
  export const DEFAULT_ANSWER_MAX_TOKENS = 4096;
15
+ export const RECENT_JOBS_LISTED = 20;
14
16
  export const MCP_INSTRUCTIONS = "Use dispatch from every MCP host — Claude, Codex, desktop, CLI, or otherwise — to hand a " +
15
17
  "whole task to another agent lane and get its answer back. Never choose a host-specific child " +
16
18
  "mechanism yourself; whether the lane is a model pool or a peer CLI is resolved here.\n\n" +
@@ -55,7 +57,8 @@ const TOOLS = [
55
57
  "environment and idle timeouts are handled here, so you never build a command line. If the " +
56
58
  "lane is still running, returns a jobId to poll with dispatch_status — it blocks at most " +
57
59
  "routing.mcp.maxWaitMs first, so a slow lane degrades to polling instead of hitting the " +
58
- "host's tool timeout. In Codex " +
60
+ "host's tool timeout. In Claude Code, a call that carries a progress token instead waits " +
61
+ "for the answer (up to routing.mcp.blockingWaitMs) and reports progress. In Codex " +
59
62
  "Desktop, use this instead of a pool/* collaboration child, which the ChatGPT launcher " +
60
63
  "rejects before the custom provider or relay is reached. Set mode to \"answer\" for a " +
61
64
  "question, draft, summary, or second opinion that needs no file access — it posts straight " +
@@ -111,13 +114,22 @@ const TOOLS = [
111
114
  },
112
115
  waitMs: {
113
116
  type: "number",
114
- description: "How long to block before returning a jobId instead (default routing.mcp.maxWaitMs; " +
115
- "a larger waitMs is clamped to that ceiling and the clamp is announced in the reply).",
117
+ description: "How long to block before returning a jobId instead (default routing.mcp.maxWaitMs, or " +
118
+ "routing.mcp.blockingWaitMs for a Claude Code call that asked for progress; a larger " +
119
+ "waitMs is clamped to that ceiling and the clamp is announced in the reply).",
116
120
  },
117
121
  timeoutMs: {
118
122
  type: "number",
119
123
  description: `Hard ceiling on the lane run (default ${DEFAULT_LANE_TIMEOUT_MS}).`,
120
124
  },
125
+ scope: {
126
+ type: "array",
127
+ items: { type: "string" },
128
+ description: "Agent mode only: the paths or globs (relative to cwd) the task may change. The answer " +
129
+ "always lists what changed in the git tree (a `tree delta` block); with scope, every " +
130
+ "changed path outside it is marked OUT OF SCOPE. The relay reports only — it never " +
131
+ "refuses or reverts.",
132
+ },
121
133
  readOnly: {
122
134
  type: "boolean",
123
135
  description: "Declare that this dispatch must not mutate anything — a review, a survey, a second " +
@@ -139,12 +151,15 @@ const TOOLS = [
139
151
  {
140
152
  name: "dispatch_status",
141
153
  title: "Check a dispatched job",
142
- description: "Report whether a dispatched lane is still running, and for how long. Poll this after " +
143
- "dispatch returned a jobId. Wait at least a few seconds between polls.",
154
+ description: "Report a dispatched job. While it runs: its lane, elapsed time and output so far. Once it " +
155
+ "has ended: its full answer, exactly as dispatch_result returns it. Wait at least a few " +
156
+ "seconds between polls. Without jobId: list the recent jobs of every llm-relay MCP server " +
157
+ "on this machine, so a lost jobId can be found again.",
144
158
  inputSchema: {
145
159
  type: "object",
146
- properties: { jobId: { type: "string", description: "The jobId dispatch returned." } },
147
- required: ["jobId"],
160
+ properties: {
161
+ jobId: { type: "string", description: "The jobId dispatch returned. Omit it to list recent jobs." },
162
+ },
148
163
  additionalProperties: false,
149
164
  },
150
165
  },
@@ -215,6 +230,13 @@ export function resolveWaitMs(requested, ceiling) {
215
230
  }
216
231
  return { waitMs: requested, clamped: false, requested };
217
232
  }
233
+ function readStringArray(params, key) {
234
+ const value = params[key];
235
+ if (!Array.isArray(value))
236
+ return undefined;
237
+ const strings = value.filter((v) => typeof v === "string" && v.trim() !== "");
238
+ return strings.length > 0 ? strings : undefined;
239
+ }
218
240
  function readRecord(params, key) {
219
241
  const v = params[key];
220
242
  return typeof v === "object" && v !== null && !Array.isArray(v) ? v : undefined;
@@ -273,6 +295,23 @@ function laneEvidenceBits(lane) {
273
295
  bits.push(`${lane.recentFailures} own failure${lane.recentFailures === 1 ? "" : "s"} in a row`);
274
296
  return bits;
275
297
  }
298
+ function relayProvenanceLines(job) {
299
+ const relay = job.relay;
300
+ if (!relay)
301
+ return [];
302
+ const lines = [];
303
+ if (relay.servedBy)
304
+ lines.push(`served-by: ${relay.servedBy}`);
305
+ if (relay.poolAttempts)
306
+ lines.push(`pool-attempts: ${relay.poolAttempts}`);
307
+ if (relay.hedged)
308
+ lines.push(`hedged: ${relay.hedged}`);
309
+ if (relay.latencyDemoted)
310
+ lines.push(`latency-demoted: ${relay.latencyDemoted}`);
311
+ if (relay.degraded)
312
+ lines.push(`degraded: ${relay.degraded}`);
313
+ return lines;
314
+ }
276
315
  function describeJob(job, now) {
277
316
  const elapsed = Math.round(((job.endedAt ?? now) - job.startedAt) / 1000);
278
317
  const head = [
@@ -287,22 +326,18 @@ function describeJob(job, now) {
287
326
  head.push("timed out: yes");
288
327
  if (job.error)
289
328
  head.push(`error: ${job.error}`);
290
- if (job.relay?.servedBy)
291
- head.push(`served-by: ${job.relay.servedBy}`);
292
- if (job.relay?.poolAttempts)
293
- head.push(`pool-attempts: ${job.relay.poolAttempts}`);
294
- if (job.relay?.hedged)
295
- head.push(`hedged: ${job.relay.hedged}`);
296
- if (job.relay?.latencyDemoted)
297
- head.push(`latency-demoted: ${job.relay.latencyDemoted}`);
298
- if (job.relay?.degraded)
299
- head.push(`degraded: ${job.relay.degraded}`);
329
+ head.push(...relayProvenanceLines(job));
300
330
  if (job.dispatchSource === "fallback")
301
331
  head.push("dispatch-source: local-fallback (daemon unreachable)");
302
- if (job.restored === true)
303
- head.push("record: restored from disk — this job ended before the llm-relay MCP server last restarted");
332
+ if (job.restored === true) {
333
+ head.push("record: restored from disk — this job ended in an earlier llm-relay MCP server process or in another host's one");
334
+ }
304
335
  if (job.readOnly)
305
336
  head.push(`read-only: ${job.readOnly.binding}`);
337
+ if (job.launch?.length)
338
+ head.push(`launch: ${job.launch.join("; ")}`);
339
+ if (job.extended?.length)
340
+ head.push(`extended: ${job.extended.join("; ")}`);
306
341
  const usually = runningTimeToAnswer(job);
307
342
  if (usually !== null)
308
343
  head.push(usually);
@@ -355,6 +390,10 @@ function describeAttempts(job) {
355
390
  return `lanes tried:\n${lines.join("\n")}${capped}`;
356
391
  }
357
392
  function jobAnswer(job, now) {
393
+ const answer = jobAnswerBody(job, now);
394
+ return job.treeDelta === undefined ? answer : `${answer}\n\n${job.treeDelta}`;
395
+ }
396
+ function jobAnswerBody(job, now) {
358
397
  const header = describeJob(job, now);
359
398
  const body = job.stdout.trim();
360
399
  const nothingAnswered = job.status !== "running"
@@ -412,6 +451,14 @@ function flagValue(args, flag) {
412
451
  const joined = args.find((a) => a.startsWith(`${flag}=`));
413
452
  return joined === undefined ? null : joined.slice(flag.length + 1);
414
453
  }
454
+ export const LANE_ACTIVITY_WINDOW_MS = 60_000;
455
+ function budgetTimer(ms) {
456
+ return new Promise((resolve) => {
457
+ const t = setTimeout(() => resolve({ kind: "budget" }), ms);
458
+ if (typeof t.unref === "function")
459
+ t.unref();
460
+ });
461
+ }
415
462
  function laneAnswered(r) {
416
463
  return r.code === 0 && !r.timedOut && !isContentEmpty(r.stdout);
417
464
  }
@@ -454,6 +501,9 @@ function laneSummary(lane, inFlight) {
454
501
  bits.push(formatLaneStats(lane.stats));
455
502
  return bits.join(" ");
456
503
  }
504
+ export const BLOCKING_WAIT_CLIENTS = ["claude-code"];
505
+ export const HOST_WAIT_CEILING_MS = { "claude-ai": 50_000 };
506
+ export const PROGRESS_INTERVAL_MS = 30_000;
457
507
  export class McpDispatchServer {
458
508
  deps;
459
509
  jobs;
@@ -463,6 +513,9 @@ export class McpDispatchServer {
463
513
  cwd;
464
514
  maxDepth;
465
515
  buffer = "";
516
+ clientName;
517
+ cancellable = new Map();
518
+ trees = new Map();
466
519
  constructor(deps) {
467
520
  this.deps = deps;
468
521
  this.jobs = new LaneJobStore(deps.journal ?? nullJobJournal, deps.archive ?? nullJobArchive);
@@ -505,12 +558,20 @@ export class McpDispatchServer {
505
558
  this.deps.write(encodeMessage({ jsonrpc: "2.0", id: 0, error: { code: RPC_PARSE_ERROR, message: "invalid JSON" } }));
506
559
  return;
507
560
  }
508
- if (isJsonRpcNotification(parsed))
561
+ if (isJsonRpcNotification(parsed)) {
562
+ if (parsed.method === "notifications/cancelled")
563
+ this.cancelRequest(parsed.params);
509
564
  return;
565
+ }
510
566
  if (!isJsonRpcRequest(parsed))
511
567
  return;
568
+ const abort = new AbortController();
569
+ if (parsed.method === "tools/call")
570
+ this.cancellable.set(parsed.id, abort);
512
571
  try {
513
- const result = await this.route(parsed.method, parsed.params);
572
+ const result = await this.route(parsed.method, parsed.params, abort.signal);
573
+ if (abort.signal.aborted)
574
+ return;
514
575
  if (result === undefined) {
515
576
  this.send(errorResponse(parsed.id, RPC_METHOD_NOT_FOUND, `unknown method: ${parsed.method}`));
516
577
  return;
@@ -520,8 +581,20 @@ export class McpDispatchServer {
520
581
  catch (e) {
521
582
  this.send(errorResponse(parsed.id, RPC_INTERNAL_ERROR, e.message));
522
583
  }
584
+ finally {
585
+ if (this.cancellable.get(parsed.id) === abort)
586
+ this.cancellable.delete(parsed.id);
587
+ }
523
588
  }
524
- async route(method, params) {
589
+ cancelRequest(params) {
590
+ if (typeof params !== "object" || params === null)
591
+ return;
592
+ const id = params["requestId"];
593
+ if (typeof id !== "string" && typeof id !== "number")
594
+ return;
595
+ this.cancellable.get(id)?.abort();
596
+ }
597
+ async route(method, params, signal) {
525
598
  switch (method) {
526
599
  case "initialize":
527
600
  return this.initialize(params);
@@ -530,13 +603,16 @@ export class McpDispatchServer {
530
603
  case "tools/list":
531
604
  return { tools: TOOLS };
532
605
  case "tools/call":
533
- return this.callTool(params);
606
+ return this.callTool(params, signal);
534
607
  default:
535
608
  return undefined;
536
609
  }
537
610
  }
538
611
  initialize(params) {
539
612
  const p = (typeof params === "object" && params !== null ? params : {});
613
+ const info = p["clientInfo"];
614
+ const name = typeof info === "object" && info !== null ? info["name"] : undefined;
615
+ this.clientName = typeof name === "string" ? name : undefined;
540
616
  return {
541
617
  protocolVersion: negotiateProtocolVersion(p["protocolVersion"]),
542
618
  capabilities: { tools: { listChanged: false } },
@@ -544,7 +620,7 @@ export class McpDispatchServer {
544
620
  instructions: MCP_INSTRUCTIONS,
545
621
  };
546
622
  }
547
- async callTool(params) {
623
+ async callTool(params, signal) {
548
624
  const p = (typeof params === "object" && params !== null ? params : {});
549
625
  const name = p["name"];
550
626
  const args = (typeof p["arguments"] === "object" && p["arguments"] !== null
@@ -553,12 +629,18 @@ export class McpDispatchServer {
553
629
  if (typeof name !== "string") {
554
630
  throw Object.assign(new Error("tools/call requires a tool name"), { code: RPC_INVALID_PARAMS });
555
631
  }
556
- return this.withVersionNotice(await this.runTool(name, args));
632
+ const meta = p["_meta"];
633
+ const token = typeof meta === "object" && meta !== null ? meta["progressToken"] : undefined;
634
+ const ctx = {
635
+ progressToken: typeof token === "string" || typeof token === "number" ? token : undefined,
636
+ signal,
637
+ };
638
+ return this.withVersionNotice(await this.runTool(name, args, ctx));
557
639
  }
558
- async runTool(name, args) {
640
+ async runTool(name, args, ctx) {
559
641
  switch (name) {
560
642
  case "dispatch":
561
- return this.toolDispatch(args);
643
+ return this.toolDispatch(args, ctx);
562
644
  case "dispatch_status":
563
645
  return this.toolStatus(args);
564
646
  case "dispatch_result":
@@ -608,32 +690,79 @@ export class McpDispatchServer {
608
690
  toolStatus(args) {
609
691
  const jobId = readString(args, "jobId");
610
692
  if (!jobId)
611
- return textResult("dispatch_status requires jobId", true);
612
- const job = this.jobs.get(jobId);
693
+ return textResult(this.recentJobs());
694
+ const job = this.jobs.find(jobId);
613
695
  if (!job)
614
- return textResult(`unknown jobId: ${jobId}`, true);
615
- return textResult(describeJob(job, this.now()));
696
+ return this.unknownJob(jobId);
697
+ if (job.status === "running")
698
+ return textResult(describeJob(job, this.now()));
699
+ return textResult(jobAnswer(job, this.now()), isFailureStatus(job.status));
616
700
  }
617
701
  toolResult(args) {
618
702
  const jobId = readString(args, "jobId");
619
703
  if (!jobId)
620
704
  return textResult("dispatch_result requires jobId", true);
621
- const job = this.jobs.get(jobId);
705
+ const job = this.jobs.find(jobId);
622
706
  if (!job)
623
- return textResult(`unknown jobId: ${jobId}`, true);
707
+ return this.unknownJob(jobId);
624
708
  return textResult(jobAnswer(job, this.now()), isFailureStatus(job.status));
625
709
  }
710
+ recentJobs() {
711
+ const now = this.now();
712
+ const rows = this.jobs.recent(RECENT_JOBS_LISTED);
713
+ if (rows.length === 0)
714
+ return "No dispatched jobs are recorded on this machine.";
715
+ const lines = rows.map((r) => {
716
+ const seconds = Math.max(0, Math.round(((r.endedAt ?? now) - r.startedAt) / 1000));
717
+ const where = r.elsewhere ? " [another MCP server process]" : "";
718
+ const label = r.label ? ` — ${r.label}` : "";
719
+ return `${r.id} ${r.status} ${seconds}s ${r.laneId}${where}${label}`;
720
+ });
721
+ return `recent jobs (newest first):\n${lines.join("\n")}\n\nPass a jobId to read one.`;
722
+ }
723
+ unknownJob(jobId) {
724
+ const elsewhere = this.jobs.runningElsewhere(jobId);
725
+ if (elsewhere === undefined)
726
+ return textResult(`unknown jobId: ${jobId}`, true);
727
+ const elapsed = Math.max(0, Math.round((this.now() - elsewhere.startedAt) / 1000));
728
+ return textResult([
729
+ `job: ${jobId}`,
730
+ `lane: ${elsewhere.laneId}${elsewhere.spec ? ` (${elsewhere.spec})` : ""}`,
731
+ "status: running",
732
+ `elapsed: ${elapsed}s`,
733
+ `record: running in another llm-relay MCP server process (pid ${elsewhere.pid})`,
734
+ ].join("\n") +
735
+ "\n\nThis process cannot report its progress or cancel it. Poll again: its answer appears " +
736
+ "here once it ends.");
737
+ }
626
738
  toolCancel(args) {
627
739
  const jobId = readString(args, "jobId");
628
740
  if (!jobId)
629
741
  return textResult("dispatch_cancel requires jobId", true);
630
- const job = this.jobs.get(jobId);
742
+ const job = this.jobs.find(jobId);
631
743
  if (!job)
632
- return textResult(`unknown jobId: ${jobId}`, true);
744
+ return this.unknownJob(jobId);
633
745
  const cancelled = this.jobs.cancel(jobId);
746
+ if (cancelled)
747
+ void this.recordTreeDelta(jobId);
634
748
  return textResult(cancelled ? `cancelled ${jobId}` : `${jobId} was already ${job.status}`);
635
749
  }
636
- async toolDispatch(args) {
750
+ blockingWaitFor(ctx, maxWaitMs) {
751
+ const cap = this.deps.config.routing?.mcp?.blockingWaitMs ?? DEFAULT_MCP_BLOCKING_WAIT_MS;
752
+ const client = this.clientName;
753
+ if (cap <= 0 || client === undefined)
754
+ return null;
755
+ if (ctx.progressToken !== undefined && BLOCKING_WAIT_CLIENTS.includes(client)) {
756
+ return { ms: Math.max(cap, maxWaitMs), progress: true, key: "routing.mcp.blockingWaitMs" };
757
+ }
758
+ if (!Object.hasOwn(HOST_WAIT_CEILING_MS, client))
759
+ return null;
760
+ const host = HOST_WAIT_CEILING_MS[client] ?? 0;
761
+ if (host <= maxWaitMs)
762
+ return null;
763
+ return { ms: Math.min(cap, host), progress: false, key: `the ${client} tool-call ceiling` };
764
+ }
765
+ async toolDispatch(args, ctx) {
637
766
  const task = readString(args, "task");
638
767
  if (!task)
639
768
  return textResult("dispatch requires a non-empty task", true);
@@ -661,6 +790,7 @@ export class McpDispatchServer {
661
790
  cwd: readString(args, "cwd") ?? this.cwd(),
662
791
  depth,
663
792
  readOnly: args["readOnly"] === true,
793
+ scope: readStringArray(args, "scope"),
664
794
  tier: view.tier ?? undefined,
665
795
  timeoutMs: readNumber(args, "timeoutMs") ?? DEFAULT_LANE_TIMEOUT_MS,
666
796
  attemptMs: walk !== null ? walk.attemptMs : null,
@@ -678,17 +808,25 @@ export class McpDispatchServer {
678
808
  if (!readOnlyVerdictResult.ok)
679
809
  return textResult(readOnlyVerdictResult.refusal, true);
680
810
  const first = view.ladder.find((l) => l.id === ordered[0]) ?? view.next;
681
- const job = this.jobs.create(first.id, first.spec, opts.cwd, opts.dispatchSource);
811
+ const job = this.jobs.create(first.id, first.spec, opts.cwd, opts.dispatchSource, taskLabel(task));
682
812
  this.jobs.noteWalkScope(job.id, { enabled: walk !== null, lanesNotTried: notTried, forced });
683
- const ceiling = this.deps.config.routing?.mcp?.maxWaitMs ?? DEFAULT_MCP_MAX_WAIT_MS;
813
+ const maxWaitMs = this.deps.config.routing?.mcp?.maxWaitMs ?? DEFAULT_MCP_MAX_WAIT_MS;
814
+ const blocking = this.blockingWaitFor(ctx, maxWaitMs);
815
+ const ceiling = blocking?.ms ?? maxWaitMs;
816
+ const ceilingKey = blocking?.key ?? "routing.mcp.maxWaitMs";
684
817
  const waitMs = resolveWaitMs(args["waitMs"], ceiling);
685
818
  if ("refusal" in waitMs)
686
819
  return textResult(waitMs.refusal, true);
687
- const settled = this.runWalk(job.id, view, ordered, task, opts).catch((e) => {
820
+ const settled = this.runWalk(job.id, view, ordered, task, opts).catch(async (e) => {
821
+ await this.recordTreeDelta(job.id);
688
822
  this.jobs.fail(job.id, `dispatch walk failed: ${e.message}`);
689
823
  return "done";
690
824
  });
691
- return this.awaitOrPoll(job.id, settled, waitMs.waitMs, waitMs.clamped ? { requested: waitMs.requested, ceiling } : undefined);
825
+ return this.awaitOrPoll(job.id, settled, waitMs.waitMs, {
826
+ clamp: waitMs.clamped ? { requested: waitMs.requested, ceiling, key: ceilingKey } : undefined,
827
+ progressToken: blocking?.progress === true ? ctx.progressToken : undefined,
828
+ signal: ctx.signal,
829
+ });
692
830
  }
693
831
  walkOrder(view, walk, forced, nextId) {
694
832
  const runnable = (id) => forced || !this.isPassThroughLane(view.ladder.find((l) => l.id === id));
@@ -707,6 +845,7 @@ export class McpDispatchServer {
707
845
  }
708
846
  }
709
847
  async runWalk(jobId, view, laneIds, task, opts) {
848
+ await this.startTreeDelta(jobId, opts);
710
849
  for (let i = 0; i < laneIds.length; i++) {
711
850
  if (this.jobs.get(jobId)?.status !== "running")
712
851
  return "done";
@@ -714,7 +853,7 @@ export class McpDispatchServer {
714
853
  const lane = laneId === undefined ? undefined : view.ladder.find((l) => l.id === laneId);
715
854
  if (lane === undefined)
716
855
  continue;
717
- if (this.skipIfAtConcurrencyCap(jobId, lane))
856
+ if (this.skipBeforeStart(jobId, lane, opts))
718
857
  continue;
719
858
  const readOnly = this.bindReadOnlyOrSkip(jobId, lane, opts);
720
859
  if (readOnly.skipped)
@@ -731,6 +870,7 @@ export class McpDispatchServer {
731
870
  if (this.jobs.get(jobId)?.status === "cancelled")
732
871
  return "done";
733
872
  if (outcome.refusal !== undefined) {
873
+ await this.recordTreeDelta(jobId);
734
874
  this.jobs.fail(jobId, outcome.refusal);
735
875
  return "done";
736
876
  }
@@ -756,6 +896,7 @@ export class McpDispatchServer {
756
896
  adHoc: lane.adHoc === true,
757
897
  }, outcome.run.stdout.length, { status, wallClockMs: elapsedMs, exitCode: outcome.run.code });
758
898
  if (status === "completed" || isLast) {
899
+ await this.recordTreeDelta(jobId);
759
900
  this.jobs.complete(jobId, outcome.run, status === "completed" ? undefined : (outcome.semanticFailure ?? reason), outcome.relay);
760
901
  return "done";
761
902
  }
@@ -764,6 +905,7 @@ export class McpDispatchServer {
764
905
  const ran = attempts.filter((a) => attemptWasTried(a.status)).length;
765
906
  const allSkipped = attempts.length > 0 && ran === 0;
766
907
  const skipReasons = [...new Set(attempts.filter((a) => !attemptWasTried(a.status)).map((a) => a.reason ?? "skipped"))];
908
+ await this.recordTreeDelta(jobId);
767
909
  this.jobs.fail(jobId, allSkipped
768
910
  ? `every lane in the selection order was skipped before it ran: ${skipReasons.join("; ")}`
769
911
  : ran === 0
@@ -771,6 +913,29 @@ export class McpDispatchServer {
771
913
  : `no lane answered, and the ladder no longer holds the remaining lanes in the selection order (${ran} tried)`);
772
914
  return "done";
773
915
  }
916
+ async startTreeDelta(jobId, opts) {
917
+ if (opts.mode !== "agent")
918
+ return;
919
+ const before = await this.readTree(opts.cwd);
920
+ if (before !== null)
921
+ this.trees.set(jobId, { cwd: opts.cwd, before, scope: opts.scope });
922
+ }
923
+ async recordTreeDelta(jobId) {
924
+ const tree = this.trees.get(jobId);
925
+ if (tree === undefined)
926
+ return;
927
+ this.trees.delete(jobId);
928
+ const after = await this.readTree(tree.cwd);
929
+ this.jobs.noteTreeDelta(jobId, renderTreeDelta(tree.cwd, tree.before, after, tree.scope));
930
+ }
931
+ async readTree(cwd) {
932
+ try {
933
+ return await (this.deps.treeSnapshot ?? defaultTreeSnapshot)(cwd);
934
+ }
935
+ catch {
936
+ return null;
937
+ }
938
+ }
774
939
  budgetWithheld(view, laneIds, i) {
775
940
  return !laneIds.slice(i + 1).some((id) => {
776
941
  const later = view.ladder.find((l) => l.id === id);
@@ -793,6 +958,50 @@ export class McpDispatchServer {
793
958
  this.jobs.noteSkippedLane(jobId);
794
959
  return true;
795
960
  }
961
+ skipBeforeStart(jobId, lane, opts) {
962
+ return this.skipIfAtConcurrencyCap(jobId, lane) || this.skipIfBelowTier(jobId, lane, opts);
963
+ }
964
+ skipIfBelowTier(jobId, lane, opts) {
965
+ if (lane.capability === undefined || opts.tier === undefined)
966
+ return false;
967
+ if (this.jobs.get(jobId)?.forcedLane === true)
968
+ return false;
969
+ const packet = EFFORT_LEVELS.indexOf(opts.tier);
970
+ const rung = EFFORT_LEVELS.indexOf(lane.capability);
971
+ if (packet < 0 || rung < 0 || rung >= packet)
972
+ return false;
973
+ this.jobs.recordAttempt(jobId, {
974
+ laneId: lane.id,
975
+ spec: lane.spec,
976
+ status: SKIPPED_LANE_STATUS,
977
+ elapsedMs: 0,
978
+ reason: `lane "${lane.id}" declares capability ${lane.capability}, below this ${opts.tier} dispatch`,
979
+ });
980
+ this.jobs.noteSkippedLane(jobId);
981
+ return true;
982
+ }
983
+ async laneStillWorking(jobId) {
984
+ const now = this.now();
985
+ const lastOutput = this.jobs.get(jobId)?.activity?.lastOutputAt;
986
+ if (typeof lastOutput === "number" && now - lastOutput <= LANE_ACTIVITY_WINDOW_MS) {
987
+ return `it wrote output ${Math.round((now - lastOutput) / 1000)}s ago`;
988
+ }
989
+ const tree = this.trees.get(jobId);
990
+ if (tree === undefined)
991
+ return null;
992
+ const reading = await this.readTree(tree.cwd);
993
+ if (reading === null)
994
+ return null;
995
+ const previous = tree.lastSeen ?? tree.before;
996
+ tree.lastSeen = reading;
997
+ if (!sameTree(previous, reading))
998
+ return "its working tree changed";
999
+ const newest = newestChangeMs(tree.cwd, reading);
1000
+ if (newest !== null && now - newest <= LANE_ACTIVITY_WINDOW_MS) {
1001
+ return `a file in its working tree changed ${Math.max(0, Math.round((now - newest) / 1000))}s ago`;
1002
+ }
1003
+ return null;
1004
+ }
796
1005
  bindReadOnlyOrSkip(jobId, lane, opts) {
797
1006
  if (!opts.readOnly)
798
1007
  return { skipped: false };
@@ -825,16 +1034,20 @@ export class McpDispatchServer {
825
1034
  }));
826
1035
  if (budgetMs === null)
827
1036
  return guarded;
828
- const raced = await Promise.race([
829
- guarded.then((o) => ({ kind: "settled", outcome: o })),
830
- new Promise((resolve) => {
831
- const t = setTimeout(() => resolve({ kind: "budget" }), budgetMs);
832
- if (typeof t.unref === "function")
833
- t.unref();
834
- }),
835
- ]);
836
- if (raced.kind === "settled")
837
- return raced.outcome;
1037
+ const settled = guarded.then((o) => ({ kind: "settled", outcome: o }));
1038
+ let waitMs = budgetMs;
1039
+ for (;;) {
1040
+ const raced = await Promise.race([settled, budgetTimer(waitMs)]);
1041
+ if (raced.kind === "settled")
1042
+ return raced.outcome;
1043
+ if (this.jobs.get(jobId)?.status !== "running")
1044
+ break;
1045
+ const working = await this.laneStillWorking(jobId);
1046
+ if (working === null)
1047
+ break;
1048
+ this.jobs.noteExtension(jobId, lane.id, `${lane.id} kept past its ${Math.round(budgetMs / 1000)}s budget because ${working}`);
1049
+ waitMs = LANE_ACTIVITY_WINDOW_MS;
1050
+ }
838
1051
  started.kill();
839
1052
  return { run: emptyRun(), abandoned: true };
840
1053
  }
@@ -864,8 +1077,8 @@ export class McpDispatchServer {
864
1077
  const cwdCheck = checkCwd(opts.cwd, this.deps.allowedRoots);
865
1078
  if (!cwdCheck.ok)
866
1079
  return { refusal: `dispatch refused: ${cwdCheck.reason}` };
867
- const invoke = invokeOverride ?? lane.invoke;
868
- if (!invoke) {
1080
+ const declared = invokeOverride ?? lane.invoke;
1081
+ if (!declared) {
869
1082
  const why = lane.unreachable ??
870
1083
  (lane.spec !== undefined && this.isPassThroughLane(lane)
871
1084
  ? mcpPassThroughReason(lane.spec)
@@ -875,8 +1088,12 @@ export class McpDispatchServer {
875
1088
  kill: () => { },
876
1089
  };
877
1090
  }
878
- const env = applyLaneEnv(process.env, invoke.env);
1091
+ const agy = agyWorkingDirInvoke(declared, opts.cwd);
1092
+ const invoke = agy?.invoke ?? declared;
1093
+ const expansion = expandEnvReferences(applyLaneEnv(process.env, invoke.env), this.deps.platform);
1094
+ const env = expansion.env;
879
1095
  env[DEPTH_ENV] = String(opts.depth + 1);
1096
+ this.jobs.noteLaunch(jobId, [...expansion.notes, ...(agy === null ? [] : [agy.note])]);
880
1097
  const startedAt = this.now();
881
1098
  this.jobs.beginAttemptActivity(jobId, startedAt);
882
1099
  let run;
@@ -1029,15 +1246,33 @@ export class McpDispatchServer {
1029
1246
  const text = extractAnswerText(parsed, opts.schema !== undefined);
1030
1247
  return { run: { code: 0, stdout: text, stderr: "", timedOut: false }, relay };
1031
1248
  }
1032
- async awaitOrPoll(jobId, settled, waitMs, clamp) {
1033
- const raced = await Promise.race([
1034
- settled,
1035
- new Promise((resolve) => {
1036
- const t = setTimeout(() => resolve("pending"), waitMs);
1037
- if (typeof t.unref === "function")
1038
- t.unref();
1039
- }),
1040
- ]);
1249
+ async awaitOrPoll(jobId, settled, waitMs, opts = {}) {
1250
+ const { clamp, progressToken, signal } = opts;
1251
+ const timers = [];
1252
+ const startedAt = this.now();
1253
+ if (progressToken !== undefined) {
1254
+ const tick = setInterval(() => this.sendProgress(jobId, progressToken, startedAt), PROGRESS_INTERVAL_MS);
1255
+ if (typeof tick.unref === "function")
1256
+ tick.unref();
1257
+ timers.push(tick);
1258
+ }
1259
+ let raced;
1260
+ try {
1261
+ raced = await Promise.race([
1262
+ settled,
1263
+ new Promise((resolve) => {
1264
+ const t = setTimeout(() => resolve("pending"), waitMs);
1265
+ if (typeof t.unref === "function")
1266
+ t.unref();
1267
+ timers.push(t);
1268
+ signal?.addEventListener("abort", () => resolve("pending"), { once: true });
1269
+ }),
1270
+ ]);
1271
+ }
1272
+ finally {
1273
+ for (const t of timers)
1274
+ clearTimeout(t);
1275
+ }
1041
1276
  const current = this.jobs.get(jobId);
1042
1277
  if (!current)
1043
1278
  return textResult(`unknown jobId: ${jobId}`, true);
@@ -1045,12 +1280,32 @@ export class McpDispatchServer {
1045
1280
  const waitedS = Math.round(waitMs / 1000);
1046
1281
  const announcement = clamp === undefined
1047
1282
  ? ""
1048
- : `waited ${waitedS} s (waitMs ${clamp.requested} clamped to routing.mcp.maxWaitMs ${clamp.ceiling})\n`;
1283
+ : `waited ${waitedS} s (waitMs ${clamp.requested} clamped to ${clamp.key} ${clamp.ceiling})\n`;
1049
1284
  return textResult(`${describeJob(current, this.now())}\n\n${announcement}Still running after ${waitedS}s. ` +
1050
1285
  `Poll dispatch_status with jobId "${jobId}", then call dispatch_result.`);
1051
1286
  }
1052
1287
  return textResult(jobAnswer(current, this.now()), isFailureStatus(current.status));
1053
1288
  }
1289
+ sendProgress(jobId, progressToken, startedAt) {
1290
+ const job = this.jobs.get(jobId);
1291
+ if (!job || job.status !== "running")
1292
+ return;
1293
+ const elapsed = Math.max(1, Math.round((this.now() - startedAt) / 1000));
1294
+ try {
1295
+ this.deps.write(encodeMessage({
1296
+ jsonrpc: "2.0",
1297
+ method: "notifications/progress",
1298
+ params: { progressToken, progress: elapsed, message: `${jobId} running on ${job.laneId} for ${elapsed}s` },
1299
+ }));
1300
+ }
1301
+ catch (e) {
1302
+ logStderr(`progress write failed: ${e.message}`);
1303
+ }
1304
+ }
1305
+ }
1306
+ export function taskLabel(task) {
1307
+ const line = task.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) ?? "";
1308
+ return line.length > 80 ? `${line.slice(0, 77)}...` : line;
1054
1309
  }
1055
1310
  function extractAnswerText(parsed, hasSchema) {
1056
1311
  const message = parsed;