@arhen/pi-core-subagent 1.3.49 → 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,29 +698,18 @@ 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
- // File tools are default policy, applied only when the call carries no
849
- // explicit tool intent: tools: or write: true win over them — silently
850
- // displacing explicit intent produced read-only children that "completed"
851
- // with zero edits (issue #3). The gate still holds: a repo-planted file
852
- // can never WIDEN past the leader's read/write choice (filtered above).
712
+
853
713
  const allowedTools = input.write ? WRITE_TOOLS : READONLY_TOOLS;
854
714
  const fileTools = file?.tools?.filter((t) => allowedTools.includes(t));
855
715
  const explicitTools = input.tools ?? (input.write ? WRITE_TOOLS : undefined);
@@ -857,30 +717,21 @@ export class SubagentManager {
857
717
  if (explicitTools && fileTools?.length)
858
718
  task.toolsNote = `explicit tools overrode agent-file tools (${fileTools.join(", ")})`;
859
719
  const tools = [...baseTools, ...CHILD_TALK_TOOLS];
860
- // Isolation follows the DELIVERED toolset, never the raw request: explicit
861
- // tools: [bash] without write:true still gets a worktree, and a file that
862
- // narrowed the child to read-only never gets the commit/merge ceremony.
720
+
863
721
  const canWrite = baseTools.some((t) => WRITE_CAPABLE.includes(t));
864
722
 
865
- // Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
866
- // Model + thinking resolve against the pi model registry BEFORE any worktree
867
- // exists — a bad request fails the TASK with a helpful message and can't leak a
868
- // checkout past this early return.
869
723
  let model: Model<Api> | undefined;
870
724
  try {
871
725
  model = resolveChildModel(ctx, file?.model ?? input.model);
872
726
  validateThinking(model, thinking);
873
- // Resolution only proves the id exists. Probe it before anything is built,
874
- // and fall back to the session's own model when the probe fails.
727
+
875
728
  const checked = await ensureUsableModel(ctx, model, signal);
876
729
  model = checked.model;
877
730
  if (checked.note) {
878
731
  task.modelNote = checked.note;
879
732
  validateThinking(model, thinking);
880
733
  }
881
- // Record the model ACTUALLY used — an agent file's `model:` overrides the
882
- // requested one, and showing the request in the widget hides that entirely
883
- // (a 403 then names a model the leader never asked for).
734
+
884
735
  if (model) this.updateTask(run, task, { model: model.id, modelNote: checked.note }, ctx, onUpdate);
885
736
  } catch (err) {
886
737
  this.updateTask(
@@ -897,16 +748,10 @@ export class SubagentManager {
897
748
  return;
898
749
  }
899
750
 
900
- // Write agents run in an isolated git worktree (branch subagents/<run>/<task>);
901
- // non-git repos fall back to in-place. Created BEFORE session start so the
902
- // child's cwd + AGENTS.md context chain are the worktree's.
903
751
  let wt: Worktree | undefined;
904
752
  let isolationReason: string | undefined;
905
753
  if (canWrite) {
906
754
  try {
907
- // Stack on the upstream write task's branch, so a chained writer actually
908
- // SEES the work it was told to build on. Read-only upstreams have no
909
- // branch, so those stay based on HEAD.
910
755
  const upstream = (task.needs ?? [])
911
756
  .map((id) => run.tasks.find((t) => t.id === id))
912
757
  .filter((t) => t?.branch && t.status === "completed")
@@ -915,14 +760,11 @@ export class SubagentManager {
915
760
  if (wt && upstream?.branch) task.stackedOn = upstream.branch;
916
761
  if (!wt) isolationReason = "not a git repository";
917
762
  } catch (err) {
918
- wt = undefined; // git failure → in-place
763
+ wt = undefined;
919
764
  isolationReason = `git worktree add failed: ${err instanceof Error ? err.message : String(err)}`;
920
765
  }
921
766
  }
922
- // Map a per-task cwd subpath into the worktree so relative paths stay correct.
923
- // Both sides go through realpath — a symlinked root would otherwise look
924
- // "outside" the repo. If the mapping can't be trusted, drop the worktree AND
925
- // reset the cwd (never point the child at a dir that was just removed).
767
+
926
768
  let childCwd = wt?.path ?? task.cwd;
927
769
  if (wt) {
928
770
  const rel = relative(safeRealPath(wt.root), safeRealPath(task.cwd));
@@ -933,8 +775,7 @@ export class SubagentManager {
933
775
  isolationReason = "task cwd is outside the repository";
934
776
  } else if (rel && rel !== ".") {
935
777
  childCwd = join(wt.path, rel);
936
- // The subpath may be gitignored/untracked, so it won't exist in a fresh
937
- // checkout — create it rather than fail session start.
778
+
938
779
  try {
939
780
  mkdirSync(childCwd, { recursive: true });
940
781
  } catch {
@@ -943,19 +784,14 @@ export class SubagentManager {
943
784
  }
944
785
  }
945
786
  if (canWrite) {
946
- // Never let isolation lapse quietly: the leader must know its edits landed
947
- // straight in the working tree with no branch to review.
948
787
  task.isolation = wt ? "worktree" : "in-place";
949
788
  task.isolationReason = wt ? undefined : (isolationReason ?? "worktree unavailable");
950
789
  }
951
790
  if (wt) {
952
- claimWorktree(wt); // pid marker: another pi session must not reap this
791
+ claimWorktree(wt);
953
792
  this.liveWorktrees.set(`${run.id}:${task.id}`, wt);
954
793
  }
955
794
 
956
- // Guarded: a throwing event listener or widget failure here would escape
957
- // runChild BEFORE the try/finally that releases the worktree — leaking a
958
- // checkout that every reaper then skips (registered + owned by a live pid).
959
795
  try {
960
796
  this.updateTask(
961
797
  run,
@@ -963,11 +799,9 @@ export class SubagentManager {
963
799
  {
964
800
  status: "starting",
965
801
  startedAt: Date.now(),
966
- // Upstream outputs were spliced in by the scheduler; the snapshot must show
967
- // the prompt the child actually receives.
802
+
968
803
  task: input.task,
969
- // RESOLVED model, not the request — an agent file's `model:` overrides it,
970
- // and this patch used to clobber the resolved id recorded above.
804
+
971
805
  model: model?.id ?? input.model,
972
806
  thinking,
973
807
  tools,
@@ -975,12 +809,8 @@ export class SubagentManager {
975
809
  ctx,
976
810
  onUpdate,
977
811
  );
978
- } catch {
979
- /* a listener/widget failure must not strand the checkout */
980
- }
812
+ } catch {}
981
813
 
982
- // Set once the dir must outlive this call: committed work awaiting the
983
- // leader's merge, or a commit failure whose work exists ONLY in the dir.
984
814
  let keepWorktreeDir = false;
985
815
  let child: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
986
816
  let unsubscribe: (() => void) | undefined;
@@ -990,13 +820,10 @@ export class SubagentManager {
990
820
 
991
821
  const key = `${run.id}:${task.id}`;
992
822
  try {
993
- // node_modules is a SHARED symlink to the leader's real tree, so dep writes
994
- // escape the worktree entirely and `rm -rf node_modules/` destroys the
995
- // project's deps. The child is the only thing that can avoid that.
996
823
  const worktreeNote = wt
997
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.`
998
825
  : "";
999
- 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}`;
1000
827
 
1001
828
  const loader = new DefaultResourceLoader({
1002
829
  cwd: childCwd,
@@ -1045,7 +872,7 @@ export class SubagentManager {
1045
872
 
1046
873
  const abortChild = () => {
1047
874
  void child?.abort();
1048
- this.runControllers.get(run.id)?.abort(); // parent abort kills ALL siblings, not just this child
875
+ this.runControllers.get(run.id)?.abort();
1049
876
  };
1050
877
  const runController = this.runControllers.get(run.id);
1051
878
  if (signal) signal.addEventListener("abort", abortChild, { once: true });
@@ -1054,7 +881,7 @@ export class SubagentManager {
1054
881
  signal?.removeEventListener("abort", abortChild);
1055
882
  runController?.signal.removeEventListener("abort", abortChild);
1056
883
  };
1057
- // Cancel may have landed during session creation — honor it before prompting.
884
+
1058
885
  if (run.status === "aborted" || TERMINAL.includes(task.status) || signal?.aborted) {
1059
886
  await child.abort();
1060
887
  throw new Error("Canceled by subagent_cancel");
@@ -1062,7 +889,7 @@ export class SubagentManager {
1062
889
  this.liveChildren.set(key, {
1063
890
  abort: () => void child?.abort(),
1064
891
  dispose: () => child?.dispose(),
1065
- // Inject a steering message mid-run; queues as steer if the child is streaming.
892
+
1066
893
  steer: (message) =>
1067
894
  void child?.prompt(message, { streamingBehavior: "steer" }).catch((err) =>
1068
895
  this.pi.sendUserMessage(`[steer_subagent] ${err instanceof Error ? err.message : String(err)}`, {
@@ -1071,9 +898,6 @@ export class SubagentManager {
1071
898
  ),
1072
899
  });
1073
900
 
1074
- // The ceiling is the ONLY bound on a child that emits events forever (retry
1075
- // or tool-call livelock). So auto-limit off RAISES it, never removes it —
1076
- // removing it reproduced the immortal-child hang.
1077
901
  const maxRuntimeMs = input.maxRuntimeMs ?? (this.autoLimit ? DEFAULT_RUNTIME_MS : UNLIMITED_RUNTIME_MS);
1078
902
  const promptPromise = child.prompt(task.task, { source: "extension" });
1079
903
  const races: Promise<unknown>[] = [promptPromise, childFailurePromise, childEndPromise];
@@ -1093,31 +917,19 @@ export class SubagentManager {
1093
917
  const finalText =
1094
918
  task.finalText ||
1095
919
  truncateText((child.messages as AssistantMessage[]).map(getFirstText).filter(Boolean).at(-1) || "");
1096
- // TERMINAL, not just "aborted": a cancel/timeout that landed while the
1097
- // final text was being assembled must not be overwritten with "completed"
1098
- // (which would also skip the partial commit and drop the child's work).
1099
- // `awaiting_parent` also means the child hadn't finished talking — marking
1100
- // it completed would publish a truncated finalText to every dependent.
920
+
1101
921
  if (task.status === "awaiting_parent") {
1102
922
  this.pendingReplies.get(key)?.resolve("(your task is being finalized — stop work and return now)");
1103
923
  }
1104
924
  if (!TERMINAL.includes(task.status)) {
1105
925
  this.updateTask(run, task, { status: "completed", finalText, endedAt: Date.now() }, ctx, onUpdate);
1106
926
  if (wt) {
1107
- // Commit the child's changes, then report the branch + diff so the
1108
- // leader can review and merge (PR-style). The worktree dir stays
1109
- // until the branch is merged — cleanupMerged removes both then.
1110
- // Commit/diff failures must NOT downgrade a completed task or destroy
1111
- // its work: the error is reported, the status stays completed.
1112
927
  let committed: "committed" | "empty" | undefined;
1113
928
  try {
1114
929
  committed = commitWorktree(wt, `subagent ${task.agent}: ${truncateText(input.task, 60)}`);
1115
- // Only a real commit is worth a branch: an empty one would send the
1116
- // leader off to review and merge nothing.
930
+
1117
931
  keepWorktreeDir = committed === "committed";
1118
932
  } catch (commitErr) {
1119
- // Never drop a checkout whose work isn't on the branch — it would be
1120
- // unreachable once the base-tip branch is reaped as "merged".
1121
933
  keepWorktreeDir = true;
1122
934
  this.updateTask(
1123
935
  run,
@@ -1130,7 +942,7 @@ export class SubagentManager {
1130
942
  onUpdate,
1131
943
  );
1132
944
  }
1133
- // Diff separately: a diff failure must not be reported as a lost commit.
945
+
1134
946
  if (committed === "committed") {
1135
947
  try {
1136
948
  const { stat, files } = branchDiff(wt);
@@ -1158,20 +970,14 @@ export class SubagentManager {
1158
970
  }
1159
971
  } catch (err) {
1160
972
  if (timeout) clearTimeout(timeout);
1161
- // Cancel is authoritative: parent tool signal OR run/task already marked aborted.
973
+
1162
974
  const aborted = signal?.aborted || run.status === "aborted" || task.status === "aborted";
1163
975
  const subagentStatus = (err as Error & { subagentStatus?: string })?.subagentStatus;
1164
976
  try {
1165
- // Unblock a child stuck in ask_parent, then time-box the abort so a
1166
- // wedged session can never hang this catch/finally.
1167
977
  this.pendingReplies.get(key)?.resolve("(parent unreachable)");
1168
978
  await Promise.race([child?.abort(), new Promise((r) => setTimeout(r, 5000))]);
1169
- } catch {
1170
- /* ignore */
1171
- }
1172
- // Publish whatever the child DID say before it was killed. A timeout or
1173
- // abort used to discard it, so a chain dependent received nothing at all
1174
- // while the child's partial work was still committed to its branch.
979
+ } catch {}
980
+
1175
981
  const salvaged =
1176
982
  task.finalText ||
1177
983
  truncateText(
@@ -1191,46 +997,37 @@ export class SubagentManager {
1191
997
  );
1192
998
  } finally {
1193
999
  this.liveChildren.delete(key);
1194
- // Resolve, don't just delete: a bare delete strands the 10-minute reply
1195
- // timer and leaves the child's await unsettled.
1000
+
1196
1001
  this.pendingReplies.get(key)?.resolve("(task ended — stop work now)");
1197
1002
  this.pendingReplies.delete(key);
1198
1003
  abortListener?.();
1199
1004
  unsubscribe?.();
1200
1005
  if (timeout) clearTimeout(timeout);
1201
1006
  child?.dispose();
1202
- // Failed/aborted: let the aborted child's last writes land (its tools may
1203
- // still be unwinding), commit whatever partial work exists so the branch
1204
- // really keeps it, then drop the checkout dir. A commit FAILURE keeps the
1205
- // dir — dropping it would make the work unreachable.
1007
+
1206
1008
  if (wt && task.status !== "completed") {
1207
1009
  await new Promise((r) => setTimeout(r, 250));
1208
1010
  let partial: "committed" | "empty" | undefined;
1209
1011
  try {
1210
1012
  partial = commitWorktree(wt, `subagent ${task.agent} (partial, ${task.status})`);
1211
1013
  } catch {
1212
- keepWorktreeDir = true; // work exists only in the dir — keep it
1014
+ keepWorktreeDir = true;
1213
1015
  }
1214
- // A branch is only worth reporting when it actually carries something.
1016
+
1215
1017
  if (partial === "committed" || keepWorktreeDir) {
1216
1018
  this.updateTask(run, task, { branch: wt.branch }, ctx, onUpdate);
1217
1019
  }
1218
1020
  }
1219
1021
  if (wt && !keepWorktreeDir) removeWorktree(wt);
1220
- // Released only after the dir is gone: while it exists, the branch must stay
1221
- // in liveBranches() so cleanup can't reap it.
1022
+
1222
1023
  this.liveWorktrees.delete(key);
1223
1024
  }
1224
1025
  }
1225
1026
 
1226
- // ── run lifecycle ───────────────────────────────────────────────────
1227
1027
  createRun(params: SubagentParamsShape, ctx: ExtensionContext): { run: RunSnapshot; inputs: TaskInput[] } {
1228
1028
  const hasChain = (params.chain?.length ?? 0) > 0;
1229
1029
  const hasTasks = (params.tasks?.length ?? 0) > 0;
1230
- // An array mode wins over stray top-level agent/task: models routinely leave
1231
- // those in place when switching to tasks:[...], and the intent is not
1232
- // ambiguous — rejecting a well-formed 3-task call over leftovers is worse
1233
- // than ignoring them. Genuine ambiguity (tasks AND chain) is still refused.
1030
+
1234
1031
  const hasSingle = !hasChain && !hasTasks && Boolean(params.agent && params.task);
1235
1032
  if (hasChain && hasTasks) {
1236
1033
  throw new Error(`Provide either tasks (parallel) or chain (sequential), not both.`);
@@ -1240,12 +1037,7 @@ export class SubagentManager {
1240
1037
  `Provide one subagent mode: agent+task (single), tasks: [...] (parallel), or chain: [...] (sequential).`,
1241
1038
  );
1242
1039
  }
1243
- // Single-mode-only fields alongside an array mode are silently dropped
1244
- // otherwise: `write: true` next to tasks:[...] produced read-only children
1245
- // that reported they "cannot edit files", with nothing explaining why.
1246
- // `cwd` and `maxRuntimeMs` are meaningful run-wide (and the schema advertises
1247
- // maxRuntimeMs without a single-mode marker), so they FAN OUT as per-task
1248
- // defaults instead of being refused. The rest genuinely describe one agent.
1040
+
1249
1041
  if (hasChain || hasTasks) {
1250
1042
  const stray = (["write", "prompt", "tools", "model", "thinking"] as const).filter((k) => params[k] !== undefined);
1251
1043
  if (stray.length > 0) {
@@ -1271,15 +1063,12 @@ export class SubagentManager {
1271
1063
  },
1272
1064
  ]
1273
1065
  : (hasTasks ? params.tasks! : params.chain!).map((item) => ({
1274
- // Run-wide defaults; a per-task value always wins.
1275
1066
  ...item,
1276
1067
  cwd: item.cwd ?? params.cwd,
1277
1068
  maxRuntimeMs: item.maxRuntimeMs ?? params.maxRuntimeMs,
1278
1069
  }));
1279
1070
  if (inputs.length > MAX_TASKS) throw new Error(`Too many subagent tasks (${inputs.length}). Max is ${MAX_TASKS}.`);
1280
- // Task ids become git refs + filesystem paths — refuse anything unsafe.
1281
- // Explicit ids are checked against each other; generated ones are checked
1282
- // against explicit ones so a collision can't silently fall back to in-place.
1071
+
1283
1072
  const ids = new Set<string>();
1284
1073
  for (const input of inputs) {
1285
1074
  if (input.id !== undefined) {
@@ -1297,14 +1086,9 @@ export class SubagentManager {
1297
1086
  }
1298
1087
  }
1299
1088
  const edges = resolveNeeds(inputs, mode);
1300
- // A new run exists, so persisting is meaningful again. Without this, a host
1301
- // that emits session_shutdown with no following session_start (SIGTERM, SDK
1302
- // reuse, tests) leaves every later persist a silent no-op.
1089
+
1303
1090
  this.cleared = false;
1304
- // Pre-flight every task's model BEFORE the run exists. A matched agent file
1305
- // overrides the requested model, so an unresolvable one is the leader's
1306
- // mistake to see NOW — not N children dying one by one on their first turn
1307
- // with an error naming a model the leader never asked for.
1091
+
1308
1092
  for (let i = 0; i < inputs.length; i++) {
1309
1093
  const input = inputs[i] as TaskInput;
1310
1094
  const cwd = input.cwd ?? ctx.cwd;
@@ -1345,8 +1129,7 @@ export class SubagentManager {
1345
1129
  })),
1346
1130
  aggregateUsage: emptyUsage(),
1347
1131
  };
1348
- // Roster: each child learns its own address + sibling addresses so
1349
- // send_agent_message/poll_agent_messages can be used reliably.
1132
+
1350
1133
  const roster = run.tasks.map((t) => `${t.id} (${t.agent})`).join(", ");
1351
1134
  for (const task of run.tasks) {
1352
1135
  task.roster = roster;
@@ -1374,15 +1157,12 @@ export class SubagentManager {
1374
1157
  run.startedAt = Date.now();
1375
1158
  this.updateRun(run, ctx, onUpdate);
1376
1159
 
1377
- // One wave scheduler for every mode. A wave is the set of tasks whose needs
1378
- // are all satisfied; the loop boundary between waves IS the gate. Chain mode
1379
- // reaches here as needs: [previous], so it needs no special case.
1380
1160
  const outputs = new Map<string, string>();
1381
1161
  const settled = new Set<string>();
1382
1162
  for (const task of run.tasks) {
1383
- if (TERMINAL.includes(task.status)) settled.add(task.id); // canceled before start
1163
+ if (TERMINAL.includes(task.status)) settled.add(task.id);
1384
1164
  }
1385
- // id → input, immune to filtered-array index drift (C4).
1165
+
1386
1166
  const inputById = new Map(run.tasks.map((t, i) => [t.id, inputs[i]]));
1387
1167
 
1388
1168
  const { skipped } = await runWaveScheduler(
@@ -1391,12 +1171,8 @@ export class SubagentManager {
1391
1171
  outputs,
1392
1172
  settled,
1393
1173
  async (task) => {
1394
- // The scheduler passes the index into the FILTERED list — never use it
1395
- // against the unfiltered inputs. Look the input up by task id instead.
1396
1174
  const input = inputById.get(task.id);
1397
1175
  if (!input) {
1398
- // Impossible unless ids drift from inputs — fail loudly instead of
1399
- // leaving the task queued forever (hasActiveRun would never clear).
1400
1176
  this.updateTask(
1401
1177
  run,
1402
1178
  task,
@@ -1421,7 +1197,7 @@ export class SubagentManager {
1421
1197
  }
1422
1198
  },
1423
1199
  );
1424
- // Broken-upstream tasks are detected by the scheduler; mark them after the wave.
1200
+
1425
1201
  for (const s of skipped) {
1426
1202
  const task = run.tasks.find((t) => t.id === s.id);
1427
1203
  if (task && !TERMINAL.includes(task.status)) {
@@ -1430,7 +1206,7 @@ export class SubagentManager {
1430
1206
  task,
1431
1207
  {
1432
1208
  status: "aborted",
1433
- // Don't overwrite a real reason (e.g. "Canceled by subagent_cancel").
1209
+
1434
1210
  error: task.error || `Skipped: upstream task(s) did not complete: ${s.needs.join(", ")}`,
1435
1211
  endedAt: Date.now(),
1436
1212
  },
@@ -1439,9 +1215,7 @@ export class SubagentManager {
1439
1215
  );
1440
1216
  }
1441
1217
  }
1442
- // Belt and braces: the wave loop breaks out when no frontier is ready, which
1443
- // would otherwise leave tasks queued inside a terminal run — hasActiveRun()
1444
- // then never clears and the widget stays pinned.
1218
+
1445
1219
  for (const task of run.tasks) {
1446
1220
  if (TERMINAL.includes(task.status)) continue;
1447
1221
  this.updateTask(
@@ -1458,11 +1232,10 @@ export class SubagentManager {
1458
1232
  run.status = aborted ? "aborted" : failed ? "failed" : "completed";
1459
1233
  run.endedAt = Date.now();
1460
1234
  this.flushWidget(run, ctx, onUpdate);
1461
- // Finished runs (including aborted ones) stay on screen so the outcome is readable.
1462
- // The agent_start handler clears them on the next turn that spawns nothing.
1235
+
1463
1236
  const live = this.listRuns().find((r) => !TERMINAL.includes(r.status));
1464
1237
  if (live) this.scheduleWidget(live, ctx);
1465
- // L7: cancelRun already emitted + settled — don't double-report.
1238
+
1466
1239
  if (this.settlers.has(run.id)) {
1467
1240
  this.emit("subagent:run-completed", {
1468
1241
  runId: run.id,
@@ -1475,9 +1248,7 @@ export class SubagentManager {
1475
1248
  this.runControllers.delete(run.id);
1476
1249
  for (const task of run.tasks) this.mailboxes.close(`${run.id}:${task.id}`);
1477
1250
  this.persist(ctx);
1478
- // Branches merged by the leader since the run ended: drop worktree dir + branch.
1479
- // Once per repo, never for a branch another live run owns, never fatal — a
1480
- // throw here would re-settle an already-finished run as failed.
1251
+
1481
1252
  try {
1482
1253
  const roots = new Set<string>();
1483
1254
  for (const task of run.tasks) {
@@ -1486,14 +1257,9 @@ export class SubagentManager {
1486
1257
  if (root) roots.add(root);
1487
1258
  }
1488
1259
  for (const root of roots) cleanupMerged(root, { skipBranches: this.liveBranches() });
1489
- } catch {
1490
- /* cleanup is best-effort; the run outcome must stand */
1491
- }
1260
+ } catch {}
1492
1261
  }
1493
1262
 
1494
- /** Branches owned by worktrees of still-running children. */
1495
- /** True only for checkouts THIS manager still runs — a marker carrying our own
1496
- * pid from a previous session in the same process is not proof of life. */
1497
1263
  ownsWorktree = (path: string): boolean => {
1498
1264
  for (const wt of this.liveWorktrees.values()) if (wt.path === path) return true;
1499
1265
  return false;
@@ -1502,7 +1268,6 @@ export class SubagentManager {
1502
1268
  return new Set(Array.from(this.liveWorktrees.values(), (wt) => wt.branch));
1503
1269
  }
1504
1270
 
1505
- /** Spawn a run that keeps executing after this call returns. Every run is background. */
1506
1271
  startInBackground(params: SubagentParamsShape, ctx: ExtensionContext): RunDetails {
1507
1272
  const { run, inputs } = this.createRun(params, ctx);
1508
1273
  void this.executeTasks(run, inputs, ctx, undefined, undefined)
@@ -1513,7 +1278,6 @@ export class SubagentManager {
1513
1278
  );
1514
1279
  })
1515
1280
  .catch((err) => {
1516
- // Never leave a background run unsettled: mark failed, settle, notify.
1517
1281
  run.status = "failed";
1518
1282
  run.endedAt = Date.now();
1519
1283
  for (const task of run.tasks) {
@@ -1533,7 +1297,6 @@ export class SubagentManager {
1533
1297
  return { run: cloneRun(run) };
1534
1298
  }
1535
1299
 
1536
- /** Push a steering message into a live child's session. Returns false when unknown or not running. */
1537
1300
  steerTask(runId: string, taskId: string | undefined, message: string): boolean {
1538
1301
  const run = this.runs.get(runId);
1539
1302
  if (!run) return false;
@@ -1543,12 +1306,11 @@ export class SubagentManager {
1543
1306
  return true;
1544
1307
  }
1545
1308
 
1546
- /** Abort ONE task; siblings keep running. Returns false when unknown or already finished. */
1547
1309
  cancelTask(runId: string, taskId: string, ctx?: ExtensionContext): boolean {
1548
1310
  const run = this.runs.get(runId);
1549
1311
  const task = run?.tasks.find((t) => t.id === taskId);
1550
1312
  if (!run || !task || TERMINAL.includes(task.status)) return false;
1551
- // Mark first: runChild's catch reads task.status to classify the outcome as aborted.
1313
+
1552
1314
  task.status = "aborted";
1553
1315
  task.error = task.error || "Canceled from peek";
1554
1316
  task.endedAt = Date.now();
@@ -1564,11 +1326,10 @@ export class SubagentManager {
1564
1326
  cancelRun(runId: string): { aborted: number } {
1565
1327
  const run = this.runs.get(runId);
1566
1328
  if (!run) return { aborted: 0 };
1567
- if (TERMINAL.includes(run.status)) return { aborted: 0 }; // never corrupt a finished run
1329
+ if (TERMINAL.includes(run.status)) return { aborted: 0 };
1568
1330
  let aborted = 0;
1569
1331
  this.runControllers.get(runId)?.abort();
1570
- // Release children parked in ask_parent first — an unresolved wait would keep
1571
- // the child alive past the abort.
1332
+
1572
1333
  for (const [key, pending] of this.pendingReplies) {
1573
1334
  if (key.startsWith(`${runId}:`)) {
1574
1335
  this.pendingReplies.delete(key);
@@ -1583,11 +1344,10 @@ export class SubagentManager {
1583
1344
  for (const task of run.tasks) {
1584
1345
  if (TERMINAL.includes(task.status)) continue;
1585
1346
  task.status = "aborted";
1586
- task.error = task.error || "Canceled by subagent_cancel"; // never overwrite a real error
1347
+ task.error = task.error || "Canceled by subagent_cancel";
1587
1348
  task.endedAt = Date.now();
1588
1349
  aborted += 1;
1589
- // The branch is recorded here so the leader can still merge partial work;
1590
- // runChild's finally commits + drops the dir (it owns the live worktree).
1350
+
1591
1351
  const wt = this.liveWorktrees.get(`${runId}:${task.id}`);
1592
1352
  if (wt) task.branch = wt.branch;
1593
1353
  }
@@ -1600,7 +1360,6 @@ export class SubagentManager {
1600
1360
  return { aborted };
1601
1361
  }
1602
1362
 
1603
- /** Settle-and-delete: every awaiter resolves once, then the set is dropped. */
1604
1363
  private settleRun(runId: string, run: RunSnapshot): void {
1605
1364
  if (!this.settlers.has(runId)) return;
1606
1365
  this.settlers.delete(runId);
@@ -1611,12 +1370,8 @@ export class SubagentManager {
1611
1370
  for (const waiter of waiters) waiter(snapshot);
1612
1371
  }
1613
1372
 
1614
- /** Child→leader messages collected while the parent is parked in await_subagent. */
1615
- /** Every awaiter parked on a run — a SET, so two concurrent awaits can't
1616
- * overwrite each other's buffer and silently swallow one side's intercom. */
1617
1373
  private parked = new Map<string, Set<{ msgs: ParkedMsg[]; wake: () => void }>>();
1618
1374
 
1619
- /** While the parent is parked on this run, deliver the message through the wait instead of the steering queue. */
1620
1375
  private collectParked(runId: string, msg: ParkedMsg): boolean {
1621
1376
  const parked = this.parked.get(runId);
1622
1377
  if (!parked || parked.size === 0) return false;
@@ -1626,22 +1381,18 @@ export class SubagentManager {
1626
1381
  p.msgs.push(msg);
1627
1382
  delivered = true;
1628
1383
  } else if (msg.kind === "ask") {
1629
- // An unanswered ask blocks a child for 10 minutes — it must never be the
1630
- // message that gets dropped by the cap. Evict the oldest NON-ask first
1631
- // (asks already block children; displacing one hangs the earlier asker).
1632
1384
  const drop = p.msgs.findIndex((m) => m.kind !== "ask");
1633
1385
  if (drop !== -1) {
1634
1386
  p.msgs.splice(drop, 1);
1635
1387
  p.msgs.push(msg);
1636
1388
  } else {
1637
- p.msgs[p.msgs.length - 1] = msg; // all asks — overwrite the oldest ask
1389
+ p.msgs[p.msgs.length - 1] = msg;
1638
1390
  }
1639
1391
  delivered = true;
1640
1392
  }
1641
- p.wake(); // resolve the parked await early — the leader breathes on every message
1393
+ p.wake();
1642
1394
  }
1643
- // Not buffered anywhere → report undelivered so the caller falls back to a
1644
- // followUp notice instead of assuming the leader saw it.
1395
+
1645
1396
  return delivered;
1646
1397
  }
1647
1398
 
@@ -1655,7 +1406,7 @@ export class SubagentManager {
1655
1406
  const finish = (): void => {
1656
1407
  const parked = this.parked.get(runId);
1657
1408
  if (!parked || !entry) return;
1658
- parked.delete(entry); // only our own park — a sibling await keeps receiving
1409
+ parked.delete(entry);
1659
1410
  if (parked.size === 0) this.parked.delete(runId);
1660
1411
  };
1661
1412
  if (TERMINAL.includes(run.status)) {
@@ -1664,9 +1415,6 @@ export class SubagentManager {
1664
1415
  }
1665
1416
  const msgs: ParkedMsg[] = [];
1666
1417
  const settled = new Promise<RunSnapshot | undefined>((resolve) => {
1667
- // Waiters are a SET, not a chain: the autoAwait loop re-parks on every
1668
- // child message, and wrapping the previous settler each time grew an
1669
- // unbounded closure chain (each holding a snapshot clone).
1670
1418
  const waiter = (r: RunSnapshot) => {
1671
1419
  this.settleWaiters.get(runId)?.delete(waiter);
1672
1420
  resolve(r);
@@ -1677,8 +1425,7 @@ export class SubagentManager {
1677
1425
  this.settleWaiters.set(runId, waiters);
1678
1426
  }
1679
1427
  waiters.add(waiter);
1680
- // A child→leader message while parked wakes the wait: the leader gets it
1681
- // IN the await result, no steering queue, no turn boundary needed.
1428
+
1682
1429
  entry = { msgs, wake: () => waiter(cloneRun(run)) };
1683
1430
  let parked = this.parked.get(runId);
1684
1431
  if (!parked) {
@@ -1692,9 +1439,7 @@ export class SubagentManager {
1692
1439
  return Promise.race([
1693
1440
  settled.then((r) => {
1694
1441
  finish();
1695
- // Only mark awaited when this call actually hands the run back to the
1696
- // leader. A slice that already timed out is abandoned — setting it here
1697
- // would suppress the run's completion notice the leader still needs.
1442
+
1698
1443
  if (!timedOut) run.awaited = true;
1699
1444
  return { run: r, intercom: msgs };
1700
1445
  }),