@signalridge/pi-subagents 1.5.0 → 1.7.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.
@@ -20,12 +20,22 @@ import {
20
20
  INTERNAL_AGENT_CONFIG_OVERRIDE,
21
21
  type InternalAgentConfigOverride,
22
22
  } from "./internal-run.js";
23
+ import { assignHandle, handleBase } from "./mention.js";
23
24
  import { shutdownAndDisposeSession } from "./session-lifecycle.js";
24
25
  import type { WorkflowThinking } from "./settings.js";
25
- import type { AgentInvocation, AgentOwner, AgentRecord, AgentRecordSnapshot, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
26
+ import type { AgentInvocation, AgentOwner, AgentRecord, AgentRecordSnapshot, IsolationMode, ResumableAgentEntry, SubagentType, ThinkingLevel } from "./types.js";
26
27
  import { addUsage } from "./usage.js";
27
28
  import type { WorkflowTierResolutionSnapshot } from "./workflow-tiers.js";
28
- import { cleanupWorktree, cleanupWorktreeAsync, createWorktree, pruneWorktrees, pruneWorktreesAsync, type WorktreeCleanupResult, type WorktreeInfo } from "./worktree.js";
29
+ import {
30
+ cleanupWorktree,
31
+ cleanupWorktreeAsync,
32
+ createWorktree,
33
+ isWorktreeIsolationEnabled,
34
+ pruneWorktrees,
35
+ pruneWorktreesAsync,
36
+ type WorktreeCleanupResult,
37
+ type WorktreeInfo,
38
+ } from "./worktree.js";
29
39
 
30
40
  export type OnAgentComplete = (record: AgentRecord) => void;
31
41
  export type OnAgentCreated = (record: AgentRecord) => void;
@@ -42,6 +52,15 @@ export interface WorktreeCleanupFailure {
42
52
 
43
53
  /** Default max concurrent background agents. */
44
54
  const DEFAULT_MAX_CONCURRENT = 4;
55
+ /** Bound on the resumable (`@handle`) index. */
56
+ const MAX_RESUMABLE_ENTRIES = 128;
57
+ /**
58
+ * Cumulative descendants one top-level agent may start, over its whole life.
59
+ *
60
+ * High enough that no ordinary delegation notices it, low enough that a runaway
61
+ * fan-out is caught by a number here rather than by the account's rate limit.
62
+ */
63
+ export const DEFAULT_MAX_SUBAGENT_SPAWNS_PER_BRANCH = 64;
45
64
  /**
46
65
  * Nested worktree cleanup waits for cooperative children, but a provider that
47
66
  * ignores abort must not hold its parent forever. Timed-out descendants are
@@ -157,15 +176,18 @@ function snapshotAgentRecord(record: AgentRecord): AgentRecordSnapshot {
157
176
 
158
177
  const WORKTREE_FAILURE_DIAGNOSTIC_LIMIT = 2_000;
159
178
 
160
- function shellQuote(value: string): string {
161
- return `'${value.replaceAll("'", "'\\''")}'`;
179
+ function shellQuote(value: string | undefined): string {
180
+ if (!value) return "''";
181
+ return `'${String(value).replaceAll("'", "'\\''")}'`;
162
182
  }
163
183
 
164
- function worktreeRecoveryCommands(cwd: string, path: string): readonly string[] {
184
+ function worktreeRecoveryCommands(cwd?: string, path?: string): readonly string[] {
185
+ const c = cwd ? shellQuote(cwd) : "'.'";
186
+ const p = path ? shellQuote(path) : "''";
165
187
  return Object.freeze([
166
- `git -C ${shellQuote(cwd)} worktree remove --force ${shellQuote(path)}`,
167
- `rm -rf -- ${shellQuote(path)}`,
168
- `git -C ${shellQuote(cwd)} worktree prune`,
188
+ `git -C ${c} worktree remove --force ${p}`,
189
+ `rm -rf -- ${p}`,
190
+ `git -C ${c} worktree prune`,
169
191
  ]);
170
192
  }
171
193
 
@@ -224,6 +246,9 @@ const MANAGED_TEXT_LIMIT = 8_000;
224
246
  const MANAGED_ERROR_LIMIT = 2_000;
225
247
  const MANAGED_PATH_LIMIT = 2_000;
226
248
  const MANAGED_MAX_TIMESTAMP = 8_640_000_000_000_000;
249
+ const MANAGED_PERSIST_RETRY_INITIAL_DELAY_MS = 25;
250
+ const MANAGED_PERSIST_RETRY_MAX_DELAY_MS = 2_000;
251
+ const MANAGED_PERSIST_RETRY_MAX_ATTEMPTS = 8;
227
252
 
228
253
  export type ManagedSpawnState = "queued" | "running" | "completed" | "failed" | "stopped" | "interrupted";
229
254
 
@@ -253,6 +278,10 @@ export interface ManagedSpawnTombstone {
253
278
  owner: AgentOwner;
254
279
  /** Semantic tier requested by the workflow; resolution remains internal. */
255
280
  tier?: WorkflowTier;
281
+ /** Named managed thread, when this spawn re-enters a sequential session. */
282
+ thread?: string;
283
+ /** Effective policy fingerprint that makes thread reuse safe across calls/reloads. */
284
+ threadPolicyFingerprint?: string;
256
285
  /** Internal audit snapshot of the resolved model/thinking policy. */
257
286
  tierSnapshot?: WorkflowTierResolutionSnapshot;
258
287
  state: ManagedSpawnState;
@@ -327,7 +356,30 @@ function normalizeManagedSpawnRequest(raw: unknown): ManagedSpawnRequest {
327
356
  return parseManagedSpawnRequest(raw);
328
357
  }
329
358
 
330
- function managedFingerprint(request: ManagedSpawnRequest): string {
359
+ function managedFingerprint(request: ManagedSpawnRequest, policyFingerprint?: string): string {
360
+ return createHash("sha256")
361
+ .update(JSON.stringify([
362
+ request.type,
363
+ request.prompt,
364
+ request.description,
365
+ ...(request.tier === undefined ? [] : [request.tier]),
366
+ request.model,
367
+ request.thinking,
368
+ request.toolset,
369
+ request.excludeTools,
370
+ request.isolation,
371
+ request.thread,
372
+ request.owner.extension,
373
+ request.owner.runId,
374
+ request.owner.nodeId,
375
+ request.owner.attemptId,
376
+ policyFingerprint,
377
+ ]))
378
+ .digest("hex");
379
+ }
380
+
381
+ /** Exact schema-v1 identity; do not add newer policy fields to this algorithm. */
382
+ function managedLegacyFingerprintV1(request: ManagedSpawnRequest): string {
331
383
  return createHash("sha256")
332
384
  .update(JSON.stringify([
333
385
  request.type,
@@ -342,6 +394,34 @@ function managedFingerprint(request: ManagedSpawnRequest): string {
342
394
  .digest("hex");
343
395
  }
344
396
 
397
+ /**
398
+ * Policy identity for a managed spawn. Prompts and owner attempt identities
399
+ * intentionally do not participate: a thread is allowed to receive new work,
400
+ * but its session cannot safely change model/tool/isolation policy.
401
+ */
402
+ function managedThreadPolicyFingerprint(request: ManagedSpawnRequest, policy: ManagedSpawnPolicy): string {
403
+ const model = policy.model as { provider?: unknown; id?: unknown } | undefined;
404
+ const excludeTools = [...new Set([...(request.excludeTools ?? []), ...(policy.excludeTools ?? [])])].sort();
405
+ return createHash("sha256")
406
+ .update(JSON.stringify([
407
+ request.type,
408
+ request.tier,
409
+ request.model,
410
+ request.thinking === "off" ? undefined : request.thinking,
411
+ request.toolset ?? policy.toolset,
412
+ excludeTools,
413
+ request.isolation ?? policy.isolation,
414
+ policy.maxTurns,
415
+ policy.isolated,
416
+ policy.inheritContext,
417
+ policy.thinkingLevel,
418
+ policy.policyFingerprint,
419
+ model?.provider,
420
+ model?.id,
421
+ ]))
422
+ .digest("hex");
423
+ }
424
+
345
425
  function isManagedState(value: unknown): value is ManagedSpawnState {
346
426
  return value === "queued" || value === "running" || value === "completed" || value === "failed" || value === "stopped" || value === "interrupted";
347
427
  }
@@ -448,6 +528,11 @@ function parseManagedTombstone(raw: unknown): ManagedSpawnTombstone | undefined
448
528
  if (!isManagedState(state)) return undefined;
449
529
  const terminalValue = raw.terminal;
450
530
  const persistedCompactionCount = raw.compactionCount;
531
+ const thread = raw.thread === undefined ? undefined : boundedManagedString(raw.thread, "thread", 128);
532
+ const threadPolicyFingerprint = raw.threadPolicyFingerprint === undefined
533
+ ? undefined
534
+ : boundedManagedString(raw.threadPolicyFingerprint, "threadPolicyFingerprint", 128);
535
+ if ((thread === undefined) !== (threadPolicyFingerprint === undefined)) return undefined;
451
536
  const tier = raw.tier;
452
537
  if (tier !== undefined && !isWorkflowTier(tier)) return undefined;
453
538
  const tierSnapshotValue = raw.tierSnapshot;
@@ -503,6 +588,7 @@ function parseManagedTombstone(raw: unknown): ManagedSpawnTombstone | undefined
503
588
  description: boundedManagedString(raw.description, "description", 512),
504
589
  owner: normalizeManagedOwner(raw.owner),
505
590
  ...(tier === undefined ? {} : { tier }),
591
+ ...(thread === undefined ? {} : { thread, threadPolicyFingerprint }),
506
592
  ...(tierSnapshot ? { tierSnapshot } : {}),
507
593
  state,
508
594
  createdAt,
@@ -526,6 +612,12 @@ export interface SpawnOptions {
526
612
  tier?: WorkflowTier;
527
613
  /** User-named model tier; resolved by pi-subagents at session start. */
528
614
  agentTier?: string;
615
+ /** Optional toolset hint; concrete tool availability remains agent-configured. */
616
+ toolset?: string;
617
+ /** Additional tool names denied for this invocation. */
618
+ excludeTools?: string[];
619
+ /** Named sequential-thread hint for workflow orchestration. */
620
+ thread?: string;
529
621
  isBackground?: boolean;
530
622
  /**
531
623
  * Skip the maxConcurrent queue check for this spawn — start immediately even
@@ -576,13 +668,48 @@ export interface SpawnOptions {
576
668
  configCwd?: string;
577
669
  /** Root session id, inherited by nested launches so transcripts stay grouped. */
578
670
  rootSessionId?: string;
671
+ /**
672
+ * Reuse an existing mention handle instead of allocating a fresh one. Set when
673
+ * a resumable entry is reopened, so `@handle` keeps addressing the same
674
+ * conversation across the eviction. Bypasses the uniqueness search on purpose:
675
+ * the handle is already spoken for by the entry being reopened.
676
+ */
677
+ reclaimHandle?: string;
678
+ /**
679
+ * Reopen the conversation in this session file rather than starting a new
680
+ * one. Like `reclaimHandle`, an internal capability: the value always comes
681
+ * from a resumable entry this extension wrote, never from a tool argument or
682
+ * an RPC payload — a forged path would let a spawn read an unrelated session.
683
+ */
684
+ resumeSessionFile?: string;
579
685
  }
580
686
 
581
687
  /** Internal managed-spawn policy; model and thinking are finalized by runAgent when a tier is present. */
582
688
  export type ManagedSpawnPolicy = Pick<
583
689
  SpawnOptions,
584
- "model" | "maxTurns" | "isolated" | "inheritContext" | "thinkingLevel" | "isolation" | "invocation" | "rootSessionId"
585
- >;
690
+ "model" | "maxTurns" | "isolated" | "inheritContext" | "thinkingLevel" | "isolation" | "invocation" | "rootSessionId" | "toolset" | "excludeTools"
691
+ > & {
692
+ /** In-process identity of the resolved agent definition/tool allowlist for thread reuse. */
693
+ policyFingerprint?: string;
694
+ };
695
+
696
+ interface ResumeTerminalSnapshot {
697
+ status: AgentRecord["status"];
698
+ startedAt: number;
699
+ completedAt?: number;
700
+ result?: string;
701
+ error?: string;
702
+ }
703
+
704
+ interface ResumeControl {
705
+ controller: AbortController;
706
+ cleanup: () => void;
707
+ snapshot: ResumeTerminalSnapshot;
708
+ deferred?: {
709
+ resolve: (value: string) => void;
710
+ reject: (error: unknown) => void;
711
+ };
712
+ }
586
713
 
587
714
  export class AgentManager {
588
715
  private agents = new Map<string, AgentRecord>();
@@ -593,14 +720,46 @@ export class AgentManager {
593
720
  private onCompact?: OnAgentCompact;
594
721
  private managedSpawns = new Map<string, ManagedSpawnTombstone>();
595
722
  private managedKeysById = new Map<string, string>();
723
+ /** Active workflow-thread name to the managed AgentSession it re-enters. */
724
+ private managedThreads = new Map<string, string>();
725
+ /** Policy identity captured with each thread; policy changes cannot bypass a denylist. */
726
+ private managedThreadPolicies = new Map<string, string>();
727
+ /** Reservation closes the synchronous onSpawned -> same-thread re-entry window. */
728
+ private managedThreadReservations = new Set<string>();
596
729
  private managedPersistence?: ManagedSpawnPersistence;
730
+ private readonly managedPersistenceRetries = new Map<string, {
731
+ tombstone: ManagedSpawnTombstone;
732
+ attempt: number;
733
+ timer?: ReturnType<typeof setTimeout>;
734
+ }>();
597
735
  private maxConcurrent: number;
598
736
  /** Base repos worktrees were created from — so dispose() can prune them all,
599
737
  * not just the parent repo (caller-supplied cwd can target other repos). */
600
738
  private worktreeRepos = new Set<string>();
601
739
 
602
- /** Queue of background agents waiting to start. */
603
- private queue: { id: string; args: SpawnArgs }[] = [];
740
+ /** Queue of background agents waiting to start (spawn or resume). */
741
+ private queue: Array<
742
+ | { kind: "spawn"; id: string; args: SpawnArgs }
743
+ | { kind: "resume"; id: string; start: () => void }
744
+ > = [];
745
+ /**
746
+ * Resumable index: evicted top-level agents whose conversations can still be
747
+ * reopened from disk (`@handle` reopen). Bounded; oldest evicted first.
748
+ * Distinct from ManagedSpawnTombstone (managed-spawn idempotency).
749
+ */
750
+ private readonly resumable = new Map<string, ResumableAgentEntry>();
751
+ /** Whether evicted records are indexed at all (settings: rememberAgents). */
752
+ private rememberAgents = true;
753
+ /** Whether spawned agents get `contact_supervisor` (settings). */
754
+ private supervisorQuestions = true;
755
+ /**
756
+ * Descendants spawned so far under each top-level agent, keyed by that
757
+ * agent's id. Cumulative for the branch's lifetime — see the budget check in
758
+ * `spawnInternal`. Pruned with the branch's metadata.
759
+ */
760
+ private readonly branchSpawnCounts = new Map<string, number>();
761
+ /** Cumulative descendants any one top-level agent may start (settings). */
762
+ private maxSubagentSpawnsPerBranch = DEFAULT_MAX_SUBAGENT_SPAWNS_PER_BRANCH;
604
763
  /** Number of currently running background agents. */
605
764
  private runningBackground = 0;
606
765
  /** IDs that currently hold a background pool slot; guards late/double settlements. */
@@ -613,6 +772,8 @@ export class AgentManager {
613
772
  private readonly removingRecords = new Set<string>();
614
773
  /** Records whose provider/session cleanup is still running after terminal status. */
615
774
  private readonly settlingRecords = new Set<string>();
775
+ /** Active resume controller/listener and the terminal state it superseded. */
776
+ private readonly resumeControls = new Map<string, ResumeControl>();
616
777
 
617
778
  /** Records whose runAgent provider/tool promise itself has not settled. */
618
779
  private readonly providerPendingRecords = new Set<string>();
@@ -744,6 +905,7 @@ export class AgentManager {
744
905
  const record = this.agents.get(ownerId);
745
906
  if (!record) return `parent agent "${ownerId}" is missing`;
746
907
  if (this.removingRecords.has(ownerId)) return `parent agent "${ownerId}" is being removed`;
908
+ if (this.deferredRecordRemovals.has(ownerId)) return `parent agent "${ownerId}" is pending removal`;
747
909
  if (record.detached) return `parent agent "${ownerId}" is detached`;
748
910
  if (this.nestedSpawnSeals.has(ownerId)) return `parent agent "${ownerId}" is sealed`;
749
911
  if (record.status !== "queued" && record.status !== "running") {
@@ -769,9 +931,57 @@ export class AgentManager {
769
931
  private canResumeNested(record: AgentRecord): boolean {
770
932
  return !record.detached && !this.removingRecords.has(record.id) &&
771
933
  !this.settlingRecords.has(record.id) &&
934
+ !this.deferredRecordRemovals.has(record.id) &&
772
935
  (record.parentAgentId === undefined || this.nestedOwnerValidation(record.parentAgentId) === undefined);
773
936
  }
774
937
 
938
+ private clearManagedPersistenceRetry(key: string): void {
939
+ const pending = this.managedPersistenceRetries.get(key);
940
+ if (!pending) return;
941
+ if (pending.timer) clearTimeout(pending.timer);
942
+ this.managedPersistenceRetries.delete(key);
943
+ }
944
+
945
+ private clearManagedPersistenceRetries(): void {
946
+ for (const key of this.managedPersistenceRetries.keys()) this.clearManagedPersistenceRetry(key);
947
+ }
948
+
949
+ private scheduleManagedPersistenceRetry(tombstone: ManagedSpawnTombstone): void {
950
+ if (this.disposed) return;
951
+ const persistence = this.managedPersistence;
952
+ if (!persistence) return;
953
+ const key = tombstone.spawnKey;
954
+ this.clearManagedPersistenceRetry(key);
955
+ const pending = { tombstone, attempt: 0, timer: undefined as ReturnType<typeof setTimeout> | undefined };
956
+ this.managedPersistenceRetries.set(key, pending);
957
+ const attempt = (): void => {
958
+ if (this.managedPersistenceRetries.get(key) !== pending) return;
959
+ pending.timer = undefined;
960
+ if (this.disposed || this.managedSpawns.get(key) !== tombstone) {
961
+ this.managedPersistenceRetries.delete(key);
962
+ return;
963
+ }
964
+ try {
965
+ persistence.append(cloneManagedTombstone(tombstone));
966
+ this.managedPersistenceRetries.delete(key);
967
+ } catch (error: unknown) {
968
+ pending.attempt += 1;
969
+ if (pending.attempt >= MANAGED_PERSIST_RETRY_MAX_ATTEMPTS) {
970
+ this.managedPersistenceRetries.delete(key);
971
+ console.warn(`[pi-subagents] managed tombstone persistence retry exhausted for ${key}: ${error instanceof Error ? error.message : String(error)}`);
972
+ return;
973
+ }
974
+ const delay = Math.min(
975
+ MANAGED_PERSIST_RETRY_MAX_DELAY_MS,
976
+ MANAGED_PERSIST_RETRY_INITIAL_DELAY_MS * 2 ** Math.min(pending.attempt - 1, 6),
977
+ );
978
+ pending.timer = setTimeout(attempt, delay);
979
+ pending.timer.unref?.();
980
+ }
981
+ };
982
+ queueMicrotask(attempt);
983
+ }
984
+
775
985
  private persistManaged(tombstone: ManagedSpawnTombstone, required = false): void {
776
986
  if (!this.managedPersistence) {
777
987
  if (required) throw new Error("managed spawn persistence is unavailable");
@@ -779,12 +989,14 @@ export class AgentManager {
779
989
  }
780
990
  try {
781
991
  this.managedPersistence.append(cloneManagedTombstone(tombstone));
992
+ this.clearManagedPersistenceRetry(tombstone.spawnKey);
782
993
  } catch (error: unknown) {
783
994
  if (required) {
784
995
  throw error instanceof Error ? error : new Error(String(error));
785
996
  }
786
- // Terminal updates are best effort; never change ordinary Agent
787
- // execution semantics because a later journal append failed.
997
+ // Terminal persistence is retried independently of the agent promise. A
998
+ // permanently unavailable journal degrades to a warning, never a failed run.
999
+ this.scheduleManagedPersistenceRetry(tombstone);
788
1000
  }
789
1001
  }
790
1002
 
@@ -800,6 +1012,7 @@ export class AgentManager {
800
1012
  private replaceManagedTombstone(key: string, next: ManagedSpawnTombstone, required = false): void {
801
1013
  const previous = this.managedSpawns.get(key);
802
1014
  if (previous && JSON.stringify(previous) === JSON.stringify(next)) return;
1015
+ this.clearManagedPersistenceRetry(key);
803
1016
  this.managedSpawns.set(key, next);
804
1017
  this.managedKeysById.set(next.id, key);
805
1018
  this.persistManaged(next, required);
@@ -889,9 +1102,13 @@ export class AgentManager {
889
1102
  entries: readonly ManagedSpawnEntryLike[],
890
1103
  options: { dropActive?: boolean } = {},
891
1104
  ): ManagedSpawnTombstone[] {
1105
+ this.clearManagedPersistenceRetries();
892
1106
  if (this.disposed) return [];
893
1107
  this.managedSpawns.clear();
894
1108
  this.managedKeysById.clear();
1109
+ this.managedThreads.clear();
1110
+ this.managedThreadPolicies.clear();
1111
+ this.managedThreadReservations.clear();
895
1112
  for (const entry of entries) {
896
1113
  if (entry.type !== "custom" || entry.customType !== MANAGED_SPAWN_ENTRY_TYPE) continue;
897
1114
  const tombstone = parseManagedTombstone(entry.data);
@@ -899,6 +1116,11 @@ export class AgentManager {
899
1116
  if (options.dropActive && !isManagedTerminalState(tombstone.state)) continue;
900
1117
  this.managedSpawns.set(tombstone.spawnKey, tombstone);
901
1118
  this.managedKeysById.set(tombstone.id, tombstone.spawnKey);
1119
+ if (tombstone.thread && tombstone.threadPolicyFingerprint) {
1120
+ const threadKey = `${tombstone.owner.runId}\u0000${tombstone.thread}`;
1121
+ this.managedThreads.set(threadKey, tombstone.id);
1122
+ this.managedThreadPolicies.set(threadKey, tombstone.threadPolicyFingerprint);
1123
+ }
902
1124
  }
903
1125
  const recovered: ManagedSpawnTombstone[] = [];
904
1126
  for (const [key, tombstone] of this.managedSpawns) {
@@ -918,6 +1140,22 @@ export class AgentManager {
918
1140
  return tombstone ? cloneManagedTombstone(tombstone) : undefined;
919
1141
  }
920
1142
 
1143
+ /** Reconcile a spawn whose RPC reply was lost after allocation. */
1144
+ reconcileManaged(spawnKey: string, owner: AgentOwner): ManagedSpawnResult | undefined {
1145
+ const key = spawnKey.trim();
1146
+ const tombstone = this.managedSpawns.get(key);
1147
+ if (!tombstone) return undefined;
1148
+ if (
1149
+ tombstone.owner.extension !== owner.extension ||
1150
+ tombstone.owner.runId !== owner.runId ||
1151
+ tombstone.owner.nodeId !== owner.nodeId ||
1152
+ tombstone.owner.attemptId !== owner.attemptId
1153
+ ) return undefined;
1154
+ const record = this.agents.get(tombstone.id);
1155
+ if (record && (record.status === "queued" || record.status === "running")) this.abortOwned(record.id, owner);
1156
+ return this.managedResult(key);
1157
+ }
1158
+
921
1159
  /**
922
1160
  * Spawn an agent and return its ID immediately (for background use).
923
1161
  * If the concurrency limit is reached, the agent is queued.
@@ -1000,6 +1238,30 @@ export class AgentManager {
1000
1238
  ? Object.freeze([...(nestedParent.ancestorAgentIds ?? []), nestedParent.id])
1001
1239
  : undefined;
1002
1240
 
1241
+ // Cumulative spawn budget for the branch. The depth cap bounds how DEEP
1242
+ // nesting goes and nothing about how WIDE it gets: with nesting on by
1243
+ // default (depth 2), a single top-level agent can fan out without limit,
1244
+ // because its only cost per child is one of its own turns — and max turns
1245
+ // is commonly unlimited. This counts every descendant of a top-level agent
1246
+ // for the branch's whole lifetime, so a runaway fan-out stops at a number
1247
+ // instead of at the account's rate limit.
1248
+ //
1249
+ // Counted here rather than in the nested tool so that every path into a
1250
+ // nested spawn is covered, and counted cumulatively rather than
1251
+ // concurrently on purpose: a loop spawning one child at a time, forever, is
1252
+ // exactly the shape a concurrency limit does not catch.
1253
+ const branchRoot = ancestorAgentIds?.[0] ?? nestedParent?.id;
1254
+ if (branchRoot !== undefined) {
1255
+ const spawned = this.branchSpawnCounts.get(branchRoot) ?? 0;
1256
+ if (spawned >= this.maxSubagentSpawnsPerBranch) {
1257
+ throw new Error(
1258
+ `Nested spawn budget exhausted for this branch (${spawned}/${this.maxSubagentSpawnsPerBranch} agents). ` +
1259
+ "Complete the remaining work directly, or raise `maxSubagentSpawnsPerBranch` in subagents.json.",
1260
+ );
1261
+ }
1262
+ this.branchSpawnCounts.set(branchRoot, spawned + 1);
1263
+ }
1264
+
1003
1265
  // Validate before the queue branch — a queued spawn should fail at the
1004
1266
  // call, not minutes later at drain. Throw (not warn): programmatic callers
1005
1267
  // can fix and retry; the RPC layer converts throws into error envelopes.
@@ -1031,6 +1293,14 @@ export class AgentManager {
1031
1293
  rootSessionId: options.rootSessionId,
1032
1294
  ...(owner ? { owner: Object.freeze({ ...owner }) } : {}),
1033
1295
  };
1296
+ // Top-level agents get a mention handle derived from their type. The name
1297
+ // space covers live records and resumable entries, so a handle is never
1298
+ // allocated twice while its conversation is still reachable.
1299
+ if (options.parentAgentId === undefined && options.reclaimHandle === undefined) {
1300
+ record.handle = assignHandle(handleBase(type), this.takenHandles());
1301
+ } else if (options.reclaimHandle !== undefined) {
1302
+ record.handle = options.reclaimHandle;
1303
+ }
1034
1304
  this.agents.set(id, record);
1035
1305
  if (!record.detached) {
1036
1306
  try { this.onCreated?.(record); } catch { /* observer failures cannot orphan a record */ }
@@ -1073,7 +1343,7 @@ export class AgentManager {
1073
1343
 
1074
1344
  if (occupiesPoolSlot(record) && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
1075
1345
  // Queue it — will be started when a running agent completes
1076
- this.queue.push({ id, args });
1346
+ this.queue.push({ kind: "spawn", id, args });
1077
1347
  return id;
1078
1348
  }
1079
1349
 
@@ -1118,16 +1388,90 @@ export class AgentManager {
1118
1388
  ): ManagedSpawnResult {
1119
1389
  if (this.disposed) throw new Error("AgentManager is disposed");
1120
1390
  const normalized = normalizeManagedSpawnRequest(request);
1391
+ if (normalized.thread && (normalized.isolation === "worktree" || policy.isolation === "worktree")) {
1392
+ throw new Error("Managed workflow threads cannot use worktree isolation; use separate calls instead.");
1393
+ }
1121
1394
  const scope = normalized.spawnKey;
1122
- const fingerprint = managedFingerprint(normalized);
1395
+ const policyFingerprint = managedThreadPolicyFingerprint(normalized, policy);
1396
+ const fingerprint = managedFingerprint(normalized, policyFingerprint);
1397
+ const legacyFingerprint = managedLegacyFingerprintV1(normalized);
1398
+ const threadPolicyFingerprint = normalized.thread ? policyFingerprint : undefined;
1123
1399
  const previous = this.managedSpawns.get(scope);
1124
1400
  if (previous) {
1125
- if (previous.fingerprint !== fingerprint) {
1401
+ if (previous.fingerprint !== fingerprint && previous.fingerprint !== legacyFingerprint) {
1126
1402
  throw new Error(`Managed spawn key conflict: "${normalized.spawnKey}"`);
1127
1403
  }
1404
+ if (normalized.thread && previous.threadPolicyFingerprint !== threadPolicyFingerprint) {
1405
+ throw new Error(`Managed workflow thread policy conflict: "${normalized.thread}"; use a new thread name for changed model/tool policy.`);
1406
+ }
1128
1407
  return this.managedResult(scope);
1129
1408
  }
1130
1409
 
1410
+ const threadKey = normalized.thread ? `${normalized.owner.runId}\u0000${normalized.thread}` : undefined;
1411
+ if (threadKey && this.managedThreadReservations.has(threadKey)) {
1412
+ throw new Error(`Managed workflow thread "${normalized.thread}" is already running; calls must be sequential.`);
1413
+ }
1414
+ const threadedId = threadKey ? this.managedThreads.get(threadKey) : undefined;
1415
+ if (threadedId) {
1416
+ if (threadPolicyFingerprint !== undefined && this.managedThreadPolicies.get(threadKey!) !== threadPolicyFingerprint) {
1417
+ throw new Error(`Managed workflow thread policy conflict: "${normalized.thread}"; use a new thread name for changed model/tool policy.`);
1418
+ }
1419
+ const record = this.agents.get(threadedId);
1420
+ if (record?.session && record.status !== "running" && record.status !== "queued" && !record.detached) {
1421
+ const previousOwner = record.owner;
1422
+ const previousInvocation = record.invocation;
1423
+ const previousManagedKey = this.managedKeysById.get(threadedId);
1424
+ record.owner = Object.freeze({ ...normalized.owner });
1425
+ record.invocation = policy.invocation;
1426
+ const now = Date.now();
1427
+ const tombstone: ManagedSpawnTombstone = {
1428
+ schemaVersion: MANAGED_SPAWN_SCHEMA_VERSION,
1429
+ spawnKey: scope,
1430
+ fingerprint,
1431
+ id: threadedId,
1432
+ requestId: normalized.requestId,
1433
+ type: normalized.type,
1434
+ description: normalized.description,
1435
+ owner: { ...normalized.owner },
1436
+ ...(normalized.tier === undefined ? {} : { tier: normalized.tier }),
1437
+ ...(normalized.thread === undefined ? {} : { thread: normalized.thread, threadPolicyFingerprint }),
1438
+ state: "queued",
1439
+ createdAt: now,
1440
+ updatedAt: now,
1441
+ compactionCount: record.compactionCount,
1442
+ };
1443
+ this.clearManagedPersistenceRetry(scope);
1444
+ this.managedSpawns.set(scope, tombstone);
1445
+ this.managedKeysById.set(threadedId, scope);
1446
+ try {
1447
+ this.persistManaged(tombstone, true);
1448
+ } catch (error: unknown) {
1449
+ this.managedSpawns.delete(scope);
1450
+ if (previousManagedKey === undefined) this.managedKeysById.delete(threadedId);
1451
+ else this.managedKeysById.set(threadedId, previousManagedKey);
1452
+ record.owner = previousOwner;
1453
+ record.invocation = previousInvocation;
1454
+ throw error;
1455
+ }
1456
+ void this.resume(threadedId, normalized.prompt, undefined, {
1457
+ isBackground: true,
1458
+ onToolActivity: callbacks?.onToolActivity,
1459
+ onAssistantUsage: callbacks?.onAssistantUsage,
1460
+ onCompaction: callbacks?.onCompaction ? (info) => callbacks.onCompaction?.(info as CompactionInfo) : undefined,
1461
+ }).catch(() => {});
1462
+ this.syncManagedRecord(record, true);
1463
+ const resumedState = String(record.status);
1464
+ return { id: threadedId, state: resumedState === "queued" ? "queued" : "running", created: true };
1465
+ }
1466
+ if (record && !record.detached && (record.status === "running" || record.status === "queued")) {
1467
+ throw new Error(`Managed workflow thread "${normalized.thread}" is already running; calls must be sequential.`);
1468
+ }
1469
+ if (!record || record.detached || !record.session) {
1470
+ this.managedThreads.delete(threadKey!);
1471
+ this.managedThreadPolicies.delete(threadKey!);
1472
+ }
1473
+ }
1474
+
1131
1475
  // Register and persist the immutable idempotency identity before starting
1132
1476
  // the session. A crash after allocation cannot cause a second AgentSession.
1133
1477
  const now = Date.now();
@@ -1141,7 +1485,8 @@ export class AgentManager {
1141
1485
  type: normalized.type,
1142
1486
  description: normalized.description,
1143
1487
  owner: { ...normalized.owner },
1144
- ...(normalized.tier === undefined ? {} : { tier: normalized.tier }),
1488
+ ...(normalized.tier === undefined ? {} : { tier: normalized.tier }),
1489
+ ...(normalized.thread === undefined ? {} : { thread: normalized.thread, threadPolicyFingerprint }),
1145
1490
  state: "queued",
1146
1491
  createdAt: now,
1147
1492
  updatedAt: now,
@@ -1149,6 +1494,11 @@ export class AgentManager {
1149
1494
  };
1150
1495
  this.managedSpawns.set(scope, tombstone);
1151
1496
  this.managedKeysById.set(id, scope);
1497
+ if (threadKey) {
1498
+ this.managedThreads.set(threadKey, id);
1499
+ this.managedThreadPolicies.set(threadKey, threadPolicyFingerprint!);
1500
+ this.managedThreadReservations.add(threadKey);
1501
+ }
1152
1502
  try {
1153
1503
  // The allocation identity must reach the session journal before the
1154
1504
  // AgentSession or queue can produce side effects. A failed append rolls
@@ -1157,6 +1507,11 @@ export class AgentManager {
1157
1507
  } catch (error: unknown) {
1158
1508
  this.managedSpawns.delete(scope);
1159
1509
  this.managedKeysById.delete(id);
1510
+ if (threadKey) {
1511
+ this.managedThreads.delete(threadKey);
1512
+ this.managedThreadPolicies.delete(threadKey);
1513
+ this.managedThreadReservations.delete(threadKey);
1514
+ }
1160
1515
  throw error;
1161
1516
  }
1162
1517
 
@@ -1164,11 +1519,17 @@ export class AgentManager {
1164
1519
  this.spawnInternal(pi, ctx, normalized.type, normalized.prompt, {
1165
1520
  ...policy,
1166
1521
  ...(normalized.tier === undefined ? {} : { tier: normalized.tier }),
1522
+ ...(normalized.thread === undefined ? {} : { thread: normalized.thread }),
1167
1523
  description: normalized.description,
1168
1524
  isBackground: true,
1169
1525
  ...callbacks,
1170
1526
  }, normalized.owner, id);
1171
1527
  } catch (error: unknown) {
1528
+ if (threadKey) {
1529
+ this.managedThreads.delete(threadKey);
1530
+ this.managedThreadPolicies.delete(threadKey);
1531
+ this.managedThreadReservations.delete(threadKey);
1532
+ }
1172
1533
  const existing = this.managedSpawns.get(scope);
1173
1534
  if (existing?.state === "failed" && existing.terminal) return this.managedResult(scope, true);
1174
1535
  const completedAt = Date.now();
@@ -1188,13 +1549,18 @@ export class AgentManager {
1188
1549
 
1189
1550
  const record = this.agents.get(id);
1190
1551
  if (record) this.syncManagedRecord(record);
1552
+ if (threadKey) this.managedThreadReservations.delete(threadKey);
1191
1553
  return this.managedResult(scope, true);
1192
1554
  }
1193
1555
 
1194
1556
  /** Forget managed idempotency keys when a logical session is replaced. */
1195
1557
  resetManagedSpawns(): void {
1558
+ this.clearManagedPersistenceRetries();
1196
1559
  this.managedSpawns.clear();
1197
1560
  this.managedKeysById.clear();
1561
+ this.managedThreads.clear();
1562
+ this.managedThreadPolicies.clear();
1563
+ this.managedThreadReservations.clear();
1198
1564
  }
1199
1565
 
1200
1566
  /** Actually start an agent (called immediately or from queue drain). */
@@ -1212,23 +1578,23 @@ export class AgentManager {
1212
1578
  // Worktree isolation: try to create a temporary git worktree. Strict —
1213
1579
  // fail loud if not possible (no silent fallback to main tree). Done
1214
1580
  // BEFORE state mutation so a throw doesn't leave the record half-running.
1581
+ // "off" explicitly opts out; global switch gates "worktree" deterministically.
1215
1582
  let worktreeCwd: string | undefined;
1216
1583
  let worktreeRepoRoot: string | undefined;
1217
- if (options.isolation === "worktree") {
1584
+ if (options.isolation === "off") {
1585
+ // Explicit opt-out — no worktree, no check.
1586
+ } else if (options.isolation === "worktree") {
1587
+ if (!isWorktreeIsolationEnabled()) {
1588
+ throw new Error('Cannot run with isolation: "worktree" — worktree isolation is disabled in project settings. Enable it or omit `isolation`.');
1589
+ }
1218
1590
  const wt = createWorktree(baseCwd, id);
1219
1591
  if (!wt) {
1220
1592
  throw new Error(
1221
1593
  'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
1222
- 'Initialize git and commit at least once, or omit `isolation`.',
1594
+ 'Initialize git and commit at least once, or omit `isolation`.',
1223
1595
  );
1224
1596
  }
1225
1597
  record.worktree = wt;
1226
- // workPath preserves subdirectory scoping for caller-supplied cwds: a
1227
- // cwd deep in a monorepo maps to the same subdir inside the copy, not
1228
- // the copied repo's root. Plain worktree spawns keep the historical
1229
- // behavior (agent at the copy's root) — moving them to workPath would
1230
- // also move .pi config discovery when the parent session sits in a repo
1231
- // subdirectory, silently dropping extensions/skills.
1232
1598
  worktreeCwd = customCwd !== undefined ? wt.workPath : wt.path;
1233
1599
  worktreeRepoRoot = wt.repoRoot;
1234
1600
  this.worktreeRepos.add(wt.repoRoot);
@@ -1287,6 +1653,14 @@ export class AgentManager {
1287
1653
  // off-limits, even when the invocation started in a subdirectory.
1288
1654
  worktreeBase: worktreeRepoRoot,
1289
1655
  configCwd: options.configCwd ?? (customCwd !== undefined ? ctx.cwd : undefined),
1656
+ // Top-level conversations persist by default so `@handle` has something
1657
+ // to reopen after the record is evicted; frontmatter still overrides.
1658
+ rememberAgents: this.rememberAgents,
1659
+ supervisorQuestions: this.supervisorQuestions,
1660
+ resumeSessionFile: options.resumeSessionFile,
1661
+ toolset: options.toolset,
1662
+ excludeTools: options.excludeTools,
1663
+ thread: options.thread,
1290
1664
  signal: record.abortController!.signal,
1291
1665
  onToolActivity: (activity) => {
1292
1666
  if (record.detached) return;
@@ -1350,6 +1724,9 @@ export class AgentManager {
1350
1724
  return;
1351
1725
  }
1352
1726
  record.session = session;
1727
+ // Capture the persisted session file so an evicted record can be
1728
+ // reopened as a resumable entry (@handle reopen).
1729
+ record.sessionFile = session.sessionManager?.getSessionFile?.() ?? (session as { sessionFile?: string })?.sessionFile;
1353
1730
  // Flush any steers that arrived before the session was ready
1354
1731
  if (record.pendingSteers?.length) {
1355
1732
  for (const msg of record.pendingSteers) {
@@ -1432,6 +1809,7 @@ export class AgentManager {
1432
1809
  // undefined output or session.
1433
1810
  record.result = responseText;
1434
1811
  record.session = session;
1812
+ record.sessionFile = session.sessionManager?.getSessionFile?.() ?? (session as { sessionFile?: string })?.sessionFile;
1435
1813
  this.syncManagedRecord(record);
1436
1814
 
1437
1815
  // Quiesce descendants before removing the parent's worktree. A nested
@@ -1634,6 +2012,20 @@ export class AgentManager {
1634
2012
  this.releasePoolSlot(record.id);
1635
2013
  continue;
1636
2014
  }
2015
+ if (next.kind === "resume") {
2016
+ try {
2017
+ next.start();
2018
+ } catch (err) {
2019
+ record.status = "error";
2020
+ record.error = err instanceof Error ? err.message : String(err);
2021
+ record.completedAt = Date.now();
2022
+ this.clearParentSignal(record.id);
2023
+ this.releasePoolSlot(record.id);
2024
+ this.syncManagedRecord(record);
2025
+ this.notifyComplete(record);
2026
+ }
2027
+ continue;
2028
+ }
1637
2029
  try {
1638
2030
  this.startAgent(next.id, record, next.args);
1639
2031
  } catch (err) {
@@ -1735,6 +2127,60 @@ export class AgentManager {
1735
2127
  return { id, record };
1736
2128
  }
1737
2129
 
2130
+ private resumeSnapshot(record: AgentRecord): ResumeTerminalSnapshot {
2131
+ return {
2132
+ status: record.status,
2133
+ startedAt: record.startedAt,
2134
+ ...(record.completedAt === undefined ? {} : { completedAt: record.completedAt }),
2135
+ ...(record.result === undefined ? {} : { result: record.result }),
2136
+ ...(record.error === undefined ? {} : { error: record.error }),
2137
+ };
2138
+ }
2139
+
2140
+ private restoreResumeSnapshot(record: AgentRecord, snapshot: ResumeTerminalSnapshot): void {
2141
+ record.status = snapshot.status;
2142
+ record.startedAt = snapshot.startedAt;
2143
+ record.completedAt = snapshot.completedAt;
2144
+ record.result = snapshot.result;
2145
+ record.error = snapshot.error;
2146
+ }
2147
+
2148
+ private beginResumeControl(
2149
+ id: string,
2150
+ record: AgentRecord,
2151
+ signal: AbortSignal | undefined,
2152
+ snapshot: ResumeTerminalSnapshot,
2153
+ deferred?: ResumeControl["deferred"],
2154
+ ): ResumeControl {
2155
+ const controller = new AbortController();
2156
+ let control!: ResumeControl;
2157
+ const onAbort = (): void => {
2158
+ controller.abort(signal?.reason);
2159
+ if (record.status === "queued" || record.status === "running") this.abort(id);
2160
+ };
2161
+ const cleanup = (): void => {
2162
+ signal?.removeEventListener("abort", onAbort);
2163
+ if (this.resumeControls.get(id) === control) this.resumeControls.delete(id);
2164
+ if (record.abortController === controller) record.abortController = undefined;
2165
+ };
2166
+ control = { controller, cleanup, snapshot, ...(deferred ? { deferred } : {}) };
2167
+ this.resumeControls.set(id, control);
2168
+ record.abortController = controller;
2169
+ if (signal) {
2170
+ signal.addEventListener("abort", onAbort, { once: true });
2171
+ if (signal.aborted) onAbort();
2172
+ }
2173
+ return control;
2174
+ }
2175
+
2176
+ private cancelResumeControl(id: string): void {
2177
+ const control = this.resumeControls.get(id);
2178
+ if (!control) return;
2179
+ control.controller.abort();
2180
+ control.cleanup();
2181
+ control.deferred?.resolve("");
2182
+ }
2183
+
1738
2184
  /**
1739
2185
  * Resume an existing agent session with a new prompt.
1740
2186
  */
@@ -1742,28 +2188,66 @@ export class AgentManager {
1742
2188
  id: string,
1743
2189
  prompt: string,
1744
2190
  signal?: AbortSignal,
2191
+ options?: {
2192
+ isBackground?: boolean;
2193
+ onToolActivity?: (activity: { type: "start" | "end"; toolName: string }) => void;
2194
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
2195
+ onCompaction?: (info: unknown) => void;
2196
+ },
1745
2197
  ): Promise<AgentRecord | undefined> {
1746
2198
  if (this.disposed) return undefined;
1747
2199
  const record = this.agents.get(id);
1748
2200
  if (!record) return undefined;
1749
- // Nested resumes are another way to restart work below an owner. Require
1750
- // the same live, unsuspended ancestor chain as a fresh nested spawn before
1751
- // touching the target record or invoking the provider.
1752
2201
  if (!this.canResumeNested(record)) return undefined;
1753
2202
  if (signal?.aborted) return record;
2203
+ if (this.resumeControls.has(id) || record.status === "running" || record.status === "queued") return undefined;
1754
2204
  if (!record.session) return undefined;
1755
- this.settlingRecords.add(id);
1756
2205
 
1757
- const previousStatus = record.status;
1758
- const previousCompletedAt = record.completedAt;
1759
- const previousResult = record.result;
1760
- const previousError = record.error;
2206
+ const snapshot = this.resumeSnapshot(record);
2207
+ if (options?.isBackground) {
2208
+ // A background resume is one lifecycle, including time spent queued. Keep
2209
+ // a promise on the record for quiesce/waitForAll instead of the completed
2210
+ // promise from the original spawn.
2211
+ let resolveResume!: (value: string) => void;
2212
+ let rejectResume!: (error: unknown) => void;
2213
+ const lifecyclePromise = new Promise<string>((resolve, reject) => {
2214
+ resolveResume = resolve;
2215
+ rejectResume = reject;
2216
+ });
2217
+ const control = this.beginResumeControl(id, record, signal, snapshot, {
2218
+ resolve: resolveResume,
2219
+ reject: rejectResume,
2220
+ });
2221
+ record.isBackground = true;
2222
+ record.resultConsumed = false;
2223
+ record.result = undefined;
2224
+ record.error = undefined;
2225
+ record.completedAt = undefined;
2226
+ record.status = "queued";
2227
+ record.promise = lifecyclePromise;
2228
+
2229
+ const start = (): void => {
2230
+ const execution = this.startResume(id, record, prompt, control, options);
2231
+ record.promise = execution;
2232
+ void execution
2233
+ .then(resolveResume, rejectResume)
2234
+ .catch(() => {});
2235
+ };
2236
+ if (occupiesPoolSlot(record) && this.runningBackground >= this.maxConcurrent) {
2237
+ this.queue.push({ kind: "resume", id, start });
2238
+ } else {
2239
+ start();
2240
+ }
2241
+ return record;
2242
+ }
2243
+
2244
+ this.settlingRecords.add(id);
2245
+ const control = this.beginResumeControl(id, record, signal, snapshot);
1761
2246
  record.status = "running";
1762
2247
  record.startedAt = Date.now();
1763
2248
  record.completedAt = undefined;
1764
2249
  record.result = undefined;
1765
2250
  record.error = undefined;
1766
-
1767
2251
  try {
1768
2252
  const { text, failure } = await resumeAgent(record.session, prompt, {
1769
2253
  onToolActivity: (activity) => {
@@ -1778,52 +2262,139 @@ export class AgentManager {
1778
2262
  this.syncManagedRecord(record);
1779
2263
  this.onCompact?.(record, info);
1780
2264
  },
1781
- signal,
2265
+ signal: control.controller.signal,
1782
2266
  });
1783
- if (!record.detached && (record.status as string) !== "stopped") {
1784
- // Same contract as the spawn path (#144): a failed final turn is an
1785
- // error, not a completion — but the resumed text stays available.
2267
+ if (!record.detached && !control.controller.signal.aborted && (record.status as AgentRecord["status"]) !== "stopped") {
1786
2268
  record.status = failure ? "error" : "completed";
1787
2269
  if (failure) record.error = failure;
1788
2270
  record.result = text;
1789
2271
  record.completedAt = Date.now();
1790
2272
  }
1791
2273
  } catch (err) {
1792
- if (!record.detached && (record.status as string) !== "stopped") {
1793
- record.status = "error";
1794
- record.error = err instanceof Error ? err.message : String(err);
2274
+ if (!record.detached && (record.status as AgentRecord["status"]) !== "stopped") {
2275
+ record.status = control.controller.signal.aborted ? "stopped" : "error";
2276
+ if (record.status === "error") record.error = err instanceof Error ? err.message : String(err);
1795
2277
  record.completedAt = Date.now();
1796
2278
  }
1797
- }
1798
-
1799
- if (record.detached) {
1800
- // A branch replacement/quiescence timeout owns this late continuation.
1801
- // Restore the pre-resume terminal snapshot rather than publishing the
1802
- // result from the detached session into the old record.
1803
- record.status = previousStatus;
1804
- record.completedAt = previousCompletedAt;
1805
- record.result = previousResult;
1806
- record.error = previousError;
2279
+ } finally {
2280
+ if (record.detached) this.restoreResumeSnapshot(record, snapshot);
2281
+ control.cleanup();
1807
2282
  this.settlingRecords.delete(id);
1808
2283
  this.retryDeferredRecordRemovals();
1809
- return undefined;
1810
2284
  }
1811
2285
 
1812
- // Same contract as the spawn settle paths: children spawned during the
1813
- // resumed turn must not outlive it — nothing else can see or reach them.
2286
+ if (record.detached) return undefined;
1814
2287
  this.syncManagedRecord(record);
1815
2288
  await this.abortOwnedChildren(id);
1816
2289
  if (record.detached) {
1817
- this.settlingRecords.delete(id);
2290
+ this.restoreResumeSnapshot(record, snapshot);
1818
2291
  this.retryDeferredRecordRemovals();
1819
2292
  return undefined;
1820
2293
  }
1821
-
1822
- this.settlingRecords.delete(id);
1823
2294
  this.retryDeferredRecordRemovals();
1824
2295
  return record;
1825
2296
  }
1826
2297
 
2298
+ /**
2299
+ * Run a background resume to completion. Mirrors the foreground resume's
2300
+ * settle contract (including nested-ownership, detach, and deferred-removal
2301
+ * handling) but acquires a pool slot and notifies on completion like a
2302
+ * background spawn. Called directly or from the queue drain.
2303
+ */
2304
+ private async startResume(
2305
+ id: string,
2306
+ record: AgentRecord,
2307
+ prompt: string,
2308
+ control: ResumeControl,
2309
+ options?: {
2310
+ isBackground?: boolean;
2311
+ onToolActivity?: (activity: { type: "start" | "end"; toolName: string }) => void;
2312
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
2313
+ onCompaction?: (info: unknown) => void;
2314
+ },
2315
+ ): Promise<string> {
2316
+ const snapshot = control.snapshot;
2317
+ let slotHeld = false;
2318
+ try {
2319
+ if (this.disposed || record.detached || this.agents.get(id) !== record) {
2320
+ if (record.detached) this.restoreResumeSnapshot(record, snapshot);
2321
+ return "";
2322
+ }
2323
+ if (!this.canResumeNested(record)) {
2324
+ record.status = "error";
2325
+ record.error = "owner branch is no longer resumable";
2326
+ record.completedAt = Date.now();
2327
+ this.syncManagedRecord(record);
2328
+ this.notifyComplete(record);
2329
+ return "";
2330
+ }
2331
+ this.settlingRecords.add(id);
2332
+ if (occupiesPoolSlot(record)) {
2333
+ this.heldPoolSlots.add(id);
2334
+ this.runningBackground++;
2335
+ slotHeld = true;
2336
+ }
2337
+ record.status = "running";
2338
+ record.startedAt = Date.now();
2339
+ record.completedAt = undefined;
2340
+ record.result = undefined;
2341
+ record.error = undefined;
2342
+ this.syncManagedRecord(record);
2343
+
2344
+ try {
2345
+ const { text, failure } = await resumeAgent(record.session!, prompt, {
2346
+ onToolActivity: (activity) => {
2347
+ if (!record.detached && activity.type === "end") record.toolUses++;
2348
+ options?.onToolActivity?.(activity);
2349
+ },
2350
+ onAssistantUsage: (usage) => {
2351
+ if (!record.detached) addUsage(record.lifetimeUsage, usage);
2352
+ options?.onAssistantUsage?.(usage);
2353
+ },
2354
+ onCompaction: (info) => {
2355
+ if (record.detached) return;
2356
+ record.compactionCount++;
2357
+ this.syncManagedRecord(record);
2358
+ this.onCompact?.(record, info);
2359
+ options?.onCompaction?.(info);
2360
+ },
2361
+ signal: control.controller.signal,
2362
+ });
2363
+ if (!record.detached && !control.controller.signal.aborted && (record.status as AgentRecord["status"]) !== "stopped") {
2364
+ record.status = failure ? "error" : "completed";
2365
+ if (failure) record.error = failure;
2366
+ record.result = text;
2367
+ record.completedAt = Date.now();
2368
+ }
2369
+ } catch (err) {
2370
+ if (!record.detached && (record.status as AgentRecord["status"]) !== "stopped") {
2371
+ record.status = control.controller.signal.aborted ? "stopped" : "error";
2372
+ if (record.status === "error") record.error = err instanceof Error ? err.message : String(err);
2373
+ record.completedAt = Date.now();
2374
+ }
2375
+ }
2376
+
2377
+ if (record.detached) {
2378
+ this.restoreResumeSnapshot(record, snapshot);
2379
+ return "";
2380
+ }
2381
+ this.syncManagedRecord(record);
2382
+ await this.abortOwnedChildren(id);
2383
+ if (record.detached) {
2384
+ this.restoreResumeSnapshot(record, snapshot);
2385
+ return "";
2386
+ }
2387
+ this.notifyComplete(record);
2388
+ return record.result ?? "";
2389
+ } finally {
2390
+ control.cleanup();
2391
+ this.settlingRecords.delete(id);
2392
+ if (slotHeld) this.releasePoolSlot(id);
2393
+ this.retryDeferredRecordRemovals();
2394
+ this.drainQueue();
2395
+ }
2396
+ }
2397
+
1827
2398
  /**
1828
2399
  * Send a steering message to an agent from the UI (mirrors the steer_subagent
1829
2400
  * tool). A live session delivers it now — it interrupts the agent after its
@@ -1879,6 +2450,7 @@ export class AgentManager {
1879
2450
  private abortRecord(id: string, drain = true): boolean {
1880
2451
  const record = this.agents.get(id);
1881
2452
  if (!record) return false;
2453
+ const resumeControl = this.resumeControls.get(id);
1882
2454
 
1883
2455
  // Remove from queue if queued.
1884
2456
  if (record.status === "queued") {
@@ -1886,6 +2458,9 @@ export class AgentManager {
1886
2458
  record.status = "stopped";
1887
2459
  record.completedAt = Date.now();
1888
2460
  this.clearParentSignal(record.id);
2461
+ resumeControl?.controller.abort();
2462
+ resumeControl?.cleanup();
2463
+ resumeControl?.deferred?.resolve("");
1889
2464
  this.syncManagedRecord(record);
1890
2465
  // Queued agents have no run promise yet. Still use the normal terminal
1891
2466
  // callback so lifecycle consumers (including workflow waits) cannot hang.
@@ -2008,6 +2583,17 @@ export class AgentManager {
2008
2583
  );
2009
2584
  if (!stillReferenced) this.nestedSpawnSeals.delete(id);
2010
2585
  }
2586
+ // Branch spawn budgets outlive their descendants on purpose — the count is
2587
+ // cumulative for the branch's life, so it must survive children being
2588
+ // evicted, or a slow loop would reset its own budget. It is dropped only
2589
+ // once the root itself is gone and nothing still descends from it.
2590
+ for (const rootId of this.branchSpawnCounts.keys()) {
2591
+ if (this.agents.has(rootId) || this.removingRecords.has(rootId)) continue;
2592
+ const stillReferenced = [...this.agents.values()].some(
2593
+ (record) => record.ancestorAgentIds?.includes(rootId) === true,
2594
+ );
2595
+ if (!stillReferenced) this.branchSpawnCounts.delete(rootId);
2596
+ }
2011
2597
  }
2012
2598
  private retryPinnedWorktreeCleanup(): void {
2013
2599
  if (this.disposed) return;
@@ -2074,9 +2660,16 @@ export class AgentManager {
2074
2660
  try { record.outputCleanup(); } catch { /* ignore stale transcript cleanup errors */ }
2075
2661
  record.outputCleanup = undefined;
2076
2662
  }
2663
+ this.indexResumable(record);
2077
2664
  if (record.session) this.trackRecordSessionTeardown(id, record.session);
2078
2665
  record.session = undefined;
2079
2666
  this.clearParentSignal(id);
2667
+ for (const [thread, threadId] of this.managedThreads) {
2668
+ if (threadId !== id) continue;
2669
+ this.managedThreads.delete(thread);
2670
+ this.managedThreadPolicies.delete(thread);
2671
+ this.managedThreadReservations.delete(thread);
2672
+ }
2080
2673
  this.agents.delete(id);
2081
2674
  return true;
2082
2675
  } finally {
@@ -2088,6 +2681,132 @@ export class AgentManager {
2088
2681
  }
2089
2682
  }
2090
2683
 
2684
+ /**
2685
+ * Preserve enough of a departing record for `@handle` to reopen its
2686
+ * conversation later. Nothing to keep unless it has a session file to reopen
2687
+ * — an in-memory session leaves no transcript, so the mention would have
2688
+ * nothing to continue from. Only top-level agents are indexed.
2689
+ */
2690
+ private indexResumable(record: AgentRecord): void {
2691
+ if (!this.rememberAgents) return;
2692
+ if (record.parentAgentId !== undefined) return;
2693
+ if (!record.sessionFile || !record.session) return;
2694
+ const entry: ResumableAgentEntry = {
2695
+ handle: record.handle ?? record.id,
2696
+ id: record.id,
2697
+ type: record.type,
2698
+ description: record.description,
2699
+ sessionFile: record.sessionFile,
2700
+ completedAt: record.completedAt ?? Date.now(),
2701
+ };
2702
+ this.resumable.set(entry.handle, entry);
2703
+ // Bound the memory a long session can accumulate. Oldest first, since the
2704
+ // agent someone still wants to reach is the one they used most recently.
2705
+ while (this.resumable.size > MAX_RESUMABLE_ENTRIES) {
2706
+ let oldest: ResumableAgentEntry | undefined;
2707
+ for (const candidate of this.resumable.values()) {
2708
+ if (!oldest || candidate.completedAt <= oldest.completedAt) oldest = candidate;
2709
+ }
2710
+ if (!oldest) break;
2711
+ this.resumable.delete(oldest.handle);
2712
+ }
2713
+ }
2714
+
2715
+ /**
2716
+ * Every handle currently spoken for — live records and resumable entries
2717
+ * alike. One shared set, so a fresh spawn can never be handed the handle of
2718
+ * an evicted conversation that `@handle` can still reopen.
2719
+ */
2720
+ private takenHandles(): ReadonlySet<string> {
2721
+ const taken = new Set<string>(this.resumable.keys());
2722
+ for (const record of this.agents.values()) {
2723
+ if (record.handle) taken.add(record.handle);
2724
+ }
2725
+ return taken;
2726
+ }
2727
+
2728
+ /**
2729
+ * What `@handle` currently addresses: the live record holding that handle,
2730
+ * else the resumable entry left behind when it was evicted.
2731
+ *
2732
+ * Live wins. A resumable entry is deliberately kept after its conversation is
2733
+ * reopened (the reopened record may die before establishing a session of its
2734
+ * own, and the original transcript is still the right thing to reopen next
2735
+ * time), so both can hold the same handle at once — and while a record is
2736
+ * live, it is the one the user means.
2737
+ */
2738
+ resolveMention(handle: string): { kind: "live"; record: AgentRecord } | { kind: "resumable"; entry: ResumableAgentEntry } | undefined {
2739
+ const wanted = handle.toLowerCase();
2740
+ for (const record of this.agents.values()) {
2741
+ if (record.detached) continue;
2742
+ if (record.handle?.toLowerCase() === wanted || record.id === handle) return { kind: "live", record };
2743
+ }
2744
+ const entry = this.getResumable(handle);
2745
+ return entry ? { kind: "resumable", entry } : undefined;
2746
+ }
2747
+
2748
+ /** Resolve a resumable entry by handle or id. */
2749
+ getResumable(name: string): ResumableAgentEntry | undefined {
2750
+ const wanted = name.toLowerCase();
2751
+ for (const entry of this.resumable.values()) {
2752
+ if (entry.handle.toLowerCase() === wanted || entry.id === name) return entry;
2753
+ }
2754
+ return undefined;
2755
+ }
2756
+
2757
+ /** Evicted agents whose conversation can still be reopened, newest first. */
2758
+ listResumable(): ResumableAgentEntry[] {
2759
+ return [...this.resumable.values()].sort((a, b) => b.completedAt - a.completedAt);
2760
+ }
2761
+
2762
+ /** Forget an evicted agent, by handle or id. */
2763
+ dropResumable(name: string): boolean {
2764
+ const entry = this.getResumable(name);
2765
+ if (!entry) return false;
2766
+ this.resumable.delete(entry.handle);
2767
+ return true;
2768
+ }
2769
+
2770
+ /** Cumulative descendants any one top-level agent may start. */
2771
+ getMaxSubagentSpawnsPerBranch(): number {
2772
+ return this.maxSubagentSpawnsPerBranch;
2773
+ }
2774
+
2775
+ /**
2776
+ * Set the branch spawn budget. `0` is refused rather than treated as
2777
+ * "unlimited": zero reads as a limit, and silently meaning its opposite is
2778
+ * how a safety valve gets disabled by accident. Nesting is turned off with
2779
+ * `maxSubagentDepth`, which says so.
2780
+ */
2781
+ setMaxSubagentSpawnsPerBranch(n: number): void {
2782
+ if (!Number.isSafeInteger(n) || n < 1) return;
2783
+ this.maxSubagentSpawnsPerBranch = n;
2784
+ }
2785
+
2786
+ /** Descendants started so far under a top-level agent (for UI and tests). */
2787
+ getBranchSpawnCount(rootAgentId: string): number {
2788
+ return this.branchSpawnCounts.get(rootAgentId) ?? 0;
2789
+ }
2790
+
2791
+ /** Whether spawned agents may ask their human a question. */
2792
+ getSupervisorQuestions(): boolean {
2793
+ return this.supervisorQuestions;
2794
+ }
2795
+
2796
+ setSupervisorQuestions(enabled: boolean): void {
2797
+ this.supervisorQuestions = enabled;
2798
+ }
2799
+
2800
+ /** Whether evicted records are indexed for `@handle` reopen. */
2801
+ getRememberAgents(): boolean {
2802
+ return this.rememberAgents;
2803
+ }
2804
+
2805
+ setRememberAgents(enabled: boolean): void {
2806
+ this.rememberAgents = enabled;
2807
+ if (!enabled) this.resumable.clear();
2808
+ }
2809
+
2091
2810
  private cleanup() {
2092
2811
  const cutoff = Date.now() - 10 * 60_000;
2093
2812
  for (const [id, record] of this.agents) {
@@ -2096,9 +2815,7 @@ export class AgentManager {
2096
2815
  this.removeRecord(id, record);
2097
2816
  }
2098
2817
  this.retryDeferredRecordRemovals();
2099
- }
2100
-
2101
- /**
2818
+ } /**
2102
2819
  * Remove all completed/stopped/errored records immediately.
2103
2820
  * Called on session start/switch so tasks from a prior session don't persist.
2104
2821
  * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet
@@ -2307,6 +3024,13 @@ export class AgentManager {
2307
3024
  /** Quarantine a record before any late provider continuation can observe it. */
2308
3025
  private quarantineRecord(id: string, record: AgentRecord): void {
2309
3026
  record.detached = true;
3027
+ const resumeControl = this.resumeControls.get(id);
3028
+ if (resumeControl) {
3029
+ this.restoreResumeSnapshot(record, resumeControl.snapshot);
3030
+ this.cancelResumeControl(id);
3031
+ } else {
3032
+ record.abortController?.abort();
3033
+ }
2310
3034
 
2311
3035
  if (record.status === "queued" || record.status === "running") {
2312
3036
  record.status = "stopped";
@@ -2408,6 +3132,7 @@ export class AgentManager {
2408
3132
  * notifications, or persistence into the replacement branch.
2409
3133
  */
2410
3134
  detachForBranchChange(): void {
3135
+ this.clearManagedPersistenceRetries();
2411
3136
  const records = [...this.agents.values()]
2412
3137
  .filter((record) => !this.isFullyCleaned(record))
2413
3138
  .sort((left, right) => this.recordDepth(right) - this.recordDepth(left));
@@ -2458,6 +3183,7 @@ export class AgentManager {
2458
3183
  * session shutdown does not block the event loop.
2459
3184
  */
2460
3185
  dispose(): Promise<readonly WorktreeCleanupFailure[]> {
3186
+ this.clearManagedPersistenceRetries();
2461
3187
  if (this.disposePromise) return this.disposePromise;
2462
3188
  this.disposed = true;
2463
3189
  clearInterval(this.cleanupInterval);
@@ -2488,6 +3214,7 @@ export class AgentManager {
2488
3214
  record.status = "stopped";
2489
3215
  record.completedAt ??= Date.now();
2490
3216
  }
3217
+ this.cancelResumeControl(record.id);
2491
3218
  record.pendingSteers = undefined;
2492
3219
  this.nestedSpawnSeals.add(record.id);
2493
3220
  this.removingRecords.add(record.id);
@@ -2496,6 +3223,7 @@ export class AgentManager {
2496
3223
  record.outputCleanup = undefined;
2497
3224
  }
2498
3225
  }
3226
+ this.resumeControls.clear();
2499
3227
 
2500
3228
  this.disposePromise = this.finishDispose(records, reposToPrune);
2501
3229
  return this.disposePromise;
@@ -2597,6 +3325,9 @@ export class AgentManager {
2597
3325
  this.agents.clear();
2598
3326
  this.managedSpawns.clear();
2599
3327
  this.managedKeysById.clear();
3328
+ this.managedThreads.clear();
3329
+ this.managedThreadPolicies.clear();
3330
+ this.managedThreadReservations.clear();
2600
3331
  this.nestedSpawnSeals.clear();
2601
3332
  this.removingRecords.clear();
2602
3333
  this.settlingRecords.clear();