@signalridge/pi-subagents 1.13.0 → 1.15.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.
package/src/index.ts CHANGED
@@ -64,6 +64,7 @@ import {
64
64
  type ManagedSpawnRequest,
65
65
  type ManagedSpawnResult,
66
66
  type ManagedSpawnTombstone,
67
+ type SpawnOptions,
67
68
  } from "./agent-manager.js";
68
69
  import {
69
70
  getAgentConversation,
@@ -327,7 +328,7 @@ function snapshotOwner(owner: AgentOwner | undefined): AgentOwner | undefined {
327
328
  * Create an AgentActivity state and spawn callbacks for tracking tool usage.
328
329
  * Used by both foreground and background paths to avoid duplication.
329
330
  */
330
- function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
331
+ function buildActivityTracker(maxTurns: number | undefined, onStreamUpdate: (() => void) | undefined, isCurrent: () => boolean) {
331
332
  const state: AgentActivity = {
332
333
  activeTools: new Map(),
333
334
  toolUses: 0,
@@ -340,6 +341,7 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
340
341
 
341
342
  const callbacks = {
342
343
  onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => {
344
+ if (!isCurrent()) return;
343
345
  if (activity.type === "start") {
344
346
  state.activeTools.set(
345
347
  activity.toolName + "_" + Date.now(),
@@ -357,14 +359,17 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
357
359
  onStreamUpdate?.();
358
360
  },
359
361
  onTextDelta: (_delta: string, fullText: string) => {
362
+ if (!isCurrent()) return;
360
363
  state.responseText = fullText;
361
364
  onStreamUpdate?.();
362
365
  },
363
366
  onTurnEnd: (turnCount: number) => {
367
+ if (!isCurrent()) return;
364
368
  state.turnCount = turnCount;
365
369
  onStreamUpdate?.();
366
370
  },
367
371
  onSessionCreated: (session: any) => {
372
+ if (!isCurrent()) return;
368
373
  state.session = session;
369
374
  },
370
375
  onAssistantUsage: (usage: {
@@ -372,6 +377,7 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
372
377
  output: number;
373
378
  cacheWrite: number;
374
379
  }) => {
380
+ if (!isCurrent()) return;
375
381
  addUsage(state.lifetimeUsage, usage);
376
382
  onStreamUpdate?.();
377
383
  },
@@ -695,17 +701,52 @@ function activateRootRuntime(
695
701
  // Holds notifications briefly so get_subagent_result can cancel them
696
702
  // before they reach pi.sendMessage (fire-and-forget).
697
703
  const pendingNudges = new Map<string, ReturnType<typeof setTimeout>>();
704
+ let notificationsRetired = false;
705
+ const invocationUiRetirements = new Set<() => void>();
706
+ const activityRetirements = new WeakMap<AgentActivity, () => void>();
707
+
708
+ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
709
+ let retired = notificationsRetired;
710
+ const timers = new Set<ReturnType<typeof setInterval>>();
711
+ const isCurrent = () => !retired && !notificationsRetired;
712
+ const retire = () => {
713
+ retired = true;
714
+ for (const timer of timers) clearInterval(timer);
715
+ timers.clear();
716
+ invocationUiRetirements.delete(retire);
717
+ };
718
+ const tracker = buildActivityTracker(maxTurns, () => {
719
+ if (isCurrent()) onStreamUpdate?.();
720
+ }, isCurrent);
721
+ if (!retired) invocationUiRetirements.add(retire);
722
+ activityRetirements.set(tracker.state, retire);
723
+ return {
724
+ ...tracker,
725
+ isCurrent,
726
+ retire,
727
+ onBudgetResolved: (maxTurns: number | undefined) => {
728
+ if (isCurrent()) tracker.state.maxTurns = maxTurns;
729
+ },
730
+ ownTimer: (timer: ReturnType<typeof setInterval>) => {
731
+ if (isCurrent()) timers.add(timer);
732
+ else clearInterval(timer);
733
+ },
734
+ };
735
+ }
736
+
698
737
  const NUDGE_HOLD_MS = 200;
699
738
  // A queued result wait must observe completion before its held notification
700
739
  // can fire, so successful waits can still suppress that redundant nudge.
701
740
  const QUEUE_WAIT_POLL_MS = Math.floor(NUDGE_HOLD_MS / 4);
702
741
 
703
742
  function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) {
743
+ if (notificationsRetired) return;
704
744
  cancelNudge(key);
705
745
  pendingNudges.set(
706
746
  key,
707
747
  setTimeout(() => {
708
748
  pendingNudges.delete(key);
749
+ if (notificationsRetired) return;
709
750
  try {
710
751
  send();
711
752
  } catch {
@@ -725,7 +766,7 @@ function activateRootRuntime(
725
766
 
726
767
  // ---- Individual nudge helper (async join mode) ----
727
768
  function emitIndividualNudge(record: AgentRecord) {
728
- if (record.resultConsumed) return; // re-check at send time
769
+ if (notificationsRetired || record.resultConsumed) return; // re-check at send time
729
770
 
730
771
  const notification = formatTaskNotification(record, 500);
731
772
  const footer = record.outputFile
@@ -755,6 +796,7 @@ function activateRootRuntime(
755
796
 
756
797
  // ---- Group join manager ----
757
798
  const groupJoin = new GroupJoinManager((records, partial) => {
799
+ if (notificationsRetired) return;
758
800
  for (const r of records) {
759
801
  agentActivity.delete(r.id);
760
802
  fleet.onAgentFinished(r.id);
@@ -951,6 +993,8 @@ function activateRootRuntime(
951
993
  const manager = new AgentManager(
952
994
  (record) => {
953
995
  if (!record.detached) recordSessionUsage(record);
996
+ const activity = agentActivity.get(record.id);
997
+ if (activity) activityRetirements.get(activity)?.();
954
998
  // Nested children report only through their owning parent's scoped tools.
955
999
  // Keep them out of top-level lifecycle, transcript, notification, and UI channels.
956
1000
  if (record.parentAgentId || record.detached) return;
@@ -999,6 +1043,10 @@ function activateRootRuntime(
999
1043
  ...(record.owner ? { owner: snapshotOwner(record.owner) } : {}),
1000
1044
  });
1001
1045
 
1046
+ // Shutdown still needs authoritative usage, lifecycle and persistence
1047
+ // receipts above. Only UI/notification delivery is retired.
1048
+ if (notificationsRetired) return;
1049
+
1002
1050
  // Managed workflow agents are consumed by their owner through lifecycle
1003
1051
  // events. Suppress only their automatic main-session nudge/group message.
1004
1052
  if (record.owner) {
@@ -1489,6 +1537,17 @@ function activateRootRuntime(
1489
1537
  let runtimeShutdown: Promise<void> | undefined;
1490
1538
  const shutdownRuntime = (): Promise<void> => {
1491
1539
  runtimeShutdown ??= (async () => {
1540
+ // Retire delivery synchronously, before any provider/workflow await.
1541
+ // Cleared timers may already have a callback captured by the host.
1542
+ notificationsRetired = true;
1543
+ for (const retire of [...invocationUiRetirements]) retire();
1544
+ groupJoin.dispose();
1545
+ if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer);
1546
+ batchFinalizeTimer = undefined;
1547
+ currentBatchAgents = [];
1548
+ for (const timer of pendingNudges.values()) clearTimeout(timer);
1549
+ pendingNudges.clear();
1550
+ agentActivity.clear();
1492
1551
  // session_shutdown is confirmed, unlike either before-event. Start the
1493
1552
  // workflow's owned-agent cleanup while its RPC responder and the old leaf
1494
1553
  // are still available, regardless of which extension loads first.
@@ -1771,12 +1830,14 @@ function activateRootRuntime(
1771
1830
  // Allocate tracking only at a real fresh/reused-thread admission. An
1772
1831
  // idempotent request replay must not create another tracker at all.
1773
1832
  let tracker: ReturnType<typeof createActivityTracker> | undefined;
1774
- const tracking = () => tracker ??= createActivityTracker(undefined, () => fleet.update());
1833
+ let resolvedResumeMaxTurns: number | undefined;
1834
+ const tracking = () => tracker ??= createActivityTracker(resolvedResumeMaxTurns, () => fleet.update());
1775
1835
  const outputTranscript =
1776
1836
  getAgentConfig(dispatch.type)?.outputTranscript ??
1777
1837
  getOutputTranscriptDefault();
1778
1838
  let id = "";
1779
1839
  const initializeRecord = (spawnedId: string): void => {
1840
+ if (notificationsRetired) return;
1780
1841
  id = spawnedId;
1781
1842
  agentActivity.set(id, tracking().state);
1782
1843
  const record = manager.getRecordMutable(id);
@@ -1802,7 +1863,12 @@ function activateRootRuntime(
1802
1863
  onTurnEnd: count => tracker?.callbacks.onTurnEnd(count),
1803
1864
  onAssistantUsage: usage => tracker?.callbacks.onAssistantUsage(usage),
1804
1865
  onSpawned: initializeRecord,
1866
+ onResumeBudgetResolved: (maxTurns) => {
1867
+ resolvedResumeMaxTurns = maxTurns;
1868
+ tracker?.onBudgetResolved(maxTurns);
1869
+ },
1805
1870
  onResumeStarting: (record: AgentRecord, startMessageIndex: number) => {
1871
+ if (notificationsRetired) return;
1806
1872
  id = record.id;
1807
1873
  const { state } = tracking();
1808
1874
  state.session = record.session;
@@ -1820,6 +1886,7 @@ function activateRootRuntime(
1820
1886
  fleet.update();
1821
1887
  },
1822
1888
  onSessionCreated: (session: AgentSession) => {
1889
+ if (notificationsRetired) return;
1823
1890
  tracking().callbacks.onSessionCreated(session);
1824
1891
  const record = manager.getRecordMutable(id);
1825
1892
  if (record?.outputFile) {
@@ -1832,6 +1899,7 @@ function activateRootRuntime(
1832
1899
  }
1833
1900
  },
1834
1901
  onCompaction: () => {
1902
+ if (notificationsRetired) return;
1835
1903
  fleet.update();
1836
1904
  },
1837
1905
  };
@@ -1907,16 +1975,17 @@ function activateRootRuntime(
1907
1975
  ): string {
1908
1976
  const config = getAgentConfig(type);
1909
1977
  const resolved = resolveAgentInvocationConfig(config, {});
1910
- const { state, callbacks } = createActivityTracker(undefined, () => {
1978
+ const tracker = createActivityTracker(undefined, () => {
1911
1979
  fleet.update();
1912
1980
  });
1981
+ const { state, callbacks } = tracker;
1913
1982
  // Same sole gate as every other spawn path: `record.outputFile` being set is
1914
1983
  // what every downstream consumer keys off, so an agent whose frontmatter
1915
1984
  // says `output_transcript: false` must not gain one by being mentioned.
1916
1985
  const outputTranscript =
1917
1986
  config?.outputTranscript ?? getOutputTranscriptDefault();
1918
1987
  let id = "";
1919
- const spawnedId = manager.spawn(pi, ctxRef, type, prompt, {
1988
+ const spawnOptions: SpawnOptions = {
1920
1989
  description: opts.description,
1921
1990
  maxTurns: normalizeMaxTurns(resolved.maxTurns ?? getDefaultMaxTurns()),
1922
1991
  isolated: resolved.isolated,
@@ -1939,6 +2008,7 @@ function activateRootRuntime(
1939
2008
  },
1940
2009
  ...callbacks,
1941
2010
  onSpawned: (newId) => {
2011
+ if (notificationsRetired) return;
1942
2012
  id = newId;
1943
2013
  agentActivity.set(id, state);
1944
2014
  const record = manager.getRecordMutable(id);
@@ -1958,6 +2028,7 @@ function activateRootRuntime(
1958
2028
  fleet.update();
1959
2029
  },
1960
2030
  onSessionCreated: (session) => {
2031
+ if (notificationsRetired) return;
1961
2032
  callbacks.onSessionCreated(session);
1962
2033
  const record = manager.getRecordMutable(id);
1963
2034
  if (record?.outputFile) {
@@ -1976,8 +2047,13 @@ function activateRootRuntime(
1976
2047
  );
1977
2048
  }
1978
2049
  },
1979
- });
1980
- return spawnedId;
2050
+ };
2051
+ try {
2052
+ return manager.spawn(pi, ctxRef, type, prompt, spawnOptions);
2053
+ } catch (error) {
2054
+ tracker.retire();
2055
+ throw error;
2056
+ }
1981
2057
  }
1982
2058
 
1983
2059
  // ---- Join mode configuration ----
@@ -2038,6 +2114,7 @@ function activateRootRuntime(
2038
2114
 
2039
2115
  /** Finalize the current batch: if 2+ smart-mode agents, register as a group. */
2040
2116
  function finalizeBatch() {
2117
+ if (notificationsRetired) return;
2041
2118
  batchFinalizeTimer = undefined;
2042
2119
  const batchAgents = [...currentBatchAgents];
2043
2120
  currentBatchAgents = [];
@@ -2240,24 +2317,53 @@ function activateRootRuntime(
2240
2317
  }
2241
2318
 
2242
2319
  if (record.session) {
2320
+ const tracker = createActivityTracker(undefined, () => fleet.update());
2321
+ const { state, callbacks } = tracker;
2322
+ state.session = record.session;
2323
+ const originCurrent = rootUiFence();
2324
+ const session = record.session;
2325
+ const owner = record.owner;
2326
+ const canNotify = () => !notificationsRetired && originCurrent() &&
2327
+ manager.getRecordMutable(record.id) === record && !record.detached &&
2328
+ record.session === session && record.owner === owner;
2329
+ let admitted = false;
2243
2330
  try {
2244
2331
  const resumed = await manager.resume(
2245
2332
  record.id,
2246
2333
  mention.message,
2247
2334
  undefined,
2248
- { isBackground: true },
2335
+ {
2336
+ isBackground: true,
2337
+ ...callbacks,
2338
+ onBudgetResolved: tracker.onBudgetResolved,
2339
+ onStarted: () => {
2340
+ if (tracker.isCurrent()) agentActivity.set(record.id, state);
2341
+ },
2342
+ },
2249
2343
  );
2250
- ctx.ui.notify(
2344
+ admitted = resumed?.promise !== undefined;
2345
+ if (resumed && tracker.isCurrent() && (resumed.status === "queued" || resumed.status === "running")) {
2346
+ agentActivity.set(record.id, state);
2347
+ }
2348
+ void resumed?.promise?.finally(() => {
2349
+ tracker.retire();
2350
+ if (agentActivity.get(record.id) !== state) return;
2351
+ agentActivity.delete(record.id);
2352
+ fleet.onAgentFinished(record.id);
2353
+ }).catch(() => {});
2354
+ if (canNotify()) ctx.ui.notify(
2251
2355
  resumed
2252
2356
  ? `Resuming ${target}`
2253
2357
  : `Could not resume ${target} — it is still running.`,
2254
2358
  resumed ? "info" : "warning",
2255
2359
  );
2256
2360
  } catch (error) {
2257
- ctx.ui.notify(
2361
+ if (canNotify()) ctx.ui.notify(
2258
2362
  `Could not resume ${target}: ${error instanceof Error ? error.message : String(error)}`,
2259
2363
  "warning",
2260
2364
  );
2365
+ } finally {
2366
+ if (!admitted) tracker.retire();
2261
2367
  }
2262
2368
  return { action: "handled" };
2263
2369
  }
@@ -3076,8 +3182,8 @@ Terse command-style prompts produce shallow, generic work.
3076
3182
  // the flag was accepted then silently dropped — a resumed agent always
3077
3183
  // blocked the caller until it finished.
3078
3184
  if (runInBackground) {
3079
- const { state: bgState, callbacks: bgCallbacks } =
3080
- createActivityTracker(effectiveMaxTurns);
3185
+ const bgTracker = createActivityTracker(undefined, () => fleet.update());
3186
+ const { state: bgState, callbacks: bgCallbacks } = bgTracker;
3081
3187
  // resumeAgent has no onSessionCreated — the session predates this run
3082
3188
  // — so seed the activity tracker directly.
3083
3189
  bgState.session = existing.session;
@@ -3097,6 +3203,8 @@ Terse command-style prompts produce shallow, generic work.
3097
3203
  // this index, so it is written exactly once.
3098
3204
  const transcriptAnchor = existing.session.messages.length ?? 0;
3099
3205
  const attachTranscript = (): void => {
3206
+ if (!bgTracker.isCurrent()) return;
3207
+ agentActivity.set(existing.id, bgState);
3100
3208
  if (!existing.outputFile || existing.outputCleanup) return;
3101
3209
  existing.outputCleanup = streamToOutputFile(
3102
3210
  existing.session!,
@@ -3106,25 +3214,38 @@ Terse command-style prompts produce shallow, generic work.
3106
3214
  transcriptAnchor,
3107
3215
  );
3108
3216
  };
3109
- const record = await manager.resume(
3110
- params.resume,
3111
- params.prompt,
3112
- undefined,
3113
- {
3217
+ let record: AgentRecord | undefined;
3218
+ let admitted = false;
3219
+ try {
3220
+ record = await manager.resume(params.resume, params.prompt, undefined, {
3114
3221
  isBackground: true,
3222
+ maxTurns: params.max_turns,
3223
+ onBudgetResolved: bgTracker.onBudgetResolved,
3115
3224
  onToolActivity: bgCallbacks.onToolActivity,
3225
+ onTurnEnd: bgCallbacks.onTurnEnd,
3226
+ onTextDelta: bgCallbacks.onTextDelta,
3116
3227
  onAssistantUsage: bgCallbacks.onAssistantUsage,
3117
3228
  onStarted: attachTranscript,
3118
- },
3119
- );
3229
+ });
3230
+ admitted = record?.promise !== undefined;
3231
+ } finally {
3232
+ if (!admitted) bgTracker.retire();
3233
+ }
3120
3234
  if (!record) {
3121
3235
  return textResult(
3122
3236
  `Cannot resume agent "${params.resume}" in background — it is already running. ` +
3123
3237
  "Wait for it to settle, or steer it with steer_subagent.",
3124
3238
  );
3125
3239
  }
3126
- agentActivity.set(params.resume, bgState);
3127
- void bgCallbacks;
3240
+ if (bgTracker.isCurrent() && (record.status === "queued" || record.status === "running")) {
3241
+ agentActivity.set(params.resume, bgState);
3242
+ }
3243
+ void record.promise?.finally(() => {
3244
+ bgTracker.retire();
3245
+ if (agentActivity.get(record.id) !== bgState) return;
3246
+ agentActivity.delete(record.id);
3247
+ fleet.onAgentFinished(record.id);
3248
+ }).catch(() => {});
3128
3249
  return textResult(
3129
3250
  record.status === "queued"
3130
3251
  ? `Agent "${params.resume}" resumed in background (queued at the concurrency limit).`
@@ -3133,11 +3254,34 @@ Terse command-style prompts produce shallow, generic work.
3133
3254
  );
3134
3255
  }
3135
3256
 
3136
- const record = await manager.resume(
3137
- params.resume,
3138
- params.prompt,
3139
- signal,
3140
- );
3257
+ const tracker = createActivityTracker(undefined, () => {
3258
+ if (!tracker.isCurrent()) return;
3259
+ fleet.update();
3260
+ if (!tracker.isCurrent()) return;
3261
+ onUpdate?.(textResult(
3262
+ "Resuming agent...",
3263
+ buildDetails(detailBase, existing, state),
3264
+ ));
3265
+ });
3266
+ const { state, callbacks } = tracker;
3267
+ state.session = existing.session;
3268
+ let record: AgentRecord | undefined;
3269
+ try {
3270
+ record = await manager.resume(params.resume, params.prompt, signal, {
3271
+ ...callbacks,
3272
+ maxTurns: params.max_turns,
3273
+ onBudgetResolved: tracker.onBudgetResolved,
3274
+ onStarted: () => {
3275
+ if (tracker.isCurrent()) agentActivity.set(existing.id, state);
3276
+ },
3277
+ });
3278
+ } finally {
3279
+ tracker.retire();
3280
+ if (agentActivity.get(existing.id) === state) {
3281
+ agentActivity.delete(existing.id);
3282
+ fleet.onAgentFinished(existing.id);
3283
+ }
3284
+ }
3141
3285
  if (!record) {
3142
3286
  return textResult(`Failed to resume agent "${params.resume}".`);
3143
3287
  }
@@ -3146,19 +3290,21 @@ Terse command-style prompts produce shallow, generic work.
3146
3290
  if (record.status === "error") {
3147
3291
  return textResult(
3148
3292
  `Agent failed: ${record.error}${partialOutputSuffix(record)}`,
3149
- buildDetails(detailBase, record),
3293
+ buildDetails(detailBase, record, state),
3150
3294
  );
3151
3295
  }
3296
+ const output = record.result?.trim() || "No output.";
3297
+ const outcome = getForegroundOutcomeNote(record.status);
3152
3298
  return textResult(
3153
- record.result?.trim() || "No output.",
3154
- buildDetails(detailBase, record),
3299
+ outcome ? `${output}\n\n[pi-subagents outcome]${outcome}` : output,
3300
+ buildDetails(detailBase, record, state),
3155
3301
  );
3156
3302
  }
3157
3303
 
3158
3304
  // Background execution
3159
3305
  if (runInBackground) {
3160
- const { state: bgState, callbacks: bgCallbacks } =
3161
- createActivityTracker(effectiveMaxTurns);
3306
+ const bgTracker = createActivityTracker(effectiveMaxTurns);
3307
+ const { state: bgState, callbacks: bgCallbacks } = bgTracker;
3162
3308
 
3163
3309
  // Wrap onSessionCreated to wire output file streaming.
3164
3310
  // The callback lazily reads record.outputFile (set right after spawn)
@@ -3166,6 +3312,7 @@ Terse command-style prompts produce shallow, generic work.
3166
3312
  let id: string;
3167
3313
  const origBgOnSession = bgCallbacks.onSessionCreated;
3168
3314
  bgCallbacks.onSessionCreated = (session: any) => {
3315
+ if (notificationsRetired) return;
3169
3316
  origBgOnSession(session);
3170
3317
  const rec = manager.getRecordMutable(id);
3171
3318
  if (rec?.outputFile) {
@@ -3180,7 +3327,7 @@ Terse command-style prompts produce shallow, generic work.
3180
3327
 
3181
3328
  // A startup throw means the agent never started. Let pi mark the tool
3182
3329
  // call as failed instead of returning a successful-looking text result.
3183
- id = manager.spawn(pi, ctx, subagentType, params.prompt, {
3330
+ const spawnOptions: SpawnOptions = {
3184
3331
  description: params.description,
3185
3332
  model: resolvedConfig.agentTierSelected ? undefined : model,
3186
3333
  maxTurns: effectiveMaxTurns,
@@ -3196,7 +3343,13 @@ Terse command-style prompts produce shallow, generic work.
3196
3343
  invocation: agentInvocation,
3197
3344
  rootSessionId: ctx.sessionManager.getSessionId(),
3198
3345
  ...bgCallbacks,
3199
- });
3346
+ };
3347
+ try {
3348
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, spawnOptions);
3349
+ } catch (error) {
3350
+ bgTracker.retire();
3351
+ throw error;
3352
+ }
3200
3353
  // The clone needs a positive spawn witness before any UI, transcript,
3201
3354
  // or event observer can throw. A startup failure inside manager.spawn
3202
3355
  // removes its record and does not reach this acknowledgement.
@@ -3275,8 +3428,9 @@ Terse command-style prompts produce shallow, generic work.
3275
3428
  let handedOff = false;
3276
3429
 
3277
3430
  const streamUpdate = () => {
3431
+ if (!fgTracker.isCurrent()) return;
3278
3432
  fleet.update();
3279
- if (!foregroundUpdates) return;
3433
+ if (!fgTracker.isCurrent() || !foregroundUpdates) return;
3280
3434
  const details: AgentDetails = {
3281
3435
  ...detailBase,
3282
3436
  toolUses: fgState.toolUses,
@@ -3294,16 +3448,15 @@ Terse command-style prompts produce shallow, generic work.
3294
3448
  });
3295
3449
  };
3296
3450
 
3297
- const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(
3298
- effectiveMaxTurns,
3299
- streamUpdate,
3300
- );
3451
+ const fgTracker = createActivityTracker(effectiveMaxTurns, streamUpdate);
3452
+ const { state: fgState, callbacks: fgCallbacks } = fgTracker;
3301
3453
 
3302
3454
  // Wire session creation: register in the FleetView list + stream to the output file.
3303
3455
  // The output file path is set synchronously after spawn (below),
3304
3456
  // before onSessionCreated fires — same pattern as background agents.
3305
3457
  const origOnSession = fgCallbacks.onSessionCreated;
3306
3458
  fgCallbacks.onSessionCreated = (session: any) => {
3459
+ if (!fgTracker.isCurrent()) return;
3307
3460
  origOnSession(session);
3308
3461
  for (const a of manager.listAgentsMutable()) {
3309
3462
  if (a.session === session) {
@@ -3329,9 +3482,11 @@ Terse command-style prompts produce shallow, generic work.
3329
3482
  };
3330
3483
 
3331
3484
  const spinnerInterval = setInterval(() => {
3485
+ if (!fgTracker.isCurrent()) return;
3332
3486
  spinnerFrame++;
3333
3487
  streamUpdate();
3334
3488
  }, SPINNER_INTERVAL_MS);
3489
+ fgTracker.ownTimer(spinnerInterval);
3335
3490
 
3336
3491
  streamUpdate();
3337
3492
 
@@ -3374,7 +3529,8 @@ Terse command-style prompts produce shallow, generic work.
3374
3529
  // A transfer ends this tool's spinner, not the child's activity or stream.
3375
3530
  foregroundUpdates = false;
3376
3531
  clearInterval(spinnerInterval);
3377
- if (fgId && !handedOff) {
3532
+ if (!handedOff) fgTracker.retire();
3533
+ if (fgId && !handedOff && !notificationsRetired) {
3378
3534
  agentActivity.delete(fgId);
3379
3535
  fleet.onAgentFinished(fgId);
3380
3536
  }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { Api, Model } from "@earendil-works/pi-ai";
8
- import type { ThinkingLevel } from "./types.js";
8
+ import type { AgentConfig, ThinkingLevel } from "./types.js";
9
9
 
10
10
  /**
11
11
  * Immutable policy used while asking a model to draft an agent definition.
@@ -39,6 +39,10 @@ export interface ParentPolicySnapshot {
39
39
 
40
40
  export const INTERNAL_PARENT_POLICY_SNAPSHOT = Symbol("pi-subagents.internal-parent-policy-snapshot");
41
41
 
42
+ /** Branch-local frontmatter only; never accepts another run/config capability. */
43
+ export type ResumeBudgetPolicy = Pick<AgentConfig, "maxTurns">;
44
+ export const INTERNAL_RESUME_BUDGET_POLICY = Symbol("pi-subagents.internal-resume-budget-policy");
45
+
42
46
  /** The only no-tool policy currently issued by the package. */
43
47
  export const AGENT_DEFINITION_GENERATION_OVERRIDE: InternalAgentConfigOverride = Object.freeze({
44
48
  builtinToolNames: Object.freeze([] as string[]),
@@ -27,6 +27,11 @@ interface AgentInvocationParams {
27
27
  isolation?: IsolationMode;
28
28
  }
29
29
 
30
+ /** Frontmatter is authoritative, including its explicit unlimited value. */
31
+ export function resolveMaxTurns(agentConfig: Pick<AgentConfig, "maxTurns"> | undefined, requested?: number): number | undefined {
32
+ return agentConfig?.maxTurns ?? requested;
33
+ }
34
+
30
35
  export function resolveAgentInvocationConfig(
31
36
  agentConfig: AgentConfig | undefined,
32
37
  params: AgentInvocationParams,
@@ -62,7 +67,7 @@ export function resolveAgentInvocationConfig(
62
67
  thinking: agentTierSelected
63
68
  ? undefined
64
69
  : (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
65
- maxTurns: agentConfig?.maxTurns ?? params.max_turns,
70
+ maxTurns: resolveMaxTurns(agentConfig, params.max_turns),
66
71
  inheritContext:
67
72
  agentConfig?.inheritContext ?? params.inherit_context ?? false,
68
73
  runInBackground:
@@ -18,6 +18,7 @@ import {
18
18
  } from "./agent-types.js";
19
19
  import { INHERIT_CONTEXT_UNAVAILABLE } from "./context-boundary.js";
20
20
  import { loadCustomAgents } from "./custom-agents.js";
21
+ import { INTERNAL_RESUME_BUDGET_POLICY, type ResumeBudgetPolicy } from "./internal-run.js";
21
22
  import { resolveAgentInvocationConfig } from "./invocation-config.js";
22
23
  import { resolveModel } from "./model-resolver.js";
23
24
  import { checkModelScope } from "./model-scope.js";
@@ -92,7 +93,7 @@ export interface NestedAgentManager {
92
93
  ): Promise<{ id: string; record: AgentRecord }>;
93
94
  /** Internal live lookup used only by the scoped manager runtime. */
94
95
  getRecordMutable(id: string): AgentRecord | undefined;
95
- resume(id: string, prompt: string, signal?: AbortSignal): Promise<AgentRecord | undefined>;
96
+ resume(id: string, prompt: string, signal?: AbortSignal, options?: { maxTurns?: number; readonly [INTERNAL_RESUME_BUDGET_POLICY]?: ResumeBudgetPolicy }): Promise<AgentRecord | undefined>;
96
97
  }
97
98
 
98
99
  export interface NestedToolContext {
@@ -194,7 +195,11 @@ export function createNestedSubagentTools(context: NestedToolContext): ToolDefin
194
195
  if (signal?.aborted) {
195
196
  return textResult("Resume aborted.", true);
196
197
  }
197
- const resumed = await context.manager.resume(params.resume, params.prompt, signal);
198
+ const config = getAgentConfigIn(loadRegistry(), existing.type);
199
+ const resumed = await context.manager.resume(params.resume, params.prompt, signal, {
200
+ maxTurns: params.max_turns,
201
+ [INTERNAL_RESUME_BUDGET_POLICY]: { maxTurns: config?.maxTurns },
202
+ });
198
203
  return resumed
199
204
  ? textResult(formatRecord(resumed, "inline"), resumed.status === "error")
200
205
  : textResult(`Failed to resume nested agent "${params.resume}".`, true);
@@ -0,0 +1,49 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { defineTool, type ExtensionContext, type ExtensionToolContext, type SettingsManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import { type ApprovalService, createApprovalToolDefinitions } from "@signalridge/pi-approval-core";
4
+ import { captureQuestionOrigin } from "./question-origin.js";
5
+
6
+ /** Human service capture and native context leases live for the entire child session. */
7
+ export function createChildApprovalTools(options: {
8
+ service: ApprovalService;
9
+ assertServiceCurrent: () => void;
10
+ root: ExtensionContext;
11
+ rootOrigin: (current: ExtensionContext) => boolean;
12
+ agentId?: string;
13
+ settings: SettingsManager;
14
+ waiting: (toolCallId: string) => () => void;
15
+ isBound: () => boolean;
16
+ isToolCurrent: (name: string) => boolean;
17
+ }): ToolDefinition[] {
18
+ const leases = new WeakMap<ExtensionToolContext, () => boolean>();
19
+ const [edit, write, bash] = createApprovalToolDefinitions({
20
+ source: `child:${options.agentId ?? randomUUID()}`,
21
+ shellPath: options.settings.getShellPath(),
22
+ commandPrefix: options.settings.getShellCommandPrefix(),
23
+ isToolCurrent: (_ctx, name) => options.isToolCurrent(name),
24
+ isCurrent: (ctx) => {
25
+ let lease = leases.get(ctx);
26
+ if (!lease) {
27
+ const childOrigin = captureQuestionOrigin(ctx);
28
+ const invocationRoot = captureQuestionOrigin(options.root);
29
+ lease = () => {
30
+ if (ctx.signal?.aborted || !options.isBound() || !options.rootOrigin(options.root) ||
31
+ !invocationRoot(options.root) || !childOrigin(ctx)) return false;
32
+ options.assertServiceCurrent();
33
+ return true;
34
+ };
35
+ leases.set(ctx, lease);
36
+ }
37
+ return lease();
38
+ },
39
+ request: async (operation, owner) => {
40
+ const resume = options.waiting(operation.toolCallId);
41
+ try {
42
+ await options.service.request(operation, owner);
43
+ } finally {
44
+ resume();
45
+ }
46
+ },
47
+ });
48
+ return [defineTool(edit), defineTool(write), defineTool(bash)];
49
+ }
@@ -0,0 +1,22 @@
1
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface RunBudgets {
4
+ maxTurns?: number;
5
+ maxTokens?: number;
6
+ maxToolCalls?: number;
7
+ }
8
+
9
+ // Session policy survives each prompt; counters and UI observers never do.
10
+ export const sessionBudgets = new WeakMap<AgentSession, RunBudgets>();
11
+ let defaultTurnPolicy: number | undefined;
12
+
13
+ export function getDefaultTurnPolicy(): number | undefined { return defaultTurnPolicy; }
14
+ export function setDefaultTurnPolicy(value: number | undefined): void { defaultTurnPolicy = value; }
15
+
16
+ /** Raw zero preserves authoritative unlimited policy across settings changes. */
17
+ export function selectResumeTurnPolicy(session: AgentSession, resolved?: number): number {
18
+ if (resolved !== undefined) return resolved;
19
+ const inherited = sessionBudgets.get(session);
20
+ if (inherited) return inherited.maxTurns ?? 0;
21
+ return defaultTurnPolicy ?? 0;
22
+ }