@signalridge/pi-subagents 1.10.2 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,11 +11,27 @@ import { createHash, randomUUID } from "node:crypto";
11
11
  import { realpathSync, statSync } from "node:fs";
12
12
  import { isAbsolute, resolve } from "node:path";
13
13
  import type { Model } from "@earendil-works/pi-ai";
14
- import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import type {
15
+ AgentSession,
16
+ ExtensionAPI,
17
+ ExtensionContext,
18
+ } from "@earendil-works/pi-coding-agent";
15
19
  import type { ManagedSpawnRequest as ProtocolManagedSpawnRequest } from "@signalridge/pi-subagents-protocol";
16
- import { isManagedAgentTier, parseManagedSpawnRequest } from "@signalridge/pi-subagents-protocol";
20
+ import {
21
+ isManagedAgentTier,
22
+ parseManagedSpawnRequest,
23
+ } from "@signalridge/pi-subagents-protocol";
17
24
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
18
- import { type AgentTierResolutionSnapshot, getAgentTiersSettings } from "./agent-tiers.js";
25
+ import {
26
+ type AgentTierResolutionSnapshot,
27
+ getAgentTiersSettings,
28
+ resolveAgentTier,
29
+ } from "./agent-tiers.js";
30
+ import { getAgentConfig } from "./agent-types.js";
31
+ import {
32
+ assertNoRawInheritance,
33
+ assertSafeChildSession,
34
+ } from "./context-boundary.js";
19
35
  import {
20
36
  INTERNAL_AGENT_CONFIG_OVERRIDE,
21
37
  INTERNAL_PARENT_POLICY_SNAPSHOT,
@@ -23,9 +39,22 @@ import {
23
39
  type ParentPolicySnapshot,
24
40
  } from "./internal-run.js";
25
41
  import { assignHandle, handleBase } from "./mention.js";
42
+ import {
43
+ assertParentModelRuntimeAvailable,
44
+ parentModelSessionOptions,
45
+ } from "./model-runtime-bridge.js";
26
46
  import { shutdownAndDisposeSession } from "./session-lifecycle.js";
27
47
  import type { TierThinking } from "./settings.js";
28
- import type { AgentInvocation, AgentOwner, AgentRecord, AgentRecordSnapshot, IsolationMode, ResumableAgentEntry, SubagentType, ThinkingLevel } from "./types.js";
48
+ import type {
49
+ AgentInvocation,
50
+ AgentOwner,
51
+ AgentRecord,
52
+ AgentRecordSnapshot,
53
+ IsolationMode,
54
+ ResumableAgentEntry,
55
+ SubagentType,
56
+ ThinkingLevel,
57
+ } from "./types.js";
29
58
  import { addUsage } from "./usage.js";
30
59
  import {
31
60
  cleanupWorktree,
@@ -41,8 +70,14 @@ import {
41
70
  export type OnAgentComplete = (record: AgentRecord) => void;
42
71
  export type OnAgentCreated = (record: AgentRecord) => void;
43
72
  export type OnAgentStart = (record: AgentRecord) => void;
44
- export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
45
- export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
73
+ export type OnAgentCompact = (
74
+ record: AgentRecord,
75
+ info: CompactionInfo,
76
+ ) => void;
77
+ export type CompactionInfo = {
78
+ reason: "manual" | "threshold" | "overflow";
79
+ tokensBefore: number;
80
+ };
46
81
 
47
82
  export interface WorktreeCleanupFailure {
48
83
  readonly path: string;
@@ -70,6 +105,7 @@ export const DEFAULT_MAX_SUBAGENT_SPAWNS_PER_BRANCH = 64;
70
105
  const OWNED_CHILD_QUIESCE_TIMEOUT_MS = 5_000;
71
106
  /** Shutdown briefly drains abort-responsive providers; stragglers retain worktrees. */
72
107
  const DISPOSE_PROVIDER_QUIESCE_TIMEOUT_MS = 1_000;
108
+ const DISPOSE_SESSION_TEARDOWN_TIMEOUT_MS = 1_000;
73
109
 
74
110
  /**
75
111
  * Validate a caller-supplied SpawnOptions.cwd. `undefined`/`null` mean "unset"
@@ -77,10 +113,14 @@ const DISPOSE_PROVIDER_QUIESCE_TIMEOUT_MS = 1_000;
77
113
  * directory — curated errors instead of TypeErrors from path/fs internals
78
114
  * (RPC callers send arbitrary JSON: null, numbers, file paths).
79
115
  */
80
- function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined | null {
116
+ function assertValidSpawnCwd(
117
+ cwd: unknown,
118
+ ): asserts cwd is string | undefined | null {
81
119
  if (cwd == null) return;
82
120
  if (typeof cwd !== "string" || !isAbsolute(cwd)) {
83
- throw new Error(`SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`);
121
+ throw new Error(
122
+ `SpawnOptions.cwd must be an absolute path: "${String(cwd)}"`,
123
+ );
84
124
  }
85
125
  let isDirectory = false;
86
126
  try {
@@ -102,17 +142,29 @@ function assertValidSpawnCwd(cwd: unknown): asserts cwd is string | undefined |
102
142
  * goes, not how WIDE. A parent's only limit on concurrent children is that each
103
143
  * spawn costs it a turn, which is unbounded when max turns is unlimited.
104
144
  */
105
- function occupiesPoolSlot(record: Pick<AgentRecord, "isBackground" | "parentAgentId">): boolean {
145
+ function occupiesPoolSlot(
146
+ record: Pick<AgentRecord, "isBackground" | "parentAgentId">,
147
+ ): boolean {
106
148
  return !!record.isBackground && record.parentAgentId === undefined;
107
149
  }
108
150
 
109
-
110
151
  /** Clone only inert JSON-like data and freeze every level to break aliases. */
111
- function cloneFrozenData<T>(value: T, seen: WeakSet<object> = new WeakSet()): T {
112
- if (value === null || value === undefined || typeof value === "string" ||
113
- typeof value === "number" || typeof value === "boolean") return value;
114
- if (typeof value !== "object") throw new Error("Agent invocation metadata must contain only inert data");
115
- if (seen.has(value)) throw new Error("Agent invocation metadata must not be cyclic");
152
+ function cloneFrozenData<T>(
153
+ value: T,
154
+ seen: WeakSet<object> = new WeakSet(),
155
+ ): T {
156
+ if (
157
+ value === null ||
158
+ value === undefined ||
159
+ typeof value === "string" ||
160
+ typeof value === "number" ||
161
+ typeof value === "boolean"
162
+ )
163
+ return value;
164
+ if (typeof value !== "object")
165
+ throw new Error("Agent invocation metadata must contain only inert data");
166
+ if (seen.has(value))
167
+ throw new Error("Agent invocation metadata must not be cyclic");
116
168
  seen.add(value);
117
169
  try {
118
170
  if (Array.isArray(value)) {
@@ -124,10 +176,13 @@ function cloneFrozenData<T>(value: T, seen: WeakSet<object> = new WeakSet()): T
124
176
  }
125
177
  const prototype = Object.getPrototypeOf(value);
126
178
  if (prototype !== Object.prototype && prototype !== null) {
127
- throw new Error("Agent invocation metadata must contain only plain objects");
179
+ throw new Error(
180
+ "Agent invocation metadata must contain only plain objects",
181
+ );
128
182
  }
129
183
  const clone: Record<string, unknown> = {};
130
- for (const [key, item] of Object.entries(value)) clone[key] = cloneFrozenData(item, seen);
184
+ for (const [key, item] of Object.entries(value))
185
+ clone[key] = cloneFrozenData(item, seen);
131
186
  return Object.freeze(clone) as T;
132
187
  } finally {
133
188
  seen.delete(value);
@@ -143,14 +198,24 @@ function cloneFrozenData<T>(value: T, seen: WeakSet<object> = new WeakSet()): T
143
198
  function snapshotAgentRecord(record: AgentRecord): AgentRecordSnapshot {
144
199
  const owner = record.owner ? Object.freeze({ ...record.owner }) : undefined;
145
200
  const lifetimeUsage = Object.freeze({ ...record.lifetimeUsage });
146
- const pendingSteers = record.pendingSteers ? Object.freeze([...record.pendingSteers]) : undefined;
147
- const invocation = record.invocation ? cloneFrozenData(record.invocation) : undefined;
148
- const worktree = record.worktree ? Object.freeze({ ...record.worktree }) : undefined;
201
+ const pendingSteers = record.pendingSteers
202
+ ? Object.freeze([...record.pendingSteers])
203
+ : undefined;
204
+ const invocation = record.invocation
205
+ ? cloneFrozenData(record.invocation)
206
+ : undefined;
207
+ const worktree = record.worktree
208
+ ? Object.freeze({ ...record.worktree })
209
+ : undefined;
149
210
  const worktreeResult = record.worktreeResult
150
211
  ? Object.freeze({
151
212
  ...record.worktreeResult,
152
213
  ...(record.worktreeResult.recoveryCommands
153
- ? { recoveryCommands: Object.freeze([...record.worktreeResult.recoveryCommands]) }
214
+ ? {
215
+ recoveryCommands: Object.freeze([
216
+ ...record.worktreeResult.recoveryCommands,
217
+ ]),
218
+ }
154
219
  : {}),
155
220
  })
156
221
  : undefined;
@@ -182,7 +247,10 @@ function shellQuote(value: string | undefined): string {
182
247
  return `'${String(value).replaceAll("'", "'\\''")}'`;
183
248
  }
184
249
 
185
- function worktreeRecoveryCommands(cwd?: string, path?: string): readonly string[] {
250
+ function worktreeRecoveryCommands(
251
+ cwd?: string,
252
+ path?: string,
253
+ ): readonly string[] {
186
254
  const c = cwd ? shellQuote(cwd) : "'.'";
187
255
  const p = path ? shellQuote(path) : "''";
188
256
  return Object.freeze([
@@ -192,7 +260,6 @@ function worktreeRecoveryCommands(cwd?: string, path?: string): readonly string[
192
260
  ]);
193
261
  }
194
262
 
195
-
196
263
  function sameFilesystemPath(left: string, right: string): boolean {
197
264
  if (left === right) return true;
198
265
  try {
@@ -202,10 +269,15 @@ function sameFilesystemPath(left: string, right: string): boolean {
202
269
  }
203
270
  }
204
271
 
205
- function cleanupFailureResult(cwd: string, worktree: WorktreeInfo, error: unknown): WorktreeCleanupResult {
206
- const reason = `Worktree cleanup threw before reporting an outcome for ${worktree.path}: ${error instanceof Error ? error.message : String(error)}`
207
- .replace(/\s+/g, " ")
208
- .slice(0, WORKTREE_FAILURE_DIAGNOSTIC_LIMIT);
272
+ function cleanupFailureResult(
273
+ cwd: string,
274
+ worktree: WorktreeInfo,
275
+ error: unknown,
276
+ ): WorktreeCleanupResult {
277
+ const reason =
278
+ `Worktree cleanup threw before reporting an outcome for ${worktree.path}: ${error instanceof Error ? error.message : String(error)}`
279
+ .replace(/\s+/g, " ")
280
+ .slice(0, WORKTREE_FAILURE_DIAGNOSTIC_LIMIT);
209
281
  return {
210
282
  hasChanges: false,
211
283
  path: worktree.path,
@@ -215,8 +287,13 @@ function cleanupFailureResult(cwd: string, worktree: WorktreeInfo, error: unknow
215
287
  };
216
288
  }
217
289
 
218
- function snapshotCleanupFailure(worktree: WorktreeInfo, result: WorktreeCleanupResult): WorktreeCleanupFailure {
219
- const reason = (result.cleanupDiagnostic ?? `Worktree cleanup failed for ${worktree.path}`)
290
+ function snapshotCleanupFailure(
291
+ worktree: WorktreeInfo,
292
+ result: WorktreeCleanupResult,
293
+ ): WorktreeCleanupFailure {
294
+ const reason = (
295
+ result.cleanupDiagnostic ?? `Worktree cleanup failed for ${worktree.path}`
296
+ )
220
297
  .replace(/\s+/g, " ")
221
298
  .slice(0, WORKTREE_FAILURE_DIAGNOSTIC_LIMIT);
222
299
  const recovery = result.recoveryCommands?.length
@@ -258,7 +335,8 @@ const MANAGED_PERSIST_RETRY_INITIAL_DELAY_MS = 25;
258
335
  const MANAGED_PERSIST_RETRY_MAX_DELAY_MS = 2_000;
259
336
  const MANAGED_PERSIST_RETRY_MAX_ATTEMPTS = 8;
260
337
 
261
- export type ManagedSpawnState = "queued" | "running" | "completed" | "failed" | "stopped" | "interrupted";
338
+ export type ManagedSpawnState =
339
+ "queued" | "running" | "completed" | "failed" | "stopped" | "interrupted";
262
340
 
263
341
  export interface ManagedTerminalSnapshot {
264
342
  status: "completed" | "failed" | "stopped" | "interrupted";
@@ -325,35 +403,56 @@ type ManagedRecordStatus = AgentRecord["status"];
325
403
 
326
404
  type ManagedRequestRecord = Record<string, unknown>;
327
405
 
328
- const MANAGED_OWNER_KEYS = new Set(["extension", "runId", "nodeId", "attemptId"]);
406
+ const MANAGED_OWNER_KEYS = new Set([
407
+ "extension",
408
+ "runId",
409
+ "nodeId",
410
+ "attemptId",
411
+ ]);
329
412
 
330
413
  function isRecord(value: unknown): value is ManagedRequestRecord {
331
414
  return typeof value === "object" && value !== null && !Array.isArray(value);
332
415
  }
333
416
 
334
- function boundedManagedString(value: unknown, label: string, max: number): string {
417
+ function boundedManagedString(
418
+ value: unknown,
419
+ label: string,
420
+ max: number,
421
+ ): string {
335
422
  if (typeof value !== "string") throw new Error(`${label} must be a string`);
336
423
  const normalized = value.trim();
337
424
  if (!normalized) throw new Error(`${label} must not be empty`);
338
- if (normalized.length > max) throw new Error(`${label} exceeds ${max} characters`);
425
+ if (normalized.length > max)
426
+ throw new Error(`${label} exceeds ${max} characters`);
339
427
  return normalized;
340
428
  }
341
429
 
342
- function rejectManagedKeys(value: ManagedRequestRecord, allowed: ReadonlySet<string>, label: string): void {
430
+ function rejectManagedKeys(
431
+ value: ManagedRequestRecord,
432
+ allowed: ReadonlySet<string>,
433
+ label: string,
434
+ ): void {
343
435
  for (const key of Object.keys(value)) {
344
- if (!allowed.has(key)) throw new Error(`${label} contains unsupported field "${key}"`);
436
+ if (!allowed.has(key))
437
+ throw new Error(`${label} contains unsupported field "${key}"`);
345
438
  }
346
439
  }
347
440
 
348
- function normalizeManagedOwner(raw: unknown, requireAttempt = false): AgentOwner {
441
+ function normalizeManagedOwner(
442
+ raw: unknown,
443
+ requireAttempt = false,
444
+ ): AgentOwner {
349
445
  if (!isRecord(raw)) throw new Error("owner must be an object");
350
446
  rejectManagedKeys(raw, MANAGED_OWNER_KEYS, "owner");
351
447
  const extension = boundedManagedString(raw.extension, "owner.extension", 64);
352
- if (extension !== "pi-workflows") throw new Error('owner.extension must be "pi-workflows"');
353
- const attemptId = raw.attemptId === undefined
354
- ? undefined
355
- : boundedManagedString(raw.attemptId, "owner.attemptId", 256);
356
- if (requireAttempt && attemptId === undefined) throw new Error("owner.attemptId is required");
448
+ if (extension !== "pi-workflows")
449
+ throw new Error('owner.extension must be "pi-workflows"');
450
+ const attemptId =
451
+ raw.attemptId === undefined
452
+ ? undefined
453
+ : boundedManagedString(raw.attemptId, "owner.attemptId", 256);
454
+ if (requireAttempt && attemptId === undefined)
455
+ throw new Error("owner.attemptId is required");
357
456
  return {
358
457
  extension,
359
458
  runId: boundedManagedString(raw.runId, "owner.runId", 256),
@@ -362,30 +461,41 @@ function normalizeManagedOwner(raw: unknown, requireAttempt = false): AgentOwner
362
461
  };
363
462
  }
364
463
 
365
- function managedFingerprint(request: ManagedSpawnRequest, policyFingerprint?: string): string {
464
+ function managedFingerprint(
465
+ request: ManagedSpawnRequest,
466
+ policyFingerprint?: string,
467
+ ): string {
366
468
  return createHash("sha256")
367
- .update(JSON.stringify([
368
- request.type,
369
- request.prompt,
370
- request.description,
371
- request.tier,
372
- request.toolset,
373
- request.excludeTools,
374
- request.isolation,
375
- request.thread,
376
- request.owner.extension,
377
- request.owner.runId,
378
- request.owner.nodeId,
379
- request.owner.attemptId,
380
- policyFingerprint,
381
- ]))
469
+ .update(
470
+ JSON.stringify([
471
+ request.type,
472
+ request.prompt,
473
+ request.description,
474
+ request.tier,
475
+ request.toolset,
476
+ request.excludeTools,
477
+ request.isolation,
478
+ request.thread,
479
+ request.owner.extension,
480
+ request.owner.runId,
481
+ request.owner.nodeId,
482
+ request.owner.attemptId,
483
+ policyFingerprint,
484
+ ]),
485
+ )
382
486
  .digest("hex");
383
487
  }
384
488
 
385
-
386
- function currentParentThinkingLevel(pi: ExtensionAPI): ThinkingLevel | undefined {
489
+ function currentParentThinkingLevel(
490
+ pi: ExtensionAPI,
491
+ ): ThinkingLevel | undefined {
387
492
  const level = pi.getThinkingLevel?.();
388
- return level === "minimal" || level === "low" || level === "medium" || level === "high" || level === "xhigh" || level === "max"
493
+ return level === "minimal" ||
494
+ level === "low" ||
495
+ level === "medium" ||
496
+ level === "high" ||
497
+ level === "xhigh" ||
498
+ level === "max"
389
499
  ? level
390
500
  : undefined;
391
501
  }
@@ -407,27 +517,39 @@ function managedThreadPolicyFingerprint(
407
517
  parentThinking: ThinkingLevel | undefined,
408
518
  ): string {
409
519
  const effectiveTier = request.tier ?? policy.invocation?.agentTier;
410
- const profile = effectiveTier ? getAgentTiersSettings().profiles?.[effectiveTier] : undefined;
520
+ const profile = effectiveTier
521
+ ? getAgentTiersSettings().profiles?.[effectiveTier]
522
+ : undefined;
411
523
  const inheritedModelIdentity =
412
- profile?.model === "inherit" ? { provider: parentModel?.provider, id: parentModel?.id } : undefined;
413
- const inheritedThinkingIdentity = profile?.thinking === "inherit" ? parentThinking : undefined;
414
- const excludeTools = [...new Set([...(request.excludeTools ?? []), ...(policy.excludeTools ?? [])])].sort();
524
+ profile?.model === "inherit"
525
+ ? { provider: parentModel?.provider, id: parentModel?.id }
526
+ : undefined;
527
+ const inheritedThinkingIdentity =
528
+ profile?.thinking === "inherit" ? parentThinking : undefined;
529
+ const excludeTools = [
530
+ ...new Set([
531
+ ...(request.excludeTools ?? []),
532
+ ...(policy.excludeTools ?? []),
533
+ ]),
534
+ ].sort();
415
535
  return createHash("sha256")
416
- .update(JSON.stringify([
417
- request.type,
418
- effectiveTier,
419
- profile?.model,
420
- profile?.thinking,
421
- inheritedModelIdentity,
422
- inheritedThinkingIdentity,
423
- request.toolset ?? policy.toolset,
424
- excludeTools,
425
- request.isolation ?? policy.isolation,
426
- policy.maxTurns,
427
- policy.isolated,
428
- policy.inheritContext,
429
- policy.policyFingerprint,
430
- ]))
536
+ .update(
537
+ JSON.stringify([
538
+ request.type,
539
+ effectiveTier,
540
+ profile?.model,
541
+ profile?.thinking,
542
+ inheritedModelIdentity,
543
+ inheritedThinkingIdentity,
544
+ request.toolset ?? policy.toolset,
545
+ excludeTools,
546
+ request.isolation ?? policy.isolation,
547
+ policy.maxTurns,
548
+ policy.isolated,
549
+ policy.inheritContext,
550
+ policy.policyFingerprint,
551
+ ]),
552
+ )
431
553
  .digest("hex");
432
554
  }
433
555
 
@@ -439,7 +561,10 @@ function managedThreadPolicyFingerprint(
439
561
  * resolves to and invalidates the session it was reusing. A message that only
440
562
  * says "policy changed" sends the reader looking in the wrong file.
441
563
  */
442
- function threadPolicyConflictMessage(thread: string, tier: string | undefined): string {
564
+ function threadPolicyConflictMessage(
565
+ thread: string,
566
+ tier: string | undefined,
567
+ ): string {
443
568
  const cause = tier
444
569
  ? `Its tier "${tier}", the session model or thinking level that tier inherits, or its tool/isolation policy changed since the thread's last call.`
445
570
  : "Its model, thinking, tool, or isolation policy changed since the thread's last call.";
@@ -450,34 +575,64 @@ function threadPolicyConflictMessage(thread: string, tier: string | undefined):
450
575
  }
451
576
 
452
577
  function isManagedState(value: unknown): value is ManagedSpawnState {
453
- return value === "queued" || value === "running" || value === "completed" || value === "failed" || value === "stopped" || value === "interrupted";
578
+ return (
579
+ value === "queued" ||
580
+ value === "running" ||
581
+ value === "completed" ||
582
+ value === "failed" ||
583
+ value === "stopped" ||
584
+ value === "interrupted"
585
+ );
454
586
  }
455
587
 
456
- function isManagedTerminalState(value: ManagedSpawnState): value is "completed" | "failed" | "stopped" | "interrupted" {
457
- return value === "completed" || value === "failed" || value === "stopped" || value === "interrupted";
588
+ function isManagedTerminalState(
589
+ value: ManagedSpawnState,
590
+ ): value is "completed" | "failed" | "stopped" | "interrupted" {
591
+ return (
592
+ value === "completed" ||
593
+ value === "failed" ||
594
+ value === "stopped" ||
595
+ value === "interrupted"
596
+ );
458
597
  }
459
598
 
460
- function capManagedText(value: string | undefined, limit: number): string | undefined {
599
+ function capManagedText(
600
+ value: string | undefined,
601
+ limit: number,
602
+ ): string | undefined {
461
603
  if (!value) return undefined;
462
604
  if (value.length <= limit) return value;
463
605
  const marker = "\n…[truncated]";
464
606
  return `${value.slice(0, Math.max(0, limit - marker.length))}${marker}`;
465
607
  }
466
608
 
467
- function cloneManagedTerminal(terminal: ManagedTerminalSnapshot | undefined): ManagedTerminalSnapshot | undefined {
609
+ function cloneManagedTerminal(
610
+ terminal: ManagedTerminalSnapshot | undefined,
611
+ ): ManagedTerminalSnapshot | undefined {
468
612
  return terminal ? { ...terminal } : undefined;
469
613
  }
470
614
 
471
- function cloneManagedTombstone(tombstone: ManagedSpawnTombstone): ManagedSpawnTombstone {
615
+ function cloneManagedTombstone(
616
+ tombstone: ManagedSpawnTombstone,
617
+ ): ManagedSpawnTombstone {
472
618
  return {
473
619
  ...tombstone,
474
620
  owner: { ...tombstone.owner },
475
- ...(tombstone.tierSnapshot ? { tierSnapshot: { ...tombstone.tierSnapshot } } : {}),
621
+ ...(tombstone.tierSnapshot
622
+ ? { tierSnapshot: { ...tombstone.tierSnapshot } }
623
+ : {}),
476
624
  terminal: cloneManagedTerminal(tombstone.terminal),
477
625
  };
478
626
  }
479
627
 
480
- const THINKING_LEVELS = new Set<ThinkingLevel>(["minimal", "low", "medium", "high", "xhigh", "max"]);
628
+ const THINKING_LEVELS = new Set<ThinkingLevel>([
629
+ "minimal",
630
+ "low",
631
+ "medium",
632
+ "high",
633
+ "xhigh",
634
+ "max",
635
+ ]);
481
636
  const TIER_SNAPSHOT_KEYS = new Set([
482
637
  "tier",
483
638
  "source",
@@ -490,29 +645,57 @@ const TIER_SNAPSHOT_KEYS = new Set([
490
645
  "diagnostic",
491
646
  ]);
492
647
 
493
- function parseTierSnapshot(raw: unknown, tier: string): AgentTierResolutionSnapshot | undefined {
494
- if (!isRecord(raw) || raw.tier !== tier || !isManagedAgentTier(tier)) return undefined;
648
+ function parseTierSnapshot(
649
+ raw: unknown,
650
+ tier: string,
651
+ ): AgentTierResolutionSnapshot | undefined {
652
+ if (!isRecord(raw) || raw.tier !== tier || !isManagedAgentTier(tier))
653
+ return undefined;
495
654
  try {
496
655
  rejectManagedKeys(raw, TIER_SNAPSHOT_KEYS, "tierSnapshot");
497
656
  const source = raw.source;
498
- if (source !== "call" && source !== "frontmatter" && source !== "default") return undefined;
657
+ if (source !== "call" && source !== "frontmatter" && source !== "default")
658
+ return undefined;
499
659
  const optionalThinking = (value: unknown): ThinkingLevel | undefined => {
500
660
  if (value === undefined) return undefined;
501
- return typeof value === "string" && THINKING_LEVELS.has(value as ThinkingLevel)
502
- ? value as ThinkingLevel
661
+ return typeof value === "string" &&
662
+ THINKING_LEVELS.has(value as ThinkingLevel)
663
+ ? (value as ThinkingLevel)
503
664
  : undefined;
504
665
  };
505
666
  const thinking = optionalThinking(raw.thinking);
506
667
  const requestedThinking = optionalThinking(raw.requestedThinking);
507
668
  const configuredThinking = raw.configuredThinking;
508
- if (typeof raw.configuredModel !== "string" || !raw.configuredModel.trim() || raw.configuredModel.length > MANAGED_PATH_LIMIT) return undefined;
509
- if (typeof configuredThinking !== "string" || ![...THINKING_LEVELS, "inherit"].includes(configuredThinking)) return undefined;
510
- if (raw.model !== undefined &&
511
- (typeof raw.model !== "string" || !raw.model.trim() || raw.model.length > MANAGED_PATH_LIMIT || !raw.model.includes("/"))) return undefined;
669
+ if (
670
+ typeof raw.configuredModel !== "string" ||
671
+ !raw.configuredModel.trim() ||
672
+ raw.configuredModel.length > MANAGED_PATH_LIMIT
673
+ )
674
+ return undefined;
675
+ if (
676
+ typeof configuredThinking !== "string" ||
677
+ ![...THINKING_LEVELS, "inherit"].includes(configuredThinking)
678
+ )
679
+ return undefined;
680
+ if (
681
+ raw.model !== undefined &&
682
+ (typeof raw.model !== "string" ||
683
+ !raw.model.trim() ||
684
+ raw.model.length > MANAGED_PATH_LIMIT ||
685
+ !raw.model.includes("/"))
686
+ )
687
+ return undefined;
512
688
  if (raw.thinking !== undefined && thinking === undefined) return undefined;
513
- if (raw.requestedThinking !== undefined && requestedThinking === undefined) return undefined;
514
- if (raw.clamped !== undefined && typeof raw.clamped !== "boolean") return undefined;
515
- if (raw.diagnostic !== undefined && (typeof raw.diagnostic !== "string" || raw.diagnostic.length > MANAGED_ERROR_LIMIT)) return undefined;
689
+ if (raw.requestedThinking !== undefined && requestedThinking === undefined)
690
+ return undefined;
691
+ if (raw.clamped !== undefined && typeof raw.clamped !== "boolean")
692
+ return undefined;
693
+ if (
694
+ raw.diagnostic !== undefined &&
695
+ (typeof raw.diagnostic !== "string" ||
696
+ raw.diagnostic.length > MANAGED_ERROR_LIMIT)
697
+ )
698
+ return undefined;
516
699
  return {
517
700
  tier,
518
701
  source,
@@ -522,14 +705,18 @@ function parseTierSnapshot(raw: unknown, tier: string): AgentTierResolutionSnaps
522
705
  configuredThinking: configuredThinking as TierThinking,
523
706
  ...(requestedThinking ? { requestedThinking } : {}),
524
707
  ...(raw.clamped === true ? { clamped: true } : {}),
525
- ...(typeof raw.diagnostic === "string" ? { diagnostic: raw.diagnostic } : {}),
708
+ ...(typeof raw.diagnostic === "string"
709
+ ? { diagnostic: raw.diagnostic }
710
+ : {}),
526
711
  };
527
712
  } catch {
528
713
  return undefined;
529
714
  }
530
715
  }
531
716
 
532
- function terminalStatusForRecord(status: ManagedRecordStatus): ManagedTerminalSnapshot["status"] | undefined {
717
+ function terminalStatusForRecord(
718
+ status: ManagedRecordStatus,
719
+ ): ManagedTerminalSnapshot["status"] | undefined {
533
720
  if (status === "completed" || status === "steered") return "completed";
534
721
  if (status === "error" || status === "aborted") return "failed";
535
722
  if (status === "stopped") return "stopped";
@@ -543,18 +730,30 @@ function managedSpawnKeyForQuarantine(raw: unknown): string | undefined {
543
730
  return key.length > 0 && key.length <= 256 ? key : undefined;
544
731
  }
545
732
 
546
- function parseManagedTombstone(raw: unknown): ManagedSpawnTombstone | undefined {
547
- if (!isRecord(raw) || raw.schemaVersion !== MANAGED_SPAWN_SCHEMA_VERSION) return undefined;
733
+ function parseManagedTombstone(
734
+ raw: unknown,
735
+ ): ManagedSpawnTombstone | undefined {
736
+ if (!isRecord(raw) || raw.schemaVersion !== MANAGED_SPAWN_SCHEMA_VERSION)
737
+ return undefined;
548
738
  try {
549
739
  const state = raw.state;
550
740
  if (!isManagedState(state)) return undefined;
551
741
  const terminalValue = raw.terminal;
552
742
  const persistedCompactionCount = raw.compactionCount;
553
- const thread = raw.thread === undefined ? undefined : boundedManagedString(raw.thread, "thread", 128);
554
- const threadPolicyFingerprint = raw.threadPolicyFingerprint === undefined
555
- ? undefined
556
- : boundedManagedString(raw.threadPolicyFingerprint, "threadPolicyFingerprint", 128);
557
- if ((thread === undefined) !== (threadPolicyFingerprint === undefined)) return undefined;
743
+ const thread =
744
+ raw.thread === undefined
745
+ ? undefined
746
+ : boundedManagedString(raw.thread, "thread", 128);
747
+ const threadPolicyFingerprint =
748
+ raw.threadPolicyFingerprint === undefined
749
+ ? undefined
750
+ : boundedManagedString(
751
+ raw.threadPolicyFingerprint,
752
+ "threadPolicyFingerprint",
753
+ 128,
754
+ );
755
+ if ((thread === undefined) !== (threadPolicyFingerprint === undefined))
756
+ return undefined;
558
757
  const rawTier = raw.tier;
559
758
  if (rawTier !== undefined && !isManagedAgentTier(rawTier)) return undefined;
560
759
  const tier = rawTier as string | undefined;
@@ -567,40 +766,108 @@ function parseManagedTombstone(raw: unknown): ManagedSpawnTombstone | undefined
567
766
  }
568
767
  if (
569
768
  typeof persistedCompactionCount !== "undefined" &&
570
- (typeof persistedCompactionCount !== "number" || !Number.isInteger(persistedCompactionCount) || persistedCompactionCount < 0)
571
- ) return undefined;
769
+ (typeof persistedCompactionCount !== "number" ||
770
+ !Number.isInteger(persistedCompactionCount) ||
771
+ persistedCompactionCount < 0)
772
+ )
773
+ return undefined;
572
774
  let terminal: ManagedTerminalSnapshot | undefined;
573
775
  if (terminalValue !== undefined) {
574
- if (!isRecord(terminalValue) || !isManagedTerminalState(terminalValue.status as ManagedSpawnState)) return undefined;
776
+ if (
777
+ !isRecord(terminalValue) ||
778
+ !isManagedTerminalState(terminalValue.status as ManagedSpawnState)
779
+ )
780
+ return undefined;
575
781
  const completedAt = terminalValue.completedAt;
576
782
  const compactionCount = terminalValue.compactionCount;
577
783
  if (
578
- typeof completedAt !== "number" || !Number.isFinite(completedAt) || completedAt < 0 || completedAt > MANAGED_MAX_TIMESTAMP ||
579
- typeof compactionCount !== "number" || !Number.isInteger(compactionCount) || compactionCount < 0
580
- ) return undefined;
581
- if (typeof terminalValue.tokenCount !== "undefined" &&
582
- (typeof terminalValue.tokenCount !== "number" || !Number.isInteger(terminalValue.tokenCount) || terminalValue.tokenCount < 0)) return undefined;
583
- if (typeof terminalValue.result !== "undefined" && typeof terminalValue.result !== "string") return undefined;
584
- if (typeof terminalValue.error !== "undefined" && typeof terminalValue.error !== "string") return undefined;
585
- if (typeof terminalValue.outputFile !== "undefined" && typeof terminalValue.outputFile !== "string") return undefined;
784
+ typeof completedAt !== "number" ||
785
+ !Number.isFinite(completedAt) ||
786
+ completedAt < 0 ||
787
+ completedAt > MANAGED_MAX_TIMESTAMP ||
788
+ typeof compactionCount !== "number" ||
789
+ !Number.isInteger(compactionCount) ||
790
+ compactionCount < 0
791
+ )
792
+ return undefined;
793
+ if (
794
+ typeof terminalValue.tokenCount !== "undefined" &&
795
+ (typeof terminalValue.tokenCount !== "number" ||
796
+ !Number.isInteger(terminalValue.tokenCount) ||
797
+ terminalValue.tokenCount < 0)
798
+ )
799
+ return undefined;
800
+ if (
801
+ typeof terminalValue.result !== "undefined" &&
802
+ typeof terminalValue.result !== "string"
803
+ )
804
+ return undefined;
805
+ if (
806
+ typeof terminalValue.error !== "undefined" &&
807
+ typeof terminalValue.error !== "string"
808
+ )
809
+ return undefined;
810
+ if (
811
+ typeof terminalValue.outputFile !== "undefined" &&
812
+ typeof terminalValue.outputFile !== "string"
813
+ )
814
+ return undefined;
586
815
  terminal = {
587
816
  status: terminalValue.status as ManagedTerminalSnapshot["status"],
588
- ...(capManagedText(typeof terminalValue.result === "string" ? terminalValue.result : undefined, MANAGED_TEXT_LIMIT) ? { result: capManagedText(terminalValue.result as string, MANAGED_TEXT_LIMIT) } : {}),
589
- ...(capManagedText(typeof terminalValue.error === "string" ? terminalValue.error : undefined, MANAGED_ERROR_LIMIT) ? { error: capManagedText(terminalValue.error as string, MANAGED_ERROR_LIMIT) } : {}),
590
- ...(typeof terminalValue.outputFile === "string" ? { outputFile: terminalValue.outputFile.slice(0, MANAGED_PATH_LIMIT) } : {}),
591
- ...(typeof terminalValue.tokenCount === "number" ? { tokenCount: terminalValue.tokenCount } : {}),
817
+ ...(capManagedText(
818
+ typeof terminalValue.result === "string"
819
+ ? terminalValue.result
820
+ : undefined,
821
+ MANAGED_TEXT_LIMIT,
822
+ )
823
+ ? {
824
+ result: capManagedText(
825
+ terminalValue.result as string,
826
+ MANAGED_TEXT_LIMIT,
827
+ ),
828
+ }
829
+ : {}),
830
+ ...(capManagedText(
831
+ typeof terminalValue.error === "string"
832
+ ? terminalValue.error
833
+ : undefined,
834
+ MANAGED_ERROR_LIMIT,
835
+ )
836
+ ? {
837
+ error: capManagedText(
838
+ terminalValue.error as string,
839
+ MANAGED_ERROR_LIMIT,
840
+ ),
841
+ }
842
+ : {}),
843
+ ...(typeof terminalValue.outputFile === "string"
844
+ ? {
845
+ outputFile: terminalValue.outputFile.slice(0, MANAGED_PATH_LIMIT),
846
+ }
847
+ : {}),
848
+ ...(typeof terminalValue.tokenCount === "number"
849
+ ? { tokenCount: terminalValue.tokenCount }
850
+ : {}),
592
851
  compactionCount,
593
852
  completedAt,
594
853
  };
595
854
  }
596
- if (isManagedTerminalState(state) !== (terminal !== undefined)) return undefined;
855
+ if (isManagedTerminalState(state) !== (terminal !== undefined))
856
+ return undefined;
597
857
  if (terminal && terminal.status !== state) return undefined;
598
858
  const createdAt = raw.createdAt;
599
859
  const updatedAt = raw.updatedAt;
600
860
  if (
601
- typeof createdAt !== "number" || !Number.isFinite(createdAt) || createdAt < 0 || createdAt > MANAGED_MAX_TIMESTAMP ||
602
- typeof updatedAt !== "number" || !Number.isFinite(updatedAt) || updatedAt < 0 || updatedAt > MANAGED_MAX_TIMESTAMP
603
- ) return undefined;
861
+ typeof createdAt !== "number" ||
862
+ !Number.isFinite(createdAt) ||
863
+ createdAt < 0 ||
864
+ createdAt > MANAGED_MAX_TIMESTAMP ||
865
+ typeof updatedAt !== "number" ||
866
+ !Number.isFinite(updatedAt) ||
867
+ updatedAt < 0 ||
868
+ updatedAt > MANAGED_MAX_TIMESTAMP
869
+ )
870
+ return undefined;
604
871
  return {
605
872
  schemaVersion: MANAGED_SPAWN_SCHEMA_VERSION,
606
873
  spawnKey: boundedManagedString(raw.spawnKey, "spawnKey", 256),
@@ -616,7 +883,10 @@ function parseManagedTombstone(raw: unknown): ManagedSpawnTombstone | undefined
616
883
  state,
617
884
  createdAt,
618
885
  updatedAt,
619
- compactionCount: typeof persistedCompactionCount === "number" ? persistedCompactionCount : terminal?.compactionCount ?? 0,
886
+ compactionCount:
887
+ typeof persistedCompactionCount === "number"
888
+ ? persistedCompactionCount
889
+ : (terminal?.compactionCount ?? 0),
620
890
  terminal,
621
891
  };
622
892
  } catch {
@@ -672,13 +942,20 @@ export interface SpawnOptions {
672
942
  /** Called when the agent session is created (for accessing session stats). */
673
943
  onSessionCreated?: (session: AgentSession) => void;
674
944
  /** Called after pi-subagents resolves the tier for this spawn; see RunOptions. */
675
- onAgentTierResolved?: (snapshot: AgentTierResolutionSnapshot, modelLabel?: string) => void;
945
+ onAgentTierResolved?: (
946
+ snapshot: AgentTierResolutionSnapshot,
947
+ modelLabel?: string,
948
+ ) => void;
676
949
  /** Called synchronously after a new record is allocated, before session creation. */
677
950
  onSpawned?: (id: string) => void;
678
951
  /** Called at the end of each agentic turn with the cumulative count. */
679
952
  onTurnEnd?: (turnCount: number) => void;
680
953
  /** Called once per assistant message_end with that message's usage delta. */
681
- onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
954
+ onAssistantUsage?: (usage: {
955
+ input: number;
956
+ output: number;
957
+ cacheWrite: number;
958
+ }) => void;
682
959
  /** Called when the session successfully compacts. */
683
960
  onCompaction?: (info: CompactionInfo) => void;
684
961
  /** Nesting depth: top-level subagent = 1. */
@@ -710,7 +987,14 @@ export interface SpawnOptions {
710
987
  /** Internal managed-spawn policy; model/thinking resolution is finalized by runAgent. */
711
988
  export type ManagedSpawnPolicy = Pick<
712
989
  SpawnOptions,
713
- "maxTurns" | "isolated" | "inheritContext" | "isolation" | "invocation" | "rootSessionId" | "toolset" | "excludeTools"
990
+ | "maxTurns"
991
+ | "isolated"
992
+ | "inheritContext"
993
+ | "isolation"
994
+ | "invocation"
995
+ | "rootSessionId"
996
+ | "toolset"
997
+ | "excludeTools"
714
998
  > & {
715
999
  /** In-process identity of the resolved agent definition/tool allowlist for thread reuse. */
716
1000
  policyFingerprint?: string;
@@ -762,11 +1046,14 @@ export class AgentManager {
762
1046
  /** Reservation closes the synchronous onSpawned -> same-thread re-entry window. */
763
1047
  private managedThreadReservations = new Set<string>();
764
1048
  private managedPersistence?: ManagedSpawnPersistence;
765
- private readonly managedPersistenceRetries = new Map<string, {
766
- tombstone: ManagedSpawnTombstone;
767
- attempt: number;
768
- timer?: ReturnType<typeof setTimeout>;
769
- }>();
1049
+ private readonly managedPersistenceRetries = new Map<
1050
+ string,
1051
+ {
1052
+ tombstone: ManagedSpawnTombstone;
1053
+ attempt: number;
1054
+ timer?: ReturnType<typeof setTimeout>;
1055
+ }
1056
+ >();
770
1057
  private maxConcurrent: number;
771
1058
  /** Base repos worktrees were created from — so dispose() can prune them all,
772
1059
  * not just the parent repo (caller-supplied cwd can target other repos). */
@@ -819,11 +1106,12 @@ export class AgentManager {
819
1106
  /** Idempotent async manager disposal shared by root shutdown and callers. */
820
1107
  private disposePromise?: Promise<readonly WorktreeCleanupFailure[]>;
821
1108
  /** Teardown promise associated with a record that has been quarantined/evicted. */
822
- private readonly recordSessionTeardowns = new Map<string, Promise<void>>;
1109
+ private readonly recordSessionTeardowns = new Map<string, Promise<void>>();
823
1110
  /** Prevent late promise continuations from recreating disposed ownership metadata. */
824
1111
  private disposed = false;
825
1112
  /** Immutable diagnostics retained when shutdown cannot remove a worktree. */
826
- private worktreeCleanupFailures: readonly WorktreeCleanupFailure[] = Object.freeze([]);
1113
+ private worktreeCleanupFailures: readonly WorktreeCleanupFailure[] =
1114
+ Object.freeze([]);
827
1115
 
828
1116
  constructor(
829
1117
  onComplete?: OnAgentComplete,
@@ -875,7 +1163,10 @@ export class AgentManager {
875
1163
  return teardown;
876
1164
  }
877
1165
 
878
- private trackRecordSessionTeardown(id: string, session: AgentSession): Promise<void> {
1166
+ private trackRecordSessionTeardown(
1167
+ id: string,
1168
+ session: AgentSession,
1169
+ ): Promise<void> {
879
1170
  const teardown = this.trackSessionTeardown(session);
880
1171
  this.recordSessionTeardowns.set(id, teardown);
881
1172
  const release = (): void => {
@@ -890,13 +1181,28 @@ export class AgentManager {
890
1181
  return teardown;
891
1182
  }
892
1183
 
893
- private async awaitSessionTeardowns(): Promise<void> {
1184
+ private async awaitSessionTeardowns(timeoutMs: number): Promise<boolean> {
1185
+ const deadline = Date.now() + timeoutMs;
894
1186
  while (this.sessionTeardowns.size > 0) {
895
- await Promise.allSettled([...this.sessionTeardowns]);
1187
+ let timer: ReturnType<typeof setTimeout> | undefined;
1188
+ const completed = await Promise.race([
1189
+ Promise.allSettled([...this.sessionTeardowns]).then(() => true),
1190
+ new Promise<boolean>((resolve) => {
1191
+ timer = setTimeout(
1192
+ () => resolve(false),
1193
+ Math.max(0, deadline - Date.now()),
1194
+ );
1195
+ }),
1196
+ ]);
1197
+ if (timer) clearTimeout(timer);
1198
+ if (!completed) return false;
896
1199
  }
1200
+ return true;
897
1201
  }
898
1202
 
899
- private async awaitSessionTeardown(session: AgentSession | undefined): Promise<void> {
1203
+ private async awaitSessionTeardown(
1204
+ session: AgentSession | undefined,
1205
+ ): Promise<void> {
900
1206
  if (!session) return;
901
1207
  try {
902
1208
  await this.trackSessionTeardown(session);
@@ -926,7 +1232,8 @@ export class AgentManager {
926
1232
  * without walking mutable parent links.
927
1233
  */
928
1234
  private nestedOwnerValidation(parentId: unknown): string | undefined {
929
- if (typeof parentId !== "string" || parentId.length === 0) return "the parent id is missing";
1235
+ if (typeof parentId !== "string" || parentId.length === 0)
1236
+ return "the parent id is missing";
930
1237
 
931
1238
  const parent = this.agents.get(parentId);
932
1239
  if (!parent) return `parent agent "${parentId}" is missing`;
@@ -934,15 +1241,19 @@ export class AgentManager {
934
1241
  const ownerIds = [parent.id, ...(parent.ancestorAgentIds ?? [])];
935
1242
  const seen = new Set<string>();
936
1243
  for (const ownerId of ownerIds) {
937
- if (seen.has(ownerId)) return `the parent chain for "${ownerId}" is cyclic`;
1244
+ if (seen.has(ownerId))
1245
+ return `the parent chain for "${ownerId}" is cyclic`;
938
1246
  seen.add(ownerId);
939
1247
 
940
1248
  const record = this.agents.get(ownerId);
941
1249
  if (!record) return `parent agent "${ownerId}" is missing`;
942
- if (this.removingRecords.has(ownerId)) return `parent agent "${ownerId}" is being removed`;
943
- if (this.deferredRecordRemovals.has(ownerId)) return `parent agent "${ownerId}" is pending removal`;
1250
+ if (this.removingRecords.has(ownerId))
1251
+ return `parent agent "${ownerId}" is being removed`;
1252
+ if (this.deferredRecordRemovals.has(ownerId))
1253
+ return `parent agent "${ownerId}" is pending removal`;
944
1254
  if (record.detached) return `parent agent "${ownerId}" is detached`;
945
- if (this.nestedSpawnSeals.has(ownerId)) return `parent agent "${ownerId}" is sealed`;
1255
+ if (this.nestedSpawnSeals.has(ownerId))
1256
+ return `parent agent "${ownerId}" is sealed`;
946
1257
  if (record.status !== "queued" && record.status !== "running") {
947
1258
  return `parent agent "${ownerId}" is terminal`;
948
1259
  }
@@ -964,10 +1275,14 @@ export class AgentManager {
964
1275
 
965
1276
  /** Validate a nested record's ancestors before a resume can restart it. */
966
1277
  private canResumeNested(record: AgentRecord): boolean {
967
- return !record.detached && !this.removingRecords.has(record.id) &&
1278
+ return (
1279
+ !record.detached &&
1280
+ !this.removingRecords.has(record.id) &&
968
1281
  !this.settlingRecords.has(record.id) &&
969
1282
  !this.deferredRecordRemovals.has(record.id) &&
970
- (record.parentAgentId === undefined || this.nestedOwnerValidation(record.parentAgentId) === undefined);
1283
+ (record.parentAgentId === undefined ||
1284
+ this.nestedOwnerValidation(record.parentAgentId) === undefined)
1285
+ );
971
1286
  }
972
1287
 
973
1288
  private clearManagedPersistenceRetry(key: string): void {
@@ -978,16 +1293,23 @@ export class AgentManager {
978
1293
  }
979
1294
 
980
1295
  private clearManagedPersistenceRetries(): void {
981
- for (const key of this.managedPersistenceRetries.keys()) this.clearManagedPersistenceRetry(key);
1296
+ for (const key of this.managedPersistenceRetries.keys())
1297
+ this.clearManagedPersistenceRetry(key);
982
1298
  }
983
1299
 
984
- private scheduleManagedPersistenceRetry(tombstone: ManagedSpawnTombstone): void {
1300
+ private scheduleManagedPersistenceRetry(
1301
+ tombstone: ManagedSpawnTombstone,
1302
+ ): void {
985
1303
  if (this.disposed) return;
986
1304
  const persistence = this.managedPersistence;
987
1305
  if (!persistence) return;
988
1306
  const key = tombstone.spawnKey;
989
1307
  this.clearManagedPersistenceRetry(key);
990
- const pending = { tombstone, attempt: 0, timer: undefined as ReturnType<typeof setTimeout> | undefined };
1308
+ const pending = {
1309
+ tombstone,
1310
+ attempt: 0,
1311
+ timer: undefined as ReturnType<typeof setTimeout> | undefined,
1312
+ };
991
1313
  this.managedPersistenceRetries.set(key, pending);
992
1314
  const attempt = (): void => {
993
1315
  if (this.managedPersistenceRetries.get(key) !== pending) return;
@@ -1003,12 +1325,15 @@ export class AgentManager {
1003
1325
  pending.attempt += 1;
1004
1326
  if (pending.attempt >= MANAGED_PERSIST_RETRY_MAX_ATTEMPTS) {
1005
1327
  this.managedPersistenceRetries.delete(key);
1006
- console.warn(`[pi-subagents] managed tombstone persistence retry exhausted for ${key}: ${error instanceof Error ? error.message : String(error)}`);
1328
+ console.warn(
1329
+ `[pi-subagents] managed tombstone persistence retry exhausted for ${key}: ${error instanceof Error ? error.message : String(error)}`,
1330
+ );
1007
1331
  return;
1008
1332
  }
1009
1333
  const delay = Math.min(
1010
1334
  MANAGED_PERSIST_RETRY_MAX_DELAY_MS,
1011
- MANAGED_PERSIST_RETRY_INITIAL_DELAY_MS * 2 ** Math.min(pending.attempt - 1, 6),
1335
+ MANAGED_PERSIST_RETRY_INITIAL_DELAY_MS *
1336
+ 2 ** Math.min(pending.attempt - 1, 6),
1012
1337
  );
1013
1338
  pending.timer = setTimeout(attempt, delay);
1014
1339
  pending.timer.unref?.();
@@ -1017,7 +1342,10 @@ export class AgentManager {
1017
1342
  queueMicrotask(attempt);
1018
1343
  }
1019
1344
 
1020
- private persistManaged(tombstone: ManagedSpawnTombstone, required = false): void {
1345
+ private persistManaged(
1346
+ tombstone: ManagedSpawnTombstone,
1347
+ required = false,
1348
+ ): void {
1021
1349
  if (!this.managedPersistence) {
1022
1350
  if (required) throw new Error("managed spawn persistence is unavailable");
1023
1351
  return;
@@ -1044,7 +1372,11 @@ export class AgentManager {
1044
1372
  }
1045
1373
  }
1046
1374
 
1047
- private replaceManagedTombstone(key: string, next: ManagedSpawnTombstone, required = false): void {
1375
+ private replaceManagedTombstone(
1376
+ key: string,
1377
+ next: ManagedSpawnTombstone,
1378
+ required = false,
1379
+ ): void {
1048
1380
  const previous = this.managedSpawns.get(key);
1049
1381
  if (previous && JSON.stringify(previous) === JSON.stringify(next)) return;
1050
1382
  this.clearManagedPersistenceRetry(key);
@@ -1067,36 +1399,56 @@ export class AgentManager {
1067
1399
  const tombstone = this.managedSpawns.get(key);
1068
1400
  if (!tombstone) return;
1069
1401
  const terminalStatus = terminalStatusForRecord(record.status);
1070
- const state: ManagedSpawnState = terminalStatus ?? (record.status === "queued" ? "queued" : "running");
1402
+ const state: ManagedSpawnState =
1403
+ terminalStatus ?? (record.status === "queued" ? "queued" : "running");
1071
1404
  const compactionCount = Math.max(0, Math.floor(record.compactionCount));
1072
1405
  const terminal = terminalStatus
1073
1406
  ? {
1074
1407
  status: terminalStatus,
1075
- ...(capManagedText(record.result, MANAGED_TEXT_LIMIT) ? { result: capManagedText(record.result, MANAGED_TEXT_LIMIT) } : {}),
1076
- ...(capManagedText(record.error, MANAGED_ERROR_LIMIT) ? { error: capManagedText(record.error, MANAGED_ERROR_LIMIT) } : {}),
1077
- ...(record.outputFile ? { outputFile: record.outputFile.slice(0, MANAGED_PATH_LIMIT) } : {}),
1408
+ ...(capManagedText(record.result, MANAGED_TEXT_LIMIT)
1409
+ ? { result: capManagedText(record.result, MANAGED_TEXT_LIMIT) }
1410
+ : {}),
1411
+ ...(capManagedText(record.error, MANAGED_ERROR_LIMIT)
1412
+ ? { error: capManagedText(record.error, MANAGED_ERROR_LIMIT) }
1413
+ : {}),
1414
+ ...(record.outputFile
1415
+ ? { outputFile: record.outputFile.slice(0, MANAGED_PATH_LIMIT) }
1416
+ : {}),
1078
1417
  ...(record.lifetimeUsage.input + record.lifetimeUsage.output > 0
1079
- ? { tokenCount: Math.floor(record.lifetimeUsage.input + record.lifetimeUsage.output) }
1418
+ ? {
1419
+ tokenCount: Math.floor(
1420
+ record.lifetimeUsage.input + record.lifetimeUsage.output,
1421
+ ),
1422
+ }
1080
1423
  : {}),
1081
1424
  compactionCount,
1082
1425
  completedAt: record.completedAt ?? Date.now(),
1083
1426
  }
1084
1427
  : undefined;
1085
- this.replaceManagedTombstone(key, {
1086
- ...tombstone,
1087
- ...(record.invocation?.agentTierSnapshot ? { tierSnapshot: { ...record.invocation.agentTierSnapshot } } : {}),
1088
- ...(record.invocation?.agentTier !== undefined ? { tier: record.invocation.agentTier } : {}),
1089
- state,
1090
- updatedAt: Date.now(),
1091
- compactionCount,
1092
- ...(terminal ? { terminal } : { terminal: undefined }),
1093
- }, required);
1428
+ this.replaceManagedTombstone(
1429
+ key,
1430
+ {
1431
+ ...tombstone,
1432
+ ...(record.invocation?.agentTierSnapshot
1433
+ ? { tierSnapshot: { ...record.invocation.agentTierSnapshot } }
1434
+ : {}),
1435
+ ...(record.invocation?.agentTier !== undefined
1436
+ ? { tier: record.invocation.agentTier }
1437
+ : {}),
1438
+ state,
1439
+ updatedAt: Date.now(),
1440
+ compactionCount,
1441
+ ...(terminal ? { terminal } : { terminal: undefined }),
1442
+ },
1443
+ required,
1444
+ );
1094
1445
  }
1095
1446
 
1096
1447
  private settleMissingManaged(key: string): ManagedSpawnTombstone {
1097
1448
  const tombstone = this.managedSpawns.get(key);
1098
1449
  if (!tombstone) throw new Error(`managed spawn key not found: "${key}"`);
1099
- if (isManagedTerminalState(tombstone.state) && tombstone.terminal) return tombstone;
1450
+ if (isManagedTerminalState(tombstone.state) && tombstone.terminal)
1451
+ return tombstone;
1100
1452
  const now = Date.now();
1101
1453
  const settled: ManagedSpawnTombstone = {
1102
1454
  ...tombstone,
@@ -1104,7 +1456,8 @@ export class AgentManager {
1104
1456
  updatedAt: now,
1105
1457
  terminal: {
1106
1458
  status: "interrupted",
1107
- error: "managed agent interrupted: no live AgentSession after session reload",
1459
+ error:
1460
+ "managed agent interrupted: no live AgentSession after session reload",
1108
1461
  compactionCount: tombstone.compactionCount,
1109
1462
  completedAt: now,
1110
1463
  },
@@ -1130,14 +1483,19 @@ export class AgentManager {
1130
1483
  ...(tombstone.tier === undefined ? {} : { tier: tombstone.tier }),
1131
1484
  state: tombstone.state,
1132
1485
  created,
1133
- ...(tombstone.terminal ? { terminal: cloneManagedTerminal(tombstone.terminal) } : {}),
1486
+ ...(tombstone.terminal
1487
+ ? { terminal: cloneManagedTerminal(tombstone.terminal) }
1488
+ : {}),
1134
1489
  };
1135
1490
  }
1136
1491
 
1137
1492
  private retireLiveManagedKeys(): void {
1138
1493
  for (const [key, tombstone] of this.managedSpawns) {
1139
1494
  const record = this.agents.get(tombstone.id);
1140
- if (!isManagedTerminalState(tombstone.state) || (record !== undefined && !this.isFullyCleaned(record))) {
1495
+ if (
1496
+ !isManagedTerminalState(tombstone.state) ||
1497
+ (record !== undefined && !this.isFullyCleaned(record))
1498
+ ) {
1141
1499
  // Re-inserting moves an existing key to the end of iteration order, so
1142
1500
  // the eviction below always drops the least recently retired key.
1143
1501
  this.managedRetiredKeys.delete(key);
@@ -1166,7 +1524,11 @@ export class AgentManager {
1166
1524
  this.managedThreadPolicies.clear();
1167
1525
  this.managedThreadReservations.clear();
1168
1526
  for (const entry of entries) {
1169
- if (entry.type !== "custom" || entry.customType !== MANAGED_SPAWN_ENTRY_TYPE) continue;
1527
+ if (
1528
+ entry.type !== "custom" ||
1529
+ entry.customType !== MANAGED_SPAWN_ENTRY_TYPE
1530
+ )
1531
+ continue;
1170
1532
  const tombstone = parseManagedTombstone(entry.data);
1171
1533
  if (!tombstone) {
1172
1534
  const key = managedSpawnKeyForQuarantine(entry.data);
@@ -1185,18 +1547,28 @@ export class AgentManager {
1185
1547
  // fact for the same key. Otherwise restore order could resurrect a
1186
1548
  // spawn that the clean-break quarantine is meant to reject.
1187
1549
  this.managedSpawns.delete(key);
1188
- console.warn(`[pi-subagents] quarantined unsupported managed spawn tombstone for ${key}`);
1550
+ console.warn(
1551
+ `[pi-subagents] quarantined unsupported managed spawn tombstone for ${key}`,
1552
+ );
1189
1553
  }
1190
1554
  continue;
1191
1555
  }
1192
- if (this.managedQuarantinedKeys.has(tombstone.spawnKey) || this.managedRetiredKeys.has(tombstone.spawnKey)) continue;
1193
- if (options.dropActive && !isManagedTerminalState(tombstone.state)) continue;
1556
+ if (
1557
+ this.managedQuarantinedKeys.has(tombstone.spawnKey) ||
1558
+ this.managedRetiredKeys.has(tombstone.spawnKey)
1559
+ )
1560
+ continue;
1561
+ if (options.dropActive && !isManagedTerminalState(tombstone.state))
1562
+ continue;
1194
1563
  this.managedSpawns.set(tombstone.spawnKey, tombstone);
1195
1564
  this.managedKeysById.set(tombstone.id, tombstone.spawnKey);
1196
1565
  if (tombstone.thread && tombstone.threadPolicyFingerprint) {
1197
1566
  const threadKey = `${tombstone.owner.runId}\u0000${tombstone.thread}`;
1198
1567
  this.managedThreads.set(threadKey, tombstone.id);
1199
- this.managedThreadPolicies.set(threadKey, tombstone.threadPolicyFingerprint);
1568
+ this.managedThreadPolicies.set(
1569
+ threadKey,
1570
+ tombstone.threadPolicyFingerprint,
1571
+ );
1200
1572
  }
1201
1573
  }
1202
1574
  const recovered: ManagedSpawnTombstone[] = [];
@@ -1218,7 +1590,10 @@ export class AgentManager {
1218
1590
  }
1219
1591
 
1220
1592
  /** Reconcile a spawn whose RPC reply was lost after allocation. */
1221
- reconcileManaged(spawnKey: string, owner: AgentOwner): ManagedSpawnResult | undefined {
1593
+ reconcileManaged(
1594
+ spawnKey: string,
1595
+ owner: AgentOwner,
1596
+ ): ManagedSpawnResult | undefined {
1222
1597
  const key = spawnKey.trim();
1223
1598
  const tombstone = this.managedSpawns.get(key);
1224
1599
  if (!tombstone) return undefined;
@@ -1227,9 +1602,11 @@ export class AgentManager {
1227
1602
  tombstone.owner.runId !== owner.runId ||
1228
1603
  tombstone.owner.nodeId !== owner.nodeId ||
1229
1604
  tombstone.owner.attemptId !== owner.attemptId
1230
- ) return undefined;
1605
+ )
1606
+ return undefined;
1231
1607
  const record = this.agents.get(tombstone.id);
1232
- if (record && (record.status === "queued" || record.status === "running")) this.abortOwned(record.id, owner);
1608
+ if (record && (record.status === "queued" || record.status === "running"))
1609
+ this.abortOwned(record.id, owner);
1233
1610
  return this.managedResult(key);
1234
1611
  }
1235
1612
 
@@ -1262,21 +1639,37 @@ export class AgentManager {
1262
1639
  if (this.disposed) throw new Error("AgentManager is disposed");
1263
1640
 
1264
1641
  if (typeof type !== "string" || !type.trim() || type.length > 256) {
1265
- throw new Error("Agent type must be a non-empty string of at most 256 characters");
1642
+ throw new Error(
1643
+ "Agent type must be a non-empty string of at most 256 characters",
1644
+ );
1266
1645
  }
1267
1646
  if (typeof prompt !== "string" || prompt.length > 100_000) {
1268
- throw new Error("Agent prompt must be a string of at most 100000 characters");
1647
+ throw new Error(
1648
+ "Agent prompt must be a string of at most 100000 characters",
1649
+ );
1269
1650
  }
1270
1651
  if (typeof options !== "object" || options === null) {
1271
1652
  throw new Error("Spawn options must be an object");
1272
1653
  }
1273
- if (options.description !== undefined &&
1274
- (typeof options.description !== "string" || options.description.length > 1_000)) {
1275
- throw new Error("Agent description must be a string of at most 1000 characters");
1654
+ if (
1655
+ options.description !== undefined &&
1656
+ (typeof options.description !== "string" ||
1657
+ options.description.length > 1_000)
1658
+ ) {
1659
+ throw new Error(
1660
+ "Agent description must be a string of at most 1000 characters",
1661
+ );
1276
1662
  }
1277
1663
  // Legacy in-process callers omitted description before it became required.
1278
1664
  // Normalize that shape to an inert string while rejecting reference values.
1279
1665
  options = { ...options, description: options.description ?? type };
1666
+ // Covers top-level, nested, managed, and queued spawns before allocating a
1667
+ // record, consuming a branch budget, or creating an isolation worktree.
1668
+ assertNoRawInheritance(
1669
+ internalOverride?.inheritContext ??
1670
+ options.inheritContext ??
1671
+ getAgentConfig(type)?.inheritContext,
1672
+ );
1280
1673
 
1281
1674
  for (const [label, value] of [
1282
1675
  ["isBackground", options.isBackground],
@@ -1297,22 +1690,36 @@ export class AgentManager {
1297
1690
  throw new Error(`${label} must be a non-negative safe integer`);
1298
1691
  }
1299
1692
  }
1300
- if (options.parentAgentId !== undefined && typeof options.parentAgentId !== "string") {
1693
+ if (
1694
+ options.parentAgentId !== undefined &&
1695
+ typeof options.parentAgentId !== "string"
1696
+ ) {
1301
1697
  throw new Error("parentAgentId must be a string");
1302
1698
  }
1303
- if (options.rootSessionId !== undefined && typeof options.rootSessionId !== "string") {
1699
+ if (
1700
+ options.rootSessionId !== undefined &&
1701
+ typeof options.rootSessionId !== "string"
1702
+ ) {
1304
1703
  throw new Error("rootSessionId must be a string");
1305
1704
  }
1705
+ // Admission must be synchronous and shape-only: a child can select another
1706
+ // model after this point, but no unsupported host may receive an ID, branch
1707
+ // budget, queue slot, lifecycle event, or transcript before the guard fails.
1708
+ assertParentModelRuntimeAvailable(ctx);
1306
1709
  // Validate the owner capability before cwd checks, ID allocation, lifecycle
1307
1710
  // callbacks, queueing, or worktree creation. A missing/terminal/sealed owner
1308
1711
  // must fail closed without leaving any observable allocation behind.
1309
- const nestedParent = options.parentAgentId === undefined
1310
- ? undefined
1311
- : this.assertNestedOwner(options.parentAgentId);
1712
+ const nestedParent =
1713
+ options.parentAgentId === undefined
1714
+ ? undefined
1715
+ : this.assertNestedOwner(options.parentAgentId);
1312
1716
  // Never trust lineage supplied through public SpawnOptions. It is copied only
1313
1717
  // from the validated live immediate parent, before this record exists.
1314
1718
  const ancestorAgentIds = nestedParent
1315
- ? Object.freeze([...(nestedParent.ancestorAgentIds ?? []), nestedParent.id])
1719
+ ? Object.freeze([
1720
+ ...(nestedParent.ancestorAgentIds ?? []),
1721
+ nestedParent.id,
1722
+ ])
1316
1723
  : undefined;
1317
1724
 
1318
1725
  // Cumulative spawn budget for the branch. The depth cap bounds how DEEP
@@ -1362,7 +1769,9 @@ export class AgentManager {
1362
1769
  // only filter excludes only explicit `false`, so undefined agents — which
1363
1770
  // have no inline surface — stay visible instead of vanishing.
1364
1771
  isBackground: options.isBackground,
1365
- invocation: options.invocation ? cloneFrozenData(options.invocation) : undefined,
1772
+ invocation: options.invocation
1773
+ ? cloneFrozenData(options.invocation)
1774
+ : undefined,
1366
1775
  depth: options.depth ?? 1,
1367
1776
  parentAgentId: options.parentAgentId,
1368
1777
  ...(ancestorAgentIds ? { ancestorAgentIds } : {}),
@@ -1373,22 +1782,44 @@ export class AgentManager {
1373
1782
  // Top-level agents get a mention handle derived from their type. The name
1374
1783
  // space covers live records and resumable entries, so a handle is never
1375
1784
  // allocated twice while its conversation is still reachable.
1376
- if (options.parentAgentId === undefined && options.reclaimHandle === undefined) {
1785
+ if (
1786
+ options.parentAgentId === undefined &&
1787
+ options.reclaimHandle === undefined
1788
+ ) {
1377
1789
  record.handle = assignHandle(handleBase(type), this.takenHandles());
1378
1790
  } else if (options.reclaimHandle !== undefined) {
1379
1791
  record.handle = options.reclaimHandle;
1380
1792
  }
1381
1793
  this.agents.set(id, record);
1382
1794
  if (!record.detached) {
1383
- try { this.onCreated?.(record); } catch { /* observer failures cannot orphan a record */ }
1384
- try { options.onSpawned?.(id); } catch { /* observer failures cannot orphan a record */ }
1795
+ try {
1796
+ this.onCreated?.(record);
1797
+ } catch {
1798
+ /* observer failures cannot orphan a record */
1799
+ }
1800
+ try {
1801
+ options.onSpawned?.(id);
1802
+ } catch {
1803
+ /* observer failures cannot orphan a record */
1804
+ }
1385
1805
  }
1386
1806
 
1387
- const args: SpawnArgs = { pi, ctx, type, prompt, options, internalOverride };
1807
+ const args: SpawnArgs = {
1808
+ pi,
1809
+ ctx,
1810
+ type,
1811
+ prompt,
1812
+ options,
1813
+ internalOverride,
1814
+ };
1388
1815
  // Lifecycle observers may synchronously stop or dispose a freshly allocated
1389
1816
  // record. Do not let the normal queue/start path resurrect that decision.
1390
- if (this.disposed || this.agents.get(id) !== record || record.detached ||
1391
- record.status !== (options.isBackground ? "queued" : "running")) {
1817
+ if (
1818
+ this.disposed ||
1819
+ this.agents.get(id) !== record ||
1820
+ record.detached ||
1821
+ record.status !== (options.isBackground ? "queued" : "running")
1822
+ ) {
1392
1823
  return id;
1393
1824
  }
1394
1825
 
@@ -1397,7 +1828,8 @@ export class AgentManager {
1397
1828
  // it only in startAgent loses both cases.
1398
1829
  if (options.signal) {
1399
1830
  const onParentAbort = () => this.abort(id);
1400
- const cleanup = () => options.signal!.removeEventListener("abort", onParentAbort);
1831
+ const cleanup = () =>
1832
+ options.signal!.removeEventListener("abort", onParentAbort);
1401
1833
  this.parentSignalCleanups.set(id, cleanup);
1402
1834
  if (options.signal.aborted) {
1403
1835
  this.clearParentSignal(id);
@@ -1418,7 +1850,11 @@ export class AgentManager {
1418
1850
  }
1419
1851
  }
1420
1852
 
1421
- if (occupiesPoolSlot(record) && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
1853
+ if (
1854
+ occupiesPoolSlot(record) &&
1855
+ !options.bypassQueue &&
1856
+ this.runningBackground >= this.maxConcurrent
1857
+ ) {
1422
1858
  // Queue it — will be started when a running agent completes
1423
1859
  this.queue.push({ kind: "spawn", id, args });
1424
1860
  return id;
@@ -1461,7 +1897,16 @@ export class AgentManager {
1461
1897
  ctx: ExtensionContext,
1462
1898
  request: ManagedSpawnRequest,
1463
1899
  policy: ManagedSpawnPolicy,
1464
- callbacks?: Pick<SpawnOptions, "onToolActivity" | "onTextDelta" | "onSessionCreated" | "onTurnEnd" | "onAssistantUsage" | "onCompaction" | "onSpawned">,
1900
+ callbacks?: Pick<
1901
+ SpawnOptions,
1902
+ | "onToolActivity"
1903
+ | "onTextDelta"
1904
+ | "onSessionCreated"
1905
+ | "onTurnEnd"
1906
+ | "onAssistantUsage"
1907
+ | "onCompaction"
1908
+ | "onSpawned"
1909
+ >,
1465
1910
  ): ManagedSpawnResult {
1466
1911
  if (this.disposed) throw new Error("AgentManager is disposed");
1467
1912
  const normalized = parseManagedSpawnRequest(request);
@@ -1474,12 +1919,22 @@ export class AgentManager {
1474
1919
  // non-null assertion on a field TypeScript cannot re-narrow across the
1475
1920
  // intervening lookups.
1476
1921
  const thread = normalized.thread;
1477
- if (thread && (normalized.isolation === "worktree" || policy.isolation === "worktree")) {
1478
- throw new Error("Managed workflow threads cannot use worktree isolation; use separate calls instead.");
1922
+ if (
1923
+ thread &&
1924
+ (normalized.isolation === "worktree" || policy.isolation === "worktree")
1925
+ ) {
1926
+ throw new Error(
1927
+ "Managed workflow threads cannot use worktree isolation; use separate calls instead.",
1928
+ );
1479
1929
  }
1480
1930
  const scope = normalized.spawnKey;
1481
- if (this.managedQuarantinedKeys.has(scope) || this.managedRetiredKeys.has(scope)) {
1482
- throw new Error(`Managed spawn key is quarantined and cannot be reused: "${scope}"`);
1931
+ if (
1932
+ this.managedQuarantinedKeys.has(scope) ||
1933
+ this.managedRetiredKeys.has(scope)
1934
+ ) {
1935
+ throw new Error(
1936
+ `Managed spawn key is quarantined and cannot be reused: "${scope}"`,
1937
+ );
1483
1938
  }
1484
1939
  const parentThinking = currentParentThinkingLevel(pi);
1485
1940
  const parentPolicySnapshot: ParentPolicySnapshot = Object.freeze({
@@ -1499,23 +1954,71 @@ export class AgentManager {
1499
1954
  if (previous.fingerprint !== fingerprint) {
1500
1955
  throw new Error(`Managed spawn key conflict: "${normalized.spawnKey}"`);
1501
1956
  }
1502
- if (thread && previous.threadPolicyFingerprint !== threadPolicyFingerprint) {
1957
+ if (
1958
+ thread &&
1959
+ previous.threadPolicyFingerprint !== threadPolicyFingerprint
1960
+ ) {
1503
1961
  throw new Error(threadPolicyConflictMessage(thread, identityTier));
1504
1962
  }
1505
1963
  return this.managedResult(scope);
1506
1964
  }
1965
+ // Existing idempotency keys remain readable even if the host subsequently
1966
+ // loses its runtime. A new key (including a resumed thread) must fail before
1967
+ // any tombstone, reservation, or persistent identity is allocated.
1968
+ assertParentModelRuntimeAvailable(ctx);
1969
+ const agentConfig = getAgentConfig(normalized.type);
1970
+ assertNoRawInheritance(
1971
+ policy.inheritContext ?? agentConfig?.inheritContext,
1972
+ );
1973
+ // A managed key is journaled before the runner starts. Resolve its tier now
1974
+ // so an unavailable selected model cannot poison the key or reserve a thread.
1975
+ // Checking only ctx.model would reject a valid explicit alternate tier.
1976
+ const selected = resolveAgentTier({
1977
+ requestedTier: normalized.tier,
1978
+ requireTier: true,
1979
+ agentConfig,
1980
+ parentModel: parentPolicySnapshot.model,
1981
+ parentThinking: parentPolicySnapshot.thinking,
1982
+ modelRegistry: ctx.modelRegistry,
1983
+ });
1984
+ if (!selected.snapshot) {
1985
+ throw new Error(
1986
+ `No agent tier selected for "${normalized.type}". A managed workflow call must name a tier, ` +
1987
+ "the agent must declare one, or agentTiers must offer a default; " +
1988
+ "inheriting the parent session's model is not a policy this call can fall back to.",
1989
+ );
1990
+ }
1991
+ parentModelSessionOptions(ctx, selected.model);
1507
1992
 
1508
- const threadKey = thread ? `${normalized.owner.runId}\u0000${thread}` : undefined;
1993
+ const threadKey = thread
1994
+ ? `${normalized.owner.runId}\u0000${thread}`
1995
+ : undefined;
1509
1996
  if (threadKey && this.managedThreadReservations.has(threadKey)) {
1510
- throw new Error(`Managed workflow thread "${thread}" is already running; calls must be sequential.`);
1997
+ throw new Error(
1998
+ `Managed workflow thread "${thread}" is already running; calls must be sequential.`,
1999
+ );
1511
2000
  }
1512
- const threadedId = threadKey ? this.managedThreads.get(threadKey) : undefined;
2001
+ const threadedId = threadKey
2002
+ ? this.managedThreads.get(threadKey)
2003
+ : undefined;
1513
2004
  if (threadedId && threadKey && thread) {
1514
- if (threadPolicyFingerprint !== undefined && this.managedThreadPolicies.get(threadKey) !== threadPolicyFingerprint) {
2005
+ if (
2006
+ threadPolicyFingerprint !== undefined &&
2007
+ this.managedThreadPolicies.get(threadKey) !== threadPolicyFingerprint
2008
+ ) {
1515
2009
  throw new Error(threadPolicyConflictMessage(thread, identityTier));
1516
2010
  }
1517
2011
  const record = this.agents.get(threadedId);
1518
- if (record?.session && record.status !== "running" && record.status !== "queued" && !record.detached) {
2012
+ if (
2013
+ record?.session &&
2014
+ record.status !== "running" &&
2015
+ record.status !== "queued" &&
2016
+ !record.detached
2017
+ ) {
2018
+ assertSafeChildSession(
2019
+ record.session,
2020
+ record.invocation?.inheritContext === true,
2021
+ );
1519
2022
  const previousOwner = record.owner;
1520
2023
  const previousInvocation = record.invocation;
1521
2024
  const previousManagedKey = this.managedKeysById.get(threadedId);
@@ -1524,7 +2027,9 @@ export class AgentManager {
1524
2027
  record.invocation = {
1525
2028
  ...(policy.invocation ?? {}),
1526
2029
  ...(resumedTier === undefined ? {} : { agentTier: resumedTier }),
1527
- ...(previousInvocation?.agentTierSnapshot ? { agentTierSnapshot: previousInvocation.agentTierSnapshot } : {}),
2030
+ ...(previousInvocation?.agentTierSnapshot
2031
+ ? { agentTierSnapshot: previousInvocation.agentTierSnapshot }
2032
+ : {}),
1528
2033
  };
1529
2034
  const now = Date.now();
1530
2035
  const tombstone: ManagedSpawnTombstone = {
@@ -1542,7 +2047,9 @@ export class AgentManager {
1542
2047
  createdAt: now,
1543
2048
  updatedAt: now,
1544
2049
  compactionCount: record.compactionCount,
1545
- ...(record.invocation?.agentTierSnapshot ? { tierSnapshot: { ...record.invocation.agentTierSnapshot } } : {}),
2050
+ ...(record.invocation?.agentTierSnapshot
2051
+ ? { tierSnapshot: { ...record.invocation.agentTierSnapshot } }
2052
+ : {}),
1546
2053
  };
1547
2054
  this.clearManagedPersistenceRetry(scope);
1548
2055
  this.managedSpawns.set(scope, tombstone);
@@ -1551,7 +2058,8 @@ export class AgentManager {
1551
2058
  this.persistManaged(tombstone, true);
1552
2059
  } catch (error: unknown) {
1553
2060
  this.managedSpawns.delete(scope);
1554
- if (previousManagedKey === undefined) this.managedKeysById.delete(threadedId);
2061
+ if (previousManagedKey === undefined)
2062
+ this.managedKeysById.delete(threadedId);
1555
2063
  else this.managedKeysById.set(threadedId, previousManagedKey);
1556
2064
  record.owner = previousOwner;
1557
2065
  record.invocation = previousInvocation;
@@ -1561,7 +2069,9 @@ export class AgentManager {
1561
2069
  isBackground: true,
1562
2070
  onToolActivity: callbacks?.onToolActivity,
1563
2071
  onAssistantUsage: callbacks?.onAssistantUsage,
1564
- onCompaction: callbacks?.onCompaction ? (info) => callbacks.onCompaction?.(info as CompactionInfo) : undefined,
2072
+ onCompaction: callbacks?.onCompaction
2073
+ ? (info) => callbacks.onCompaction?.(info as CompactionInfo)
2074
+ : undefined,
1565
2075
  }).catch(() => {});
1566
2076
  this.syncManagedRecord(record, true);
1567
2077
  const resumedState = String(record.status);
@@ -1572,8 +2082,14 @@ export class AgentManager {
1572
2082
  created: true,
1573
2083
  };
1574
2084
  }
1575
- if (record && !record.detached && (record.status === "running" || record.status === "queued")) {
1576
- throw new Error(`Managed workflow thread "${thread}" is already running; calls must be sequential.`);
2085
+ if (
2086
+ record &&
2087
+ !record.detached &&
2088
+ (record.status === "running" || record.status === "queued")
2089
+ ) {
2090
+ throw new Error(
2091
+ `Managed workflow thread "${thread}" is already running; calls must be sequential.`,
2092
+ );
1577
2093
  }
1578
2094
  if (!record || record.detached || !record.session) {
1579
2095
  this.managedThreads.delete(threadKey!);
@@ -1625,18 +2141,28 @@ export class AgentManager {
1625
2141
  }
1626
2142
 
1627
2143
  try {
1628
- this.spawnInternal(pi, ctx, normalized.type, normalized.prompt, {
1629
- ...policy,
1630
- [INTERNAL_PARENT_POLICY_SNAPSHOT]: parentPolicySnapshot,
1631
- // A managed workflow must resolve a tier or fail closed; it may not
1632
- // silently inherit the parent session's model when nobody named one.
1633
- requireAgentTier: true,
1634
- ...(normalized.tier === undefined ? {} : { agentTier: normalized.tier }),
1635
- ...(thread === undefined ? {} : { thread }),
1636
- description: normalized.description,
1637
- isBackground: true,
1638
- ...callbacks,
1639
- }, normalized.owner, id);
2144
+ this.spawnInternal(
2145
+ pi,
2146
+ ctx,
2147
+ normalized.type,
2148
+ normalized.prompt,
2149
+ {
2150
+ ...policy,
2151
+ [INTERNAL_PARENT_POLICY_SNAPSHOT]: parentPolicySnapshot,
2152
+ // A managed workflow must resolve a tier or fail closed; it may not
2153
+ // silently inherit the parent session's model when nobody named one.
2154
+ requireAgentTier: true,
2155
+ ...(normalized.tier === undefined
2156
+ ? {}
2157
+ : { agentTier: normalized.tier }),
2158
+ ...(thread === undefined ? {} : { thread }),
2159
+ description: normalized.description,
2160
+ isBackground: true,
2161
+ ...callbacks,
2162
+ },
2163
+ normalized.owner,
2164
+ id,
2165
+ );
1640
2166
  } catch (error: unknown) {
1641
2167
  if (threadKey) {
1642
2168
  this.managedThreads.delete(threadKey);
@@ -1644,7 +2170,8 @@ export class AgentManager {
1644
2170
  this.managedThreadReservations.delete(threadKey);
1645
2171
  }
1646
2172
  const existing = this.managedSpawns.get(scope);
1647
- if (existing?.state === "failed" && existing.terminal) return this.managedResult(scope, true);
2173
+ if (existing?.state === "failed" && existing.terminal)
2174
+ return this.managedResult(scope, true);
1648
2175
  const completedAt = Date.now();
1649
2176
  this.replaceManagedTombstone(scope, {
1650
2177
  ...tombstone,
@@ -1652,7 +2179,10 @@ export class AgentManager {
1652
2179
  updatedAt: completedAt,
1653
2180
  terminal: {
1654
2181
  status: "failed",
1655
- error: capManagedText(error instanceof Error ? error.message : String(error), MANAGED_ERROR_LIMIT),
2182
+ error: capManagedText(
2183
+ error instanceof Error ? error.message : String(error),
2184
+ MANAGED_ERROR_LIMIT,
2185
+ ),
1656
2186
  compactionCount: 0,
1657
2187
  completedAt,
1658
2188
  },
@@ -1682,8 +2212,13 @@ export class AgentManager {
1682
2212
  }
1683
2213
 
1684
2214
  /** Actually start an agent (called immediately or from queue drain). */
1685
- private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options, internalOverride }: SpawnArgs) {
1686
- if (this.disposed || record.detached || this.agents.get(id) !== record) return;
2215
+ private startAgent(
2216
+ id: string,
2217
+ record: AgentRecord,
2218
+ { pi, ctx, type, prompt, options, internalOverride }: SpawnArgs,
2219
+ ) {
2220
+ if (this.disposed || record.detached || this.agents.get(id) !== record)
2221
+ return;
1687
2222
  // Re-validate a caller-supplied cwd: queued spawns can start minutes after
1688
2223
  // spawn()'s check, and the directory may be gone by then (TOCTOU). Same
1689
2224
  // curated errors; drainQueue parks a throw on the record as an error.
@@ -1703,13 +2238,15 @@ export class AgentManager {
1703
2238
  // Explicit opt-out — no worktree, no check.
1704
2239
  } else if (options.isolation === "worktree") {
1705
2240
  if (!isWorktreeIsolationEnabled()) {
1706
- throw new Error('Cannot run with isolation: "worktree" — worktree isolation is disabled in project settings. Enable it or omit `isolation`.');
2241
+ throw new Error(
2242
+ 'Cannot run with isolation: "worktree" — worktree isolation is disabled in project settings. Enable it or omit `isolation`.',
2243
+ );
1707
2244
  }
1708
2245
  const wt = createWorktree(baseCwd, id);
1709
2246
  if (!wt) {
1710
2247
  throw new Error(
1711
2248
  'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
1712
- 'Initialize git and commit at least once, or omit `isolation`.',
2249
+ "Initialize git and commit at least once, or omit `isolation`.",
1713
2250
  );
1714
2251
  }
1715
2252
  record.worktree = wt;
@@ -1725,16 +2262,29 @@ export class AgentManager {
1725
2262
  this.runningBackground++;
1726
2263
  }
1727
2264
  if (!record.detached) {
1728
- try { this.onStart?.(record); } catch { /* lifecycle observers are best effort */ }
2265
+ try {
2266
+ this.onStart?.(record);
2267
+ } catch {
2268
+ /* lifecycle observers are best effort */
2269
+ }
1729
2270
  }
1730
2271
  // `onStart` can synchronously stop the record (for example, an owner-scoped
1731
2272
  // stop arriving from a lifecycle observer). Do not invoke the runner after
1732
2273
  // that decision, and release the slot acquired above.
1733
- if (this.disposed || record.detached || this.agents.get(id) !== record || record.status !== "running") {
2274
+ if (
2275
+ this.disposed ||
2276
+ record.detached ||
2277
+ this.agents.get(id) !== record ||
2278
+ record.status !== "running"
2279
+ ) {
1734
2280
  this.clearParentSignal(id);
1735
2281
  this.releasePoolSlot(id);
1736
2282
  if (record.worktree) {
1737
- this.cleanupRecordWorktree(record, worktreeRepoRoot ?? baseCwd, options.description);
2283
+ this.cleanupRecordWorktree(
2284
+ record,
2285
+ worktreeRepoRoot ?? baseCwd,
2286
+ options.description,
2287
+ );
1738
2288
  }
1739
2289
  return;
1740
2290
  }
@@ -1750,101 +2300,108 @@ export class AgentManager {
1750
2300
  let rawPromise: ReturnType<typeof runAgent>;
1751
2301
  try {
1752
2302
  rawPromise = runAgent(ctx, type, prompt, {
1753
- pi,
1754
- agentId: id,
1755
- model: options.model,
1756
- maxTurns: options.maxTurns,
1757
- isolated: internalOverride?.isolated ?? options.isolated,
1758
- inheritContext: internalOverride?.inheritContext ?? options.inheritContext,
1759
- ...(internalOverride ? { [INTERNAL_AGENT_CONFIG_OVERRIDE]: internalOverride } : {}),
1760
- [INTERNAL_PARENT_POLICY_SNAPSHOT]: options[INTERNAL_PARENT_POLICY_SNAPSHOT],
1761
- thinkingLevel: options.thinkingLevel,
1762
- agentTier: options.agentTier,
1763
- requireAgentTier: options.requireAgentTier,
1764
- // Worktree wins for the working dir (the agent must run in the copy —
1765
- // which, with a custom cwd, was created from that target). Config stays
1766
- // with the parent project when a caller-supplied cwd is in play; it must
1767
- // stay undefined otherwise so plain worktree runs keep resolving config
1768
- // (incl. relative extension paths and memory) inside the worktree copy.
1769
- cwd: worktreeCwd ?? customCwd,
1770
- // Preserve the original repository top-level separately from the
1771
- // worktree cwd so the child prompt can mark the whole base checkout
1772
- // off-limits, even when the invocation started in a subdirectory.
1773
- worktreeBase: worktreeRepoRoot,
1774
- configCwd: options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
1775
- // Top-level conversations persist by default so `@handle` has something
1776
- // to reopen after the record is evicted; frontmatter still overrides.
1777
- rememberAgents: this.rememberAgents,
1778
- supervisorQuestions: this.supervisorQuestions,
1779
- resumeSessionFile: options.resumeSessionFile,
1780
- toolset: options.toolset,
1781
- excludeTools: options.excludeTools,
1782
- thread: options.thread,
1783
- signal: record.abortController!.signal,
1784
- onToolActivity: (activity) => {
1785
- if (record.detached) return;
1786
- if (activity.type === "end") record.toolUses++;
1787
- options.onToolActivity?.(activity);
1788
- },
1789
- onTurnEnd: (turnCount) => {
1790
- if (!record.detached) options.onTurnEnd?.(turnCount);
1791
- },
1792
- onTextDelta: (delta, fullText) => {
1793
- if (!record.detached) options.onTextDelta?.(delta, fullText);
1794
- },
1795
- onAssistantUsage: (usage) => {
1796
- if (record.detached) return;
1797
- addUsage(record.lifetimeUsage, usage);
1798
- options.onAssistantUsage?.(usage);
1799
- },
1800
- onCompaction: (info) => {
1801
- if (record.detached) return;
1802
- record.compactionCount++;
1803
- this.syncManagedRecord(record);
1804
- this.onCompact?.(record, info);
1805
- options.onCompaction?.(info);
1806
- },
1807
- nestedRuntime: {
1808
- manager: this,
1809
- parentAgentId: id,
1810
- depth: record.depth ?? 1,
1811
- maxSubagentDepth: record.maxSubagentDepth,
1812
- },
1813
- onAgentTierResolved: (snapshot, modelLabel) => {
1814
- if (!record.detached) {
1815
- record.invocation = {
1816
- ...(record.invocation ?? {}),
1817
- // The tier owns model and thinking, so it owns their display too —
1818
- // including clearing a label a caller pre-computed before the tier
1819
- // resolved. Without this a tiered spawn shows its tier name and no
1820
- // model at all, even when the profile pins one.
1821
- modelName: modelLabel,
1822
- agentTier: snapshot.tier,
1823
- thinking: snapshot.thinking,
1824
- agentTierSnapshot: { ...snapshot },
1825
- };
1826
- this.syncManagedRecord(record, true);
1827
- options.onAgentTierResolved?.(snapshot, modelLabel);
1828
- }
1829
- },
1830
- onSessionCreated: (session) => {
1831
- if (record.detached) {
1832
- this.trackRecordSessionTeardown(id, session);
1833
- return;
1834
- }
1835
- record.session = session;
1836
- // Capture the persisted session file so an evicted record can be
1837
- // reopened as a resumable entry (@handle reopen).
1838
- record.sessionFile = session.sessionManager?.getSessionFile?.() ?? (session as { sessionFile?: string })?.sessionFile;
1839
- // Flush any steers that arrived before the session was ready
1840
- if (record.pendingSteers?.length) {
1841
- for (const msg of record.pendingSteers) {
1842
- session.steer(msg).catch(() => {});
2303
+ pi,
2304
+ agentId: id,
2305
+ model: options.model,
2306
+ maxTurns: options.maxTurns,
2307
+ isolated: internalOverride?.isolated ?? options.isolated,
2308
+ inheritContext:
2309
+ internalOverride?.inheritContext ?? options.inheritContext,
2310
+ ...(internalOverride
2311
+ ? { [INTERNAL_AGENT_CONFIG_OVERRIDE]: internalOverride }
2312
+ : {}),
2313
+ [INTERNAL_PARENT_POLICY_SNAPSHOT]:
2314
+ options[INTERNAL_PARENT_POLICY_SNAPSHOT],
2315
+ thinkingLevel: options.thinkingLevel,
2316
+ agentTier: options.agentTier,
2317
+ requireAgentTier: options.requireAgentTier,
2318
+ // Worktree wins for the working dir (the agent must run in the copy —
2319
+ // which, with a custom cwd, was created from that target). Config stays
2320
+ // with the parent project when a caller-supplied cwd is in play; it must
2321
+ // stay undefined otherwise so plain worktree runs keep resolving config
2322
+ // (incl. relative extension paths and memory) inside the worktree copy.
2323
+ cwd: worktreeCwd ?? customCwd,
2324
+ // Preserve the original repository top-level separately from the
2325
+ // worktree cwd so the child prompt can mark the whole base checkout
2326
+ // off-limits, even when the invocation started in a subdirectory.
2327
+ worktreeBase: worktreeRepoRoot,
2328
+ configCwd:
2329
+ options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
2330
+ // Top-level conversations persist by default so `@handle` has something
2331
+ // to reopen after the record is evicted; frontmatter still overrides.
2332
+ rememberAgents: this.rememberAgents,
2333
+ supervisorQuestions: this.supervisorQuestions,
2334
+ resumeSessionFile: options.resumeSessionFile,
2335
+ toolset: options.toolset,
2336
+ excludeTools: options.excludeTools,
2337
+ thread: options.thread,
2338
+ signal: record.abortController!.signal,
2339
+ onToolActivity: (activity) => {
2340
+ if (record.detached) return;
2341
+ if (activity.type === "end") record.toolUses++;
2342
+ options.onToolActivity?.(activity);
2343
+ },
2344
+ onTurnEnd: (turnCount) => {
2345
+ if (!record.detached) options.onTurnEnd?.(turnCount);
2346
+ },
2347
+ onTextDelta: (delta, fullText) => {
2348
+ if (!record.detached) options.onTextDelta?.(delta, fullText);
2349
+ },
2350
+ onAssistantUsage: (usage) => {
2351
+ if (record.detached) return;
2352
+ addUsage(record.lifetimeUsage, usage);
2353
+ options.onAssistantUsage?.(usage);
2354
+ },
2355
+ onCompaction: (info) => {
2356
+ if (record.detached) return;
2357
+ record.compactionCount++;
2358
+ this.syncManagedRecord(record);
2359
+ this.onCompact?.(record, info);
2360
+ options.onCompaction?.(info);
2361
+ },
2362
+ nestedRuntime: {
2363
+ manager: this,
2364
+ parentAgentId: id,
2365
+ depth: record.depth ?? 1,
2366
+ maxSubagentDepth: record.maxSubagentDepth,
2367
+ },
2368
+ onAgentTierResolved: (snapshot, modelLabel) => {
2369
+ if (!record.detached) {
2370
+ record.invocation = {
2371
+ ...(record.invocation ?? {}),
2372
+ // The tier owns model and thinking, so it owns their display too —
2373
+ // including clearing a label a caller pre-computed before the tier
2374
+ // resolved. Without this a tiered spawn shows its tier name and no
2375
+ // model at all, even when the profile pins one.
2376
+ modelName: modelLabel,
2377
+ agentTier: snapshot.tier,
2378
+ thinking: snapshot.thinking,
2379
+ agentTierSnapshot: { ...snapshot },
2380
+ };
2381
+ this.syncManagedRecord(record, true);
2382
+ options.onAgentTierResolved?.(snapshot, modelLabel);
1843
2383
  }
1844
- record.pendingSteers = undefined;
1845
- }
1846
- options.onSessionCreated?.(session);
1847
- },
2384
+ },
2385
+ onSessionCreated: (session) => {
2386
+ if (record.detached) {
2387
+ this.trackRecordSessionTeardown(id, session);
2388
+ return;
2389
+ }
2390
+ record.session = session;
2391
+ // Capture the persisted session file so an evicted record can be
2392
+ // reopened as a resumable entry (@handle reopen).
2393
+ record.sessionFile =
2394
+ session.sessionManager?.getSessionFile?.() ??
2395
+ (session as { sessionFile?: string })?.sessionFile;
2396
+ // Flush any steers that arrived before the session was ready
2397
+ if (record.pendingSteers?.length) {
2398
+ for (const msg of record.pendingSteers) {
2399
+ session.steer(msg).catch(() => {});
2400
+ }
2401
+ record.pendingSteers = undefined;
2402
+ }
2403
+ options.onSessionCreated?.(session);
2404
+ },
1848
2405
  });
1849
2406
  } catch (error) {
1850
2407
  this.settlingRecords.delete(id);
@@ -1858,10 +2415,18 @@ export class AgentManager {
1858
2415
  }
1859
2416
  const cleanupFailedStartWorktree = (): void => {
1860
2417
  if (this.disposed || !record.worktree) return;
1861
- this.cleanupRecordWorktree(record, worktreeRepoRoot ?? baseCwd, options.description);
2418
+ this.cleanupRecordWorktree(
2419
+ record,
2420
+ worktreeRepoRoot ?? baseCwd,
2421
+ options.description,
2422
+ );
1862
2423
  this.retryDeferredRecordRemovals();
1863
2424
  };
1864
- if (sessionTeardown) void sessionTeardown.then(cleanupFailedStartWorktree, cleanupFailedStartWorktree);
2425
+ if (sessionTeardown)
2426
+ void sessionTeardown.then(
2427
+ cleanupFailedStartWorktree,
2428
+ cleanupFailedStartWorktree,
2429
+ );
1865
2430
  else cleanupFailedStartWorktree();
1866
2431
  throw error;
1867
2432
  }
@@ -1896,7 +2461,11 @@ export class AgentManager {
1896
2461
 
1897
2462
  // Final flush of streaming output file
1898
2463
  if (record.outputCleanup) {
1899
- try { record.outputCleanup(); } catch { /* ignore */ }
2464
+ try {
2465
+ record.outputCleanup();
2466
+ } catch {
2467
+ /* ignore */
2468
+ }
1900
2469
  record.outputCleanup = undefined;
1901
2470
  }
1902
2471
 
@@ -1918,7 +2487,9 @@ export class AgentManager {
1918
2487
  // undefined output or session.
1919
2488
  record.result = responseText;
1920
2489
  record.session = session;
1921
- record.sessionFile = session.sessionManager?.getSessionFile?.() ?? (session as { sessionFile?: string })?.sessionFile;
2490
+ record.sessionFile =
2491
+ session.sessionManager?.getSessionFile?.() ??
2492
+ (session as { sessionFile?: string })?.sessionFile;
1922
2493
  this.syncManagedRecord(record);
1923
2494
 
1924
2495
  // Quiesce descendants before removing the parent's worktree. A nested
@@ -1939,14 +2510,19 @@ export class AgentManager {
1939
2510
  if (record.worktree) {
1940
2511
  worktreeResult = this.hasIncompleteRepoDependency(record)
1941
2512
  ? this.blockedWorktreeCleanup(record)
1942
- : this.cleanupRecordWorktree(record, worktreeRepoRoot ?? baseCwd, options.description);
2513
+ : this.cleanupRecordWorktree(
2514
+ record,
2515
+ worktreeRepoRoot ?? baseCwd,
2516
+ options.description,
2517
+ );
1943
2518
  }
1944
2519
  if (worktreeResult?.hasChanges && worktreeResult.branch) {
1945
2520
  // With a caller-supplied cwd the branch lives in THAT repo, not the
1946
2521
  // parent session's — say so, or the orchestrator merges in the wrong repo.
1947
- const repoNote = worktreeRepoRoot ? ` in \`${worktreeRepoRoot}\`` : "";
1948
- record.result +=
1949
- `\n\n---\nChanges saved to branch \`${worktreeResult.branch}\`${repoNote}. Merge with: \`git merge ${worktreeResult.branch}\`${worktreeRepoRoot ? ` (run in \`${worktreeRepoRoot}\`)` : ""}`;
2522
+ const repoNote = worktreeRepoRoot
2523
+ ? ` in \`${worktreeRepoRoot}\``
2524
+ : "";
2525
+ record.result += `\n\n---\nChanges saved to branch \`${worktreeResult.branch}\`${repoNote}. Merge with: \`git merge ${worktreeResult.branch}\`${worktreeRepoRoot ? ` (run in \`${worktreeRepoRoot}\`)` : ""}`;
1950
2526
  }
1951
2527
  if (worktreeResult && !worktreeResult.cleanupSucceeded) {
1952
2528
  record.result +=
@@ -1989,7 +2565,11 @@ export class AgentManager {
1989
2565
 
1990
2566
  // Final flush of streaming output file on error
1991
2567
  if (record.outputCleanup) {
1992
- try { record.outputCleanup(); } catch { /* ignore */ }
2568
+ try {
2569
+ record.outputCleanup();
2570
+ } catch {
2571
+ /* ignore */
2572
+ }
1993
2573
  record.outputCleanup = undefined;
1994
2574
  }
1995
2575
 
@@ -2008,7 +2588,11 @@ export class AgentManager {
2008
2588
  }
2009
2589
 
2010
2590
  if (record.worktree && !this.hasIncompleteRepoDependency(record)) {
2011
- this.cleanupRecordWorktree(record, worktreeRepoRoot ?? baseCwd, options.description);
2591
+ this.cleanupRecordWorktree(
2592
+ record,
2593
+ worktreeRepoRoot ?? baseCwd,
2594
+ options.description,
2595
+ );
2012
2596
  }
2013
2597
 
2014
2598
  if (record.detached) {
@@ -2048,7 +2632,11 @@ export class AgentManager {
2048
2632
  // Called synchronously — onSessionCreated fires asynchronously inside runAgent.
2049
2633
  // Used by spawnAndWait to set up output files before streaming starts.
2050
2634
  if (!this.agents.get(id)?.detached) {
2051
- try { this.onSpawned?.(id); } catch { /* observer failures cannot reject the run */ }
2635
+ try {
2636
+ this.onSpawned?.(id);
2637
+ } catch {
2638
+ /* observer failures cannot reject the run */
2639
+ }
2052
2640
  }
2053
2641
  }
2054
2642
 
@@ -2075,17 +2663,22 @@ export class AgentManager {
2075
2663
  // Direct parent links are only an optimization for live records. Immutable
2076
2664
  // lineage is authoritative, so a grandchild remains discoverable after its
2077
2665
  // intermediate owner has been evicted.
2078
- const descendants = this.descendantsOf(parentId)
2079
- .sort((left, right) => this.recordDepth(right) - this.recordDepth(left));
2666
+ const descendants = this.descendantsOf(parentId).sort(
2667
+ (left, right) => this.recordDepth(right) - this.recordDepth(left),
2668
+ );
2080
2669
  for (const record of descendants) this.sealNestedSpawns(record.id);
2081
2670
 
2082
2671
  const active = descendants.filter((record) => !this.isFullyCleaned(record));
2083
2672
  for (const record of active) this.abortRecord(record.id, false);
2084
2673
  this.drainQueue();
2085
2674
 
2086
- const quiesced = active.length > 0
2087
- ? await this.waitForTerminalRecords(active, OWNED_CHILD_QUIESCE_TIMEOUT_MS)
2088
- : { settled: true, pending: [] as string[] };
2675
+ const quiesced =
2676
+ active.length > 0
2677
+ ? await this.waitForTerminalRecords(
2678
+ active,
2679
+ OWNED_CHILD_QUIESCE_TIMEOUT_MS,
2680
+ )
2681
+ : { settled: true, pending: [] as string[] };
2089
2682
  if (quiesced.pending.length === 0) {
2090
2683
  this.retryDeferredRecordRemovals();
2091
2684
  return;
@@ -2103,14 +2696,20 @@ export class AgentManager {
2103
2696
  this.queue = [];
2104
2697
  return;
2105
2698
  }
2106
- while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
2699
+ while (
2700
+ this.queue.length > 0 &&
2701
+ this.runningBackground < this.maxConcurrent
2702
+ ) {
2107
2703
  const next = this.queue.shift()!;
2108
2704
  const record = this.agents.get(next.id);
2109
2705
  if (record?.status !== "queued") continue;
2110
2706
  // A queued nested record can survive only while its immutable owner branch
2111
2707
  // remains live. Never start one whose owner was sealed/removed while it
2112
2708
  // waited in the queue.
2113
- if (record.parentAgentId !== undefined && this.nestedOwnerValidation(record.parentAgentId) !== undefined) {
2709
+ if (
2710
+ record.parentAgentId !== undefined &&
2711
+ this.nestedOwnerValidation(record.parentAgentId) !== undefined
2712
+ ) {
2114
2713
  this.abortRecord(record.id, false);
2115
2714
  continue;
2116
2715
  }
@@ -2146,7 +2745,7 @@ export class AgentManager {
2146
2745
  this.clearParentSignal(record.id);
2147
2746
  this.releasePoolSlot(record.id);
2148
2747
  this.syncManagedRecord(record);
2149
- this.notifyComplete(record)
2748
+ this.notifyComplete(record);
2150
2749
  }
2151
2750
  }
2152
2751
  }
@@ -2195,7 +2794,12 @@ export class AgentManager {
2195
2794
  ctx,
2196
2795
  type,
2197
2796
  prompt,
2198
- { ...options, isolated: true, isolation: undefined, inheritContext: false },
2797
+ {
2798
+ ...options,
2799
+ isolated: true,
2800
+ isolation: undefined,
2801
+ inheritContext: false,
2802
+ },
2199
2803
  onSpawned,
2200
2804
  internalOverride,
2201
2805
  );
@@ -2240,21 +2844,28 @@ export class AgentManager {
2240
2844
  return {
2241
2845
  status: record.status,
2242
2846
  startedAt: record.startedAt,
2243
- ...(record.completedAt === undefined ? {} : { completedAt: record.completedAt }),
2847
+ ...(record.completedAt === undefined
2848
+ ? {}
2849
+ : { completedAt: record.completedAt }),
2244
2850
  ...(record.result === undefined ? {} : { result: record.result }),
2245
2851
  ...(record.error === undefined ? {} : { error: record.error }),
2246
- ...(record.invocation ? { invocation: cloneFrozenData(record.invocation) as AgentInvocation } : {}),
2852
+ ...(record.invocation
2853
+ ? { invocation: cloneFrozenData(record.invocation) as AgentInvocation }
2854
+ : {}),
2247
2855
  };
2248
2856
  }
2249
2857
 
2250
- private restoreResumeSnapshot(record: AgentRecord, snapshot: ResumeTerminalSnapshot): void {
2858
+ private restoreResumeSnapshot(
2859
+ record: AgentRecord,
2860
+ snapshot: ResumeTerminalSnapshot,
2861
+ ): void {
2251
2862
  record.status = snapshot.status;
2252
2863
  record.startedAt = snapshot.startedAt;
2253
2864
  record.completedAt = snapshot.completedAt;
2254
2865
  record.result = snapshot.result;
2255
2866
  record.error = snapshot.error;
2256
2867
  record.invocation = snapshot.invocation
2257
- ? cloneFrozenData(snapshot.invocation) as AgentInvocation
2868
+ ? (cloneFrozenData(snapshot.invocation) as AgentInvocation)
2258
2869
  : undefined;
2259
2870
  }
2260
2871
 
@@ -2269,14 +2880,22 @@ export class AgentManager {
2269
2880
  let control!: ResumeControl;
2270
2881
  const onAbort = (): void => {
2271
2882
  controller.abort(signal?.reason);
2272
- if (record.status === "queued" || record.status === "running") this.abort(id);
2883
+ if (record.status === "queued" || record.status === "running")
2884
+ this.abort(id);
2273
2885
  };
2274
2886
  const cleanup = (): void => {
2275
2887
  signal?.removeEventListener("abort", onAbort);
2276
- if (this.resumeControls.get(id) === control) this.resumeControls.delete(id);
2277
- if (record.abortController === controller) record.abortController = undefined;
2888
+ if (this.resumeControls.get(id) === control)
2889
+ this.resumeControls.delete(id);
2890
+ if (record.abortController === controller)
2891
+ record.abortController = undefined;
2892
+ };
2893
+ control = {
2894
+ controller,
2895
+ cleanup,
2896
+ snapshot,
2897
+ ...(deferred ? { deferred } : {}),
2278
2898
  };
2279
- control = { controller, cleanup, snapshot, ...(deferred ? { deferred } : {}) };
2280
2899
  this.resumeControls.set(id, control);
2281
2900
  record.abortController = controller;
2282
2901
  if (signal) {
@@ -2303,8 +2922,15 @@ export class AgentManager {
2303
2922
  signal?: AbortSignal,
2304
2923
  options?: {
2305
2924
  isBackground?: boolean;
2306
- onToolActivity?: (activity: { type: "start" | "end"; toolName: string }) => void;
2307
- onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
2925
+ onToolActivity?: (activity: {
2926
+ type: "start" | "end";
2927
+ toolName: string;
2928
+ }) => void;
2929
+ onAssistantUsage?: (usage: {
2930
+ input: number;
2931
+ output: number;
2932
+ cacheWrite: number;
2933
+ }) => void;
2308
2934
  onCompaction?: (info: unknown) => void;
2309
2935
  /** Called once a queued background resume has acquired its run slot. */
2310
2936
  onStarted?: () => void;
@@ -2315,8 +2941,17 @@ export class AgentManager {
2315
2941
  if (!record) return undefined;
2316
2942
  if (!this.canResumeNested(record)) return undefined;
2317
2943
  if (signal?.aborted) return record;
2318
- if (this.resumeControls.has(id) || record.status === "running" || record.status === "queued") return undefined;
2944
+ if (
2945
+ this.resumeControls.has(id) ||
2946
+ record.status === "running" ||
2947
+ record.status === "queued"
2948
+ )
2949
+ return undefined;
2319
2950
  if (!record.session) return undefined;
2951
+ assertSafeChildSession(
2952
+ record.session,
2953
+ record.invocation?.inheritContext === true,
2954
+ );
2320
2955
 
2321
2956
  const snapshot = this.resumeSnapshot(record);
2322
2957
  if (options?.isBackground) {
@@ -2342,13 +2977,20 @@ export class AgentManager {
2342
2977
  record.promise = lifecyclePromise;
2343
2978
 
2344
2979
  const start = (): void => {
2345
- const execution = this.startResume(id, record, prompt, control, options);
2980
+ const execution = this.startResume(
2981
+ id,
2982
+ record,
2983
+ prompt,
2984
+ control,
2985
+ options,
2986
+ );
2346
2987
  record.promise = execution;
2347
- void execution
2348
- .then(resolveResume, rejectResume)
2349
- .catch(() => {});
2988
+ void execution.then(resolveResume, rejectResume).catch(() => {});
2350
2989
  };
2351
- if (occupiesPoolSlot(record) && this.runningBackground >= this.maxConcurrent) {
2990
+ if (
2991
+ occupiesPoolSlot(record) &&
2992
+ this.runningBackground >= this.maxConcurrent
2993
+ ) {
2352
2994
  this.queue.push({ kind: "resume", id, start });
2353
2995
  } else {
2354
2996
  start();
@@ -2379,16 +3021,24 @@ export class AgentManager {
2379
3021
  },
2380
3022
  signal: control.controller.signal,
2381
3023
  });
2382
- if (!record.detached && !control.controller.signal.aborted && (record.status as AgentRecord["status"]) !== "stopped") {
3024
+ if (
3025
+ !record.detached &&
3026
+ !control.controller.signal.aborted &&
3027
+ (record.status as AgentRecord["status"]) !== "stopped"
3028
+ ) {
2383
3029
  record.status = failure ? "error" : "completed";
2384
3030
  if (failure) record.error = failure;
2385
3031
  record.result = text;
2386
3032
  record.completedAt = Date.now();
2387
3033
  }
2388
3034
  } catch (err) {
2389
- if (!record.detached && (record.status as AgentRecord["status"]) !== "stopped") {
3035
+ if (
3036
+ !record.detached &&
3037
+ (record.status as AgentRecord["status"]) !== "stopped"
3038
+ ) {
2390
3039
  record.status = control.controller.signal.aborted ? "stopped" : "error";
2391
- if (record.status === "error") record.error = err instanceof Error ? err.message : String(err);
3040
+ if (record.status === "error")
3041
+ record.error = err instanceof Error ? err.message : String(err);
2392
3042
  record.completedAt = Date.now();
2393
3043
  }
2394
3044
  } finally {
@@ -2423,8 +3073,15 @@ export class AgentManager {
2423
3073
  control: ResumeControl,
2424
3074
  options?: {
2425
3075
  isBackground?: boolean;
2426
- onToolActivity?: (activity: { type: "start" | "end"; toolName: string }) => void;
2427
- onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
3076
+ onToolActivity?: (activity: {
3077
+ type: "start" | "end";
3078
+ toolName: string;
3079
+ }) => void;
3080
+ onAssistantUsage?: (usage: {
3081
+ input: number;
3082
+ output: number;
3083
+ cacheWrite: number;
3084
+ }) => void;
2428
3085
  onCompaction?: (info: unknown) => void;
2429
3086
  /** Called once a queued background resume has acquired its run slot. */
2430
3087
  onStarted?: () => void;
@@ -2499,16 +3156,26 @@ export class AgentManager {
2499
3156
  },
2500
3157
  signal: control.controller.signal,
2501
3158
  });
2502
- if (!record.detached && !control.controller.signal.aborted && (record.status as AgentRecord["status"]) !== "stopped") {
3159
+ if (
3160
+ !record.detached &&
3161
+ !control.controller.signal.aborted &&
3162
+ (record.status as AgentRecord["status"]) !== "stopped"
3163
+ ) {
2503
3164
  record.status = failure ? "error" : "completed";
2504
3165
  if (failure) record.error = failure;
2505
3166
  record.result = text;
2506
3167
  record.completedAt = Date.now();
2507
3168
  }
2508
3169
  } catch (err) {
2509
- if (!record.detached && (record.status as AgentRecord["status"]) !== "stopped") {
2510
- record.status = control.controller.signal.aborted ? "stopped" : "error";
2511
- if (record.status === "error") record.error = err instanceof Error ? err.message : String(err);
3170
+ if (
3171
+ !record.detached &&
3172
+ (record.status as AgentRecord["status"]) !== "stopped"
3173
+ ) {
3174
+ record.status = control.controller.signal.aborted
3175
+ ? "stopped"
3176
+ : "error";
3177
+ if (record.status === "error")
3178
+ record.error = err instanceof Error ? err.message : String(err);
2512
3179
  record.completedAt = Date.now();
2513
3180
  }
2514
3181
  }
@@ -2559,7 +3226,7 @@ export class AgentManager {
2559
3226
  return true;
2560
3227
  }
2561
3228
 
2562
- /**
3229
+ /**
2563
3230
  * Return an immutable observation snapshot. Internal manager/index code must
2564
3231
  * use `getRecordMutable()` when it needs to update authoritative state.
2565
3232
  */
@@ -2577,7 +3244,6 @@ export class AgentManager {
2577
3244
  return this.agents.get(id);
2578
3245
  }
2579
3246
 
2580
-
2581
3247
  /** Internal live list for this extension's UI and lifecycle wiring. */
2582
3248
  listAgentsMutable(): AgentRecord[] {
2583
3249
  return [...this.agents.values()]
@@ -2597,7 +3263,7 @@ export class AgentManager {
2597
3263
 
2598
3264
  // Remove from queue if queued.
2599
3265
  if (record.status === "queued") {
2600
- this.queue = this.queue.filter(q => q.id !== id);
3266
+ this.queue = this.queue.filter((q) => q.id !== id);
2601
3267
  record.status = "stopped";
2602
3268
  record.completedAt = Date.now();
2603
3269
  this.clearParentSignal(record.id);
@@ -2610,7 +3276,11 @@ export class AgentManager {
2610
3276
  this.syncManagedRecord(record);
2611
3277
  // Queued agents have no run promise yet. Still use the normal terminal
2612
3278
  // callback so lifecycle consumers (including workflow waits) cannot hang.
2613
- try { this.notifyComplete(record) } catch { /* ignore side-effect errors */ }
3279
+ try {
3280
+ this.notifyComplete(record);
3281
+ } catch {
3282
+ /* ignore side-effect errors */
3283
+ }
2614
3284
  if (drain) this.drainQueue();
2615
3285
  return true;
2616
3286
  }
@@ -2635,7 +3305,9 @@ export class AgentManager {
2635
3305
  if (!record) return false;
2636
3306
 
2637
3307
  const descendants = this.descendantsOf(id);
2638
- const hasActiveDescendant = descendants.some((child) => !this.isFullyCleaned(child));
3308
+ const hasActiveDescendant = descendants.some(
3309
+ (child) => !this.isFullyCleaned(child),
3310
+ );
2639
3311
  const isActive = record.status === "queued" || record.status === "running";
2640
3312
  if (!isActive && !hasActiveDescendant) return false;
2641
3313
 
@@ -2666,37 +3338,52 @@ export class AgentManager {
2666
3338
  }
2667
3339
 
2668
3340
  private descendantsOf(rootId: string): AgentRecord[] {
2669
- return [...this.agents.values()]
2670
- .filter((record) => record.id !== rootId && record.ancestorAgentIds?.includes(rootId) === true);
3341
+ return [...this.agents.values()].filter(
3342
+ (record) =>
3343
+ record.id !== rootId &&
3344
+ record.ancestorAgentIds?.includes(rootId) === true,
3345
+ );
2671
3346
  }
2672
3347
 
2673
3348
  private hasIncompleteRepoDependency(record: AgentRecord): boolean {
2674
3349
  const worktreePath = record.worktree?.path;
2675
3350
  if (!worktreePath) return false;
2676
- return this.descendantsOf(record.id).some((child) =>
2677
- !this.isFullyCleaned(child) &&
2678
- // Isolated children depend on the checkout they were created from. A
2679
- // non-isolated nested child has no worktree record and may execute in any
2680
- // ancestor checkout, so retaining the ancestor is the fail-closed choice.
2681
- ((child.worktree !== undefined && sameFilesystemPath(child.worktree.repoRoot, worktreePath)) ||
2682
- child.worktree === undefined),
3351
+ return this.descendantsOf(record.id).some(
3352
+ (child) =>
3353
+ !this.isFullyCleaned(child) &&
3354
+ // Isolated children depend on the checkout they were created from. A
3355
+ // non-isolated nested child has no worktree record and may execute in any
3356
+ // ancestor checkout, so retaining the ancestor is the fail-closed choice.
3357
+ ((child.worktree !== undefined &&
3358
+ sameFilesystemPath(child.worktree.repoRoot, worktreePath)) ||
3359
+ child.worktree === undefined),
2683
3360
  );
2684
3361
  }
2685
3362
 
2686
- private blockedWorktreeCleanup(record: AgentRecord, diagnostic?: string): WorktreeCleanupResult | undefined {
3363
+ private blockedWorktreeCleanup(
3364
+ record: AgentRecord,
3365
+ diagnostic?: string,
3366
+ ): WorktreeCleanupResult | undefined {
2687
3367
  const worktree = record.worktree;
2688
3368
  if (!worktree) return undefined;
2689
- const dependent = this.descendantsOf(record.id).find((child) =>
2690
- !this.isFullyCleaned(child) &&
2691
- ((child.worktree !== undefined && sameFilesystemPath(child.worktree.repoRoot, worktree.path)) ||
2692
- child.worktree === undefined),
3369
+ const dependent = this.descendantsOf(record.id).find(
3370
+ (child) =>
3371
+ !this.isFullyCleaned(child) &&
3372
+ ((child.worktree !== undefined &&
3373
+ sameFilesystemPath(child.worktree.repoRoot, worktree.path)) ||
3374
+ child.worktree === undefined),
2693
3375
  );
2694
3376
  const result: WorktreeCleanupResult = {
2695
3377
  hasChanges: false,
2696
3378
  path: worktree.path,
2697
3379
  cleanupSucceeded: false,
2698
- cleanupDiagnostic: diagnostic ?? `Worktree cleanup is pinned because descendant ${dependent?.id ?? "agent"} still depends on ${worktree.path}`,
2699
- recoveryCommands: worktreeRecoveryCommands(worktree.repoRoot, worktree.path),
3380
+ cleanupDiagnostic:
3381
+ diagnostic ??
3382
+ `Worktree cleanup is pinned because descendant ${dependent?.id ?? "agent"} still depends on ${worktree.path}`,
3383
+ recoveryCommands: worktreeRecoveryCommands(
3384
+ worktree.repoRoot,
3385
+ worktree.path,
3386
+ ),
2700
3387
  };
2701
3388
  record.worktreeResult = result;
2702
3389
  return result;
@@ -2706,15 +3393,21 @@ export class AgentManager {
2706
3393
  // A quarantined record remains non-quiescent until its provider promise,
2707
3394
  // worktree cleanup, transcript cleanup, and session teardown all finish.
2708
3395
  // Terminal status alone is never proof that the branch is safe to replace.
2709
- return record.status !== "queued" && record.status !== "running" &&
3396
+ return (
3397
+ record.status !== "queued" &&
3398
+ record.status !== "running" &&
2710
3399
  !this.settlingRecords.has(record.id) &&
3400
+ !this.providerPendingRecords.has(record.id) &&
2711
3401
  !this.recordSessionTeardowns.has(record.id) &&
2712
- record.worktree === undefined && record.outputCleanup === undefined;
3402
+ record.worktree === undefined &&
3403
+ record.outputCleanup === undefined
3404
+ );
2713
3405
  }
2714
3406
 
2715
3407
  private abortDescendantsSynchronously(rootId: string): void {
2716
- const descendants = this.descendantsOf(rootId)
2717
- .sort((left, right) => this.recordDepth(right) - this.recordDepth(left));
3408
+ const descendants = this.descendantsOf(rootId).sort(
3409
+ (left, right) => this.recordDepth(right) - this.recordDepth(left),
3410
+ );
2718
3411
  for (const child of descendants) this.sealNestedSpawns(child.id);
2719
3412
  for (const child of descendants) this.abortRecord(child.id, false);
2720
3413
  this.drainQueue();
@@ -2724,8 +3417,8 @@ export class AgentManager {
2724
3417
  if (this.disposed) return;
2725
3418
  for (const id of this.nestedSpawnSeals) {
2726
3419
  if (this.removingRecords.has(id) || this.agents.has(id)) continue;
2727
- const stillReferenced = [...this.agents.values()].some((record) =>
2728
- record.ancestorAgentIds?.includes(id) === true,
3420
+ const stillReferenced = [...this.agents.values()].some(
3421
+ (record) => record.ancestorAgentIds?.includes(id) === true,
2729
3422
  );
2730
3423
  if (!stillReferenced) this.nestedSpawnSeals.delete(id);
2731
3424
  }
@@ -2744,20 +3437,22 @@ export class AgentManager {
2744
3437
  private retryPinnedWorktreeCleanup(): void {
2745
3438
  if (this.disposed) return;
2746
3439
  const records = [...this.agents.values()]
2747
- .filter((record) =>
2748
- record.status !== "queued" && record.status !== "running" &&
2749
- record.worktree &&
2750
- !this.recordSessionTeardowns.has(record.id) &&
2751
- !this.settlingRecords.has(record.id),
3440
+ .filter(
3441
+ (record) =>
3442
+ record.status !== "queued" &&
3443
+ record.status !== "running" &&
3444
+ record.worktree &&
3445
+ !this.recordSessionTeardowns.has(record.id) &&
3446
+ !this.settlingRecords.has(record.id),
2752
3447
  )
2753
3448
  .sort((left, right) => this.recordDepth(right) - this.recordDepth(left));
2754
3449
  for (const record of records) {
2755
- if (this.hasIncompleteRepoDependency(record)) this.blockedWorktreeCleanup(record);
3450
+ if (this.hasIncompleteRepoDependency(record))
3451
+ this.blockedWorktreeCleanup(record);
2756
3452
  else this.cleanupDetachedWorktree(record);
2757
3453
  }
2758
3454
  }
2759
3455
 
2760
-
2761
3456
  private retryDeferredRecordRemovals(): void {
2762
3457
  if (this.disposed || this.deferredRecordRemovals.size === 0) {
2763
3458
  this.pruneNestedMetadata();
@@ -2790,7 +3485,9 @@ export class AgentManager {
2790
3485
  // invoke extension callbacks, and those callbacks must not reopen this owner.
2791
3486
  this.sealNestedSpawns(id);
2792
3487
  this.abortDescendantsSynchronously(id);
2793
- const blockedByDescendant = this.descendantsOf(id).some((child) => !this.isFullyCleaned(child));
3488
+ const blockedByDescendant = this.descendantsOf(id).some(
3489
+ (child) => !this.isFullyCleaned(child),
3490
+ );
2794
3491
  if (!this.isFullyCleaned(record) || blockedByDescendant) {
2795
3492
  // Synchronous cleanup cannot await provider quiescence. Leave the owner
2796
3493
  // visible and retry after descendant settlement or on the next interval.
@@ -2803,7 +3500,11 @@ export class AgentManager {
2803
3500
  this.removingRecords.add(id);
2804
3501
  try {
2805
3502
  if (record.outputCleanup) {
2806
- try { record.outputCleanup(); } catch { /* ignore stale transcript cleanup errors */ }
3503
+ try {
3504
+ record.outputCleanup();
3505
+ } catch {
3506
+ /* ignore stale transcript cleanup errors */
3507
+ }
2807
3508
  record.outputCleanup = undefined;
2808
3509
  }
2809
3510
  this.indexResumable(record);
@@ -2851,7 +3552,8 @@ export class AgentManager {
2851
3552
  while (this.resumable.size > MAX_RESUMABLE_ENTRIES) {
2852
3553
  let oldest: ResumableAgentEntry | undefined;
2853
3554
  for (const candidate of this.resumable.values()) {
2854
- if (!oldest || candidate.completedAt <= oldest.completedAt) oldest = candidate;
3555
+ if (!oldest || candidate.completedAt <= oldest.completedAt)
3556
+ oldest = candidate;
2855
3557
  }
2856
3558
  if (!oldest) break;
2857
3559
  this.resumable.delete(oldest.handle);
@@ -2881,11 +3583,17 @@ export class AgentManager {
2881
3583
  * time), so both can hold the same handle at once — and while a record is
2882
3584
  * live, it is the one the user means.
2883
3585
  */
2884
- resolveMention(handle: string): { kind: "live"; record: AgentRecord } | { kind: "resumable"; entry: ResumableAgentEntry } | undefined {
3586
+ resolveMention(
3587
+ handle: string,
3588
+ ):
3589
+ | { kind: "live"; record: AgentRecord }
3590
+ | { kind: "resumable"; entry: ResumableAgentEntry }
3591
+ | undefined {
2885
3592
  const wanted = handle.toLowerCase();
2886
3593
  for (const record of this.agents.values()) {
2887
3594
  if (record.detached) continue;
2888
- if (record.handle?.toLowerCase() === wanted || record.id === handle) return { kind: "live", record };
3595
+ if (record.handle?.toLowerCase() === wanted || record.id === handle)
3596
+ return { kind: "live", record };
2889
3597
  }
2890
3598
  const entry = this.getResumable(handle);
2891
3599
  return entry ? { kind: "resumable", entry } : undefined;
@@ -2895,14 +3603,17 @@ export class AgentManager {
2895
3603
  getResumable(name: string): ResumableAgentEntry | undefined {
2896
3604
  const wanted = name.toLowerCase();
2897
3605
  for (const entry of this.resumable.values()) {
2898
- if (entry.handle.toLowerCase() === wanted || entry.id === name) return entry;
3606
+ if (entry.handle.toLowerCase() === wanted || entry.id === name)
3607
+ return entry;
2899
3608
  }
2900
3609
  return undefined;
2901
3610
  }
2902
3611
 
2903
3612
  /** Evicted agents whose conversation can still be reopened, newest first. */
2904
3613
  listResumable(): ResumableAgentEntry[] {
2905
- return [...this.resumable.values()].sort((a, b) => b.completedAt - a.completedAt);
3614
+ return [...this.resumable.values()].sort(
3615
+ (a, b) => b.completedAt - a.completedAt,
3616
+ );
2906
3617
  }
2907
3618
 
2908
3619
  /** Forget an evicted agent, by handle or id. */
@@ -2961,7 +3672,7 @@ export class AgentManager {
2961
3672
  this.removeRecord(id, record);
2962
3673
  }
2963
3674
  this.retryDeferredRecordRemovals();
2964
- } /**
3675
+ } /**
2965
3676
  * Remove all completed/stopped/errored records immediately.
2966
3677
  * Called on session start/switch so tasks from a prior session don't persist.
2967
3678
  * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
@@ -2979,7 +3690,21 @@ export class AgentManager {
2979
3690
  /** Whether any agents are still running or queued. */
2980
3691
  hasRunning(): boolean {
2981
3692
  return [...this.agents.values()].some(
2982
- r => r.status === "running" || r.status === "queued",
3693
+ (r) => r.status === "running" || r.status === "queued",
3694
+ );
3695
+ }
3696
+
3697
+ /** Synchronous navigation preflight: terminal status does not imply cleanup. */
3698
+ hasUnsettledWork(): boolean {
3699
+ return (
3700
+ this.sessionTeardowns.size > 0 ||
3701
+ this.managedPersistenceRetries.size > 0 ||
3702
+ [...this.agents.values()].some(
3703
+ (record) => !this.isFullyCleaned(record),
3704
+ ) ||
3705
+ [...this.managedSpawns.values()].some(
3706
+ (spawn) => !isManagedTerminalState(spawn.state),
3707
+ )
2983
3708
  );
2984
3709
  }
2985
3710
 
@@ -3005,8 +3730,16 @@ export class AgentManager {
3005
3730
  }
3006
3731
  for (const [index, owner] of owners.entries()) {
3007
3732
  const id = agentIds[index];
3008
- if (!id || owner.extension !== "pi-workflows" || owner.runId !== runId || !owner.attemptId) {
3009
- return { settled: false, pending: [...new Set(agentIds)].slice(0, 256) };
3733
+ if (
3734
+ !id ||
3735
+ owner.extension !== "pi-workflows" ||
3736
+ owner.runId !== runId ||
3737
+ !owner.attemptId
3738
+ ) {
3739
+ return {
3740
+ settled: false,
3741
+ pending: [...new Set(agentIds)].slice(0, 256),
3742
+ };
3010
3743
  }
3011
3744
  ownerById.set(id, owner);
3012
3745
  }
@@ -3017,12 +3750,21 @@ export class AgentManager {
3017
3750
  record.owner?.extension !== "pi-workflows" ||
3018
3751
  record.owner.runId !== runId ||
3019
3752
  !expected.has(record.id)
3020
- ) continue;
3753
+ )
3754
+ continue;
3021
3755
  const expectedOwner = ownerById.get(record.id);
3022
- if (!expectedOwner ||
3756
+ if (
3757
+ !expectedOwner ||
3023
3758
  record.owner.nodeId !== expectedOwner.nodeId ||
3024
- record.owner.attemptId !== expectedOwner.attemptId) continue;
3025
- if (!this.isFullyCleaned(record) || this.descendantsOf(record.id).some((child) => !this.isFullyCleaned(child))) {
3759
+ record.owner.attemptId !== expectedOwner.attemptId
3760
+ )
3761
+ continue;
3762
+ if (
3763
+ !this.isFullyCleaned(record) ||
3764
+ this.descendantsOf(record.id).some(
3765
+ (child) => !this.isFullyCleaned(child),
3766
+ )
3767
+ ) {
3026
3768
  records.push(record);
3027
3769
  } else {
3028
3770
  // An exact-owner terminal record with no live descendant is already
@@ -3031,7 +3773,10 @@ export class AgentManager {
3031
3773
  settledIds.add(record.id);
3032
3774
  }
3033
3775
  }
3034
- const matched = new Set([...records.map((record) => record.id), ...settledIds]);
3776
+ const matched = new Set([
3777
+ ...records.map((record) => record.id),
3778
+ ...settledIds,
3779
+ ]);
3035
3780
  const pendingOwners = agentIds.filter((id) => !matched.has(id));
3036
3781
 
3037
3782
  // Managed callers name only top-level attempts, but nested descendants can
@@ -3040,32 +3785,45 @@ export class AgentManager {
3040
3785
  const quiescenceRecords = [...records];
3041
3786
  const branchOwnerById = new Map<string, string>();
3042
3787
  for (const record of records) branchOwnerById.set(record.id, record.id);
3043
- const branchRootIds = new Set([...agentIds, ...records.map((record) => record.id)]);
3788
+ const branchRootIds = new Set([
3789
+ ...agentIds,
3790
+ ...records.map((record) => record.id),
3791
+ ]);
3044
3792
  for (const rootId of branchRootIds) {
3045
3793
  for (const child of this.descendantsOf(rootId)) {
3046
- if (this.isFullyCleaned(child) || branchOwnerById.has(child.id)) continue;
3794
+ if (this.isFullyCleaned(child) || branchOwnerById.has(child.id))
3795
+ continue;
3047
3796
  branchOwnerById.set(child.id, rootId);
3048
3797
  quiescenceRecords.push(child);
3049
3798
  }
3050
3799
  }
3051
3800
  for (const record of quiescenceRecords) this.abort(record.id);
3052
- const result = await this.waitForTerminalRecords(quiescenceRecords, timeoutMs);
3801
+ const result = await this.waitForTerminalRecords(
3802
+ quiescenceRecords,
3803
+ timeoutMs,
3804
+ );
3053
3805
  const pendingBranchOwners = result.pending
3054
- .map((id) => expected.has(id) ? undefined : branchOwnerById.get(id))
3806
+ .map((id) => (expected.has(id) ? undefined : branchOwnerById.get(id)))
3055
3807
  .filter((id): id is string => id !== undefined);
3056
3808
  return {
3057
3809
  settled: result.settled && pendingOwners.length === 0,
3058
- pending: [...new Set([
3059
- ...result.pending.filter((id) => expected.has(id)),
3060
- ...pendingOwners,
3061
- ...pendingBranchOwners,
3062
- ])].slice(0, 256),
3810
+ pending: [
3811
+ ...new Set([
3812
+ ...result.pending.filter((id) => expected.has(id)),
3813
+ ...pendingOwners,
3814
+ ...pendingBranchOwners,
3815
+ ]),
3816
+ ].slice(0, 256),
3063
3817
  };
3064
3818
  }
3065
3819
 
3066
3820
  /** Stop every active record for session-tree preparation. */
3067
- async quiesceAll(timeoutMs: number): Promise<{ settled: boolean; pending: string[] }> {
3068
- const records = [...this.agents.values()].filter((record) => !this.isFullyCleaned(record));
3821
+ async quiesceAll(
3822
+ timeoutMs: number,
3823
+ ): Promise<{ settled: boolean; pending: string[] }> {
3824
+ const records = [...this.agents.values()].filter(
3825
+ (record) => !this.isFullyCleaned(record),
3826
+ );
3069
3827
  for (const record of records) this.abort(record.id);
3070
3828
  return this.waitForTerminalRecords(records, timeoutMs);
3071
3829
  }
@@ -3086,14 +3844,20 @@ export class AgentManager {
3086
3844
  if (!worktree) return undefined;
3087
3845
 
3088
3846
  if (this.providerPendingRecords.has(record.id)) {
3089
- return this.blockedWorktreeCleanup(record, `Worktree cleanup is pinned because provider/tool settlement for ${record.id} is still pending`);
3847
+ return this.blockedWorktreeCleanup(
3848
+ record,
3849
+ `Worktree cleanup is pinned because provider/tool settlement for ${record.id} is still pending`,
3850
+ );
3090
3851
  }
3091
3852
 
3092
3853
  if (this.hasIncompleteRepoDependency(record)) {
3093
3854
  return this.blockedWorktreeCleanup(record);
3094
3855
  }
3095
3856
  if (this.recordSessionTeardowns.has(record.id)) {
3096
- return this.blockedWorktreeCleanup(record, `Worktree cleanup is pinned because child session teardown for ${record.id} is still pending`);
3857
+ return this.blockedWorktreeCleanup(
3858
+ record,
3859
+ `Worktree cleanup is pinned because child session teardown for ${record.id} is still pending`,
3860
+ );
3097
3861
  }
3098
3862
  if (this.disposed && !allowDuringDispose) return record.worktreeResult;
3099
3863
 
@@ -3106,7 +3870,9 @@ export class AgentManager {
3106
3870
  if (!result.cleanupSucceeded && !result.recoveryCommands) {
3107
3871
  result = {
3108
3872
  ...result,
3109
- cleanupDiagnostic: result.cleanupDiagnostic ?? `Worktree cleanup failed for ${worktree.path}`,
3873
+ cleanupDiagnostic:
3874
+ result.cleanupDiagnostic ??
3875
+ `Worktree cleanup failed for ${worktree.path}`,
3110
3876
  recoveryCommands: worktreeRecoveryCommands(cwd, worktree.path),
3111
3877
  };
3112
3878
  }
@@ -3126,28 +3892,37 @@ export class AgentManager {
3126
3892
  if (!worktree) return undefined;
3127
3893
 
3128
3894
  if (this.providerPendingRecords.has(record.id)) {
3129
- return this.blockedWorktreeCleanup(record, `Worktree cleanup is pinned because provider/tool settlement for ${record.id} is still pending`);
3895
+ return this.blockedWorktreeCleanup(
3896
+ record,
3897
+ `Worktree cleanup is pinned because provider/tool settlement for ${record.id} is still pending`,
3898
+ );
3130
3899
  }
3131
3900
 
3132
3901
  if (this.hasIncompleteRepoDependency(record)) {
3133
3902
  return this.blockedWorktreeCleanup(record);
3134
3903
  }
3135
3904
  if (this.recordSessionTeardowns.has(record.id)) {
3136
- return this.blockedWorktreeCleanup(record, `Worktree cleanup is pinned because child session teardown for ${record.id} is still pending`);
3905
+ return this.blockedWorktreeCleanup(
3906
+ record,
3907
+ `Worktree cleanup is pinned because child session teardown for ${record.id} is still pending`,
3908
+ );
3137
3909
  }
3138
3910
 
3139
3911
  let result: WorktreeCleanupResult;
3140
3912
  try {
3141
- result = typeof cleanupWorktreeAsync === "function"
3142
- ? await cleanupWorktreeAsync(cwd, worktree, description)
3143
- : cleanupWorktree(cwd, worktree, description);
3913
+ result =
3914
+ typeof cleanupWorktreeAsync === "function"
3915
+ ? await cleanupWorktreeAsync(cwd, worktree, description)
3916
+ : cleanupWorktree(cwd, worktree, description);
3144
3917
  } catch (error: unknown) {
3145
3918
  result = cleanupFailureResult(cwd, worktree, error);
3146
3919
  }
3147
3920
  if (!result.cleanupSucceeded && !result.recoveryCommands) {
3148
3921
  result = {
3149
3922
  ...result,
3150
- cleanupDiagnostic: result.cleanupDiagnostic ?? `Worktree cleanup failed for ${worktree.path}`,
3923
+ cleanupDiagnostic:
3924
+ result.cleanupDiagnostic ??
3925
+ `Worktree cleanup failed for ${worktree.path}`,
3151
3926
  recoveryCommands: worktreeRecoveryCommands(cwd, worktree.path),
3152
3927
  };
3153
3928
  }
@@ -3156,7 +3931,10 @@ export class AgentManager {
3156
3931
  return result;
3157
3932
  }
3158
3933
 
3159
- private cleanupDetachedWorktree(record: AgentRecord, allowDuringDispose = false): WorktreeCleanupResult | undefined {
3934
+ private cleanupDetachedWorktree(
3935
+ record: AgentRecord,
3936
+ allowDuringDispose = false,
3937
+ ): WorktreeCleanupResult | undefined {
3160
3938
  if (this.disposed && !allowDuringDispose) return record.worktreeResult;
3161
3939
  const worktree = record.worktree;
3162
3940
  if (!worktree) return undefined;
@@ -3164,7 +3942,12 @@ export class AgentManager {
3164
3942
  if (this.hasIncompleteRepoDependency(record)) {
3165
3943
  return this.blockedWorktreeCleanup(record);
3166
3944
  }
3167
- return this.cleanupRecordWorktree(record, worktree.repoRoot, record.description, allowDuringDispose);
3945
+ return this.cleanupRecordWorktree(
3946
+ record,
3947
+ worktree.repoRoot,
3948
+ record.description,
3949
+ allowDuringDispose,
3950
+ );
3168
3951
  }
3169
3952
 
3170
3953
  /** Quarantine a record before any late provider continuation can observe it. */
@@ -3186,7 +3969,11 @@ export class AgentManager {
3186
3969
  this.sealNestedSpawns(id);
3187
3970
  this.clearParentSignal(id);
3188
3971
  if (record.outputCleanup) {
3189
- try { record.outputCleanup(); } catch { /* ignore stale transcript cleanup errors */ }
3972
+ try {
3973
+ record.outputCleanup();
3974
+ } catch {
3975
+ /* ignore stale transcript cleanup errors */
3976
+ }
3190
3977
  record.outputCleanup = undefined;
3191
3978
  }
3192
3979
  if (record.session) {
@@ -3201,7 +3988,9 @@ export class AgentManager {
3201
3988
  timeoutMs: number,
3202
3989
  ): Promise<{ settled: boolean; pending: string[] }> {
3203
3990
  const pendingIds = new Set(
3204
- records.filter((record) => !this.isFullyCleaned(record)).map((record) => record.id),
3991
+ records
3992
+ .filter((record) => !this.isFullyCleaned(record))
3993
+ .map((record) => record.id),
3205
3994
  );
3206
3995
  const recheck = (record: AgentRecord): void => {
3207
3996
  if (this.isFullyCleaned(record)) pendingIds.delete(record.id);
@@ -3213,7 +4002,10 @@ export class AgentManager {
3213
4002
  let timer: ReturnType<typeof setTimeout> | undefined;
3214
4003
  const allSettled = Promise.allSettled(pendingPromises).then(() => true);
3215
4004
  const timedOut = new Promise<boolean>((resolve) => {
3216
- timer = setTimeout(() => resolve(false), Math.max(0, deadline - Date.now()));
4005
+ timer = setTimeout(
4006
+ () => resolve(false),
4007
+ Math.max(0, deadline - Date.now()),
4008
+ );
3217
4009
  });
3218
4010
  const completed = await Promise.race([allSettled, timedOut]);
3219
4011
  if (timer) clearTimeout(timer);
@@ -3238,10 +4030,15 @@ export class AgentManager {
3238
4030
  .map((record) => this.recordSessionTeardowns.get(record.id))
3239
4031
  .filter((promise): promise is Promise<void> => promise !== undefined);
3240
4032
  if (teardownPromises.length > 0) {
3241
- const allTeardowns = Promise.allSettled(teardownPromises).then(() => true);
4033
+ const allTeardowns = Promise.allSettled(teardownPromises).then(
4034
+ () => true,
4035
+ );
3242
4036
  let teardownTimer: ReturnType<typeof setTimeout> | undefined;
3243
4037
  const teardownTimeout = new Promise<boolean>((resolve) => {
3244
- teardownTimer = setTimeout(() => resolve(false), Math.max(0, deadline - Date.now()));
4038
+ teardownTimer = setTimeout(
4039
+ () => resolve(false),
4040
+ Math.max(0, deadline - Date.now()),
4041
+ );
3245
4042
  });
3246
4043
  await Promise.race([allTeardowns, teardownTimeout]);
3247
4044
  if (teardownTimer) clearTimeout(teardownTimer);
@@ -3254,9 +4051,15 @@ export class AgentManager {
3254
4051
  .sort((left, right) => this.recordDepth(right) - this.recordDepth(left))
3255
4052
  .forEach((record) => {
3256
4053
  if (this.settlingRecords.has(record.id)) {
3257
- this.blockedWorktreeCleanup(record, `Worktree cleanup is pinned because provider settlement for ${record.id} is still pending`);
4054
+ this.blockedWorktreeCleanup(
4055
+ record,
4056
+ `Worktree cleanup is pinned because provider settlement for ${record.id} is still pending`,
4057
+ );
3258
4058
  } else if (this.recordSessionTeardowns.has(record.id)) {
3259
- this.blockedWorktreeCleanup(record, `Worktree cleanup is pinned because child session teardown for ${record.id} is still pending`);
4059
+ this.blockedWorktreeCleanup(
4060
+ record,
4061
+ `Worktree cleanup is pinned because child session teardown for ${record.id} is still pending`,
4062
+ );
3260
4063
  } else if (this.hasIncompleteRepoDependency(record)) {
3261
4064
  this.blockedWorktreeCleanup(record);
3262
4065
  } else {
@@ -3282,11 +4085,18 @@ export class AgentManager {
3282
4085
  const records = [...this.agents.values()]
3283
4086
  .filter((record) => !this.isFullyCleaned(record))
3284
4087
  .sort((left, right) => this.recordDepth(right) - this.recordDepth(left));
4088
+ // An abort listener for one record can synchronously stop another owned
4089
+ // record. Seal every journal owner before aborting any provider on the new leaf.
4090
+ for (const record of records) record.detached = true;
3285
4091
  for (const record of records) this.quarantineRecord(record.id, record);
3286
4092
  for (const record of records) {
3287
4093
  if (this.settlingRecords.has(record.id)) {
3288
- this.blockedWorktreeCleanup(record, `Worktree cleanup is pinned because provider settlement for ${record.id} is still pending`);
3289
- } else if (this.hasIncompleteRepoDependency(record)) this.blockedWorktreeCleanup(record);
4094
+ this.blockedWorktreeCleanup(
4095
+ record,
4096
+ `Worktree cleanup is pinned because provider settlement for ${record.id} is still pending`,
4097
+ );
4098
+ } else if (this.hasIncompleteRepoDependency(record))
4099
+ this.blockedWorktreeCleanup(record);
3290
4100
  else this.cleanupDetachedWorktree(record);
3291
4101
  }
3292
4102
  this.drainQueue();
@@ -3315,8 +4125,8 @@ export class AgentManager {
3315
4125
  while (true) {
3316
4126
  this.drainQueue();
3317
4127
  const pending = [...this.agents.values()]
3318
- .filter(r => r.status === "running" || r.status === "queued")
3319
- .map(r => r.promise)
4128
+ .filter((r) => r.status === "running" || r.status === "queued")
4129
+ .map((r) => r.promise)
3320
4130
  .filter(Boolean);
3321
4131
  if (pending.length === 0) break;
3322
4132
  await Promise.allSettled(pending);
@@ -3339,13 +4149,21 @@ export class AgentManager {
3339
4149
  for (const record of records) {
3340
4150
  if (record.worktree) reposToPrune.add(record.worktree.repoRoot);
3341
4151
  }
3342
- try { reposToPrune.add(process.cwd()); } catch { /* ignore an invalid cwd */ }
4152
+ try {
4153
+ reposToPrune.add(process.cwd());
4154
+ } catch {
4155
+ /* ignore an invalid cwd */
4156
+ }
3343
4157
 
3344
4158
  // Stop dispatch first. A late settlement must not start queued work while
3345
4159
  // the old session is being torn down.
3346
4160
  this.queue = [];
3347
4161
  for (const cleanup of this.parentSignalCleanups.values()) {
3348
- try { cleanup(); } catch { /* ignore stale signal cleanup errors */ }
4162
+ try {
4163
+ cleanup();
4164
+ } catch {
4165
+ /* ignore stale signal cleanup errors */
4166
+ }
3349
4167
  }
3350
4168
  this.parentSignalCleanups.clear();
3351
4169
  this.heldPoolSlots.clear();
@@ -3365,7 +4183,11 @@ export class AgentManager {
3365
4183
  this.nestedSpawnSeals.add(record.id);
3366
4184
  this.removingRecords.add(record.id);
3367
4185
  if (record.outputCleanup) {
3368
- try { record.outputCleanup(); } catch { /* ignore stale transcript cleanup errors */ }
4186
+ try {
4187
+ record.outputCleanup();
4188
+ } catch {
4189
+ /* ignore stale transcript cleanup errors */
4190
+ }
3369
4191
  record.outputCleanup = undefined;
3370
4192
  }
3371
4193
  }
@@ -3382,14 +4204,24 @@ export class AgentManager {
3382
4204
  // Abort every record first, then start every session teardown before awaiting
3383
4205
  // any one of them. A failing handler therefore cannot starve its siblings.
3384
4206
  for (const record of records) {
3385
- try { await record.abortController?.abort(); } catch { /* ignore stale abort errors */ }
4207
+ try {
4208
+ await record.abortController?.abort();
4209
+ } catch {
4210
+ /* ignore stale abort errors */
4211
+ }
3386
4212
  if (record.session) {
3387
4213
  this.trackRecordSessionTeardown(record.id, record.session);
3388
4214
  record.session = undefined;
3389
4215
  }
3390
4216
  }
3391
- await this.awaitSessionTeardowns();
3392
-
4217
+ const teardownsSettled = await this.awaitSessionTeardowns(
4218
+ DISPOSE_SESSION_TEARDOWN_TIMEOUT_MS,
4219
+ );
4220
+ if (!teardownsSettled) {
4221
+ console.warn(
4222
+ `[pi-subagents] child session shutdown timed out; ${this.sessionTeardowns.size} teardown(s) still pending; attached worktrees remain pinned`,
4223
+ );
4224
+ }
3393
4225
 
3394
4226
  const providerPromises = records
3395
4227
  .map((record) => record.promise)
@@ -3397,30 +4229,49 @@ export class AgentManager {
3397
4229
  if (providerPromises.length > 0) {
3398
4230
  let providerTimer: ReturnType<typeof setTimeout> | undefined;
3399
4231
  const providerTimeout = new Promise<void>((resolve) => {
3400
- providerTimer = setTimeout(resolve, DISPOSE_PROVIDER_QUIESCE_TIMEOUT_MS);
4232
+ providerTimer = setTimeout(
4233
+ resolve,
4234
+ DISPOSE_PROVIDER_QUIESCE_TIMEOUT_MS,
4235
+ );
3401
4236
  });
3402
- await Promise.race([Promise.allSettled(providerPromises).then(() => undefined), providerTimeout]);
4237
+ await Promise.race([
4238
+ Promise.allSettled(providerPromises).then(() => undefined),
4239
+ providerTimeout,
4240
+ ]);
3403
4241
  if (providerTimer) clearTimeout(providerTimer);
3404
4242
  }
3405
4243
 
3406
- const orderedRecords = records.sort((left, right) => this.recordDepth(right) - this.recordDepth(left));
4244
+ const orderedRecords = records.sort(
4245
+ (left, right) => this.recordDepth(right) - this.recordDepth(left),
4246
+ );
3407
4247
  const attemptCleanup = async (record: AgentRecord): Promise<void> => {
3408
4248
  if (!record.worktree) return;
3409
4249
  try {
3410
4250
  if (this.settlingRecords.has(record.id)) {
3411
- this.blockedWorktreeCleanup(record, `Worktree cleanup is pinned because provider settlement for ${record.id} is still pending`);
4251
+ this.blockedWorktreeCleanup(
4252
+ record,
4253
+ `Worktree cleanup is pinned because provider settlement for ${record.id} is still pending`,
4254
+ );
3412
4255
  return;
3413
4256
  }
3414
4257
  if (this.hasIncompleteRepoDependency(record)) {
3415
4258
  this.blockedWorktreeCleanup(record);
3416
4259
  return;
3417
4260
  }
3418
- await this.cleanupRecordWorktreeAsync(record, record.worktree.repoRoot, record.description);
4261
+ await this.cleanupRecordWorktreeAsync(
4262
+ record,
4263
+ record.worktree.repoRoot,
4264
+ record.description,
4265
+ );
3419
4266
  } catch (error: unknown) {
3420
4267
  // Keep the live worktree reference so the final diagnostic remains
3421
4268
  // actionable, while continuing through every sibling and ancestor.
3422
4269
  if (record.worktree) {
3423
- record.worktreeResult = cleanupFailureResult(record.worktree.repoRoot, record.worktree, error);
4270
+ record.worktreeResult = cleanupFailureResult(
4271
+ record.worktree.repoRoot,
4272
+ record.worktree,
4273
+ error,
4274
+ );
3424
4275
  }
3425
4276
  }
3426
4277
  };
@@ -3430,7 +4281,8 @@ export class AgentManager {
3430
4281
  for (const record of orderedRecords) await attemptCleanup(record);
3431
4282
  const pruneRepository = async (repo: string): Promise<void> => {
3432
4283
  try {
3433
- if (typeof pruneWorktreesAsync === "function") await pruneWorktreesAsync(repo);
4284
+ if (typeof pruneWorktreesAsync === "function")
4285
+ await pruneWorktreesAsync(repo);
3434
4286
  else pruneWorktrees(repo);
3435
4287
  } catch {
3436
4288
  // Pruning is best effort; individual cleanup results retain recovery data.
@@ -3449,22 +4301,89 @@ export class AgentManager {
3449
4301
  await pruneRepositories();
3450
4302
 
3451
4303
  const failures = orderedRecords
3452
- .filter((record): record is AgentRecord & { worktree: WorktreeInfo } => record.worktree !== undefined)
3453
- .map((record) => snapshotCleanupFailure(record.worktree, record.worktreeResult ?? {
3454
- hasChanges: false,
3455
- path: record.worktree.path,
3456
- cleanupSucceeded: false,
3457
- cleanupDiagnostic: `Worktree cleanup did not report an outcome for ${record.worktree.path}`,
3458
- recoveryCommands: worktreeRecoveryCommands(record.worktree.repoRoot, record.worktree.path),
3459
- }));
4304
+ .filter(
4305
+ (record): record is AgentRecord & { worktree: WorktreeInfo } =>
4306
+ record.worktree !== undefined,
4307
+ )
4308
+ .map((record) =>
4309
+ snapshotCleanupFailure(
4310
+ record.worktree,
4311
+ record.worktreeResult ?? {
4312
+ hasChanges: false,
4313
+ path: record.worktree.path,
4314
+ cleanupSucceeded: false,
4315
+ cleanupDiagnostic: `Worktree cleanup did not report an outcome for ${record.worktree.path}`,
4316
+ recoveryCommands: worktreeRecoveryCommands(
4317
+ record.worktree.repoRoot,
4318
+ record.worktree.path,
4319
+ ),
4320
+ },
4321
+ ),
4322
+ );
3460
4323
  this.worktreeCleanupFailures = Object.freeze(failures);
3461
4324
  if (failures.length > 0) {
3462
4325
  console.warn(
3463
- `[pi-subagents] Worktree cleanup failures:\n${failures.map((failure) =>
3464
- `${failure.path}: ${failure.reason} Recovery: ${failure.recoveryCommands.join(" && ")}`).join("\n")}`,
4326
+ `[pi-subagents] Worktree cleanup failures:\n${failures
4327
+ .map(
4328
+ (failure) =>
4329
+ `${failure.path}: ${failure.reason} Recovery: ${failure.recoveryCommands.join(" && ")}`,
4330
+ )
4331
+ .join("\n")}`,
3465
4332
  );
3466
4333
  }
3467
4334
 
4335
+ if (!teardownsSettled && failures.length > 0) {
4336
+ // Do not make host shutdown wait forever for an extension-owned child
4337
+ // session. Keep worktrees attached until *both* child teardown and all
4338
+ // providers settle; only then retry cleanup, deepest first. Detached
4339
+ // records cannot publish into a replacement session while this waits.
4340
+ const pending = [...this.sessionTeardowns, ...providerPromises];
4341
+ void Promise.allSettled(pending)
4342
+ .then(async (outcomes) => {
4343
+ if (outcomes.some((outcome) => outcome.status === "rejected")) return;
4344
+ const laterTeardowns = await Promise.allSettled([
4345
+ ...this.sessionTeardowns,
4346
+ ]);
4347
+ if (laterTeardowns.some((outcome) => outcome.status === "rejected"))
4348
+ return;
4349
+ for (const record of orderedRecords) {
4350
+ if (!record.worktree) continue;
4351
+ await this.cleanupRecordWorktreeAsync(
4352
+ record,
4353
+ record.worktree.repoRoot,
4354
+ record.description,
4355
+ );
4356
+ }
4357
+ this.worktreeCleanupFailures = Object.freeze(
4358
+ orderedRecords
4359
+ .filter(
4360
+ (record): record is AgentRecord & { worktree: WorktreeInfo } =>
4361
+ record.worktree !== undefined,
4362
+ )
4363
+ .map((record) =>
4364
+ snapshotCleanupFailure(
4365
+ record.worktree,
4366
+ record.worktreeResult ?? {
4367
+ hasChanges: false,
4368
+ path: record.worktree.path,
4369
+ cleanupSucceeded: false,
4370
+ cleanupDiagnostic: `Worktree cleanup did not report an outcome for ${record.worktree.path}`,
4371
+ recoveryCommands: worktreeRecoveryCommands(
4372
+ record.worktree.repoRoot,
4373
+ record.worktree.path,
4374
+ ),
4375
+ },
4376
+ ),
4377
+ ),
4378
+ );
4379
+ })
4380
+ .catch(() => {
4381
+ console.warn(
4382
+ "[pi-subagents] deferred child worktree cleanup failed; use the retained recovery instructions",
4383
+ );
4384
+ });
4385
+ }
4386
+
3468
4387
  // Clear authoritative and ownership metadata only after quarantine, session
3469
4388
  // shutdown, and all cleanup attempts. Detached continuations retain their
3470
4389
  // record object but cannot find it through this manager or recreate anything.