@signalridge/pi-subagents 1.12.0 → 1.14.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.
@@ -56,7 +56,7 @@ import type {
56
56
  SubagentType,
57
57
  ThinkingLevel,
58
58
  } from "./types.js";
59
- import { addUsage } from "./usage.js";
59
+ import { addUsage, type LifetimeUsage } from "./usage.js";
60
60
  import {
61
61
  cleanupWorktree,
62
62
  cleanupWorktreeAsync,
@@ -71,6 +71,13 @@ import {
71
71
  export type OnAgentComplete = (record: AgentRecord) => void;
72
72
  export type OnAgentCreated = (record: AgentRecord) => void;
73
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
+ }
74
81
  export type OnAgentCompact = (
75
82
  record: AgentRecord,
76
83
  info: CompactionInfo,
@@ -1004,6 +1011,20 @@ export type ManagedSpawnPolicy = Pick<
1004
1011
  policyFingerprint?: string;
1005
1012
  };
1006
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
+
1007
1028
  interface ResumeTerminalSnapshot {
1008
1029
  status: AgentRecord["status"];
1009
1030
  startedAt: number;
@@ -1031,8 +1052,32 @@ export class AgentManager {
1031
1052
  private onCreated?: OnAgentCreated;
1032
1053
  private onStart?: OnAgentStart;
1033
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>();
1034
1073
  private managedSpawns = new Map<string, ManagedSpawnTombstone>();
1035
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
+ }>();
1036
1081
  /** Spawn keys found in unsupported/corrupt persisted tombstones. */
1037
1082
  private managedQuarantinedKeys = new Set<string>();
1038
1083
  /**
@@ -1131,11 +1176,13 @@ export class AgentManager {
1131
1176
  onCompact?: OnAgentCompact,
1132
1177
  onCreated?: OnAgentCreated,
1133
1178
  managedPersistence?: ManagedSpawnPersistence,
1179
+ onHandoff?: OnAgentHandoff,
1134
1180
  ) {
1135
1181
  this.onComplete = onComplete;
1136
1182
  this.onCreated = onCreated;
1137
1183
  this.onStart = onStart;
1138
1184
  this.onCompact = onCompact;
1185
+ this.onHandoff = onHandoff;
1139
1186
  this.managedPersistence = managedPersistence;
1140
1187
  this.maxConcurrent = maxConcurrent;
1141
1188
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
@@ -1290,12 +1337,22 @@ export class AgentManager {
1290
1337
  !record.detached &&
1291
1338
  !this.removingRecords.has(record.id) &&
1292
1339
  !this.settlingRecords.has(record.id) &&
1340
+ !this.providerPendingRecords.has(record.id) &&
1341
+ !this.recordSessionTeardowns.has(record.id) &&
1342
+ !this.hasPendingSteering(record) &&
1293
1343
  !this.deferredRecordRemovals.has(record.id) &&
1294
1344
  (record.parentAgentId === undefined ||
1295
1345
  this.nestedOwnerValidation(record.parentAgentId) === undefined)
1296
1346
  );
1297
1347
  }
1298
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
+
1299
1356
  private clearManagedPersistenceRetry(key: string): void {
1300
1357
  const pending = this.managedPersistenceRetries.get(key);
1301
1358
  if (!pending) return;
@@ -1413,6 +1470,7 @@ export class AgentManager {
1413
1470
  const state: ManagedSpawnState =
1414
1471
  terminalStatus ?? (record.status === "queued" ? "queued" : "running");
1415
1472
  const compactionCount = Math.max(0, Math.floor(record.compactionCount));
1473
+ const usage = this.getManagedUsage(record.id, tombstone.owner);
1416
1474
  const terminal = terminalStatus
1417
1475
  ? {
1418
1476
  status: terminalStatus,
@@ -1425,10 +1483,10 @@ export class AgentManager {
1425
1483
  ...(record.outputFile
1426
1484
  ? { outputFile: record.outputFile.slice(0, MANAGED_PATH_LIMIT) }
1427
1485
  : {}),
1428
- ...(record.lifetimeUsage.input + record.lifetimeUsage.output > 0
1486
+ ...(usage
1429
1487
  ? {
1430
1488
  tokenCount: Math.floor(
1431
- record.lifetimeUsage.input + record.lifetimeUsage.output,
1489
+ usage.input + usage.output + usage.cacheWrite,
1432
1490
  ),
1433
1491
  }
1434
1492
  : {}),
@@ -1595,6 +1653,23 @@ export class AgentManager {
1595
1653
  return recovered;
1596
1654
  }
1597
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
+
1598
1673
  getManagedSpawn(spawnKey: string): ManagedSpawnTombstone | undefined {
1599
1674
  const tombstone = this.managedSpawns.get(spawnKey.trim());
1600
1675
  return tombstone ? cloneManagedTombstone(tombstone) : undefined;
@@ -1810,6 +1885,10 @@ export class AgentManager {
1810
1885
  record.handle = options.reclaimHandle;
1811
1886
  }
1812
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
+ });
1813
1892
  if (!record.detached) {
1814
1893
  try {
1815
1894
  this.onCreated?.(record);
@@ -1918,16 +1997,7 @@ export class AgentManager {
1918
1997
  ctx: ExtensionContext,
1919
1998
  request: ManagedSpawnRequest,
1920
1999
  policy: ManagedSpawnPolicy,
1921
- callbacks?: Pick<
1922
- SpawnOptions,
1923
- | "onToolActivity"
1924
- | "onTextDelta"
1925
- | "onSessionCreated"
1926
- | "onTurnEnd"
1927
- | "onAssistantUsage"
1928
- | "onCompaction"
1929
- | "onSpawned"
1930
- >,
2000
+ callbacks?: ManagedSpawnCallbacks,
1931
2001
  ): ManagedSpawnResult {
1932
2002
  if (this.disposed) throw new Error("AgentManager is disposed");
1933
2003
  const normalized = parseManagedSpawnRequest(request);
@@ -2014,6 +2084,9 @@ export class AgentManager {
2014
2084
  const threadKey = thread
2015
2085
  ? `${normalized.owner.runId}\u0000${thread}`
2016
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
+ }
2017
2090
  if (threadKey && this.managedThreadReservations.has(threadKey)) {
2018
2091
  throw new Error(
2019
2092
  `Managed workflow thread "${thread}" is already running; calls must be sequential.`,
@@ -2030,21 +2103,34 @@ export class AgentManager {
2030
2103
  throw new Error(threadPolicyConflictMessage(thread, identityTier));
2031
2104
  }
2032
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
+ }
2033
2109
  if (
2034
2110
  record?.session &&
2035
2111
  record.status !== "running" &&
2036
2112
  record.status !== "queued" &&
2037
2113
  !record.detached
2038
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
+ }
2039
2120
  assertSafeChildSession(
2040
2121
  record.session,
2041
2122
  record.invocation?.inheritContext === true,
2042
2123
  );
2043
2124
  const previousOwner = record.owner;
2125
+ const previousUsageBaseline = this.managedUsageBaselines.get(threadedId);
2044
2126
  const previousInvocation = record.invocation;
2045
2127
  const previousManagedKey = this.managedKeysById.get(threadedId);
2046
2128
  const resumedTier = identityTier ?? previousInvocation?.agentTier;
2047
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
+ });
2048
2134
  record.invocation = {
2049
2135
  ...(policy.invocation ?? {}),
2050
2136
  ...(resumedTier === undefined ? {} : { agentTier: resumedTier }),
@@ -2083,11 +2169,16 @@ export class AgentManager {
2083
2169
  this.managedKeysById.delete(threadedId);
2084
2170
  else this.managedKeysById.set(threadedId, previousManagedKey);
2085
2171
  record.owner = previousOwner;
2172
+ if (previousUsageBaseline) this.managedUsageBaselines.set(threadedId, previousUsageBaseline);
2173
+ else this.managedUsageBaselines.delete(threadedId);
2086
2174
  record.invocation = previousInvocation;
2087
2175
  throw error;
2088
2176
  }
2089
2177
  void this.resume(threadedId, normalized.prompt, undefined, {
2090
2178
  isBackground: true,
2179
+ onStarted: () => {
2180
+ void Promise.resolve(callbacks?.onResumeStarting?.(record, record.session!.messages.length)).catch(() => {});
2181
+ },
2091
2182
  onToolActivity: callbacks?.onToolActivity,
2092
2183
  onAssistantUsage: callbacks?.onAssistantUsage,
2093
2184
  onCompaction: callbacks?.onCompaction
@@ -2112,6 +2203,9 @@ export class AgentManager {
2112
2203
  `Managed workflow thread "${thread}" is already running; calls must be sequential.`,
2113
2204
  );
2114
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
+ }
2115
2209
  if (!record || record.detached || !record.session) {
2116
2210
  this.managedThreads.delete(threadKey!);
2117
2211
  this.managedThreadPolicies.delete(threadKey!);
@@ -2416,11 +2510,8 @@ export class AgentManager {
2416
2510
  session.sessionManager?.getSessionFile?.() ??
2417
2511
  (session as { sessionFile?: string })?.sessionFile;
2418
2512
  // Flush any steers that arrived before the session was ready
2419
- if (record.pendingSteers?.length) {
2420
- for (const msg of record.pendingSteers) {
2421
- session.steer(msg).catch(() => {});
2422
- }
2423
- record.pendingSteers = undefined;
2513
+ for (const pending of this.takePendingSteers(record)) {
2514
+ void this.submitPendingSteer(record, session, pending).catch(() => {});
2424
2515
  }
2425
2516
  options.onSessionCreated?.(session);
2426
2517
  },
@@ -2496,7 +2587,7 @@ export class AgentManager {
2496
2587
  await this.awaitSessionTeardown(session);
2497
2588
  this.cleanupDetachedWorktree(record);
2498
2589
  if (record.session === session) record.session = undefined;
2499
- if (options.isBackground) {
2590
+ if (record.isBackground) {
2500
2591
  this.releasePoolSlot(record.id);
2501
2592
  this.drainQueue();
2502
2593
  }
@@ -2521,7 +2612,7 @@ export class AgentManager {
2521
2612
  await this.awaitSessionTeardown(session);
2522
2613
  this.cleanupDetachedWorktree(record);
2523
2614
  if (record.session === session) record.session = undefined;
2524
- if (options.isBackground) {
2615
+ if (record.isBackground) {
2525
2616
  this.releasePoolSlot(record.id);
2526
2617
  this.drainQueue();
2527
2618
  }
@@ -2556,7 +2647,7 @@ export class AgentManager {
2556
2647
 
2557
2648
  // Fire onComplete for foreground agents too — lifecycle symmetry.
2558
2649
  // Mark resultConsumed so the callback skips notifications (result returned inline).
2559
- if (!options.isBackground) {
2650
+ if (!record.isBackground) {
2560
2651
  record.resultConsumed = true;
2561
2652
  this.notifyComplete(record);
2562
2653
  } else {
@@ -2602,7 +2693,7 @@ export class AgentManager {
2602
2693
  if (record.detached) {
2603
2694
  await this.awaitSessionTeardown(record.session);
2604
2695
  this.cleanupDetachedWorktree(record);
2605
- if (options.isBackground) {
2696
+ if (record.isBackground) {
2606
2697
  this.releasePoolSlot(record.id);
2607
2698
  this.drainQueue();
2608
2699
  }
@@ -2620,7 +2711,7 @@ export class AgentManager {
2620
2711
  if (record.detached) {
2621
2712
  await this.awaitSessionTeardown(record.session);
2622
2713
  this.cleanupDetachedWorktree(record);
2623
- if (options.isBackground) {
2714
+ if (record.isBackground) {
2624
2715
  this.releasePoolSlot(record.id);
2625
2716
  this.drainQueue();
2626
2717
  }
@@ -2630,7 +2721,7 @@ export class AgentManager {
2630
2721
 
2631
2722
  // Fire onComplete for foreground agents too — lifecycle symmetry.
2632
2723
  // Mark resultConsumed so the callback skips notifications (result returned inline).
2633
- if (!options.isBackground) {
2724
+ if (!record.isBackground) {
2634
2725
  record.resultConsumed = true;
2635
2726
  this.notifyComplete(record);
2636
2727
  } else {
@@ -2794,7 +2885,7 @@ export class AgentManager {
2794
2885
  prompt: string,
2795
2886
  options: Omit<SpawnOptions, "isBackground">,
2796
2887
  onSpawned?: (id: string) => void,
2797
- ): Promise<{ id: string; record: AgentRecord }> {
2888
+ ): Promise<{ id: string; record: AgentRecord; handedOff?: boolean }> {
2798
2889
  return this.spawnAndWaitCore(pi, ctx, type, prompt, options, onSpawned);
2799
2890
  }
2800
2891
 
@@ -2835,7 +2926,7 @@ export class AgentManager {
2835
2926
  options: Omit<SpawnOptions, "isBackground">,
2836
2927
  onSpawned?: (id: string) => void,
2837
2928
  internalOverride?: InternalAgentConfigOverride,
2838
- ): Promise<{ id: string; record: AgentRecord }> {
2929
+ ): Promise<{ id: string; record: AgentRecord; handedOff?: boolean }> {
2839
2930
  // Temporarily register the onSpawned hook so startAgent can call it.
2840
2931
  const prevOnSpawned = this.onSpawned;
2841
2932
  this.onSpawned = onSpawned;
@@ -2858,8 +2949,69 @@ export class AgentManager {
2858
2949
  this.onSpawned = prevOnSpawned;
2859
2950
  }
2860
2951
  const record = this.agents.get(id)!;
2861
- await record.promise;
2862
- 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;
2863
3015
  }
2864
3016
 
2865
3017
  private resumeSnapshot(record: AgentRecord): ResumeTerminalSnapshot {
@@ -2961,14 +3113,8 @@ export class AgentManager {
2961
3113
  if (this.disposed) return undefined;
2962
3114
  const record = this.agents.get(id);
2963
3115
  if (!record) return undefined;
2964
- if (!this.canResumeNested(record)) return undefined;
3116
+ if (!this.canResumeRecord(record)) return undefined;
2965
3117
  if (signal?.aborted) return record;
2966
- if (
2967
- this.resumeControls.has(id) ||
2968
- record.status === "running" ||
2969
- record.status === "queued"
2970
- )
2971
- return undefined;
2972
3118
  if (!record.session) return undefined;
2973
3119
  if (this.parentQuestionAgents.has(record) && options?.isBackground !== true) {
2974
3120
  throw new Error('This child uses parent clarification; resume it with run_in_background: true to avoid blocking the answering model.');
@@ -3149,16 +3295,12 @@ export class AgentManager {
3149
3295
  // sent while it waits must be flushed here rather than through the
3150
3296
  // session's idle steering queue. Otherwise cancelling this queued run
3151
3297
  // would leave those messages behind for a later resume.
3152
- const pendingSteers = record.pendingSteers;
3153
- record.pendingSteers = undefined;
3154
- if (pendingSteers?.length) {
3155
- for (const message of pendingSteers) {
3156
- if (control.controller.signal.aborted) break;
3157
- try {
3158
- await record.session!.steer(message);
3159
- } catch {
3160
- // A malformed or stale queued message must not prevent the resume.
3161
- }
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.
3162
3304
  }
3163
3305
  }
3164
3306
 
@@ -3246,11 +3388,139 @@ export class AgentManager {
3246
3388
  if (!record.pendingSteers) record.pendingSteers = [];
3247
3389
  record.pendingSteers.push(message);
3248
3390
  } else {
3391
+ this.retireEmptySteeringQueue(record, record.session);
3249
3392
  record.session.steer(message).catch(() => {});
3250
3393
  }
3251
3394
  return true;
3252
3395
  }
3253
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
+
3254
3524
  /**
3255
3525
  * Return an immutable observation snapshot. Internal manager/index code must
3256
3526
  * use `getRecordMutable()` when it needs to update authoritative state.
@@ -3423,6 +3693,7 @@ export class AgentManager {
3423
3693
  record.status !== "running" &&
3424
3694
  !this.settlingRecords.has(record.id) &&
3425
3695
  !this.providerPendingRecords.has(record.id) &&
3696
+ !this.hasPendingSteering(record) &&
3426
3697
  !this.recordSessionTeardowns.has(record.id) &&
3427
3698
  record.worktree === undefined &&
3428
3699
  record.outputCleanup === undefined
@@ -3543,6 +3814,7 @@ export class AgentManager {
3543
3814
  this.managedThreadReservations.delete(thread);
3544
3815
  }
3545
3816
  this.agents.delete(id);
3817
+ this.managedUsageBaselines.delete(id);
3546
3818
  return true;
3547
3819
  } finally {
3548
3820
  // The map deletion is the point at which a later continuation becomes an
@@ -3868,6 +4140,14 @@ export class AgentManager {
3868
4140
  const worktree = record.worktree;
3869
4141
  if (!worktree) return undefined;
3870
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
+ }
3871
4151
  if (this.providerPendingRecords.has(record.id)) {
3872
4152
  return this.blockedWorktreeCleanup(
3873
4153
  record,
@@ -3916,6 +4196,12 @@ export class AgentManager {
3916
4196
  const worktree = record.worktree;
3917
4197
  if (!worktree) return undefined;
3918
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
+ }
3919
4205
  if (this.providerPendingRecords.has(record.id)) {
3920
4206
  return this.blockedWorktreeCleanup(
3921
4207
  record,
@@ -4021,19 +4307,35 @@ export class AgentManager {
4021
4307
  if (this.isFullyCleaned(record)) pendingIds.delete(record.id);
4022
4308
  };
4023
4309
  const deadline = Date.now() + Math.max(0, timeoutMs);
4024
- const pendingPromises = records
4025
- .map((record) => record.promise?.then(() => recheck(record)))
4026
- .filter((promise): promise is Promise<void> => promise !== undefined);
4027
- let timer: ReturnType<typeof setTimeout> | undefined;
4028
- const allSettled = Promise.allSettled(pendingPromises).then(() => true);
4029
- const timedOut = new Promise<boolean>((resolve) => {
4030
- timer = setTimeout(
4031
- () => resolve(false),
4032
- Math.max(0, deadline - Date.now()),
4033
- );
4034
- });
4035
- const completed = await Promise.race([allSettled, timedOut]);
4036
- 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
+ }
4037
4339
 
4038
4340
  let pending = [...pendingIds];
4039
4341
  if (!completed || pending.length > 0) {
@@ -4167,6 +4469,7 @@ export class AgentManager {
4167
4469
  this.clearManagedPersistenceRetries();
4168
4470
  if (this.disposePromise) return this.disposePromise;
4169
4471
  this.disposed = true;
4472
+ this.managedUsageBaselines.clear();
4170
4473
  clearInterval(this.cleanupInterval);
4171
4474
 
4172
4475
  const records = [...this.agents.values()];
@@ -4251,7 +4554,8 @@ export class AgentManager {
4251
4554
  const providerPromises = records
4252
4555
  .map((record) => record.promise)
4253
4556
  .filter((promise): promise is Promise<string> => promise !== undefined);
4254
- if (providerPromises.length > 0) {
4557
+ const steeringPromises = records.flatMap(record => [...(this.steeringOperations.get(record) ?? [])]);
4558
+ if (providerPromises.length > 0 || steeringPromises.length > 0) {
4255
4559
  let providerTimer: ReturnType<typeof setTimeout> | undefined;
4256
4560
  const providerTimeout = new Promise<void>((resolve) => {
4257
4561
  providerTimer = setTimeout(
@@ -4260,7 +4564,7 @@ export class AgentManager {
4260
4564
  );
4261
4565
  });
4262
4566
  await Promise.race([
4263
- Promise.allSettled(providerPromises).then(() => undefined),
4567
+ Promise.allSettled([...providerPromises, ...steeringPromises]).then(() => undefined),
4264
4568
  providerTimeout,
4265
4569
  ]);
4266
4570
  if (providerTimer) clearTimeout(providerTimer);
@@ -4357,12 +4661,12 @@ export class AgentManager {
4357
4661
  );
4358
4662
  }
4359
4663
 
4360
- if (!teardownsSettled && failures.length > 0) {
4664
+ if ((!teardownsSettled || steeringPromises.length > 0) && failures.length > 0) {
4361
4665
  // Do not make host shutdown wait forever for an extension-owned child
4362
4666
  // session. Keep worktrees attached until *both* child teardown and all
4363
- // providers settle; only then retry cleanup, deepest first. Detached
4364
- // records cannot publish into a replacement session while this waits.
4365
- 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];
4366
4670
  void Promise.allSettled(pending)
4367
4671
  .then(async (outcomes) => {
4368
4672
  if (outcomes.some((outcome) => outcome.status === "rejected")) return;