@arhen/pi-core-subagent 1.3.48 → 1.3.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/manager.ts CHANGED
@@ -1,4 +1,3 @@
1
- /** SubagentManager: run lifecycle, child sessions, intercom, persistence, widget plumbing. */
2
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
3
2
  import { rename, rm, writeFile } from "node:fs/promises";
4
3
  import { basename, dirname, join, relative, sep } from "node:path";
@@ -57,34 +56,16 @@ import {
57
56
 
58
57
  export const DEFAULT_CONCURRENCY = 3;
59
58
  export const MAX_CONCURRENCY = 8;
60
- /**
61
- * Hard wall-clock ceiling per child — the ONLY liveness bound.
62
- *
63
- * There used to be a second one, an event-heartbeat "stall" watchdog. It was
64
- * deleted: it was armed before the child session even existed, so the only
65
- * window it could fire in was a slow startup (where firing is always wrong),
66
- * and once events flowed it could never fire at all. Every observed firing
67
- * across three releases was a false kill. A wedged child that emits events was
68
- * always bounded by this cap alone; nothing else changed by removing it.
69
- */
70
- const DEFAULT_RUNTIME_MS = 3_600_000; // 1 h
71
- /** "Unlimited" still has a ceiling — an unbounded child pins hasActiveRun() and
72
- * its concurrency slot for the life of the session. */
73
- const UNLIMITED_RUNTIME_MS = 21_600_000; // 6 h
74
- /** Cap on a child's wait for reply_subagent — an ignored question must not pin the run open forever. */
75
- const PARENT_REPLY_TIMEOUT_MS = 600_000; // 10 min
76
- /** Intercom messages buffered per park before the followUp path takes over. */
59
+ const DEFAULT_RUNTIME_MS = 3_600_000;
60
+ const UNLIMITED_RUNTIME_MS = 21_600_000;
61
+ const PARENT_REPLY_TIMEOUT_MS = 600_000;
77
62
  const PARKED_MSG_CAP = 24;
78
63
  const READONLY_TOOLS = ["read", "grep", "find", "ls"];
79
64
  const WRITE_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"];
80
- /** Tools that can mutate the tree — their presence is what earns a worktree. */
81
65
  const WRITE_CAPABLE = ["bash", "edit", "write"];
82
- /** Task ids become git refs + filesystem paths. */
83
66
  const SAFE_TASK_ID = /^[A-Za-z0-9_-]{1,64}$/;
84
67
  const WIDGET_THROTTLE_MS = 150;
85
68
 
86
- // ── helpers ──────────────────────────────────────────────────────────────
87
-
88
69
  function newId(prefix: string): string {
89
70
  return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
90
71
  }
@@ -103,7 +84,6 @@ function aggregateUsage(tasks: TaskSnapshot[]): UsageStats {
103
84
  }
104
85
  return total;
105
86
  }
106
- /** realpath when possible; the raw path otherwise (cwd may not exist yet). */
107
87
  function safeRealPath(path: string): string {
108
88
  try {
109
89
  return realpathSync(path);
@@ -118,10 +98,6 @@ function getParentSessionFile(ctx: ExtensionContext): string | undefined {
118
98
  return undefined;
119
99
  }
120
100
  }
121
- /**
122
- * pi 0.84 StopReason enum: "stop" is NORMAL completion (was "end" in older pi).
123
- * Only length/error/aborted/deferred/pending/toolUse-as-final are failures.
124
- */
125
101
  export function classifyFailure(
126
102
  stopReason: string | undefined,
127
103
  errorMessage?: string,
@@ -159,20 +135,10 @@ function updateUsageFromMessage(task: TaskSnapshot, message: AssistantMessage):
159
135
  export function cloneRun(run: RunSnapshot): RunSnapshot {
160
136
  return JSON.parse(JSON.stringify(run)) as RunSnapshot;
161
137
  }
162
- /** Resolve a child model from the pi model registry.
163
- * Order: explicit "provider/model-id" or bare id → agent file model → parent's
164
- * current model (ctx.model) → undefined (createAgentSession falls back to settings).
165
- *
166
- * A BARE id is ambiguous: `claude-sonnet-5` exists on anthropic, commandcode,
167
- * github-copilot and openrouter at once, and an agent file's `model:` is written
168
- * without a provider. Taking the registry's first match routed children to a
169
- * provider the session never chose (403 MODEL_NOT_IN_PLAN on every spawn), so the
170
- * ACTIVE SESSION's provider is searched first — exact id, then its own prefixed
171
- * form (9router carries `cc/claude-opus-5`, not `claude-opus-5`). */
172
138
  export function resolveChildModel(ctx: ExtensionContext, explicit: string | undefined) {
173
- if (!explicit?.trim()) return ctx.model; // inherit the parent's active model
139
+ if (!explicit?.trim()) return ctx.model;
174
140
  const ref = explicit.trim();
175
- if (!ctx.modelRegistry) return ctx.model; // no registry to check against (tests, headless)
141
+ if (!ctx.modelRegistry) return ctx.model;
176
142
  const available = ctx.modelRegistry.getAvailable();
177
143
  const sessionProvider = ctx.model?.provider;
178
144
  if (sessionProvider && !ref.includes("/")) {
@@ -180,8 +146,7 @@ export function resolveChildModel(ctx: ExtensionContext, explicit: string | unde
180
146
  const hit = own.find((m) => m.id === ref) ?? own.find((m) => m.id.endsWith(`/${ref}`));
181
147
  if (hit) return hit;
182
148
  }
183
- // Model ids can contain slashes (e.g. 9router/cc/claude-opus-5), so a bare id
184
- // match and every provider/id split point must be tried, not just the first.
149
+
185
150
  const byId = available.find((m) => m.id === ref);
186
151
  if (byId) return byId;
187
152
  for (let slash = ref.indexOf("/"); slash > 0; slash = ref.indexOf("/", slash + 1)) {
@@ -191,11 +156,6 @@ export function resolveChildModel(ctx: ExtensionContext, explicit: string | unde
191
156
  throw new Error(`Model not found: ${ref}`);
192
157
  }
193
158
 
194
- /** One throwaway request against the resolved model. A child that cannot reach its
195
- * model dies on its FIRST turn with no output, after a worktree and a session have
196
- * already been built — and an agent file's `model:` is chosen by a file the leader
197
- * never wrote, so "it resolved" is not evidence it is usable (plan gates, ZDR,
198
- * dead keys all pass resolution). Returns the error text, or undefined when OK. */
199
159
  async function probeModel(
200
160
  ctx: ExtensionContext,
201
161
  model: Model<Api>,
@@ -213,9 +173,6 @@ async function probeModel(
213
173
  }
214
174
  }
215
175
 
216
- /** Preflight for the model a child is about to run on. Probes only what is not
217
- * already proven: the session's own model answered this very turn. On failure the
218
- * session model is the fallback — the one model known to work right now. */
219
176
  export async function ensureUsableModel(
220
177
  ctx: ExtensionContext,
221
178
  model: Model<Api> | undefined,
@@ -233,12 +190,9 @@ export async function ensureUsableModel(
233
190
  };
234
191
  }
235
192
 
236
- /** Extension-registered providers (e.g. 9router) live only in the parent's
237
- * in-memory runtime. A child builds its runtime from disk and would lose them,
238
- * so replay the parent's registrations before the child resolves auth. */
239
193
  async function createChildModelRuntime(ctx: ExtensionContext) {
240
194
  const ids = ctx.modelRegistry.getRegisteredProviderIds?.() ?? [];
241
- if (ids.length === 0) return undefined; // no extension providers: disk runtime is enough
195
+ if (ids.length === 0) return undefined;
242
196
  const agentDir = getAgentDir();
243
197
  const runtime = await ModelRuntime.create({
244
198
  authPath: join(agentDir, "auth.json"),
@@ -257,9 +211,6 @@ async function createChildModelRuntime(ctx: ExtensionContext) {
257
211
  return runtime;
258
212
  }
259
213
 
260
- /** Validate a thinking level against the RESOLVED model's registry entry.
261
- * thinkingLevelMap: null = unsupported, missing key = provider default,
262
- * absent map = provider defaults. Non-reasoning models only accept "off". */
263
214
  export function validateThinking(model: Model<Api> | undefined, level: string | undefined): void {
264
215
  if (!level || level === "off") return;
265
216
  if (!model) return;
@@ -275,9 +226,6 @@ export function validateThinking(model: Model<Api> | undefined, level: string |
275
226
  }
276
227
  }
277
228
 
278
- // Cached catalog removed: agents are defined inline by the leader per call,
279
- // so there is nothing to inject into the parent context. Zero per-request cost.
280
-
281
229
  interface ChildEventState {
282
230
  pendingFailure?: ReturnType<typeof classifyFailure>;
283
231
  failChildEnd?: (error: Error) => void;
@@ -293,9 +241,7 @@ export interface ParkedMsg {
293
241
 
294
242
  export class SubagentManager {
295
243
  private runs = new Map<string, RunSnapshot>();
296
- /** Runs that are still settleable (presence = not yet settled). */
297
244
  private settlers = new Map<string, true>();
298
- /** Everyone parked on a run — a set, so re-parking can't build a closure chain. */
299
245
  private settleWaiters = new Map<string, Set<(run: RunSnapshot) => void>>();
300
246
  private pendingReplies = new Map<string, PendingReply>();
301
247
  private liveChildren = new Map<
@@ -303,25 +249,17 @@ export class SubagentManager {
303
249
  { abort: () => void; dispose: () => void; steer: (message: string) => void }
304
250
  >();
305
251
  private mailboxes: Mailbox = createMailbox();
306
- /** Live worktrees by `${runId}:${taskId}` — lets cancel drop dirs and keeps
307
- * cleanup from touching a branch that a running child owns. */
308
252
  private liveWorktrees = new Map<string, Worktree>();
309
253
  private runControllers = new Map<string, AbortController>();
310
- private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>(); // per-run stream throttle
254
+ private widgetTimers = new Map<string, ReturnType<typeof setTimeout>>();
311
255
  private widgetRuns: RunSnapshot[] = [];
312
256
  private eventSeq = 0;
313
- /** Sidecar writes are serialized on this chain — ordering the DECISION isn't
314
- * enough, two renames in flight can still land out of order. */
315
257
  private persistSeq = 0;
316
258
  private persistedSeq = 0;
317
259
  private persistChain: Promise<unknown> = Promise.resolve();
318
- /** Distinguishes managers sharing a pid (tests, SDK hosts with two sessions)
319
- * so their tmp paths can't collide. */
320
260
  private readonly instanceNonce = Math.random().toString(36).slice(2, 8);
321
- /** Set by clearRuns — blocks late persists from erasing the sidecar. */
322
261
  private cleared = false;
323
262
 
324
- /** When true, tasks without an explicit maxRuntimeMs get the 1 h default ceiling; when false (default) the raised 6 h ceiling applies — toggle via `/subagents auto-limit on|off`. */
325
263
  private autoLimit = false;
326
264
 
327
265
  turnActivity = false;
@@ -330,12 +268,9 @@ export class SubagentManager {
330
268
  try {
331
269
  const cfg = JSON.parse(readFileSync(join(getAgentDir(), "subagents-config.json"), "utf8"));
332
270
  if (typeof cfg.autoLimit === "boolean") this.autoLimit = cfg.autoLimit;
333
- } catch {
334
- /* no config yet — defaults */
335
- }
271
+ } catch {}
336
272
  }
337
273
 
338
- /** Flip the auto-limit flag; persists to the agent dir. Returns the new value. */
339
274
  setAutoLimit(on: boolean): boolean {
340
275
  this.autoLimit = on;
341
276
  void writeFile(join(getAgentDir(), "subagents-config.json"), JSON.stringify({ autoLimit: on }, null, 2)).catch(
@@ -348,7 +283,6 @@ export class SubagentManager {
348
283
  return this.autoLimit;
349
284
  }
350
285
 
351
- /** Any run still has queued/running tasks? */
352
286
  hasActiveRun(): boolean {
353
287
  for (const run of this.runs.values()) {
354
288
  if (run.tasks.some((t) => !TERMINAL.includes(t.status))) return true;
@@ -356,16 +290,13 @@ export class SubagentManager {
356
290
  return false;
357
291
  }
358
292
 
359
- /** Hide the widget + clear the footer status entry. */
360
293
  clearWidget(ctx: ExtensionContext): void {
361
294
  this.widgetRuns = [];
362
295
  this.widgetTui = null;
363
296
  if (ctx.hasUI) {
364
297
  try {
365
298
  ctx.ui.setWidget("subagents", undefined);
366
- } catch {
367
- /* ignore */
368
- }
299
+ } catch {}
369
300
  }
370
301
  }
371
302
 
@@ -381,10 +312,7 @@ export class SubagentManager {
381
312
  child.dispose();
382
313
  }
383
314
  this.liveChildren.clear();
384
- // Mark every non-terminal task aborted BEFORE resolving pendingReplies: the
385
- // resumed onAskParent closure re-checks status, and a still-awaiting task
386
- // would flip back to "running" and re-insert the run after the maps clear —
387
- // a ghost run that widgets re-arm on and persist forever.
315
+
388
316
  for (const run of this.runs.values()) {
389
317
  for (const task of run.tasks) {
390
318
  if (!TERMINAL.includes(task.status)) {
@@ -393,8 +321,7 @@ export class SubagentManager {
393
321
  }
394
322
  }
395
323
  }
396
- // Release anyone parked on a run before the maps go — dropping waiters would
397
- // leave their promises pending forever (autoAwait / await_subagent hang).
324
+
398
325
  for (const [runId, waiters] of this.settleWaiters) {
399
326
  const run = this.runs.get(runId);
400
327
  for (const waiter of waiters) waiter(run ? cloneRun(run) : ({ id: runId, status: "aborted" } as RunSnapshot));
@@ -403,17 +330,16 @@ export class SubagentManager {
403
330
  pending.resolve("(session ended — stop work immediately)");
404
331
  }
405
332
  this.parked.clear();
406
- // Ownership markers stay on disk; the next session reaps those dirs (commit,
407
- // keep branch, drop dir) once this pid is gone.
333
+
408
334
  this.liveWorktrees.clear();
409
335
  this.runs.clear();
410
336
  this.settlers.clear();
411
337
  this.settleWaiters.clear();
412
338
  this.pendingReplies.clear();
413
339
  this.runControllers.clear();
414
- this.cleared = true; // any persist after this point would write an empty sidecar
340
+ this.cleared = true;
415
341
  this.mailboxes = createMailbox();
416
- this.widgetTui = null; // force re-registration on the next session
342
+ this.widgetTui = null;
417
343
  if (this.pulseTimer) {
418
344
  clearTimeout(this.pulseTimer);
419
345
  this.pulseTimer = null;
@@ -423,31 +349,27 @@ export class SubagentManager {
423
349
  this.widgetRuns = [];
424
350
  }
425
351
 
426
- // ── persistence (sidecar per parent session) ────────────────────────
427
352
  async restoreFromSidecar(ctx: ExtensionContext): Promise<void> {
428
- this.cleared = false; // a new session may persist again
353
+ this.cleared = false;
429
354
  const parentFile = getParentSessionFile(ctx);
430
355
  if (!parentFile) return;
431
356
  const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
432
357
  let runs: RunSnapshot[];
433
- // Sweep tmp files a crash left between write and rename (one per dead session).
358
+
434
359
  try {
435
360
  const dir = dirname(sidecar);
436
361
  const prefix = `${basename(sidecar)}.`;
437
362
  for (const entry of readdirSync(dir)) {
438
363
  if (entry.startsWith(prefix) && entry.endsWith(".tmp")) rmSync(join(dir, entry), { force: true });
439
364
  }
440
- } catch {
441
- /* best-effort */
442
- }
365
+ } catch {}
443
366
  try {
444
367
  if (!existsSync(sidecar)) return;
445
368
  const raw = JSON.parse(readFileSync(sidecar, "utf-8"));
446
369
  if (!Array.isArray(raw)) return;
447
370
  runs = (raw as RunSnapshot[]).map((run) => {
448
371
  const interrupted = run.tasks.some((t) => !TERMINAL.includes(t.status));
449
- // A persisted "running" run whose tasks are all terminal (crash between
450
- // task end and run end) must not stay "running" forever.
372
+
451
373
  let status = interrupted ? ("aborted" as RunStatus) : run.status;
452
374
  if (!TERMINAL.includes(status)) {
453
375
  const anyFailed = run.tasks.some((t) => t.status === "failed");
@@ -480,93 +402,67 @@ export class SubagentManager {
480
402
  }
481
403
  }
482
404
  private persist(ctx: ExtensionContext): void {
483
- // After clearRuns the map is empty by design; a late persist (e.g. the
484
- // background rejection handler firing after session_shutdown) would write
485
- // `[]` over a good sidecar and erase the session's history.
486
405
  if (this.cleared) return;
487
406
  try {
488
407
  const parentFile = getParentSessionFile(ctx);
489
408
  if (!parentFile) return;
490
409
  const sidecar = parentFile.replace(/\.jsonl$/, ".subagents.json");
491
- // Write-then-rename: a plain writeFile can tear on crash and silently drop
492
- // ALL run history for the session on the next read. The tmp name must be
493
- // UNIQUE per write — a shared one lets two concurrent persists interleave
494
- // their bytes (unparseable sidecar) or rename an older snapshot last.
410
+
495
411
  const seq = ++this.persistSeq;
496
412
  const tmp = `${sidecar}.${process.pid}.${this.instanceNonce}.${seq}.tmp`;
497
413
  const payload = JSON.stringify(this.listRuns().slice(0, 50).map(cloneRun), null, 2);
498
- // Serialized: each write+rename runs after the previous one finishes, so two
499
- // renames can never be in flight and land out of order.
414
+
500
415
  this.persistChain = this.persistChain.then(async () => {
501
- // A newer snapshot already landed — this one is stale, don't write it.
502
416
  if (seq < this.persistedSeq) return;
503
417
  try {
504
418
  await writeFile(tmp, payload);
505
419
  await rename(tmp, sidecar);
506
420
  this.persistedSeq = seq;
507
421
  } catch {
508
- await rm(tmp, { force: true }).catch(() => {}); // never leak a tmp, never throw
422
+ await rm(tmp, { force: true }).catch(() => {});
509
423
  }
510
424
  });
511
- } catch {
512
- /* ignore */
513
- }
425
+ } catch {}
514
426
  }
515
427
 
516
428
  private emit(type: string, payload: Record<string, unknown>): void {
517
429
  this.pi.events.emit(type, { type, timestamp: Date.now(), ...payload });
518
430
  }
519
431
 
520
- /** Only startup failures are forced: they're dead-on-arrival and the leader must
521
- * notice before it respawns the same broken config. Completed/aborted and even
522
- * mid-run failures wait for the turn's end (followUp) — they're not urgent and
523
- * steering every one of them would interrupt the leader mid-tool-call.
524
- * force = deliver immediately even while streaming (steer). */
525
432
  private deliverMode(kind: string, task: TaskSnapshot): "steer" | "followUp" {
526
433
  return isStartupFailure(task, kind) ? "steer" : "followUp";
527
434
  }
528
435
 
529
- /** Per-task wake-up: failures steer in immediately, the rest queue as follow-up. */
530
436
  private notifyTask(run: RunSnapshot, task: TaskSnapshot, kind: "completed" | "failed" | "aborted"): void {
531
437
  const body = makeTaskNotice(run, task, kind);
532
- // Parked leader (await_subagent) receives completions through the wait — no queue.
438
+
533
439
  if (this.collectParked(run.id, { kind: "done", taskId: task.id, agent: task.agent, text: body })) {
534
440
  this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body });
535
441
  return;
536
442
  }
537
443
  try {
538
444
  this.pi.sendUserMessage(body, { deliverAs: this.deliverMode(kind, task) });
539
- } catch {
540
- /* parent mid-stream; consumers can poll subagent_status */
541
- }
445
+ } catch {}
542
446
  this.emit("subagent:notification", { runId: run.id, taskId: task.id, kind, body });
543
447
  }
544
448
 
545
- /** Wake the parent with a 3-line notice. Full text stays out of context.
546
- * deliverAs queues the message if the parent is mid-stream (e.g. inside
547
- * await_subagent) instead of throwing/aborting. */
548
449
  private notifyParent(
549
450
  run: RunSnapshot,
550
451
  kind: "completed" | "failed" | "aborted" | "asked",
551
452
  extra?: { taskId?: string; question?: string },
552
453
  ): void {
553
- if (kind !== "asked" && run.awaited) return; // parent already got the result via await_subagent
454
+ if (kind !== "asked" && run.awaited) return;
554
455
  const body =
555
456
  kind === "asked"
556
457
  ? `A subagent is asking you a question (task ${extra?.taskId}): ${extra?.question ?? ""}\nReply with reply_subagent(runId: "${run.id}", taskId: "${extra?.taskId}", message: ...).`
557
458
  : makeNotice(run, kind);
558
459
  try {
559
460
  this.pi.sendUserMessage(body, { deliverAs: "followUp" });
560
- } catch {
561
- /* parent mid-stream; consumers can poll subagent_status */
562
- }
461
+ } catch {}
563
462
  this.emit("subagent:notification", { runId: run.id, kind, body });
564
463
  }
565
464
 
566
- // Widget: register-once + requestRender (todo-overlay pattern).
567
- // scheduleWidget throttles status changes into requestRender calls.
568
465
  private widgetTui: TUI | null = null;
569
- /** Upsert a run into the widget's visible set (all runs, not just the latest). */
570
466
  private upsertWidgetRun(run: RunSnapshot | undefined): void {
571
467
  if (!run) return;
572
468
  const idx = this.widgetRuns.findIndex((r) => r.id === run.id);
@@ -589,12 +485,11 @@ export class SubagentManager {
589
485
  );
590
486
  }
591
487
 
592
- /** While any live task's last activity is a talk tool, keep re-rendering so its name pulses. */
593
488
  private pulseTimer: ReturnType<typeof setTimeout> | null = null;
594
489
  private maybePulse(ctx?: ExtensionContext): void {
595
490
  if (this.pulseTimer || !this.widgetTui) return;
596
491
  const talking = this.widgetRuns.some((r) => r.tasks.some(isTalking));
597
- if (!talking) return; // last tick stops the loop: talking→normal resumes instantly
492
+ if (!talking) return;
598
493
  this.pulseTimer = setTimeout(() => {
599
494
  this.pulseTimer = null;
600
495
  this.widgetTui?.requestRender();
@@ -615,7 +510,7 @@ export class SubagentManager {
615
510
  this.widgetTui?.requestRender();
616
511
  }
617
512
  this.maybePulse(ctx);
618
- // Transcript gets one status line only — the live per-task view is the widget's job.
513
+
619
514
  onUpdate?.({
620
515
  content: [
621
516
  {
@@ -659,30 +554,20 @@ export class SubagentManager {
659
554
  this.updateRun(run, ctx, onUpdate);
660
555
  }
661
556
 
662
- // ── intercom + mailbox ──────────────────────────────────────────────
663
557
  private makeChildHandlers(run: RunSnapshot, task: TaskSnapshot, ctx: ExtensionContext): ChildHandlers {
664
558
  return {
665
559
  onAskParent: async (_taskId, question) => {
666
- // A tool call already in flight can reach here AFTER the task ended
667
- // (abort/timeout/cancel). Reviving it would leave a "running" task in a
668
- // finished run — hasActiveRun() then never clears.
669
560
  if (TERMINAL.includes(task.status)) {
670
561
  return "(your task has already ended — stop work and return immediately)";
671
562
  }
672
563
  this.updateTask(run, task, { status: "awaiting_parent" }, ctx);
673
- // While the leader is parked in await_subagent the question rides the wait
674
- // (no steering queue, no turn boundary); otherwise it goes out as a notice.
675
- // Either way the pending reply entry must exist, or reply_subagent has
676
- // nowhere to land and the child waits on an answer that never comes.
564
+
677
565
  if (!this.collectParked(run.id, { kind: "ask", taskId: task.id, agent: task.agent, text: question })) {
678
566
  this.notifyParent(run, "asked", { taskId: task.id, question });
679
567
  }
680
- // The wait is BOUNDED: an unanswered question would otherwise keep the run
681
- // non-terminal forever (widget never clears, run never settles).
568
+
682
569
  const reply = await this.awaitParentReply(run.id, task.id, PARENT_REPLY_TIMEOUT_MS);
683
- // Cancel wins over a reply that arrived in the same tick: never move a
684
- // terminal task back to "running" (that would let a canceled task be
685
- // reported as completed).
570
+
686
571
  if (TERMINAL.includes(task.status)) {
687
572
  return "(your task was canceled while you waited — stop work and return immediately)";
688
573
  }
@@ -691,14 +576,11 @@ export class SubagentManager {
691
576
  },
692
577
  onNotifyParent: (_taskId, message, level) => {
693
578
  this.emit("subagent:intercom", { runId: run.id, taskId: task.id, kind: "notify", level, message });
694
- // Parked leader gets it through the wait; otherwise queue it. `awaited` must
695
- // NOT gate this — between two parks the leader is awaited but listening.
579
+
696
580
  if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text: message })) return;
697
581
  try {
698
582
  this.pi.sendUserMessage(`[Subagent ${task.agent}] ${message}`, { deliverAs: "followUp" });
699
- } catch {
700
- /* parent mid-stream */
701
- }
583
+ } catch {}
702
584
  },
703
585
  onSendMessage: (_taskId, to, text) => {
704
586
  if (to === "leader") {
@@ -712,12 +594,10 @@ export class SubagentManager {
712
594
  if (this.collectParked(run.id, { kind: "notify", taskId: task.id, agent: task.agent, text })) return true;
713
595
  try {
714
596
  this.pi.sendUserMessage(`[Subagent ${task.agent}] ${text}`, { deliverAs: "followUp" });
715
- } catch {
716
- /* parent mid-stream */
717
- }
597
+ } catch {}
718
598
  return true;
719
599
  }
720
- // Run-scoped keys: sibling ids are run-local; cross-run task_1 can never collide.
600
+
721
601
  return this.mailboxes.send(`${run.id}:${task.id}`, `${run.id}:${to}`, text);
722
602
  },
723
603
  onPollMailbox: (taskId) => this.mailboxes.poll(`${run.id}:${taskId}`),
@@ -726,8 +606,6 @@ export class SubagentManager {
726
606
  private awaitParentReply(runId: string, taskId: string, timeoutMs = 0): Promise<string> {
727
607
  const key = `${runId}:${taskId}`;
728
608
  return new Promise<string>((resolve) => {
729
- // Identity-tagged: two asks from one child must not delete each other's
730
- // entry (the loser would hang until its own timer).
731
609
  const entry: PendingReply = {
732
610
  resolve: (message) => {
733
611
  if (timer) clearTimeout(timer);
@@ -751,14 +629,10 @@ export class SubagentManager {
751
629
  deliverReply(runId: string, taskId: string, message: string): boolean {
752
630
  const pending = this.pendingReplies.get(`${runId}:${taskId}`);
753
631
  if (!pending) return false;
754
- pending.resolve(message); // clears its own entry + timer
632
+ pending.resolve(message);
755
633
  return true;
756
634
  }
757
635
 
758
- /**
759
- * Child session events → task state. Extracted from runChild so the
760
- * per-event classification is readable and unit-testable.
761
- */
762
636
  private onChildEvent(
763
637
  event: AgentSessionEvent,
764
638
  run: RunSnapshot,
@@ -807,16 +681,13 @@ export class SubagentManager {
807
681
  this.updateRun(run, ctx, onUpdate);
808
682
  } else if (event.type === "agent_end") {
809
683
  if (event.willRetry) {
810
- state.pendingFailure = undefined; // retry in flight — don't trust stale failures
684
+ state.pendingFailure = undefined;
811
685
  } else {
812
686
  const failure = lastAssistantFailure(event.messages as AssistantMessage[]);
813
687
  if (failure) {
814
688
  state.pendingFailure = failure;
815
689
  state.failChildEnd?.(failureError(failure));
816
690
  }
817
- // NOTE: success does NOT resolve childEndPromise here — pi may run a
818
- // continuation leg (compaction/overflow recovery) that emits another
819
- // agent_end. Resolve only on agent_settled, after all legs finish.
820
691
  }
821
692
  } else if (event.type === "agent_settled") {
822
693
  state.childEndResolve?.();
@@ -827,54 +698,40 @@ export class SubagentManager {
827
698
  run: RunSnapshot,
828
699
  task: TaskSnapshot,
829
700
  input: TaskInput,
830
- /** The task text as WRITTEN, before upstream outputs were spliced in. */
831
701
  routingTask: string,
832
702
  ctx: ExtensionContext,
833
703
  signal: AbortSignal | undefined,
834
704
  onUpdate?: (partial: any) => void,
835
705
  ): Promise<void> {
836
- if (TERMINAL.includes(task.status)) return; // canceled while queued
837
-
838
- // Matched user agent file (`.agents/agents` etc., by description): the file
839
- // is authoritative — body = system prompt, frontmatter model/tools win over
840
- // inline. No match → inline on-demand definition as usual.
841
- // Route on the task as written, never on the upstream output spliced into
842
- // it: a chain step would otherwise match a different file (and a different
843
- // model) at runtime than the one createRun pre-flighted.
706
+ if (TERMINAL.includes(task.status)) return;
707
+
844
708
  const file = resolveAgentFile(input.agent, routingTask, task.cwd, getAgentDir());
845
- if (file?.path) task.agentFile = file.path; // recorded for audit — which file won
709
+ if (file?.path) task.agentFile = file.path;
846
710
  const prompt = file?.body ?? input.prompt?.trim();
847
711
  const thinking = input.thinking;
848
- // Trust boundary: a file can NARROW the toolset (intersect with the leader's
849
- // intent) but never widen it — a repo-planted agent file can't grant write.
712
+
850
713
  const allowedTools = input.write ? WRITE_TOOLS : READONLY_TOOLS;
851
714
  const fileTools = file?.tools?.filter((t) => allowedTools.includes(t));
852
- const baseTools = fileTools?.length ? fileTools : (input.tools ?? allowedTools);
715
+ const explicitTools = input.tools ?? (input.write ? WRITE_TOOLS : undefined);
716
+ const baseTools = explicitTools ?? (fileTools?.length ? fileTools : allowedTools);
717
+ if (explicitTools && fileTools?.length)
718
+ task.toolsNote = `explicit tools overrode agent-file tools (${fileTools.join(", ")})`;
853
719
  const tools = [...baseTools, ...CHILD_TALK_TOOLS];
854
- // Isolation follows the DELIVERED toolset, never the raw request: explicit
855
- // tools: [bash] without write:true still gets a worktree, and a file that
856
- // narrowed the child to read-only never gets the commit/merge ceremony.
720
+
857
721
  const canWrite = baseTools.some((t) => WRITE_CAPABLE.includes(t));
858
722
 
859
- // Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
860
- // Model + thinking resolve against the pi model registry BEFORE any worktree
861
- // exists — a bad request fails the TASK with a helpful message and can't leak a
862
- // checkout past this early return.
863
723
  let model: Model<Api> | undefined;
864
724
  try {
865
725
  model = resolveChildModel(ctx, file?.model ?? input.model);
866
726
  validateThinking(model, thinking);
867
- // Resolution only proves the id exists. Probe it before anything is built,
868
- // and fall back to the session's own model when the probe fails.
727
+
869
728
  const checked = await ensureUsableModel(ctx, model, signal);
870
729
  model = checked.model;
871
730
  if (checked.note) {
872
731
  task.modelNote = checked.note;
873
732
  validateThinking(model, thinking);
874
733
  }
875
- // Record the model ACTUALLY used — an agent file's `model:` overrides the
876
- // requested one, and showing the request in the widget hides that entirely
877
- // (a 403 then names a model the leader never asked for).
734
+
878
735
  if (model) this.updateTask(run, task, { model: model.id, modelNote: checked.note }, ctx, onUpdate);
879
736
  } catch (err) {
880
737
  this.updateTask(
@@ -891,16 +748,10 @@ export class SubagentManager {
891
748
  return;
892
749
  }
893
750
 
894
- // Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
895
- // non-git repos fall back to in-place. Created BEFORE session start so the
896
- // child's cwd + AGENTS.md context chain are the worktree's.
897
751
  let wt: Worktree | undefined;
898
752
  let isolationReason: string | undefined;
899
753
  if (canWrite) {
900
754
  try {
901
- // Stack on the upstream write task's branch, so a chained writer actually
902
- // SEES the work it was told to build on. Read-only upstreams have no
903
- // branch, so those stay based on HEAD.
904
755
  const upstream = (task.needs ?? [])
905
756
  .map((id) => run.tasks.find((t) => t.id === id))
906
757
  .filter((t) => t?.branch && t.status === "completed")
@@ -909,14 +760,11 @@ export class SubagentManager {
909
760
  if (wt && upstream?.branch) task.stackedOn = upstream.branch;
910
761
  if (!wt) isolationReason = "not a git repository";
911
762
  } catch (err) {
912
- wt = undefined; // git failure → in-place
763
+ wt = undefined;
913
764
  isolationReason = `git worktree add failed: ${err instanceof Error ? err.message : String(err)}`;
914
765
  }
915
766
  }
916
- // Map a per-task cwd subpath into the worktree so relative paths stay correct.
917
- // Both sides go through realpath — a symlinked root would otherwise look
918
- // "outside" the repo. If the mapping can't be trusted, drop the worktree AND
919
- // reset the cwd (never point the child at a dir that was just removed).
767
+
920
768
  let childCwd = wt?.path ?? task.cwd;
921
769
  if (wt) {
922
770
  const rel = relative(safeRealPath(wt.root), safeRealPath(task.cwd));
@@ -927,8 +775,7 @@ export class SubagentManager {
927
775
  isolationReason = "task cwd is outside the repository";
928
776
  } else if (rel && rel !== ".") {
929
777
  childCwd = join(wt.path, rel);
930
- // The subpath may be gitignored/untracked, so it won't exist in a fresh
931
- // checkout — create it rather than fail session start.
778
+
932
779
  try {
933
780
  mkdirSync(childCwd, { recursive: true });
934
781
  } catch {
@@ -937,19 +784,14 @@ export class SubagentManager {
937
784
  }
938
785
  }
939
786
  if (canWrite) {
940
- // Never let isolation lapse quietly: the leader must know its edits landed
941
- // straight in the working tree with no branch to review.
942
787
  task.isolation = wt ? "worktree" : "in-place";
943
788
  task.isolationReason = wt ? undefined : (isolationReason ?? "worktree unavailable");
944
789
  }
945
790
  if (wt) {
946
- claimWorktree(wt); // pid marker: another pi session must not reap this
791
+ claimWorktree(wt);
947
792
  this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
948
793
  }
949
794
 
950
- // Guarded: a throwing event listener or widget failure here would escape
951
- // runChild BEFORE the try/finally that releases the worktree — leaking a
952
- // checkout that every reaper then skips (registered + owned by a live pid).
953
795
  try {
954
796
  this.updateTask(
955
797
  run,
@@ -957,11 +799,9 @@ export class SubagentManager {
957
799
  {
958
800
  status: "starting",
959
801
  startedAt: Date.now(),
960
- // Upstream outputs were spliced in by the scheduler; the snapshot must show
961
- // the prompt the child actually receives.
802
+
962
803
  task: input.task,
963
- // RESOLVED model, not the request — an agent file's `model:` overrides it,
964
- // and this patch used to clobber the resolved id recorded above.
804
+
965
805
  model: model?.id ?? input.model,
966
806
  thinking,
967
807
  tools,
@@ -969,12 +809,8 @@ export class SubagentManager {
969
809
  ctx,
970
810
  onUpdate,
971
811
  );
972
- } catch {
973
- /* a listener/widget failure must not strand the checkout */
974
- }
812
+ } catch {}
975
813
 
976
- // Set once the dir must outlive this call: committed work awaiting the
977
- // leader's merge, or a commit failure whose work exists ONLY in the dir.
978
814
  let keepWorktreeDir = false;
979
815
  let child: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
980
816
  let unsubscribe: (() => void) | undefined;
@@ -984,13 +820,10 @@ export class SubagentManager {
984
820
 
985
821
  const key = `${run.id}:${task.id}`;
986
822
  try {
987
- // node_modules is a SHARED symlink to the leader's real tree, so dep writes
988
- // escape the worktree entirely and `rm -rf node_modules/` destroys the
989
- // project's deps. The child is the only thing that can avoid that.
990
823
  const worktreeNote = wt
991
824
  ? ` You work in an isolated git worktree (branch ${wt.branch})${task.stackedOn ? `, stacked on ${task.stackedOn} (its changes are already in your tree)` : ""}. Never run git commands that switch branches, create branches, or move the worktree (git switch/checkout/branch/worktree). The extension commits your changes when you finish. git status/diff are fine for inspecting your own changes. node_modules is a SHARED symlink to the main checkout: never install, upgrade, or delete dependencies (no npm/bun/yarn/pnpm install, no \`rm -rf node_modules\`) — those writes escape your worktree and damage the user's project. If the task truly needs a dependency change, edit the manifest only and say so in your answer.`
992
825
  : "";
993
- const subagentInstruction = `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has; notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. Stalled waits get the whole run killed. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.${worktreeNote}`;
826
+ const subagentInstruction = `You are running as a subagent. Your bash tool already executes in the project working directory — never prefix commands with \`cd\`. Do not call subagent/delegation tools unless the parent explicitly asks. Return a concise final answer. You MAY use ask_parent only when truly blocked on information only the parent has; notify_parent for one-way updates; send_agent_message/poll_agent_messages to coordinate with siblings. Your mailbox address and siblings: ${task.roster ?? "(none)"}. Use the exact task ids (e.g. task_2) as send_agent_message targets. Siblings run independently and may start late or finish early — never block indefinitely on their replies: poll at most 5 times, then proceed with your best judgment. A gated sibling (marked ↳ waits in the graph) may not be running yet; do not wait for it. An unanswered ask_parent times out after 10 minutes — proceed with your best judgment then. When your work is done, call notify_parent ONCE with a concise result summary — key findings, verdicts, file:line evidence — so the leader can start consuming your output before the run finishes.${worktreeNote}`;
994
827
 
995
828
  const loader = new DefaultResourceLoader({
996
829
  cwd: childCwd,
@@ -1039,7 +872,7 @@ export class SubagentManager {
1039
872
 
1040
873
  const abortChild = () => {
1041
874
  void child?.abort();
1042
- this.runControllers.get(run.id)?.abort(); // parent abort kills ALL siblings, not just this child
875
+ this.runControllers.get(run.id)?.abort();
1043
876
  };
1044
877
  const runController = this.runControllers.get(run.id);
1045
878
  if (signal) signal.addEventListener("abort", abortChild, { once: true });
@@ -1048,7 +881,7 @@ export class SubagentManager {
1048
881
  signal?.removeEventListener("abort", abortChild);
1049
882
  runController?.signal.removeEventListener("abort", abortChild);
1050
883
  };
1051
- // Cancel may have landed during session creation — honor it before prompting.
884
+
1052
885
  if (run.status === "aborted" || TERMINAL.includes(task.status) || signal?.aborted) {
1053
886
  await child.abort();
1054
887
  throw new Error("Canceled by subagent_cancel");
@@ -1056,7 +889,7 @@ export class SubagentManager {
1056
889
  this.liveChildren.set(key, {
1057
890
  abort: () => void child?.abort(),
1058
891
  dispose: () => child?.dispose(),
1059
- // Inject a steering message mid-run; queues as steer if the child is streaming.
892
+
1060
893
  steer: (message) =>
1061
894
  void child?.prompt(message, { streamingBehavior: "steer" }).catch((err) =>
1062
895
  this.pi.sendUserMessage(`[steer_subagent] ${err instanceof Error ? err.message : String(err)}`, {
@@ -1065,9 +898,6 @@ export class SubagentManager {
1065
898
  ),
1066
899
  });
1067
900
 
1068
- // The ceiling is the ONLY bound on a child that emits events forever (retry
1069
- // or tool-call livelock). So auto-limit off RAISES it, never removes it —
1070
- // removing it reproduced the immortal-child hang.
1071
901
  const maxRuntimeMs = input.maxRuntimeMs ?? (this.autoLimit ? DEFAULT_RUNTIME_MS : UNLIMITED_RUNTIME_MS);
1072
902
  const promptPromise = child.prompt(task.task, { source: "extension" });
1073
903
  const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise];
@@ -1087,31 +917,19 @@ export class SubagentManager {
1087
917
  const finalText =
1088
918
  task.finalText ||
1089
919
  truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
1090
- // TERMINAL, not just "aborted": a cancel/timeout that landed while the
1091
- // final text was being assembled must not be overwritten with "completed"
1092
- // (which would also skip the partial commit and drop the child's work).
1093
- // `awaiting_parent` also means the child hadn't finished talking — marking
1094
- // it completed would publish a truncated finalText to every dependent.
920
+
1095
921
  if (task.status === "awaiting_parent") {
1096
922
  this.pendingReplies.get(key)?.resolve("(your task is being finalized — stop work and return now)");
1097
923
  }
1098
924
  if (!TERMINAL.includes(task.status)) {
1099
925
  this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
1100
926
  if (wt) {
1101
- // Commit the child's changes, then report the branch + diff so the
1102
- // leader can review and merge (PR-style). The worktree dir stays
1103
- // until the branch is merged — cleanupMerged removes both then.
1104
- // Commit/diff failures must NOT downgrade a completed task or destroy
1105
- // its work: the error is reported, the status stays completed.
1106
927
  let committed: "committed" | "empty" | undefined;
1107
928
  try {
1108
929
  committed = commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
1109
- // Only a real commit is worth a branch: an empty one would send the
1110
- // leader off to review and merge nothing.
930
+
1111
931
  keepWorktreeDir = committed === "committed";
1112
932
  } catch (commitErr) {
1113
- // Never drop a checkout whose work isn't on the branch — it would be
1114
- // unreachable once the base-tip branch is reaped as "merged".
1115
933
  keepWorktreeDir = true;
1116
934
  this.updateTask(
1117
935
  run,
@@ -1124,7 +942,7 @@ export class SubagentManager {
1124
942
  onUpdate,
1125
943
  );
1126
944
  }
1127
- // Diff separately: a diff failure must not be reported as a lost commit.
945
+
1128
946
  if (committed === "committed") {
1129
947
  try {
1130
948
  const { stat, files } = branchDiff(wt);
@@ -1152,20 +970,14 @@ export class SubagentManager {
1152
970
  }
1153
971
  } catch (err) {
1154
972
  if (timeout) clearTimeout(timeout);
1155
- // Cancel is authoritative: parent tool signal OR run/task already marked aborted.
973
+
1156
974
  const aborted = signal?.aborted || run.status === "aborted" || task.status === "aborted";
1157
975
  const subagentStatus = (err as Error & { subagentStatus?: string })?.subagentStatus;
1158
976
  try {
1159
- // Unblock a child stuck in ask_parent, then time-box the abort so a
1160
- // wedged session can never hang this catch/finally.
1161
977
  this.pendingReplies.get(key)?.resolve("(parent unreachable)");
1162
978
  await Promise.race([child?.abort(), new Promise((r) => setTimeout(r, 5000))]);
1163
- } catch {
1164
- /* ignore */
1165
- }
1166
- // Publish whatever the child DID say before it was killed. A timeout or
1167
- // abort used to discard it, so a chain dependent received nothing at all
1168
- // while the child's partial work was still committed to its branch.
979
+ } catch {}
980
+
1169
981
  const salvaged =
1170
982
  task.finalText ||
1171
983
  truncateText(
@@ -1185,46 +997,37 @@ export class SubagentManager {
1185
997
  );
1186
998
  } finally {
1187
999
  this.liveChildren.delete(key);
1188
- // Resolve, don't just delete: a bare delete strands the 10-minute reply
1189
- // timer and leaves the child's await unsettled.
1000
+
1190
1001
  this.pendingReplies.get(key)?.resolve("(task ended — stop work now)");
1191
1002
  this.pendingReplies.delete(key);
1192
1003
  abortListener?.();
1193
1004
  unsubscribe?.();
1194
1005
  if (timeout) clearTimeout(timeout);
1195
1006
  child?.dispose();
1196
- // Failed/aborted: let the aborted child's last writes land (its tools may
1197
- // still be unwinding), commit whatever partial work exists so the branch
1198
- // really keeps it, then drop the checkout dir. A commit FAILURE keeps the
1199
- // dir — dropping it would make the work unreachable.
1007
+
1200
1008
  if (wt && task.status !== "completed") {
1201
1009
  await new Promise((r) => setTimeout(r, 250));
1202
1010
  let partial: "committed" | "empty" | undefined;
1203
1011
  try {
1204
1012
  partial = commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
1205
1013
  } catch {
1206
- keepWorktreeDir = true; // work exists only in the dir — keep it
1014
+ keepWorktreeDir = true;
1207
1015
  }
1208
- // A branch is only worth reporting when it actually carries something.
1016
+
1209
1017
  if (partial === "committed" || keepWorktreeDir) {
1210
1018
  this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
1211
1019
  }
1212
1020
  }
1213
1021
  if (wt && !keepWorktreeDir) removeWorktree(wt);
1214
- // Released only after the dir is gone: while it exists, the branch must stay
1215
- // in liveBranches() so cleanup can't reap it.
1022
+
1216
1023
  this.liveWorktrees.delete(key);
1217
1024
  }
1218
1025
  }
1219
1026
 
1220
- // ── run lifecycle ───────────────────────────────────────────────────
1221
1027
  createRun(params: SubagentParamsShape, ctx: ExtensionContext): { run: RunSnapshot; inputs: TaskInput[] } {
1222
1028
  const hasChain = (params.chain?.length ?? 0) > 0;
1223
1029
  const hasTasks = (params.tasks?.length ?? 0) > 0;
1224
- // An array mode wins over stray top-level agent/task: models routinely leave
1225
- // those in place when switching to tasks:[...], and the intent is not
1226
- // ambiguous — rejecting a well-formed 3-task call over leftovers is worse
1227
- // than ignoring them. Genuine ambiguity (tasks AND chain) is still refused.
1030
+
1228
1031
  const hasSingle = !hasChain && !hasTasks && Boolean(params.agent && params.task);
1229
1032
  if (hasChain && hasTasks) {
1230
1033
  throw new Error(`Provide either tasks (parallel) or chain (sequential), not both.`);
@@ -1234,12 +1037,7 @@ export class SubagentManager {
1234
1037
  `Provide one subagent mode: agent+task (single), tasks: [...] (parallel), or chain: [...] (sequential).`,
1235
1038
  );
1236
1039
  }
1237
- // Single-mode-only fields alongside an array mode are silently dropped
1238
- // otherwise: `write: true` next to tasks:[...] produced read-only children
1239
- // that reported they "cannot edit files", with nothing explaining why.
1240
- // `cwd` and `maxRuntimeMs` are meaningful run-wide (and the schema advertises
1241
- // maxRuntimeMs without a single-mode marker), so they FAN OUT as per-task
1242
- // defaults instead of being refused. The rest genuinely describe one agent.
1040
+
1243
1041
  if (hasChain || hasTasks) {
1244
1042
  const stray = (["write", "prompt", "tools", "model", "thinking"] as const).filter((k) => params[k] !== undefined);
1245
1043
  if (stray.length > 0) {
@@ -1265,15 +1063,12 @@ export class SubagentManager {
1265
1063
  },
1266
1064
  ]
1267
1065
  : (hasTasks ? params.tasks! : params.chain!).map((item) => ({
1268
- // Run-wide defaults; a per-task value always wins.
1269
1066
  ...item,
1270
1067
  cwd: item.cwd ?? params.cwd,
1271
1068
  maxRuntimeMs: item.maxRuntimeMs ?? params.maxRuntimeMs,
1272
1069
  }));
1273
1070
  if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
1274
- // Task ids become git refs + filesystem paths — refuse anything unsafe.
1275
- // Explicit ids are checked against each other; generated ones are checked
1276
- // against explicit ones so a collision can't silently fall back to in-place.
1071
+
1277
1072
  const ids = new Set<string>();
1278
1073
  for (const input of inputs) {
1279
1074
  if (input.id !== undefined) {
@@ -1291,14 +1086,9 @@ export class SubagentManager {
1291
1086
  }
1292
1087
  }
1293
1088
  const edges = resolveNeeds(inputs, mode);
1294
- // A new run exists, so persisting is meaningful again. Without this, a host
1295
- // that emits session_shutdown with no following session_start (SIGTERM, SDK
1296
- // reuse, tests) leaves every later persist a silent no-op.
1089
+
1297
1090
  this.cleared = false;
1298
- // Pre-flight every task's model BEFORE the run exists. A matched agent file
1299
- // overrides the requested model, so an unresolvable one is the leader's
1300
- // mistake to see NOW — not N children dying one by one on their first turn
1301
- // with an error naming a model the leader never asked for.
1091
+
1302
1092
  for (let i = 0; i < inputs.length; i++) {
1303
1093
  const input = inputs[i] as TaskInput;
1304
1094
  const cwd = input.cwd ?? ctx.cwd;
@@ -1339,8 +1129,7 @@ export class SubagentManager {
1339
1129
  })),
1340
1130
  aggregateUsage: emptyUsage(),
1341
1131
  };
1342
- // Roster: each child learns its own address + sibling addresses so
1343
- // send_agent_message/poll_agent_messages can be used reliably.
1132
+
1344
1133
  const roster = run.tasks.map((t) => `${t.id} (${t.agent})`).join(", ");
1345
1134
  for (const task of run.tasks) {
1346
1135
  task.roster = roster;
@@ -1368,15 +1157,12 @@ export class SubagentManager {
1368
1157
  run.startedAt = Date.now();
1369
1158
  this.updateRun(run, ctx, onUpdate);
1370
1159
 
1371
- // One wave scheduler for every mode. A wave is the set of tasks whose needs
1372
- // are all satisfied; the loop boundary between waves IS the gate. Chain mode
1373
- // reaches here as needs: [previous], so it needs no special case.
1374
1160
  const outputs = new Map<string, string>();
1375
1161
  const settled = new Set<string>();
1376
1162
  for (const task of run.tasks) {
1377
- if (TERMINAL.includes(task.status)) settled.add(task.id); // canceled before start
1163
+ if (TERMINAL.includes(task.status)) settled.add(task.id);
1378
1164
  }
1379
- // id → input, immune to filtered-array index drift (C4).
1165
+
1380
1166
  const inputById = new Map(run.tasks.map((t, i) => [t.id, inputs[i]]));
1381
1167
 
1382
1168
  const { skipped } = await runWaveScheduler(
@@ -1385,12 +1171,8 @@ export class SubagentManager {
1385
1171
  outputs,
1386
1172
  settled,
1387
1173
  async (task) => {
1388
- // The scheduler passes the index into the FILTERED list — never use it
1389
- // against the unfiltered inputs. Look the input up by task id instead.
1390
1174
  const input = inputById.get(task.id);
1391
1175
  if (!input) {
1392
- // Impossible unless ids drift from inputs — fail loudly instead of
1393
- // leaving the task queued forever (hasActiveRun would never clear).
1394
1176
  this.updateTask(
1395
1177
  run,
1396
1178
  task,
@@ -1415,7 +1197,7 @@ export class SubagentManager {
1415
1197
  }
1416
1198
  },
1417
1199
  );
1418
- // Broken-upstream tasks are detected by the scheduler; mark them after the wave.
1200
+
1419
1201
  for (const s of skipped) {
1420
1202
  const task = run.tasks.find((t) => t.id === s.id);
1421
1203
  if (task && !TERMINAL.includes(task.status)) {
@@ -1424,7 +1206,7 @@ export class SubagentManager {
1424
1206
  task,
1425
1207
  {
1426
1208
  status: "aborted",
1427
- // Don't overwrite a real reason (e.g. "Canceled by subagent_cancel").
1209
+
1428
1210
  error: task.error || `Skipped: upstream task(s) did not complete: ${s.needs.join(", ")}`,
1429
1211
  endedAt: Date.now(),
1430
1212
  },
@@ -1433,9 +1215,7 @@ export class SubagentManager {
1433
1215
  );
1434
1216
  }
1435
1217
  }
1436
- // Belt and braces: the wave loop breaks out when no frontier is ready, which
1437
- // would otherwise leave tasks queued inside a terminal run — hasActiveRun()
1438
- // then never clears and the widget stays pinned.
1218
+
1439
1219
  for (const task of run.tasks) {
1440
1220
  if (TERMINAL.includes(task.status)) continue;
1441
1221
  this.updateTask(
@@ -1452,11 +1232,10 @@ export class SubagentManager {
1452
1232
  run.status = aborted ? "aborted" : failed ? "failed" : "completed";
1453
1233
  run.endedAt = Date.now();
1454
1234
  this.flushWidget(run, ctx, onUpdate);
1455
- // Finished runs (including aborted ones) stay on screen so the outcome is readable.
1456
- // The agent_start handler clears them on the next turn that spawns nothing.
1235
+
1457
1236
  const live = this.listRuns().find((r) => !TERMINAL.includes(r.status));
1458
1237
  if (live) this.scheduleWidget(live, ctx);
1459
- // L7: cancelRun already emitted + settled — don't double-report.
1238
+
1460
1239
  if (this.settlers.has(run.id)) {
1461
1240
  this.emit("subagent:run-completed", {
1462
1241
  runId: run.id,
@@ -1469,9 +1248,7 @@ export class SubagentManager {
1469
1248
  this.runControllers.delete(run.id);
1470
1249
  for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
1471
1250
  this.persist(ctx);
1472
- // Branches merged by the leader since the run ended: drop worktree dir + branch.
1473
- // Once per repo, never for a branch another live run owns, never fatal — a
1474
- // throw here would re-settle an already-finished run as failed.
1251
+
1475
1252
  try {
1476
1253
  const roots = new Set<string>();
1477
1254
  for (const task of run.tasks) {
@@ -1480,14 +1257,9 @@ export class SubagentManager {
1480
1257
  if (root) roots.add(root);
1481
1258
  }
1482
1259
  for (const root of roots) cleanupMerged(root, { skipBranches: this.liveBranches() });
1483
- } catch {
1484
- /* cleanup is best-effort; the run outcome must stand */
1485
- }
1260
+ } catch {}
1486
1261
  }
1487
1262
 
1488
- /** Branches owned by worktrees of still-running children. */
1489
- /** True only for checkouts THIS manager still runs — a marker carrying our own
1490
- * pid from a previous session in the same process is not proof of life. */
1491
1263
  ownsWorktree = (path: string): boolean => {
1492
1264
  for (const wt of this.liveWorktrees.values()) if (wt.path === path) return true;
1493
1265
  return false;
@@ -1496,7 +1268,6 @@ export class SubagentManager {
1496
1268
  return new Set(Array.from(this.liveWorktrees.values(), (wt) => wt.branch));
1497
1269
  }
1498
1270
 
1499
- /** Spawn a run that keeps executing after this call returns. Every run is background. */
1500
1271
  startInBackground(params: SubagentParamsShape, ctx: ExtensionContext): RunDetails {
1501
1272
  const { run, inputs } = this.createRun(params, ctx);
1502
1273
  void this.executeTasks(run, inputs, ctx, undefined, undefined)
@@ -1507,7 +1278,6 @@ export class SubagentManager {
1507
1278
  );
1508
1279
  })
1509
1280
  .catch((err) => {
1510
- // Never leave a background run unsettled: mark failed, settle, notify.
1511
1281
  run.status = "failed";
1512
1282
  run.endedAt = Date.now();
1513
1283
  for (const task of run.tasks) {
@@ -1527,7 +1297,6 @@ export class SubagentManager {
1527
1297
  return { run: cloneRun(run) };
1528
1298
  }
1529
1299
 
1530
- /** Push a steering message into a live child's session. Returns false when unknown or not running. */
1531
1300
  steerTask(runId: string, taskId: string | undefined, message: string): boolean {
1532
1301
  const run = this.runs.get(runId);
1533
1302
  if (!run) return false;
@@ -1537,12 +1306,11 @@ export class SubagentManager {
1537
1306
  return true;
1538
1307
  }
1539
1308
 
1540
- /** Abort ONE task; siblings keep running. Returns false when unknown or already finished. */
1541
1309
  cancelTask(runId: string, taskId: string, ctx?: ExtensionContext): boolean {
1542
1310
  const run = this.runs.get(runId);
1543
1311
  const task = run?.tasks.find((t) => t.id === taskId);
1544
1312
  if (!run || !task || TERMINAL.includes(task.status)) return false;
1545
- // Mark first: runChild's catch reads task.status to classify the outcome as aborted.
1313
+
1546
1314
  task.status = "aborted";
1547
1315
  task.error = task.error || "Canceled from peek";
1548
1316
  task.endedAt = Date.now();
@@ -1558,11 +1326,10 @@ export class SubagentManager {
1558
1326
  cancelRun(runId: string): { aborted: number } {
1559
1327
  const run = this.runs.get(runId);
1560
1328
  if (!run) return { aborted: 0 };
1561
- if (TERMINAL.includes(run.status)) return { aborted: 0 }; // never corrupt a finished run
1329
+ if (TERMINAL.includes(run.status)) return { aborted: 0 };
1562
1330
  let aborted = 0;
1563
1331
  this.runControllers.get(runId)?.abort();
1564
- // Release children parked in ask_parent first — an unresolved wait would keep
1565
- // the child alive past the abort.
1332
+
1566
1333
  for (const [key, pending] of this.pendingReplies) {
1567
1334
  if (key.startsWith(`${runId}:`)) {
1568
1335
  this.pendingReplies.delete(key);
@@ -1577,11 +1344,10 @@ export class SubagentManager {
1577
1344
  for (const task of run.tasks) {
1578
1345
  if (TERMINAL.includes(task.status)) continue;
1579
1346
  task.status = "aborted";
1580
- task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
1347
+ task.error = task.error || "Canceled by subagent_cancel";
1581
1348
  task.endedAt = Date.now();
1582
1349
  aborted += 1;
1583
- // The branch is recorded here so the leader can still merge partial work;
1584
- // runChild's finally commits + drops the dir (it owns the live worktree).
1350
+
1585
1351
  const wt = this.liveWorktrees.get(`${runId}:${task.id}`);
1586
1352
  if (wt) task.branch = wt.branch;
1587
1353
  }
@@ -1594,7 +1360,6 @@ export class SubagentManager {
1594
1360
  return { aborted };
1595
1361
  }
1596
1362
 
1597
- /** Settle-and-delete: every awaiter resolves once, then the set is dropped. */
1598
1363
  private settleRun(runId: string, run: RunSnapshot): void {
1599
1364
  if (!this.settlers.has(runId)) return;
1600
1365
  this.settlers.delete(runId);
@@ -1605,12 +1370,8 @@ export class SubagentManager {
1605
1370
  for (const waiter of waiters) waiter(snapshot);
1606
1371
  }
1607
1372
 
1608
- /** Child→leader messages collected while the parent is parked in await_subagent. */
1609
- /** Every awaiter parked on a run — a SET, so two concurrent awaits can't
1610
- * overwrite each other's buffer and silently swallow one side's intercom. */
1611
1373
  private parked = new Map<string, Set<{ msgs: ParkedMsg[]; wake: () => void }>>();
1612
1374
 
1613
- /** While the parent is parked on this run, deliver the message through the wait instead of the steering queue. */
1614
1375
  private collectParked(runId: string, msg: ParkedMsg): boolean {
1615
1376
  const parked = this.parked.get(runId);
1616
1377
  if (!parked || parked.size === 0) return false;
@@ -1620,22 +1381,18 @@ export class SubagentManager {
1620
1381
  p.msgs.push(msg);
1621
1382
  delivered = true;
1622
1383
  } else if (msg.kind === "ask") {
1623
- // An unanswered ask blocks a child for 10 minutes — it must never be the
1624
- // message that gets dropped by the cap. Evict the oldest NON-ask first
1625
- // (asks already block children; displacing one hangs the earlier asker).
1626
1384
  const drop = p.msgs.findIndex((m) => m.kind !== "ask");
1627
1385
  if (drop !== -1) {
1628
1386
  p.msgs.splice(drop, 1);
1629
1387
  p.msgs.push(msg);
1630
1388
  } else {
1631
- p.msgs[p.msgs.length - 1] = msg; // all asks — overwrite the oldest ask
1389
+ p.msgs[p.msgs.length - 1] = msg;
1632
1390
  }
1633
1391
  delivered = true;
1634
1392
  }
1635
- p.wake(); // resolve the parked await early — the leader breathes on every message
1393
+ p.wake();
1636
1394
  }
1637
- // Not buffered anywhere → report undelivered so the caller falls back to a
1638
- // followUp notice instead of assuming the leader saw it.
1395
+
1639
1396
  return delivered;
1640
1397
  }
1641
1398
 
@@ -1649,7 +1406,7 @@ export class SubagentManager {
1649
1406
  const finish = (): void => {
1650
1407
  const parked = this.parked.get(runId);
1651
1408
  if (!parked || !entry) return;
1652
- parked.delete(entry); // only our own park — a sibling await keeps receiving
1409
+ parked.delete(entry);
1653
1410
  if (parked.size === 0) this.parked.delete(runId);
1654
1411
  };
1655
1412
  if (TERMINAL.includes(run.status)) {
@@ -1658,9 +1415,6 @@ export class SubagentManager {
1658
1415
  }
1659
1416
  const msgs: ParkedMsg[] = [];
1660
1417
  const settled = new Promise<RunSnapshot | undefined>((resolve) => {
1661
- // Waiters are a SET, not a chain: the autoAwait loop re-parks on every
1662
- // child message, and wrapping the previous settler each time grew an
1663
- // unbounded closure chain (each holding a snapshot clone).
1664
1418
  const waiter = (r: RunSnapshot) => {
1665
1419
  this.settleWaiters.get(runId)?.delete(waiter);
1666
1420
  resolve(r);
@@ -1671,8 +1425,7 @@ export class SubagentManager {
1671
1425
  this.settleWaiters.set(runId, waiters);
1672
1426
  }
1673
1427
  waiters.add(waiter);
1674
- // A child→leader message while parked wakes the wait: the leader gets it
1675
- // IN the await result, no steering queue, no turn boundary needed.
1428
+
1676
1429
  entry = { msgs, wake: () => waiter(cloneRun(run)) };
1677
1430
  let parked = this.parked.get(runId);
1678
1431
  if (!parked) {
@@ -1686,9 +1439,7 @@ export class SubagentManager {
1686
1439
  return Promise.race([
1687
1440
  settled.then((r) => {
1688
1441
  finish();
1689
- // Only mark awaited when this call actually hands the run back to the
1690
- // leader. A slice that already timed out is abandoned — setting it here
1691
- // would suppress the run's completion notice the leader still needs.
1442
+
1692
1443
  if (!timedOut) run.awaited = true;
1693
1444
  return { run: r, intercom: msgs };
1694
1445
  }),