llm-relay 0.82.2 → 0.83.1

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,8 @@ 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 PROGRESS_INTERVAL_MS = 30_000;
457
506
  export class McpDispatchServer {
458
507
  deps;
459
508
  jobs;
@@ -463,6 +512,9 @@ export class McpDispatchServer {
463
512
  cwd;
464
513
  maxDepth;
465
514
  buffer = "";
515
+ clientName;
516
+ cancellable = new Map();
517
+ trees = new Map();
466
518
  constructor(deps) {
467
519
  this.deps = deps;
468
520
  this.jobs = new LaneJobStore(deps.journal ?? nullJobJournal, deps.archive ?? nullJobArchive);
@@ -505,12 +557,20 @@ export class McpDispatchServer {
505
557
  this.deps.write(encodeMessage({ jsonrpc: "2.0", id: 0, error: { code: RPC_PARSE_ERROR, message: "invalid JSON" } }));
506
558
  return;
507
559
  }
508
- if (isJsonRpcNotification(parsed))
560
+ if (isJsonRpcNotification(parsed)) {
561
+ if (parsed.method === "notifications/cancelled")
562
+ this.cancelRequest(parsed.params);
509
563
  return;
564
+ }
510
565
  if (!isJsonRpcRequest(parsed))
511
566
  return;
567
+ const abort = new AbortController();
568
+ if (parsed.method === "tools/call")
569
+ this.cancellable.set(parsed.id, abort);
512
570
  try {
513
- const result = await this.route(parsed.method, parsed.params);
571
+ const result = await this.route(parsed.method, parsed.params, abort.signal);
572
+ if (abort.signal.aborted)
573
+ return;
514
574
  if (result === undefined) {
515
575
  this.send(errorResponse(parsed.id, RPC_METHOD_NOT_FOUND, `unknown method: ${parsed.method}`));
516
576
  return;
@@ -520,8 +580,20 @@ export class McpDispatchServer {
520
580
  catch (e) {
521
581
  this.send(errorResponse(parsed.id, RPC_INTERNAL_ERROR, e.message));
522
582
  }
583
+ finally {
584
+ if (this.cancellable.get(parsed.id) === abort)
585
+ this.cancellable.delete(parsed.id);
586
+ }
523
587
  }
524
- async route(method, params) {
588
+ cancelRequest(params) {
589
+ if (typeof params !== "object" || params === null)
590
+ return;
591
+ const id = params["requestId"];
592
+ if (typeof id !== "string" && typeof id !== "number")
593
+ return;
594
+ this.cancellable.get(id)?.abort();
595
+ }
596
+ async route(method, params, signal) {
525
597
  switch (method) {
526
598
  case "initialize":
527
599
  return this.initialize(params);
@@ -530,13 +602,16 @@ export class McpDispatchServer {
530
602
  case "tools/list":
531
603
  return { tools: TOOLS };
532
604
  case "tools/call":
533
- return this.callTool(params);
605
+ return this.callTool(params, signal);
534
606
  default:
535
607
  return undefined;
536
608
  }
537
609
  }
538
610
  initialize(params) {
539
611
  const p = (typeof params === "object" && params !== null ? params : {});
612
+ const info = p["clientInfo"];
613
+ const name = typeof info === "object" && info !== null ? info["name"] : undefined;
614
+ this.clientName = typeof name === "string" ? name : undefined;
540
615
  return {
541
616
  protocolVersion: negotiateProtocolVersion(p["protocolVersion"]),
542
617
  capabilities: { tools: { listChanged: false } },
@@ -544,7 +619,7 @@ export class McpDispatchServer {
544
619
  instructions: MCP_INSTRUCTIONS,
545
620
  };
546
621
  }
547
- async callTool(params) {
622
+ async callTool(params, signal) {
548
623
  const p = (typeof params === "object" && params !== null ? params : {});
549
624
  const name = p["name"];
550
625
  const args = (typeof p["arguments"] === "object" && p["arguments"] !== null
@@ -553,12 +628,18 @@ export class McpDispatchServer {
553
628
  if (typeof name !== "string") {
554
629
  throw Object.assign(new Error("tools/call requires a tool name"), { code: RPC_INVALID_PARAMS });
555
630
  }
556
- return this.withVersionNotice(await this.runTool(name, args));
631
+ const meta = p["_meta"];
632
+ const token = typeof meta === "object" && meta !== null ? meta["progressToken"] : undefined;
633
+ const ctx = {
634
+ progressToken: typeof token === "string" || typeof token === "number" ? token : undefined,
635
+ signal,
636
+ };
637
+ return this.withVersionNotice(await this.runTool(name, args, ctx));
557
638
  }
558
- async runTool(name, args) {
639
+ async runTool(name, args, ctx) {
559
640
  switch (name) {
560
641
  case "dispatch":
561
- return this.toolDispatch(args);
642
+ return this.toolDispatch(args, ctx);
562
643
  case "dispatch_status":
563
644
  return this.toolStatus(args);
564
645
  case "dispatch_result":
@@ -608,32 +689,72 @@ export class McpDispatchServer {
608
689
  toolStatus(args) {
609
690
  const jobId = readString(args, "jobId");
610
691
  if (!jobId)
611
- return textResult("dispatch_status requires jobId", true);
612
- const job = this.jobs.get(jobId);
692
+ return textResult(this.recentJobs());
693
+ const job = this.jobs.find(jobId);
613
694
  if (!job)
614
- return textResult(`unknown jobId: ${jobId}`, true);
615
- return textResult(describeJob(job, this.now()));
695
+ return this.unknownJob(jobId);
696
+ if (job.status === "running")
697
+ return textResult(describeJob(job, this.now()));
698
+ return textResult(jobAnswer(job, this.now()), isFailureStatus(job.status));
616
699
  }
617
700
  toolResult(args) {
618
701
  const jobId = readString(args, "jobId");
619
702
  if (!jobId)
620
703
  return textResult("dispatch_result requires jobId", true);
621
- const job = this.jobs.get(jobId);
704
+ const job = this.jobs.find(jobId);
622
705
  if (!job)
623
- return textResult(`unknown jobId: ${jobId}`, true);
706
+ return this.unknownJob(jobId);
624
707
  return textResult(jobAnswer(job, this.now()), isFailureStatus(job.status));
625
708
  }
709
+ recentJobs() {
710
+ const now = this.now();
711
+ const rows = this.jobs.recent(RECENT_JOBS_LISTED);
712
+ if (rows.length === 0)
713
+ return "No dispatched jobs are recorded on this machine.";
714
+ const lines = rows.map((r) => {
715
+ const seconds = Math.max(0, Math.round(((r.endedAt ?? now) - r.startedAt) / 1000));
716
+ const where = r.elsewhere ? " [another MCP server process]" : "";
717
+ const label = r.label ? ` — ${r.label}` : "";
718
+ return `${r.id} ${r.status} ${seconds}s ${r.laneId}${where}${label}`;
719
+ });
720
+ return `recent jobs (newest first):\n${lines.join("\n")}\n\nPass a jobId to read one.`;
721
+ }
722
+ unknownJob(jobId) {
723
+ const elsewhere = this.jobs.runningElsewhere(jobId);
724
+ if (elsewhere === undefined)
725
+ return textResult(`unknown jobId: ${jobId}`, true);
726
+ const elapsed = Math.max(0, Math.round((this.now() - elsewhere.startedAt) / 1000));
727
+ return textResult([
728
+ `job: ${jobId}`,
729
+ `lane: ${elsewhere.laneId}${elsewhere.spec ? ` (${elsewhere.spec})` : ""}`,
730
+ "status: running",
731
+ `elapsed: ${elapsed}s`,
732
+ `record: running in another llm-relay MCP server process (pid ${elsewhere.pid})`,
733
+ ].join("\n") +
734
+ "\n\nThis process cannot report its progress or cancel it. Poll again: its answer appears " +
735
+ "here once it ends.");
736
+ }
626
737
  toolCancel(args) {
627
738
  const jobId = readString(args, "jobId");
628
739
  if (!jobId)
629
740
  return textResult("dispatch_cancel requires jobId", true);
630
- const job = this.jobs.get(jobId);
741
+ const job = this.jobs.find(jobId);
631
742
  if (!job)
632
- return textResult(`unknown jobId: ${jobId}`, true);
743
+ return this.unknownJob(jobId);
633
744
  const cancelled = this.jobs.cancel(jobId);
745
+ if (cancelled)
746
+ void this.recordTreeDelta(jobId);
634
747
  return textResult(cancelled ? `cancelled ${jobId}` : `${jobId} was already ${job.status}`);
635
748
  }
636
- async toolDispatch(args) {
749
+ blockingWaitFor(ctx, maxWaitMs) {
750
+ if (ctx.progressToken === undefined)
751
+ return null;
752
+ if (this.clientName === undefined || !BLOCKING_WAIT_CLIENTS.includes(this.clientName))
753
+ return null;
754
+ const cap = this.deps.config.routing?.mcp?.blockingWaitMs ?? DEFAULT_MCP_BLOCKING_WAIT_MS;
755
+ return cap > 0 ? Math.max(cap, maxWaitMs) : null;
756
+ }
757
+ async toolDispatch(args, ctx) {
637
758
  const task = readString(args, "task");
638
759
  if (!task)
639
760
  return textResult("dispatch requires a non-empty task", true);
@@ -661,6 +782,7 @@ export class McpDispatchServer {
661
782
  cwd: readString(args, "cwd") ?? this.cwd(),
662
783
  depth,
663
784
  readOnly: args["readOnly"] === true,
785
+ scope: readStringArray(args, "scope"),
664
786
  tier: view.tier ?? undefined,
665
787
  timeoutMs: readNumber(args, "timeoutMs") ?? DEFAULT_LANE_TIMEOUT_MS,
666
788
  attemptMs: walk !== null ? walk.attemptMs : null,
@@ -678,17 +800,25 @@ export class McpDispatchServer {
678
800
  if (!readOnlyVerdictResult.ok)
679
801
  return textResult(readOnlyVerdictResult.refusal, true);
680
802
  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);
803
+ const job = this.jobs.create(first.id, first.spec, opts.cwd, opts.dispatchSource, taskLabel(task));
682
804
  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;
805
+ const maxWaitMs = this.deps.config.routing?.mcp?.maxWaitMs ?? DEFAULT_MCP_MAX_WAIT_MS;
806
+ const blocking = this.blockingWaitFor(ctx, maxWaitMs);
807
+ const ceiling = blocking ?? maxWaitMs;
808
+ const ceilingKey = blocking === null ? "routing.mcp.maxWaitMs" : "routing.mcp.blockingWaitMs";
684
809
  const waitMs = resolveWaitMs(args["waitMs"], ceiling);
685
810
  if ("refusal" in waitMs)
686
811
  return textResult(waitMs.refusal, true);
687
- const settled = this.runWalk(job.id, view, ordered, task, opts).catch((e) => {
812
+ const settled = this.runWalk(job.id, view, ordered, task, opts).catch(async (e) => {
813
+ await this.recordTreeDelta(job.id);
688
814
  this.jobs.fail(job.id, `dispatch walk failed: ${e.message}`);
689
815
  return "done";
690
816
  });
691
- return this.awaitOrPoll(job.id, settled, waitMs.waitMs, waitMs.clamped ? { requested: waitMs.requested, ceiling } : undefined);
817
+ return this.awaitOrPoll(job.id, settled, waitMs.waitMs, {
818
+ clamp: waitMs.clamped ? { requested: waitMs.requested, ceiling, key: ceilingKey } : undefined,
819
+ progressToken: blocking === null ? undefined : ctx.progressToken,
820
+ signal: ctx.signal,
821
+ });
692
822
  }
693
823
  walkOrder(view, walk, forced, nextId) {
694
824
  const runnable = (id) => forced || !this.isPassThroughLane(view.ladder.find((l) => l.id === id));
@@ -707,6 +837,7 @@ export class McpDispatchServer {
707
837
  }
708
838
  }
709
839
  async runWalk(jobId, view, laneIds, task, opts) {
840
+ await this.startTreeDelta(jobId, opts);
710
841
  for (let i = 0; i < laneIds.length; i++) {
711
842
  if (this.jobs.get(jobId)?.status !== "running")
712
843
  return "done";
@@ -714,7 +845,7 @@ export class McpDispatchServer {
714
845
  const lane = laneId === undefined ? undefined : view.ladder.find((l) => l.id === laneId);
715
846
  if (lane === undefined)
716
847
  continue;
717
- if (this.skipIfAtConcurrencyCap(jobId, lane))
848
+ if (this.skipBeforeStart(jobId, lane, opts))
718
849
  continue;
719
850
  const readOnly = this.bindReadOnlyOrSkip(jobId, lane, opts);
720
851
  if (readOnly.skipped)
@@ -731,6 +862,7 @@ export class McpDispatchServer {
731
862
  if (this.jobs.get(jobId)?.status === "cancelled")
732
863
  return "done";
733
864
  if (outcome.refusal !== undefined) {
865
+ await this.recordTreeDelta(jobId);
734
866
  this.jobs.fail(jobId, outcome.refusal);
735
867
  return "done";
736
868
  }
@@ -756,6 +888,7 @@ export class McpDispatchServer {
756
888
  adHoc: lane.adHoc === true,
757
889
  }, outcome.run.stdout.length, { status, wallClockMs: elapsedMs, exitCode: outcome.run.code });
758
890
  if (status === "completed" || isLast) {
891
+ await this.recordTreeDelta(jobId);
759
892
  this.jobs.complete(jobId, outcome.run, status === "completed" ? undefined : (outcome.semanticFailure ?? reason), outcome.relay);
760
893
  return "done";
761
894
  }
@@ -764,6 +897,7 @@ export class McpDispatchServer {
764
897
  const ran = attempts.filter((a) => attemptWasTried(a.status)).length;
765
898
  const allSkipped = attempts.length > 0 && ran === 0;
766
899
  const skipReasons = [...new Set(attempts.filter((a) => !attemptWasTried(a.status)).map((a) => a.reason ?? "skipped"))];
900
+ await this.recordTreeDelta(jobId);
767
901
  this.jobs.fail(jobId, allSkipped
768
902
  ? `every lane in the selection order was skipped before it ran: ${skipReasons.join("; ")}`
769
903
  : ran === 0
@@ -771,6 +905,29 @@ export class McpDispatchServer {
771
905
  : `no lane answered, and the ladder no longer holds the remaining lanes in the selection order (${ran} tried)`);
772
906
  return "done";
773
907
  }
908
+ async startTreeDelta(jobId, opts) {
909
+ if (opts.mode !== "agent")
910
+ return;
911
+ const before = await this.readTree(opts.cwd);
912
+ if (before !== null)
913
+ this.trees.set(jobId, { cwd: opts.cwd, before, scope: opts.scope });
914
+ }
915
+ async recordTreeDelta(jobId) {
916
+ const tree = this.trees.get(jobId);
917
+ if (tree === undefined)
918
+ return;
919
+ this.trees.delete(jobId);
920
+ const after = await this.readTree(tree.cwd);
921
+ this.jobs.noteTreeDelta(jobId, renderTreeDelta(tree.cwd, tree.before, after, tree.scope));
922
+ }
923
+ async readTree(cwd) {
924
+ try {
925
+ return await (this.deps.treeSnapshot ?? defaultTreeSnapshot)(cwd);
926
+ }
927
+ catch {
928
+ return null;
929
+ }
930
+ }
774
931
  budgetWithheld(view, laneIds, i) {
775
932
  return !laneIds.slice(i + 1).some((id) => {
776
933
  const later = view.ladder.find((l) => l.id === id);
@@ -793,6 +950,50 @@ export class McpDispatchServer {
793
950
  this.jobs.noteSkippedLane(jobId);
794
951
  return true;
795
952
  }
953
+ skipBeforeStart(jobId, lane, opts) {
954
+ return this.skipIfAtConcurrencyCap(jobId, lane) || this.skipIfBelowTier(jobId, lane, opts);
955
+ }
956
+ skipIfBelowTier(jobId, lane, opts) {
957
+ if (lane.capability === undefined || opts.tier === undefined)
958
+ return false;
959
+ if (this.jobs.get(jobId)?.forcedLane === true)
960
+ return false;
961
+ const packet = EFFORT_LEVELS.indexOf(opts.tier);
962
+ const rung = EFFORT_LEVELS.indexOf(lane.capability);
963
+ if (packet < 0 || rung < 0 || rung >= packet)
964
+ return false;
965
+ this.jobs.recordAttempt(jobId, {
966
+ laneId: lane.id,
967
+ spec: lane.spec,
968
+ status: SKIPPED_LANE_STATUS,
969
+ elapsedMs: 0,
970
+ reason: `lane "${lane.id}" declares capability ${lane.capability}, below this ${opts.tier} dispatch`,
971
+ });
972
+ this.jobs.noteSkippedLane(jobId);
973
+ return true;
974
+ }
975
+ async laneStillWorking(jobId) {
976
+ const now = this.now();
977
+ const lastOutput = this.jobs.get(jobId)?.activity?.lastOutputAt;
978
+ if (typeof lastOutput === "number" && now - lastOutput <= LANE_ACTIVITY_WINDOW_MS) {
979
+ return `it wrote output ${Math.round((now - lastOutput) / 1000)}s ago`;
980
+ }
981
+ const tree = this.trees.get(jobId);
982
+ if (tree === undefined)
983
+ return null;
984
+ const reading = await this.readTree(tree.cwd);
985
+ if (reading === null)
986
+ return null;
987
+ const previous = tree.lastSeen ?? tree.before;
988
+ tree.lastSeen = reading;
989
+ if (!sameTree(previous, reading))
990
+ return "its working tree changed";
991
+ const newest = newestChangeMs(tree.cwd, reading);
992
+ if (newest !== null && now - newest <= LANE_ACTIVITY_WINDOW_MS) {
993
+ return `a file in its working tree changed ${Math.max(0, Math.round((now - newest) / 1000))}s ago`;
994
+ }
995
+ return null;
996
+ }
796
997
  bindReadOnlyOrSkip(jobId, lane, opts) {
797
998
  if (!opts.readOnly)
798
999
  return { skipped: false };
@@ -825,16 +1026,20 @@ export class McpDispatchServer {
825
1026
  }));
826
1027
  if (budgetMs === null)
827
1028
  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;
1029
+ const settled = guarded.then((o) => ({ kind: "settled", outcome: o }));
1030
+ let waitMs = budgetMs;
1031
+ for (;;) {
1032
+ const raced = await Promise.race([settled, budgetTimer(waitMs)]);
1033
+ if (raced.kind === "settled")
1034
+ return raced.outcome;
1035
+ if (this.jobs.get(jobId)?.status !== "running")
1036
+ break;
1037
+ const working = await this.laneStillWorking(jobId);
1038
+ if (working === null)
1039
+ break;
1040
+ this.jobs.noteExtension(jobId, lane.id, `${lane.id} kept past its ${Math.round(budgetMs / 1000)}s budget because ${working}`);
1041
+ waitMs = LANE_ACTIVITY_WINDOW_MS;
1042
+ }
838
1043
  started.kill();
839
1044
  return { run: emptyRun(), abandoned: true };
840
1045
  }
@@ -864,8 +1069,8 @@ export class McpDispatchServer {
864
1069
  const cwdCheck = checkCwd(opts.cwd, this.deps.allowedRoots);
865
1070
  if (!cwdCheck.ok)
866
1071
  return { refusal: `dispatch refused: ${cwdCheck.reason}` };
867
- const invoke = invokeOverride ?? lane.invoke;
868
- if (!invoke) {
1072
+ const declared = invokeOverride ?? lane.invoke;
1073
+ if (!declared) {
869
1074
  const why = lane.unreachable ??
870
1075
  (lane.spec !== undefined && this.isPassThroughLane(lane)
871
1076
  ? mcpPassThroughReason(lane.spec)
@@ -875,8 +1080,12 @@ export class McpDispatchServer {
875
1080
  kill: () => { },
876
1081
  };
877
1082
  }
878
- const env = applyLaneEnv(process.env, invoke.env);
1083
+ const agy = agyWorkingDirInvoke(declared, opts.cwd);
1084
+ const invoke = agy?.invoke ?? declared;
1085
+ const expansion = expandEnvReferences(applyLaneEnv(process.env, invoke.env), this.deps.platform);
1086
+ const env = expansion.env;
879
1087
  env[DEPTH_ENV] = String(opts.depth + 1);
1088
+ this.jobs.noteLaunch(jobId, [...expansion.notes, ...(agy === null ? [] : [agy.note])]);
880
1089
  const startedAt = this.now();
881
1090
  this.jobs.beginAttemptActivity(jobId, startedAt);
882
1091
  let run;
@@ -1029,15 +1238,33 @@ export class McpDispatchServer {
1029
1238
  const text = extractAnswerText(parsed, opts.schema !== undefined);
1030
1239
  return { run: { code: 0, stdout: text, stderr: "", timedOut: false }, relay };
1031
1240
  }
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
- ]);
1241
+ async awaitOrPoll(jobId, settled, waitMs, opts = {}) {
1242
+ const { clamp, progressToken, signal } = opts;
1243
+ const timers = [];
1244
+ const startedAt = this.now();
1245
+ if (progressToken !== undefined) {
1246
+ const tick = setInterval(() => this.sendProgress(jobId, progressToken, startedAt), PROGRESS_INTERVAL_MS);
1247
+ if (typeof tick.unref === "function")
1248
+ tick.unref();
1249
+ timers.push(tick);
1250
+ }
1251
+ let raced;
1252
+ try {
1253
+ raced = await Promise.race([
1254
+ settled,
1255
+ new Promise((resolve) => {
1256
+ const t = setTimeout(() => resolve("pending"), waitMs);
1257
+ if (typeof t.unref === "function")
1258
+ t.unref();
1259
+ timers.push(t);
1260
+ signal?.addEventListener("abort", () => resolve("pending"), { once: true });
1261
+ }),
1262
+ ]);
1263
+ }
1264
+ finally {
1265
+ for (const t of timers)
1266
+ clearTimeout(t);
1267
+ }
1041
1268
  const current = this.jobs.get(jobId);
1042
1269
  if (!current)
1043
1270
  return textResult(`unknown jobId: ${jobId}`, true);
@@ -1045,12 +1272,32 @@ export class McpDispatchServer {
1045
1272
  const waitedS = Math.round(waitMs / 1000);
1046
1273
  const announcement = clamp === undefined
1047
1274
  ? ""
1048
- : `waited ${waitedS} s (waitMs ${clamp.requested} clamped to routing.mcp.maxWaitMs ${clamp.ceiling})\n`;
1275
+ : `waited ${waitedS} s (waitMs ${clamp.requested} clamped to ${clamp.key} ${clamp.ceiling})\n`;
1049
1276
  return textResult(`${describeJob(current, this.now())}\n\n${announcement}Still running after ${waitedS}s. ` +
1050
1277
  `Poll dispatch_status with jobId "${jobId}", then call dispatch_result.`);
1051
1278
  }
1052
1279
  return textResult(jobAnswer(current, this.now()), isFailureStatus(current.status));
1053
1280
  }
1281
+ sendProgress(jobId, progressToken, startedAt) {
1282
+ const job = this.jobs.get(jobId);
1283
+ if (!job || job.status !== "running")
1284
+ return;
1285
+ const elapsed = Math.max(1, Math.round((this.now() - startedAt) / 1000));
1286
+ try {
1287
+ this.deps.write(encodeMessage({
1288
+ jsonrpc: "2.0",
1289
+ method: "notifications/progress",
1290
+ params: { progressToken, progress: elapsed, message: `${jobId} running on ${job.laneId} for ${elapsed}s` },
1291
+ }));
1292
+ }
1293
+ catch (e) {
1294
+ logStderr(`progress write failed: ${e.message}`);
1295
+ }
1296
+ }
1297
+ }
1298
+ export function taskLabel(task) {
1299
+ const line = task.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) ?? "";
1300
+ return line.length > 80 ? `${line.slice(0, 77)}...` : line;
1054
1301
  }
1055
1302
  function extractAnswerText(parsed, hasSchema) {
1056
1303
  const message = parsed;