@signalridge/pi-subagents 1.11.1 → 1.13.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.
@@ -43,6 +43,7 @@ import {
43
43
  assertParentModelRuntimeAvailable,
44
44
  parentModelSessionOptions,
45
45
  } from "./model-runtime-bridge.js";
46
+ import type { ParentQuestionHandler } from "./parent-questions.js";
46
47
  import { shutdownAndDisposeSession } from "./session-lifecycle.js";
47
48
  import type { TierThinking } from "./settings.js";
48
49
  import type {
@@ -55,7 +56,7 @@ import type {
55
56
  SubagentType,
56
57
  ThinkingLevel,
57
58
  } from "./types.js";
58
- import { addUsage } from "./usage.js";
59
+ import { addUsage, type LifetimeUsage } from "./usage.js";
59
60
  import {
60
61
  cleanupWorktree,
61
62
  cleanupWorktreeAsync,
@@ -70,6 +71,13 @@ import {
70
71
  export type OnAgentComplete = (record: AgentRecord) => void;
71
72
  export type OnAgentCreated = (record: AgentRecord) => void;
72
73
  export type OnAgentStart = (record: AgentRecord) => void;
74
+ export type OnAgentHandoff = (record: AgentRecord) => void;
75
+
76
+ /** Optional UI identity captured before submitting asynchronous steering. */
77
+ export interface SteeringIdentity {
78
+ session: AgentSession;
79
+ owner?: AgentOwner;
80
+ }
73
81
  export type OnAgentCompact = (
74
82
  record: AgentRecord,
75
83
  info: CompactionInfo,
@@ -314,6 +322,7 @@ interface SpawnArgs {
314
322
  prompt: string;
315
323
  options: SpawnOptions;
316
324
  internalOverride?: InternalAgentConfigOverride;
325
+ parentQuestion?: ParentQuestionHandler;
317
326
  }
318
327
 
319
328
  export type ManagedSpawnRequest = ProtocolManagedSpawnRequest;
@@ -912,6 +921,8 @@ export interface SpawnOptions {
912
921
  /** Named sequential-thread hint for workflow orchestration. */
913
922
  thread?: string;
914
923
  isBackground?: boolean;
924
+ /** Explicit clarification routing. Parent mode requires a fresh root background spawn. */
925
+ supervisorQuestions?: boolean | "parent";
915
926
  /**
916
927
  * Skip the maxConcurrent queue check for this spawn — start immediately even
917
928
  * if the configured concurrency limit would otherwise queue it. Used by the
@@ -1000,6 +1011,20 @@ export type ManagedSpawnPolicy = Pick<
1000
1011
  policyFingerprint?: string;
1001
1012
  };
1002
1013
 
1014
+ export type ManagedSpawnCallbacks = Pick<
1015
+ SpawnOptions,
1016
+ | "onToolActivity"
1017
+ | "onTextDelta"
1018
+ | "onSessionCreated"
1019
+ | "onTurnEnd"
1020
+ | "onAssistantUsage"
1021
+ | "onCompaction"
1022
+ | "onSpawned"
1023
+ > & {
1024
+ /** Reused thread's actual run boundary, before native steering or prompt. */
1025
+ onResumeStarting?: (record: AgentRecord, startMessageIndex: number) => void;
1026
+ };
1027
+
1003
1028
  interface ResumeTerminalSnapshot {
1004
1029
  status: AgentRecord["status"];
1005
1030
  startedAt: number;
@@ -1027,8 +1052,32 @@ export class AgentManager {
1027
1052
  private onCreated?: OnAgentCreated;
1028
1053
  private onStart?: OnAgentStart;
1029
1054
  private onCompact?: OnAgentCompact;
1055
+ private onHandoff?: OnAgentHandoff;
1056
+ /** Only public, fresh foreground waiters hold this one-way capability. */
1057
+ private readonly foregroundWaiters = new Map<string, {
1058
+ record: AgentRecord;
1059
+ promise: Promise<string>;
1060
+ resolve: () => void;
1061
+ }>();
1062
+ /** Native input handlers may await before writing to their session's queue. */
1063
+ private readonly steeringOperations = new Map<AgentRecord, Set<Promise<void>>>();
1064
+ /** Keep UI provenance without changing legacy/public pendingSteers strings. */
1065
+ private readonly acceptedPendingSteers = new WeakMap<string[], Map<number, {
1066
+ owner: AgentOwner | undefined;
1067
+ controller: AbortController | undefined;
1068
+ }>>();
1069
+ private readonly steeringThreadPins = new Map<string, number>();
1070
+ /** An accepted native queue must not leak into a replacement attempt. */
1071
+ private readonly steeringQueuePins = new WeakMap<AgentRecord, AgentSession>();
1072
+ private readonly steeringQueueObservers = new WeakMap<AgentRecord, () => void>();
1030
1073
  private managedSpawns = new Map<string, ManagedSpawnTombstone>();
1031
1074
  private managedKeysById = new Map<string, string>();
1075
+ /** Runtime-only attempt baselines; session lifetime counters never reset. */
1076
+ private readonly managedUsageBaselines = new Map<string, {
1077
+ record: AgentRecord;
1078
+ owner: AgentOwner;
1079
+ usage: LifetimeUsage;
1080
+ }>();
1032
1081
  /** Spawn keys found in unsupported/corrupt persisted tombstones. */
1033
1082
  private managedQuarantinedKeys = new Set<string>();
1034
1083
  /**
@@ -1074,6 +1123,13 @@ export class AgentManager {
1074
1123
  private rememberAgents = true;
1075
1124
  /** Whether spawned agents get `contact_supervisor` (settings). */
1076
1125
  private supervisorQuestions = true;
1126
+ private parentQuestionFactory?: (record: AgentRecord, ctx: ExtensionContext) => ParentQuestionHandler;
1127
+ private parentQuestionAgents = new WeakSet<AgentRecord>();
1128
+
1129
+ /** Package-local root capability; no managed RPC schema or global settings change. */
1130
+ setParentQuestionFactory(factory: (record: AgentRecord, ctx: ExtensionContext) => ParentQuestionHandler): void {
1131
+ this.parentQuestionFactory = factory;
1132
+ }
1077
1133
  /**
1078
1134
  * Descendants spawned so far under each top-level agent, keyed by that
1079
1135
  * agent's id. Cumulative for the branch's lifetime — see the budget check in
@@ -1120,11 +1176,13 @@ export class AgentManager {
1120
1176
  onCompact?: OnAgentCompact,
1121
1177
  onCreated?: OnAgentCreated,
1122
1178
  managedPersistence?: ManagedSpawnPersistence,
1179
+ onHandoff?: OnAgentHandoff,
1123
1180
  ) {
1124
1181
  this.onComplete = onComplete;
1125
1182
  this.onCreated = onCreated;
1126
1183
  this.onStart = onStart;
1127
1184
  this.onCompact = onCompact;
1185
+ this.onHandoff = onHandoff;
1128
1186
  this.managedPersistence = managedPersistence;
1129
1187
  this.maxConcurrent = maxConcurrent;
1130
1188
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
@@ -1279,12 +1337,22 @@ export class AgentManager {
1279
1337
  !record.detached &&
1280
1338
  !this.removingRecords.has(record.id) &&
1281
1339
  !this.settlingRecords.has(record.id) &&
1340
+ !this.providerPendingRecords.has(record.id) &&
1341
+ !this.recordSessionTeardowns.has(record.id) &&
1342
+ !this.hasPendingSteering(record) &&
1282
1343
  !this.deferredRecordRemovals.has(record.id) &&
1283
1344
  (record.parentAgentId === undefined ||
1284
1345
  this.nestedOwnerValidation(record.parentAgentId) === undefined)
1285
1346
  );
1286
1347
  }
1287
1348
 
1349
+ private canResumeRecord(record: AgentRecord): boolean {
1350
+ return this.canResumeNested(record) &&
1351
+ !this.resumeControls.has(record.id) &&
1352
+ record.status !== "running" && record.status !== "queued" &&
1353
+ record.session !== undefined;
1354
+ }
1355
+
1288
1356
  private clearManagedPersistenceRetry(key: string): void {
1289
1357
  const pending = this.managedPersistenceRetries.get(key);
1290
1358
  if (!pending) return;
@@ -1402,6 +1470,7 @@ export class AgentManager {
1402
1470
  const state: ManagedSpawnState =
1403
1471
  terminalStatus ?? (record.status === "queued" ? "queued" : "running");
1404
1472
  const compactionCount = Math.max(0, Math.floor(record.compactionCount));
1473
+ const usage = this.getManagedUsage(record.id, tombstone.owner);
1405
1474
  const terminal = terminalStatus
1406
1475
  ? {
1407
1476
  status: terminalStatus,
@@ -1414,10 +1483,10 @@ export class AgentManager {
1414
1483
  ...(record.outputFile
1415
1484
  ? { outputFile: record.outputFile.slice(0, MANAGED_PATH_LIMIT) }
1416
1485
  : {}),
1417
- ...(record.lifetimeUsage.input + record.lifetimeUsage.output > 0
1486
+ ...(usage
1418
1487
  ? {
1419
1488
  tokenCount: Math.floor(
1420
- record.lifetimeUsage.input + record.lifetimeUsage.output,
1489
+ usage.input + usage.output + usage.cacheWrite,
1421
1490
  ),
1422
1491
  }
1423
1492
  : {}),
@@ -1584,6 +1653,23 @@ export class AgentManager {
1584
1653
  return recovered;
1585
1654
  }
1586
1655
 
1656
+ /** Fresh per-attempt usage, available only for the current exact live owner. */
1657
+ getManagedUsage(id: string, owner: AgentOwner): LifetimeUsage | undefined {
1658
+ const baseline = this.managedUsageBaselines.get(id);
1659
+ const record = this.agents.get(id);
1660
+ if (!baseline || !record || record.detached || this.disposed ||
1661
+ baseline.record !== record || baseline.owner !== record.owner ||
1662
+ baseline.owner.extension !== owner.extension ||
1663
+ baseline.owner.runId !== owner.runId ||
1664
+ baseline.owner.nodeId !== owner.nodeId ||
1665
+ baseline.owner.attemptId !== owner.attemptId) return undefined;
1666
+ const delta = (component: keyof LifetimeUsage): number => {
1667
+ const value = record.lifetimeUsage[component] - baseline.usage[component];
1668
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
1669
+ };
1670
+ return { input: delta("input"), output: delta("output"), cacheWrite: delta("cacheWrite") };
1671
+ }
1672
+
1587
1673
  getManagedSpawn(spawnKey: string): ManagedSpawnTombstone | undefined {
1588
1674
  const tombstone = this.managedSpawns.get(spawnKey.trim());
1589
1675
  return tombstone ? cloneManagedTombstone(tombstone) : undefined;
@@ -1663,6 +1749,14 @@ export class AgentManager {
1663
1749
  // Legacy in-process callers omitted description before it became required.
1664
1750
  // Normalize that shape to an inert string while rejecting reference values.
1665
1751
  options = { ...options, description: options.description ?? type };
1752
+ if (options.supervisorQuestions !== undefined && typeof options.supervisorQuestions !== "boolean" && options.supervisorQuestions !== "parent") {
1753
+ throw new Error('supervisorQuestions must be boolean or "parent".');
1754
+ }
1755
+ if (options.supervisorQuestions === "parent" &&
1756
+ (options.isBackground !== true || options.parentAgentId !== undefined || options.resumeSessionFile ||
1757
+ options.isolated || getAgentConfig(type)?.isolated || !this.supervisorQuestions || !this.parentQuestionFactory)) {
1758
+ throw new Error('Parent clarification requires a fresh, non-isolated top-level background Agent with supervisor questions enabled. Set run_in_background: true; foreground calls cannot answer while blocked.');
1759
+ }
1666
1760
  // Covers top-level, nested, managed, and queued spawns before allocating a
1667
1761
  // record, consuming a branch budget, or creating an isolation worktree.
1668
1762
  assertNoRawInheritance(
@@ -1791,6 +1885,10 @@ export class AgentManager {
1791
1885
  record.handle = options.reclaimHandle;
1792
1886
  }
1793
1887
  this.agents.set(id, record);
1888
+ // Capture before observers or a synchronous runner can report early usage.
1889
+ if (record.owner) this.managedUsageBaselines.set(id, {
1890
+ record, owner: record.owner, usage: { ...record.lifetimeUsage },
1891
+ });
1794
1892
  if (!record.detached) {
1795
1893
  try {
1796
1894
  this.onCreated?.(record);
@@ -1811,7 +1909,9 @@ export class AgentManager {
1811
1909
  prompt,
1812
1910
  options,
1813
1911
  internalOverride,
1912
+ ...(options.supervisorQuestions === "parent" ? { parentQuestion: this.parentQuestionFactory!(record, ctx) } : {}),
1814
1913
  };
1914
+ if (options.supervisorQuestions === "parent") this.parentQuestionAgents.add(record);
1815
1915
  // Lifecycle observers may synchronously stop or dispose a freshly allocated
1816
1916
  // record. Do not let the normal queue/start path resurrect that decision.
1817
1917
  if (
@@ -1897,16 +1997,7 @@ export class AgentManager {
1897
1997
  ctx: ExtensionContext,
1898
1998
  request: ManagedSpawnRequest,
1899
1999
  policy: ManagedSpawnPolicy,
1900
- callbacks?: Pick<
1901
- SpawnOptions,
1902
- | "onToolActivity"
1903
- | "onTextDelta"
1904
- | "onSessionCreated"
1905
- | "onTurnEnd"
1906
- | "onAssistantUsage"
1907
- | "onCompaction"
1908
- | "onSpawned"
1909
- >,
2000
+ callbacks?: ManagedSpawnCallbacks,
1910
2001
  ): ManagedSpawnResult {
1911
2002
  if (this.disposed) throw new Error("AgentManager is disposed");
1912
2003
  const normalized = parseManagedSpawnRequest(request);
@@ -1993,6 +2084,9 @@ export class AgentManager {
1993
2084
  const threadKey = thread
1994
2085
  ? `${normalized.owner.runId}\u0000${thread}`
1995
2086
  : undefined;
2087
+ if (threadKey && this.steeringThreadPins.has(threadKey)) {
2088
+ throw new Error(`Managed workflow thread "${thread}" has pending UI steering; retry after it settles.`);
2089
+ }
1996
2090
  if (threadKey && this.managedThreadReservations.has(threadKey)) {
1997
2091
  throw new Error(
1998
2092
  `Managed workflow thread "${thread}" is already running; calls must be sequential.`,
@@ -2009,21 +2103,34 @@ export class AgentManager {
2009
2103
  throw new Error(threadPolicyConflictMessage(thread, identityTier));
2010
2104
  }
2011
2105
  const record = this.agents.get(threadedId);
2106
+ if (record && this.hasPendingSteering(record)) {
2107
+ throw new Error(`Managed workflow thread "${thread}" has pending UI steering; retry after it settles.`);
2108
+ }
2012
2109
  if (
2013
2110
  record?.session &&
2014
2111
  record.status !== "running" &&
2015
2112
  record.status !== "queued" &&
2016
2113
  !record.detached
2017
2114
  ) {
2115
+ // Admission must precede every attempt identity and persistence mutation.
2116
+ // A stopped status alone says nothing about provider/session quiescence.
2117
+ if (!this.canResumeRecord(record)) {
2118
+ throw new Error(`Managed workflow thread "${thread}" is still settling and is not resumable; retry after it settles.`);
2119
+ }
2018
2120
  assertSafeChildSession(
2019
2121
  record.session,
2020
2122
  record.invocation?.inheritContext === true,
2021
2123
  );
2022
2124
  const previousOwner = record.owner;
2125
+ const previousUsageBaseline = this.managedUsageBaselines.get(threadedId);
2023
2126
  const previousInvocation = record.invocation;
2024
2127
  const previousManagedKey = this.managedKeysById.get(threadedId);
2025
2128
  const resumedTier = identityTier ?? previousInvocation?.agentTier;
2026
2129
  record.owner = Object.freeze({ ...normalized.owner });
2130
+ // Owner rotation, not resume start/settlement, is the attempt boundary.
2131
+ this.managedUsageBaselines.set(threadedId, {
2132
+ record, owner: record.owner, usage: { ...record.lifetimeUsage },
2133
+ });
2027
2134
  record.invocation = {
2028
2135
  ...(policy.invocation ?? {}),
2029
2136
  ...(resumedTier === undefined ? {} : { agentTier: resumedTier }),
@@ -2062,11 +2169,16 @@ export class AgentManager {
2062
2169
  this.managedKeysById.delete(threadedId);
2063
2170
  else this.managedKeysById.set(threadedId, previousManagedKey);
2064
2171
  record.owner = previousOwner;
2172
+ if (previousUsageBaseline) this.managedUsageBaselines.set(threadedId, previousUsageBaseline);
2173
+ else this.managedUsageBaselines.delete(threadedId);
2065
2174
  record.invocation = previousInvocation;
2066
2175
  throw error;
2067
2176
  }
2068
2177
  void this.resume(threadedId, normalized.prompt, undefined, {
2069
2178
  isBackground: true,
2179
+ onStarted: () => {
2180
+ void Promise.resolve(callbacks?.onResumeStarting?.(record, record.session!.messages.length)).catch(() => {});
2181
+ },
2070
2182
  onToolActivity: callbacks?.onToolActivity,
2071
2183
  onAssistantUsage: callbacks?.onAssistantUsage,
2072
2184
  onCompaction: callbacks?.onCompaction
@@ -2091,6 +2203,9 @@ export class AgentManager {
2091
2203
  `Managed workflow thread "${thread}" is already running; calls must be sequential.`,
2092
2204
  );
2093
2205
  }
2206
+ if (record && !this.isFullyCleaned(record)) {
2207
+ throw new Error(`Managed workflow thread "${thread}" is still settling and is not resumable; retry after it settles.`);
2208
+ }
2094
2209
  if (!record || record.detached || !record.session) {
2095
2210
  this.managedThreads.delete(threadKey!);
2096
2211
  this.managedThreadPolicies.delete(threadKey!);
@@ -2215,7 +2330,7 @@ export class AgentManager {
2215
2330
  private startAgent(
2216
2331
  id: string,
2217
2332
  record: AgentRecord,
2218
- { pi, ctx, type, prompt, options, internalOverride }: SpawnArgs,
2333
+ { pi, ctx, type, prompt, options, internalOverride, parentQuestion }: SpawnArgs,
2219
2334
  ) {
2220
2335
  if (this.disposed || record.detached || this.agents.get(id) !== record)
2221
2336
  return;
@@ -2330,7 +2445,8 @@ export class AgentManager {
2330
2445
  // Top-level conversations persist by default so `@handle` has something
2331
2446
  // to reopen after the record is evicted; frontmatter still overrides.
2332
2447
  rememberAgents: this.rememberAgents,
2333
- supervisorQuestions: this.supervisorQuestions,
2448
+ supervisorQuestions: options.supervisorQuestions ?? this.supervisorQuestions,
2449
+ parentQuestion,
2334
2450
  resumeSessionFile: options.resumeSessionFile,
2335
2451
  toolset: options.toolset,
2336
2452
  excludeTools: options.excludeTools,
@@ -2394,11 +2510,8 @@ export class AgentManager {
2394
2510
  session.sessionManager?.getSessionFile?.() ??
2395
2511
  (session as { sessionFile?: string })?.sessionFile;
2396
2512
  // 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;
2513
+ for (const pending of this.takePendingSteers(record)) {
2514
+ void this.submitPendingSteer(record, session, pending).catch(() => {});
2402
2515
  }
2403
2516
  options.onSessionCreated?.(session);
2404
2517
  },
@@ -2474,7 +2587,7 @@ export class AgentManager {
2474
2587
  await this.awaitSessionTeardown(session);
2475
2588
  this.cleanupDetachedWorktree(record);
2476
2589
  if (record.session === session) record.session = undefined;
2477
- if (options.isBackground) {
2590
+ if (record.isBackground) {
2478
2591
  this.releasePoolSlot(record.id);
2479
2592
  this.drainQueue();
2480
2593
  }
@@ -2499,7 +2612,7 @@ export class AgentManager {
2499
2612
  await this.awaitSessionTeardown(session);
2500
2613
  this.cleanupDetachedWorktree(record);
2501
2614
  if (record.session === session) record.session = undefined;
2502
- if (options.isBackground) {
2615
+ if (record.isBackground) {
2503
2616
  this.releasePoolSlot(record.id);
2504
2617
  this.drainQueue();
2505
2618
  }
@@ -2534,7 +2647,7 @@ export class AgentManager {
2534
2647
 
2535
2648
  // Fire onComplete for foreground agents too — lifecycle symmetry.
2536
2649
  // Mark resultConsumed so the callback skips notifications (result returned inline).
2537
- if (!options.isBackground) {
2650
+ if (!record.isBackground) {
2538
2651
  record.resultConsumed = true;
2539
2652
  this.notifyComplete(record);
2540
2653
  } else {
@@ -2580,7 +2693,7 @@ export class AgentManager {
2580
2693
  if (record.detached) {
2581
2694
  await this.awaitSessionTeardown(record.session);
2582
2695
  this.cleanupDetachedWorktree(record);
2583
- if (options.isBackground) {
2696
+ if (record.isBackground) {
2584
2697
  this.releasePoolSlot(record.id);
2585
2698
  this.drainQueue();
2586
2699
  }
@@ -2598,7 +2711,7 @@ export class AgentManager {
2598
2711
  if (record.detached) {
2599
2712
  await this.awaitSessionTeardown(record.session);
2600
2713
  this.cleanupDetachedWorktree(record);
2601
- if (options.isBackground) {
2714
+ if (record.isBackground) {
2602
2715
  this.releasePoolSlot(record.id);
2603
2716
  this.drainQueue();
2604
2717
  }
@@ -2608,7 +2721,7 @@ export class AgentManager {
2608
2721
 
2609
2722
  // Fire onComplete for foreground agents too — lifecycle symmetry.
2610
2723
  // Mark resultConsumed so the callback skips notifications (result returned inline).
2611
- if (!options.isBackground) {
2724
+ if (!record.isBackground) {
2612
2725
  record.resultConsumed = true;
2613
2726
  this.notifyComplete(record);
2614
2727
  } else {
@@ -2772,7 +2885,7 @@ export class AgentManager {
2772
2885
  prompt: string,
2773
2886
  options: Omit<SpawnOptions, "isBackground">,
2774
2887
  onSpawned?: (id: string) => void,
2775
- ): Promise<{ id: string; record: AgentRecord }> {
2888
+ ): Promise<{ id: string; record: AgentRecord; handedOff?: boolean }> {
2776
2889
  return this.spawnAndWaitCore(pi, ctx, type, prompt, options, onSpawned);
2777
2890
  }
2778
2891
 
@@ -2813,7 +2926,7 @@ export class AgentManager {
2813
2926
  options: Omit<SpawnOptions, "isBackground">,
2814
2927
  onSpawned?: (id: string) => void,
2815
2928
  internalOverride?: InternalAgentConfigOverride,
2816
- ): Promise<{ id: string; record: AgentRecord }> {
2929
+ ): Promise<{ id: string; record: AgentRecord; handedOff?: boolean }> {
2817
2930
  // Temporarily register the onSpawned hook so startAgent can call it.
2818
2931
  const prevOnSpawned = this.onSpawned;
2819
2932
  this.onSpawned = onSpawned;
@@ -2836,8 +2949,69 @@ export class AgentManager {
2836
2949
  this.onSpawned = prevOnSpawned;
2837
2950
  }
2838
2951
  const record = this.agents.get(id)!;
2839
- await record.promise;
2840
- return { id, record };
2952
+ if (internalOverride || options.resumeSessionFile || options.parentAgentId !== undefined || (options.depth ?? 1) !== 1 || !record.promise) {
2953
+ await record.promise;
2954
+ return { id, record };
2955
+ }
2956
+ let resolveHandoff!: () => void;
2957
+ const handedOff = new Promise<true>((resolve) => {
2958
+ resolveHandoff = () => resolve(true);
2959
+ });
2960
+ const waiter = { record, promise: record.promise, resolve: resolveHandoff };
2961
+ this.foregroundWaiters.set(id, waiter);
2962
+ try {
2963
+ const transferred = await Promise.race([
2964
+ record.promise.then(() => false),
2965
+ handedOff,
2966
+ ]);
2967
+ return { id, record, ...(transferred ? { handedOff: true } : {}) };
2968
+ } finally {
2969
+ if (this.foregroundWaiters.get(id) === waiter) this.foregroundWaiters.delete(id);
2970
+ }
2971
+ }
2972
+
2973
+ /** A diagnostic suitable for the UI; undefined means transfer is available. */
2974
+ handoffUnavailableReason(id: string): string | undefined {
2975
+ const record = this.agents.get(id);
2976
+ const waiter = this.foregroundWaiters.get(id);
2977
+ if (this.disposed || !record || record.detached || record.status !== "running")
2978
+ return "Agent is no longer running.";
2979
+ if (!waiter || waiter.record !== record || waiter.promise !== record.promise ||
2980
+ record.isBackground !== false || record.owner || record.parentAgentId !== undefined ||
2981
+ (record.depth ?? 1) !== 1 || this.resumeControls.has(id))
2982
+ return "Only a fresh top-level foreground Agent call can be handed off.";
2983
+ if (!record.session || !this.providerPendingRecords.has(id) || record.abortController?.signal.aborted)
2984
+ return "Agent is not ready for handoff or has already settled.";
2985
+ if (this.runningBackground >= this.maxConcurrent)
2986
+ return `Background pool is full (${this.runningBackground}/${this.maxConcurrent}); foreground Agent remains running.`;
2987
+ return undefined;
2988
+ }
2989
+
2990
+ canHandoff(id: string): boolean {
2991
+ return this.handoffUnavailableReason(id) === undefined;
2992
+ }
2993
+
2994
+ /** Transfer the live run, never its provider promise, session or worktree. */
2995
+ handoff(id: string): boolean {
2996
+ if (!this.canHandoff(id)) return false;
2997
+ const waiter = this.foregroundWaiters.get(id)!;
2998
+ const record = waiter.record;
2999
+ // Cut parent cancellation BEFORE releasing its waiter. Explicit child stop
3000
+ // and manager shutdown retain the child's own controller.
3001
+ this.clearParentSignal(id);
3002
+ this.heldPoolSlots.add(id);
3003
+ this.runningBackground++;
3004
+ record.isBackground = true;
3005
+ record.invocation = { ...(record.invocation ?? {}), runInBackground: true };
3006
+ record.resultConsumed = false;
3007
+ this.foregroundWaiters.delete(id);
3008
+ waiter.resolve();
3009
+ try {
3010
+ void Promise.resolve(this.onHandoff?.(record)).catch(() => {});
3011
+ } catch {
3012
+ // This callback observes a committed transfer, never controls it.
3013
+ }
3014
+ return true;
2841
3015
  }
2842
3016
 
2843
3017
  private resumeSnapshot(record: AgentRecord): ResumeTerminalSnapshot {
@@ -2939,15 +3113,12 @@ export class AgentManager {
2939
3113
  if (this.disposed) return undefined;
2940
3114
  const record = this.agents.get(id);
2941
3115
  if (!record) return undefined;
2942
- if (!this.canResumeNested(record)) return undefined;
3116
+ if (!this.canResumeRecord(record)) return undefined;
2943
3117
  if (signal?.aborted) return record;
2944
- if (
2945
- this.resumeControls.has(id) ||
2946
- record.status === "running" ||
2947
- record.status === "queued"
2948
- )
2949
- return undefined;
2950
3118
  if (!record.session) return undefined;
3119
+ if (this.parentQuestionAgents.has(record) && options?.isBackground !== true) {
3120
+ throw new Error('This child uses parent clarification; resume it with run_in_background: true to avoid blocking the answering model.');
3121
+ }
2951
3122
  assertSafeChildSession(
2952
3123
  record.session,
2953
3124
  record.invocation?.inheritContext === true,
@@ -3124,16 +3295,12 @@ export class AgentManager {
3124
3295
  // sent while it waits must be flushed here rather than through the
3125
3296
  // session's idle steering queue. Otherwise cancelling this queued run
3126
3297
  // would leave those messages behind for a later resume.
3127
- const pendingSteers = record.pendingSteers;
3128
- record.pendingSteers = undefined;
3129
- if (pendingSteers?.length) {
3130
- for (const message of pendingSteers) {
3131
- if (control.controller.signal.aborted) break;
3132
- try {
3133
- await record.session!.steer(message);
3134
- } catch {
3135
- // A malformed or stale queued message must not prevent the resume.
3136
- }
3298
+ for (const pending of this.takePendingSteers(record)) {
3299
+ if (control.controller.signal.aborted) break;
3300
+ try {
3301
+ await this.submitPendingSteer(record, record.session!, pending);
3302
+ } catch {
3303
+ // A malformed or stale queued message must not prevent the resume.
3137
3304
  }
3138
3305
  }
3139
3306
 
@@ -3221,11 +3388,139 @@ export class AgentManager {
3221
3388
  if (!record.pendingSteers) record.pendingSteers = [];
3222
3389
  record.pendingSteers.push(message);
3223
3390
  } else {
3391
+ this.retireEmptySteeringQueue(record, record.session);
3224
3392
  record.session.steer(message).catch(() => {});
3225
3393
  }
3226
3394
  return true;
3227
3395
  }
3228
3396
 
3397
+ private takePendingSteers(record: AgentRecord) {
3398
+ const messages = record.pendingSteers;
3399
+ record.pendingSteers = undefined;
3400
+ const accepted = messages ? this.acceptedPendingSteers.get(messages) : undefined;
3401
+ return (messages ?? []).map((message, index) => ({ message, acceptance: accepted?.get(index) }));
3402
+ }
3403
+
3404
+ private async submitPendingSteer(
3405
+ record: AgentRecord,
3406
+ session: AgentSession,
3407
+ pending: ReturnType<AgentManager["takePendingSteers"]>[number],
3408
+ ): Promise<void> {
3409
+ const { message, acceptance } = pending;
3410
+ if (!acceptance) {
3411
+ // Preserve optimistic legacy boolean steering, including its queue lifetime.
3412
+ this.retireEmptySteeringQueue(record, session);
3413
+ await session.steer(message);
3414
+ return;
3415
+ }
3416
+ if (this.disposed || record.detached || this.agents.get(record.id) !== record ||
3417
+ record.status !== "running" || record.session !== session ||
3418
+ record.owner !== acceptance.owner || record.abortController !== acceptance.controller ||
3419
+ acceptance.controller?.signal.aborted) return;
3420
+ await this.steerNativeAccepted(record, session, message);
3421
+ }
3422
+
3423
+ /** Queue provenance ends at an observed empty queue, not at a record's lifetime. */
3424
+ private retireEmptySteeringQueue(record: AgentRecord, session = record.session ?? this.steeringQueuePins.get(record)): void {
3425
+ if (session?.getSteeringMessages?.().length !== 0) return;
3426
+ this.steeringQueuePins.delete(record);
3427
+ // An empty queue cannot release an input handler still awaiting acceptance.
3428
+ // Its eventual native write will establish fresh queue provenance.
3429
+ if (this.steeringOperations.has(record)) return;
3430
+ const unsubscribe = this.steeringQueueObservers.get(record);
3431
+ this.steeringQueueObservers.delete(record);
3432
+ // SDK dispatch visits a live listener array. Retire ownership now, but
3433
+ // defer removal even when a later subscriber reenters the manager.
3434
+ // Capture the old observer so a replacement installed now stays intact.
3435
+ if (unsubscribe) queueMicrotask(unsubscribe);
3436
+ }
3437
+
3438
+ private hasPendingSteering(record: AgentRecord): boolean {
3439
+ this.retireEmptySteeringQueue(record);
3440
+ return this.steeringOperations.has(record) ||
3441
+ (this.steeringQueuePins.get(record)?.getSteeringMessages?.().length ?? 0) > 0;
3442
+ }
3443
+
3444
+ /**
3445
+ * Truthful UI submission. False means stale/inactive identity; native input
3446
+ * rejection propagates to the caller. Queued messages remain manager-owned.
3447
+ * Native steer awaits input handlers BEFORE enqueueing, even if its run ends
3448
+ * meanwhile. Pin both record and managed thread until that await settles;
3449
+ * an accepted queue also pins reuse until the SDK consumes it. Never rewrite
3450
+ * private native queues or clear unrelated legacy callers' messages.
3451
+ */
3452
+ async steerAccepted(id: string, message: string, expected?: SteeringIdentity): Promise<boolean> {
3453
+ const record = this.agents.get(id);
3454
+ if (this.disposed || !record || record.detached ||
3455
+ (record.status !== "running" && record.status !== "queued")) return false;
3456
+ const session = record.session;
3457
+ const owner = record.owner;
3458
+ if ((expected !== undefined && expected.session !== session) ||
3459
+ (expected?.owner !== undefined && (!owner ||
3460
+ expected.owner.extension !== owner.extension || expected.owner.runId !== owner.runId ||
3461
+ expected.owner.nodeId !== owner.nodeId || expected.owner.attemptId !== owner.attemptId))) return false;
3462
+ if (record.status === "queued" || !session) {
3463
+ record.pendingSteers ??= [];
3464
+ let accepted = this.acceptedPendingSteers.get(record.pendingSteers);
3465
+ if (!accepted) {
3466
+ accepted = new Map();
3467
+ this.acceptedPendingSteers.set(record.pendingSteers, accepted);
3468
+ }
3469
+ accepted.set(record.pendingSteers.length, { owner, controller: record.abortController });
3470
+ record.pendingSteers.push(message);
3471
+ return true;
3472
+ }
3473
+ return this.steerNativeAccepted(record, session, message);
3474
+ }
3475
+
3476
+ /** Every accepted native submission, including both queued flushes, owns its await. */
3477
+ private async steerNativeAccepted(record: AgentRecord, session: AgentSession, message: string): Promise<boolean> {
3478
+ this.retireEmptySteeringQueue(record, session);
3479
+ const id = record.id;
3480
+ const owner = record.owner;
3481
+ const promise = record.promise;
3482
+ const controller = record.abortController;
3483
+ const thread = this.managedSpawns.get(this.managedKeyForId(id) ?? "")?.thread;
3484
+ const threadKey = owner && thread ? `${owner.runId}\u0000${thread}` : undefined;
3485
+ let settle!: () => void;
3486
+ const settled = new Promise<void>(resolve => { settle = resolve; });
3487
+ let operations = this.steeringOperations.get(record);
3488
+ if (!operations) {
3489
+ operations = new Set();
3490
+ this.steeringOperations.set(record, operations);
3491
+ }
3492
+ operations.add(settled);
3493
+ if (threadKey) this.steeringThreadPins.set(threadKey, (this.steeringThreadPins.get(threadKey) ?? 0) + 1);
3494
+ try {
3495
+ if (!this.steeringQueueObservers.has(record) && session.subscribe) {
3496
+ this.steeringQueueObservers.set(record, session.subscribe(event => {
3497
+ if (event.type !== "queue_update" || event.steering.length !== 0) return;
3498
+ this.retireEmptySteeringQueue(record, session);
3499
+ }));
3500
+ }
3501
+ const disposition = await session.steer(message);
3502
+ // Handled input writes nothing: a nonempty queue can belong entirely to
3503
+ // legacy steering. Keep existing accepted pins without acquiring one.
3504
+ if (disposition === "queued" && (session.getSteeringMessages?.().length ?? 0) > 0)
3505
+ this.steeringQueuePins.set(record, session);
3506
+ return !this.disposed && !record.detached && this.agents.get(id) === record &&
3507
+ record.session === session && record.owner === owner && record.promise === promise &&
3508
+ record.abortController === controller && record.status === "running";
3509
+ } finally {
3510
+ operations.delete(settled);
3511
+ if (operations.size === 0) this.steeringOperations.delete(record);
3512
+ this.retireEmptySteeringQueue(record, session);
3513
+ if (threadKey) {
3514
+ const pendingThread = this.steeringThreadPins.get(threadKey)! - 1;
3515
+ if (pendingThread > 0) this.steeringThreadPins.set(threadKey, pendingThread);
3516
+ else this.steeringThreadPins.delete(threadKey);
3517
+ }
3518
+ settle();
3519
+ this.retryPinnedWorktreeCleanup();
3520
+ this.retryDeferredRecordRemovals();
3521
+ }
3522
+ }
3523
+
3229
3524
  /**
3230
3525
  * Return an immutable observation snapshot. Internal manager/index code must
3231
3526
  * use `getRecordMutable()` when it needs to update authoritative state.
@@ -3398,6 +3693,7 @@ export class AgentManager {
3398
3693
  record.status !== "running" &&
3399
3694
  !this.settlingRecords.has(record.id) &&
3400
3695
  !this.providerPendingRecords.has(record.id) &&
3696
+ !this.hasPendingSteering(record) &&
3401
3697
  !this.recordSessionTeardowns.has(record.id) &&
3402
3698
  record.worktree === undefined &&
3403
3699
  record.outputCleanup === undefined
@@ -3518,6 +3814,7 @@ export class AgentManager {
3518
3814
  this.managedThreadReservations.delete(thread);
3519
3815
  }
3520
3816
  this.agents.delete(id);
3817
+ this.managedUsageBaselines.delete(id);
3521
3818
  return true;
3522
3819
  } finally {
3523
3820
  // The map deletion is the point at which a later continuation becomes an
@@ -3843,6 +4140,14 @@ export class AgentManager {
3843
4140
  const worktree = record.worktree;
3844
4141
  if (!worktree) return undefined;
3845
4142
 
4143
+ // Queue pins guard reuse/eviction, not cwd lifetime. Only native handlers
4144
+ // still executing can touch the checkout after the provider has ended.
4145
+ if (this.steeringOperations.has(record)) {
4146
+ return this.blockedWorktreeCleanup(
4147
+ record,
4148
+ `Worktree cleanup is pinned because native input handling for ${record.id} is still pending`,
4149
+ );
4150
+ }
3846
4151
  if (this.providerPendingRecords.has(record.id)) {
3847
4152
  return this.blockedWorktreeCleanup(
3848
4153
  record,
@@ -3891,6 +4196,12 @@ export class AgentManager {
3891
4196
  const worktree = record.worktree;
3892
4197
  if (!worktree) return undefined;
3893
4198
 
4199
+ if (this.steeringOperations.has(record)) {
4200
+ return this.blockedWorktreeCleanup(
4201
+ record,
4202
+ `Worktree cleanup is pinned because native input handling for ${record.id} is still pending`,
4203
+ );
4204
+ }
3894
4205
  if (this.providerPendingRecords.has(record.id)) {
3895
4206
  return this.blockedWorktreeCleanup(
3896
4207
  record,
@@ -3996,19 +4307,35 @@ export class AgentManager {
3996
4307
  if (this.isFullyCleaned(record)) pendingIds.delete(record.id);
3997
4308
  };
3998
4309
  const deadline = Date.now() + Math.max(0, timeoutMs);
3999
- const pendingPromises = records
4000
- .map((record) => record.promise?.then(() => recheck(record)))
4001
- .filter((promise): promise is Promise<void> => promise !== undefined);
4002
- let timer: ReturnType<typeof setTimeout> | undefined;
4003
- const allSettled = Promise.allSettled(pendingPromises).then(() => true);
4004
- const timedOut = new Promise<boolean>((resolve) => {
4005
- timer = setTimeout(
4006
- () => resolve(false),
4007
- Math.max(0, deadline - Date.now()),
4008
- );
4009
- });
4010
- const completed = await Promise.race([allSettled, timedOut]);
4011
- if (timer) clearTimeout(timer);
4310
+ const observed = new Set<Promise<unknown>>();
4311
+ let completed = true;
4312
+ while (pendingIds.size > 0) {
4313
+ // These exact records own native input awaits independently of their
4314
+ // already-settled provider promises. Recollect after settlement to cover
4315
+ // queued flushes admitted during the wait, using one shared deadline.
4316
+ const pendingPromises: Promise<unknown>[] = [];
4317
+ for (const record of records) {
4318
+ if (!pendingIds.has(record.id)) continue;
4319
+ for (const promise of [record.promise, ...(this.steeringOperations.get(record) ?? [])]) {
4320
+ if (!promise || observed.has(promise)) continue;
4321
+ observed.add(promise);
4322
+ pendingPromises.push(promise);
4323
+ }
4324
+ }
4325
+ if (pendingPromises.length === 0) break;
4326
+ let timer: ReturnType<typeof setTimeout> | undefined;
4327
+ const allSettled = Promise.allSettled(pendingPromises).then(() => true);
4328
+ const timedOut = new Promise<boolean>((resolve) => {
4329
+ timer = setTimeout(
4330
+ () => resolve(false),
4331
+ Math.max(0, deadline - Date.now()),
4332
+ );
4333
+ });
4334
+ completed = await Promise.race([allSettled, timedOut]);
4335
+ if (timer) clearTimeout(timer);
4336
+ for (const record of records) recheck(record);
4337
+ if (!completed) break;
4338
+ }
4012
4339
 
4013
4340
  let pending = [...pendingIds];
4014
4341
  if (!completed || pending.length > 0) {
@@ -4142,6 +4469,7 @@ export class AgentManager {
4142
4469
  this.clearManagedPersistenceRetries();
4143
4470
  if (this.disposePromise) return this.disposePromise;
4144
4471
  this.disposed = true;
4472
+ this.managedUsageBaselines.clear();
4145
4473
  clearInterval(this.cleanupInterval);
4146
4474
 
4147
4475
  const records = [...this.agents.values()];
@@ -4226,7 +4554,8 @@ export class AgentManager {
4226
4554
  const providerPromises = records
4227
4555
  .map((record) => record.promise)
4228
4556
  .filter((promise): promise is Promise<string> => promise !== undefined);
4229
- if (providerPromises.length > 0) {
4557
+ const steeringPromises = records.flatMap(record => [...(this.steeringOperations.get(record) ?? [])]);
4558
+ if (providerPromises.length > 0 || steeringPromises.length > 0) {
4230
4559
  let providerTimer: ReturnType<typeof setTimeout> | undefined;
4231
4560
  const providerTimeout = new Promise<void>((resolve) => {
4232
4561
  providerTimer = setTimeout(
@@ -4235,7 +4564,7 @@ export class AgentManager {
4235
4564
  );
4236
4565
  });
4237
4566
  await Promise.race([
4238
- Promise.allSettled(providerPromises).then(() => undefined),
4567
+ Promise.allSettled([...providerPromises, ...steeringPromises]).then(() => undefined),
4239
4568
  providerTimeout,
4240
4569
  ]);
4241
4570
  if (providerTimer) clearTimeout(providerTimer);
@@ -4332,12 +4661,12 @@ export class AgentManager {
4332
4661
  );
4333
4662
  }
4334
4663
 
4335
- if (!teardownsSettled && failures.length > 0) {
4664
+ if ((!teardownsSettled || steeringPromises.length > 0) && failures.length > 0) {
4336
4665
  // Do not make host shutdown wait forever for an extension-owned child
4337
4666
  // 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];
4667
+ // providers and native input handlers settle; only then retry cleanup,
4668
+ // deepest first. Detached records cannot publish into a replacement session.
4669
+ const pending = [...this.sessionTeardowns, ...providerPromises, ...steeringPromises];
4341
4670
  void Promise.allSettled(pending)
4342
4671
  .then(async (outcomes) => {
4343
4672
  if (outcomes.some((outcome) => outcome.status === "rejected")) return;