@signalridge/pi-subagents 1.14.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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.15.0
4
+ ### Minor Changes
5
+
6
+ - a4dc1de: Enforce missing per-invocation safety budgets on resumed agents, including foreground/background calls, nested resumes, mentions, and reused managed threads. Share fresh/resumed turn, token, and tool-call enforcement and terminal outcome mapping; preserve configured grace and tool timeouts. Resume inherits the original invocation's turn budget when not overridden, respects authoritative frontmatter/default policy, validates explicit limits like spawn, and does not let zero erase a configured cap. Nested resumes resolve only their branch-local frontmatter through an in-process budget-policy capability; internal callers without it retain the admitted invocation budget rather than consulting the root registry. Authoritative frontmatter zero remains unlimited after a finite earlier invocation. A retained session's finite or unlimited policy survives later default changes; one manager-resolved cap drives enforcement and queued/running UI counters. Activity updates are invocation-owned and lifetime usage is counted only once.
7
+
8
+ Dispose grouped notifications and pending batch/nudge timers synchronously at confirmed shutdown, retire invocation-owned foreground progress/spinner callbacks before any teardown await, fence captured delivery callbacks, and preserve managed lifecycle/usage receipts while cooperative child teardown completes. Refused/failed resume admissions retire their own UI callbacks, while admitted lifetimes own final cleanup; mention notifications are fenced after asynchronous admission. Vetoable navigation remains unchanged.
9
+
10
+ Selective upstream parity references: tintinweb/pi-subagents `d7c9ec0b027b91c7ab9da0fa5f4ae203474a5bdf`, `ca93a5247e72e7053fcd10bea7125d6c4205f949` (unreleased after the 0.22 tag), and `83be963a969eb359af29cbbb4d4af2d01748bfb5` (released in 0.22). The integrated engine is not imported; managed protocol v4 and payloads are unchanged. Event-based budgets cannot forcibly terminate a provider or tool that ignores abort.
11
+
12
+ ### Patch Changes
13
+
14
+ - a4dc1de: Distinguish expandable Workflow groups from individual agents with a dedicated `▸` type mark across all run outcomes. Remove the duplicate trailing blank row before the footer while preserving Fleet navigation, exact-owner conversation opening, and the existing Workflow detail UI.
15
+
3
16
  ## 1.14.0
4
17
  ### Minor Changes
5
18
 
package/README.md CHANGED
@@ -55,6 +55,8 @@ Derived from [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents)
55
55
 
56
56
  The container-first FleetView and bounded drill-down patterns are independently adapted from tintinweb/pi-subagents at [`221df02323c6b6350ea8474579e0ee0f6cc84cea`](https://github.com/tintinweb/pi-subagents/tree/221df02323c6b6350ea8474579e0ee0f6cc84cea/src/ui) and [`fd8d81618d80ae83a3faf40cb87dae99cca33264`](https://github.com/tintinweb/pi-subagents/tree/fd8d81618d80ae83a3faf40cb87dae99cca33264/src/ui). Collision-only child-label discriminators are inspired by [nicobailon/pi-subagents `69673c7fbf3351e54a5b0786a7e9d9fff625a749`](https://github.com/nicobailon/pi-subagents/blob/69673c7fbf3351e54a5b0786a7e9d9fff625a749/src/tui/render-helpers.ts). The workflow engine and phase inspector remain owned by `pi-workflows`, not copied into this package. A separate optional, validated UI protocol supplies authoritative run names, statuses and observed phases and opens that owner's inspector. Without that peer, groups retain the observation-only child list. No workflow source imports, inferred DAG state or second queue/model policy are introduced. The managed protocol remains v4.
57
57
 
58
+ Resume-budget and shutdown parity fixes selectively adapt upstream work at [`d7c9ec0`](https://github.com/tintinweb/pi-subagents/commit/d7c9ec0b027b91c7ab9da0fa5f4ae203474a5bdf), [`ca93a52`](https://github.com/tintinweb/pi-subagents/commit/ca93a5247e72e7053fcd10bea7125d6c4205f949) (unreleased after the 0.22 tag), and [`83be963`](https://github.com/tintinweb/pi-subagents/commit/83be963a969eb359af29cbbb4d4af2d01748bfb5) (released in 0.22). They retain this fork's manager-owned sessions, resource budgets, activity accounting, and protocol v4; the integrated upstream engine is not imported.
59
+
58
60
  The opt-in parent clarification broker is locally implemented. It uses the existing main session, not an upstream LLM arbitrator, cloned parent history, private UI automation, or an additional Agent per question. Permission gates remain human-owned.
59
61
 
60
62
  ## Lint policy
@@ -469,6 +471,10 @@ Instead of hard-aborting at the turn limit, agents get a graceful shutdown:
469
471
  2. Up to 5 grace turns to finish cleanly
470
472
  3. Hard abort only after the grace period
471
473
 
474
+ Each resumed prompt gets its own counters, not an unlimited continuation or a deduction from prior turns. This applies to foreground/background resumes, nested children, mentions, and reused managed threads. Agent frontmatter remains authoritative; without a per-resume override, the original invocation's turn limit is inherited, falling back to configured defaults. Resume overrides use the same non-negative safe-integer validation as spawning; caller zero cannot disable an inherited/configured cap. An authoritative frontmatter zero remains unlimited, even after an earlier finite invocation. Nested resumes reload the same branch-local definitions as nested spawns, never the root registry; an internal resume without scoped policy retains its admitted invocation budget. A retained session's admitted turn policy, including unlimited, survives later project-default changes; the manager supplies that same effective cap to queued and running progress trackers. The existing token/tool-call caps and tool timeout also apply to resumed prompts. Budgets are event-based: an already-running provider/tool can overshoot within one event or ignore abort; these are not process-isolation guarantees.
475
+
476
+ Confirmed shutdown retires grouped completion, batch-finalization, held-nudge timers, and invocation-owned foreground spinners before awaiting child teardown. Captured delivery callbacks cannot wake the main model or publish foreground progress afterward, even while a provider ignores abort. Refused or failed resume admissions retire their own UI callbacks; an admitted run owns cleanup until settlement. Mention continuations cannot publish notifications after their root/runtime retires. Managed lifecycle/usage receipts remain available during cooperative shutdown; vetoable navigation attempts do not dispose these resources.
477
+
472
478
  | Status | Meaning | UI treatment |
473
479
  |--------|---------|-------------|
474
480
  | `completed` | Finished naturally | `completed` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalridge/pi-subagents",
3
- "version": "1.14.0",
3
+ "version": "1.15.0",
4
4
  "description": "Signalridge's managed subagent runtime with workflow-owned orchestration RPC.",
5
5
  "author": "tintinweb and signalridge contributors",
6
6
  "license": "MIT",
@@ -35,15 +35,19 @@ import {
35
35
  import {
36
36
  INTERNAL_AGENT_CONFIG_OVERRIDE,
37
37
  INTERNAL_PARENT_POLICY_SNAPSHOT,
38
+ INTERNAL_RESUME_BUDGET_POLICY,
38
39
  type InternalAgentConfigOverride,
39
40
  type ParentPolicySnapshot,
41
+ type ResumeBudgetPolicy,
40
42
  } from "./internal-run.js";
43
+ import { resolveMaxTurns } from "./invocation-config.js";
41
44
  import { assignHandle, handleBase } from "./mention.js";
42
45
  import {
43
46
  assertParentModelRuntimeAvailable,
44
47
  parentModelSessionOptions,
45
48
  } from "./model-runtime-bridge.js";
46
49
  import type { ParentQuestionHandler } from "./parent-questions.js";
50
+ import { selectResumeTurnPolicy } from "./run-budget-policy.js";
47
51
  import { shutdownAndDisposeSession } from "./session-lifecycle.js";
48
52
  import type { TierThinking } from "./settings.js";
49
53
  import type {
@@ -315,6 +319,21 @@ function snapshotCleanupFailure(
315
319
  });
316
320
  }
317
321
 
322
+ /** Hard budgets take precedence over provider failure, then soft wrap-up. */
323
+ function applyRunOutcome(
324
+ record: AgentRecord,
325
+ outcome: { aborted?: boolean; steered?: boolean; failure?: string },
326
+ ): void {
327
+ if (outcome.aborted) {
328
+ record.status = "aborted";
329
+ } else if (outcome.failure) {
330
+ record.status = "error";
331
+ record.error = outcome.failure;
332
+ } else {
333
+ record.status = outcome.steered ? "steered" : "completed";
334
+ }
335
+ }
336
+
318
337
  interface SpawnArgs {
319
338
  pi: ExtensionAPI;
320
339
  ctx: ExtensionContext;
@@ -1021,10 +1040,27 @@ export type ManagedSpawnCallbacks = Pick<
1021
1040
  | "onCompaction"
1022
1041
  | "onSpawned"
1023
1042
  > & {
1043
+ /** Reused thread's effective display budget, before queue admission. */
1044
+ onResumeBudgetResolved?: (maxTurns: number | undefined) => void;
1024
1045
  /** Reused thread's actual run boundary, before native steering or prompt. */
1025
1046
  onResumeStarting?: (record: AgentRecord, startMessageIndex: number) => void;
1026
1047
  };
1027
1048
 
1049
+ export interface ResumeOptions {
1050
+ readonly [INTERNAL_RESUME_BUDGET_POLICY]?: ResumeBudgetPolicy;
1051
+ isBackground?: boolean;
1052
+ maxTurns?: number;
1053
+ onToolActivity?: (activity: ToolActivity) => void;
1054
+ onTurnEnd?: (count: number) => void;
1055
+ onTextDelta?: (delta: string, fullText: string) => void;
1056
+ onAssistantUsage?: SpawnOptions["onAssistantUsage"];
1057
+ onCompaction?: (info: CompactionInfo) => void;
1058
+ /** Effective display cap, published synchronously before queue admission. */
1059
+ onBudgetResolved?: (maxTurns: number | undefined) => void;
1060
+ /** Called at actual admission, including a queued background resume. */
1061
+ onStarted?: () => void;
1062
+ }
1063
+
1028
1064
  interface ResumeTerminalSnapshot {
1029
1065
  status: AgentRecord["status"];
1030
1066
  startedAt: number;
@@ -1863,9 +1899,13 @@ export class AgentManager {
1863
1899
  // only filter excludes only explicit `false`, so undefined agents — which
1864
1900
  // have no inline surface — stay visible instead of vanishing.
1865
1901
  isBackground: options.isBackground,
1866
- invocation: options.invocation
1867
- ? cloneFrozenData(options.invocation)
1868
- : undefined,
1902
+ invocation:
1903
+ options.invocation || options.maxTurns !== undefined
1904
+ ? cloneFrozenData({
1905
+ ...options.invocation,
1906
+ maxTurns: options.maxTurns ?? options.invocation?.maxTurns,
1907
+ })
1908
+ : undefined,
1869
1909
  depth: options.depth ?? 1,
1870
1910
  parentAgentId: options.parentAgentId,
1871
1911
  ...(ancestorAgentIds ? { ancestorAgentIds } : {}),
@@ -2176,9 +2216,13 @@ export class AgentManager {
2176
2216
  }
2177
2217
  void this.resume(threadedId, normalized.prompt, undefined, {
2178
2218
  isBackground: true,
2219
+ maxTurns: policy.maxTurns,
2220
+ onBudgetResolved: callbacks?.onResumeBudgetResolved,
2179
2221
  onStarted: () => {
2180
2222
  void Promise.resolve(callbacks?.onResumeStarting?.(record, record.session!.messages.length)).catch(() => {});
2181
2223
  },
2224
+ onTurnEnd: callbacks?.onTurnEnd,
2225
+ onTextDelta: callbacks?.onTextDelta,
2182
2226
  onToolActivity: callbacks?.onToolActivity,
2183
2227
  onAssistantUsage: callbacks?.onAssistantUsage,
2184
2228
  onCompaction: callbacks?.onCompaction
@@ -2560,14 +2604,7 @@ export class AgentManager {
2560
2604
  // Precedence: a hard abort keeps "aborted"; then a failed final turn
2561
2605
  // (provider error that pi resolved instead of rejecting, #144) is an
2562
2606
  // honest "error" — not a completion with an empty or stale result.
2563
- if (aborted) {
2564
- record.status = "aborted";
2565
- } else if (failure) {
2566
- record.status = "error";
2567
- record.error = failure;
2568
- } else {
2569
- record.status = steered ? "steered" : "completed";
2570
- }
2607
+ applyRunOutcome(record, { aborted, steered, failure });
2571
2608
  }
2572
2609
  record.completedAt ??= Date.now();
2573
2610
  detach();
@@ -3094,21 +3131,7 @@ export class AgentManager {
3094
3131
  id: string,
3095
3132
  prompt: string,
3096
3133
  signal?: AbortSignal,
3097
- options?: {
3098
- isBackground?: boolean;
3099
- onToolActivity?: (activity: {
3100
- type: "start" | "end";
3101
- toolName: string;
3102
- }) => void;
3103
- onAssistantUsage?: (usage: {
3104
- input: number;
3105
- output: number;
3106
- cacheWrite: number;
3107
- }) => void;
3108
- onCompaction?: (info: unknown) => void;
3109
- /** Called once a queued background resume has acquired its run slot. */
3110
- onStarted?: () => void;
3111
- },
3134
+ options?: ResumeOptions,
3112
3135
  ): Promise<AgentRecord | undefined> {
3113
3136
  if (this.disposed) return undefined;
3114
3137
  const record = this.agents.get(id);
@@ -3124,6 +3147,28 @@ export class AgentManager {
3124
3147
  record.invocation?.inheritContext === true,
3125
3148
  );
3126
3149
 
3150
+ if (
3151
+ options?.maxTurns !== undefined &&
3152
+ (!Number.isSafeInteger(options.maxTurns) || options.maxTurns < 0)
3153
+ ) {
3154
+ throw new Error("maxTurns must be a non-negative safe integer");
3155
+ }
3156
+ // Nested definitions belong to their branch, never the global root registry.
3157
+ // Without the scoped tool's capability, retain the admitted invocation policy.
3158
+ const policy = record.parentAgentId === undefined
3159
+ ? getAgentConfig(record.type)
3160
+ : options?.[INTERNAL_RESUME_BUDGET_POLICY] ?? record.invocation;
3161
+ const maxTurns = selectResumeTurnPolicy(record.session, resolveMaxTurns(
3162
+ policy,
3163
+ // A caller's zero cannot erase the original/configured safety policy.
3164
+ options?.maxTurns || record.invocation?.maxTurns,
3165
+ ));
3166
+ try {
3167
+ options?.onBudgetResolved?.(maxTurns === 0 ? undefined : maxTurns);
3168
+ } catch {
3169
+ // UI observers cannot alter the authoritative policy or admission.
3170
+ }
3171
+ options = { ...options, maxTurns };
3127
3172
  const snapshot = this.resumeSnapshot(record);
3128
3173
  if (options?.isBackground) {
3129
3174
  // A background resume is one lifecycle, including time spent queued. Keep
@@ -3177,18 +3222,31 @@ export class AgentManager {
3177
3222
  record.result = undefined;
3178
3223
  record.error = undefined;
3179
3224
  try {
3180
- const { text, failure } = await resumeAgent(record.session, prompt, {
3225
+ options.onStarted?.();
3226
+ const { text, failure, aborted, steered } = await resumeAgent(record.session, prompt, {
3227
+ maxTurns,
3228
+ onTurnEnd: (count) => {
3229
+ if (!record.detached) options.onTurnEnd?.(count);
3230
+ },
3231
+ onTextDelta: (delta, text) => {
3232
+ if (!record.detached) options.onTextDelta?.(delta, text);
3233
+ },
3181
3234
  onToolActivity: (activity) => {
3182
- if (!record.detached && activity.type === "end") record.toolUses++;
3235
+ if (record.detached) return;
3236
+ if (activity.type === "end") record.toolUses++;
3237
+ options.onToolActivity?.(activity);
3183
3238
  },
3184
3239
  onAssistantUsage: (usage) => {
3185
- if (!record.detached) addUsage(record.lifetimeUsage, usage);
3240
+ if (record.detached) return;
3241
+ addUsage(record.lifetimeUsage, usage);
3242
+ options.onAssistantUsage?.(usage);
3186
3243
  },
3187
3244
  onCompaction: (info) => {
3188
3245
  if (record.detached) return;
3189
3246
  record.compactionCount++;
3190
3247
  this.syncManagedRecord(record);
3191
3248
  this.onCompact?.(record, info);
3249
+ options.onCompaction?.(info);
3192
3250
  },
3193
3251
  signal: control.controller.signal,
3194
3252
  });
@@ -3197,8 +3255,7 @@ export class AgentManager {
3197
3255
  !control.controller.signal.aborted &&
3198
3256
  (record.status as AgentRecord["status"]) !== "stopped"
3199
3257
  ) {
3200
- record.status = failure ? "error" : "completed";
3201
- if (failure) record.error = failure;
3258
+ applyRunOutcome(record, { aborted, steered, failure });
3202
3259
  record.result = text;
3203
3260
  record.completedAt = Date.now();
3204
3261
  }
@@ -3242,21 +3299,7 @@ export class AgentManager {
3242
3299
  record: AgentRecord,
3243
3300
  prompt: string,
3244
3301
  control: ResumeControl,
3245
- options?: {
3246
- isBackground?: boolean;
3247
- onToolActivity?: (activity: {
3248
- type: "start" | "end";
3249
- toolName: string;
3250
- }) => void;
3251
- onAssistantUsage?: (usage: {
3252
- input: number;
3253
- output: number;
3254
- cacheWrite: number;
3255
- }) => void;
3256
- onCompaction?: (info: unknown) => void;
3257
- /** Called once a queued background resume has acquired its run slot. */
3258
- onStarted?: () => void;
3259
- },
3302
+ options?: ResumeOptions,
3260
3303
  ): Promise<string> {
3261
3304
  const snapshot = control.snapshot;
3262
3305
  let slotHeld = false;
@@ -3305,13 +3348,22 @@ export class AgentManager {
3305
3348
  }
3306
3349
 
3307
3350
  try {
3308
- const { text, failure } = await resumeAgent(record.session!, prompt, {
3351
+ const { text, failure, aborted, steered } = await resumeAgent(record.session!, prompt, {
3352
+ maxTurns: options?.maxTurns,
3353
+ onTurnEnd: (count) => {
3354
+ if (!record.detached) options?.onTurnEnd?.(count);
3355
+ },
3356
+ onTextDelta: (delta, text) => {
3357
+ if (!record.detached) options?.onTextDelta?.(delta, text);
3358
+ },
3309
3359
  onToolActivity: (activity) => {
3310
- if (!record.detached && activity.type === "end") record.toolUses++;
3360
+ if (record.detached) return;
3361
+ if (activity.type === "end") record.toolUses++;
3311
3362
  options?.onToolActivity?.(activity);
3312
3363
  },
3313
3364
  onAssistantUsage: (usage) => {
3314
- if (!record.detached) addUsage(record.lifetimeUsage, usage);
3365
+ if (record.detached) return;
3366
+ addUsage(record.lifetimeUsage, usage);
3315
3367
  options?.onAssistantUsage?.(usage);
3316
3368
  },
3317
3369
  onCompaction: (info) => {
@@ -3328,8 +3380,7 @@ export class AgentManager {
3328
3380
  !control.controller.signal.aborted &&
3329
3381
  (record.status as AgentRecord["status"]) !== "stopped"
3330
3382
  ) {
3331
- record.status = failure ? "error" : "completed";
3332
- if (failure) record.error = failure;
3383
+ applyRunOutcome(record, { aborted, steered, failure });
3333
3384
  record.result = text;
3334
3385
  record.completedAt = Date.now();
3335
3386
  }
@@ -48,6 +48,7 @@ import { ANSWER_SUBAGENT_QUESTION, type ParentQuestionHandler } from "./parent-q
48
48
  import { captureProjectTrust, childProjectTrust, configurationMcpFactory, isProjectResource } from "./project-trust.js";
49
49
  import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
50
50
  import { captureQuestionOrigin } from "./question-origin.js";
51
+ import { getDefaultTurnPolicy, type RunBudgets, selectResumeTurnPolicy, sessionBudgets, setDefaultTurnPolicy } from "./run-budget-policy.js";
51
52
  import { shutdownAndDisposeSession } from "./session-lifecycle.js";
52
53
  import { preloadSkills } from "./skill-loader.js";
53
54
  import { createSupervisorTool } from "./supervisor.js";
@@ -353,9 +354,6 @@ async function runConfiguredGate(command: string, cwd: string, pi: ExtensionAPI)
353
354
  }
354
355
  }
355
356
 
356
- /** Default max turns. undefined = unlimited (no turn limit). */
357
- let defaultMaxTurns: number | undefined;
358
-
359
357
  /** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
360
358
  export function normalizeMaxTurns(n: number | undefined): number | undefined {
361
359
  if (n == null || n === 0) return undefined;
@@ -363,9 +361,9 @@ export function normalizeMaxTurns(n: number | undefined): number | undefined {
363
361
  }
364
362
 
365
363
  /** Get the default max turns value. undefined = unlimited. */
366
- export function getDefaultMaxTurns(): number | undefined { return defaultMaxTurns; }
364
+ export function getDefaultMaxTurns(): number | undefined { return getDefaultTurnPolicy(); }
367
365
  /** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
368
- export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = normalizeMaxTurns(n); }
366
+ export function setDefaultMaxTurns(n: number | undefined): void { setDefaultTurnPolicy(normalizeMaxTurns(n)); }
369
367
 
370
368
  /** Additional turns allowed after the soft limit steer message. */
371
369
  /**
@@ -1810,85 +1808,23 @@ export async function runAgent(
1810
1808
  options.onSessionCreated(session);
1811
1809
  }
1812
1810
 
1813
- // Track turns for graceful max_turns enforcement
1814
- let turnCount = 0;
1815
- const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns);
1816
- let softLimitReached = false;
1817
- let aborted = false;
1818
-
1819
- // Resource budgets, in the same shape as max_turns: a wrap-up steer at the
1820
- // soft threshold, an abort at the hard one. They bound what a single agent
1821
- // can spend on its own, which turn count does not — one turn can burn an
1822
- // arbitrary number of tokens or tool calls.
1823
- //
1824
- // `0` means unlimited here, matching this package's existing convention for
1825
- // `maxTurns`. That is the opposite of `maxSubagentSpawnsPerBranch`, and
1826
- // deliberately: these are opt-in resource caps that ship off, while the
1827
- // branch spawn budget is a safety valve that ships on.
1828
- const tokenBudget = normalizeMaxTurns(agentConfig?.maxTokens ?? defaultMaxTokens);
1829
- const toolCallBudget = normalizeMaxTurns(agentConfig?.maxToolCalls ?? defaultMaxToolCalls);
1830
- let budgetTokens = 0;
1831
- let budgetToolCalls = 0;
1832
- let tokenSoftReached = false;
1833
- let toolSoftReached = false;
1834
-
1835
- /**
1836
- * Apply one budget, returning the next soft-limit state.
1837
- *
1838
- * The soft threshold is `SOFT_BUDGET_FRACTION` of the budget so the agent has
1839
- * room left to actually write its answer after being told to wrap up — a
1840
- * steer sent at 100% would be asking for a final response with no allowance
1841
- * to produce it.
1842
- */
1843
- const applyBudget = (used: number, budget: number | undefined, softReached: boolean, label: string): boolean => {
1844
- if (budget == null) return softReached;
1845
- // The hard limit is checked FIRST. One message can consume more than the
1846
- // whole budget, and testing the soft threshold first would answer that with
1847
- // a wrap-up steer and no abort — leaving an agent already over budget to
1848
- // run on until its next event.
1849
- if (used >= budget) {
1850
- aborted = true;
1851
- session.abort();
1852
- return true;
1853
- }
1854
- if (!softReached && used >= budget * SOFT_BUDGET_FRACTION) {
1855
- session.steer(
1856
- `You are near your ${label} budget for this task. Wrap up immediately — provide your final answer now.`,
1857
- );
1858
- return true;
1859
- }
1860
- return softReached;
1811
+ const limits = {
1812
+ maxTurns: options.maxTurns ?? agentConfig?.maxTurns ?? getDefaultMaxTurns(),
1813
+ maxTokens: normalizeMaxTurns(agentConfig?.maxTokens ?? defaultMaxTokens),
1814
+ maxToolCalls: normalizeMaxTurns(agentConfig?.maxToolCalls ?? defaultMaxToolCalls),
1861
1815
  };
1816
+ sessionBudgets.set(session, limits);
1817
+ const budget = createRunBudget(session, limits, options.onTurnEnd);
1862
1818
 
1863
1819
  let currentMessageText = "";
1864
- // Per-tool timeout: a hung bash/MCP call must not stall the subagent forever.
1865
- // Each tool_execution_start arms a timer; the matching end clears it. On
1866
- // timeout the session is aborted — which propagates via the tool's
1867
- // AbortSignal into the hanging execute() and surfaces as an error result.
1868
- // `contact_supervisor` is excluded: it intentionally waits for a human.
1869
1820
  const toolTimeouts = createToolTimeouts(session, noExtensions ? 0 : TOOL_CALL_ABORT_GRACE_MS, approvalTimer.isInteractiveAsk);
1870
1821
  approvalTimer.armApproved = toolTimeouts.armApproved;
1871
1822
  approvalTimer.pauseHuman = toolTimeouts.pauseHuman;
1872
- // Own finalized output before observer dispatch: a synchronous callback
1873
- // failure must remain visible without erasing the answer it interrupted.
1823
+ // Own finalized output before dispatching observers that may throw.
1874
1824
  const collector = collectResponseText(session);
1875
1825
  const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
1876
- if (event.type === "turn_end") {
1877
- turnCount++;
1878
- options.onTurnEnd?.(turnCount);
1879
- if (maxTurns != null) {
1880
- if (!softLimitReached && turnCount >= maxTurns) {
1881
- softLimitReached = true;
1882
- session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
1883
- } else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
1884
- aborted = true;
1885
- session.abort();
1886
- }
1887
- }
1888
- }
1889
- if (event.type === "message_start") {
1890
- currentMessageText = "";
1891
- }
1826
+ budget.onEvent(event);
1827
+ if (event.type === "message_start") currentMessageText = "";
1892
1828
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
1893
1829
  currentMessageText += event.assistantMessageEvent.delta;
1894
1830
  options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
@@ -1900,22 +1836,14 @@ export async function runAgent(
1900
1836
  if (event.type === "tool_execution_end") {
1901
1837
  toolTimeouts.end(event);
1902
1838
  options.onToolActivity?.({ type: "end", toolName: event.toolName });
1903
- budgetToolCalls++;
1904
- toolSoftReached = applyBudget(budgetToolCalls, toolCallBudget, toolSoftReached, "tool call");
1905
1839
  }
1906
1840
  if (event.type === "message_end" && event.message.role === "assistant") {
1907
- const u = (event.message as any).usage;
1841
+ const u = event.message.usage;
1908
1842
  if (u) options.onAssistantUsage?.({
1909
1843
  input: u.input ?? 0,
1910
1844
  output: u.output ?? 0,
1911
1845
  cacheWrite: u.cacheWrite ?? 0,
1912
1846
  });
1913
- if (u) {
1914
- // Same total as `getLifetimeTotal`: input + output + cacheWrite, with
1915
- // cacheRead deliberately excluded.
1916
- budgetTokens += (u.input ?? 0) + (u.output ?? 0) + (u.cacheWrite ?? 0);
1917
- tokenSoftReached = applyBudget(budgetTokens, tokenBudget, tokenSoftReached, "token");
1918
- }
1919
1847
  }
1920
1848
  if (event.type === "compaction_end" && !event.aborted && event.result) {
1921
1849
  options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
@@ -1934,20 +1862,16 @@ export async function runAgent(
1934
1862
  }
1935
1863
 
1936
1864
  const baseText = collector.getText();
1937
- // The acceptance gate runs AFTER the agent is done and its verdict is
1938
- // appended to the result, so the parent reads the check and the claim it is
1939
- // checking side by side. It deliberately does not steer the agent to fix
1940
- // what failed: the gate is evidence for the caller, not another turn.
1865
+ // Acceptance gates report evidence after the run, never spend another turn.
1941
1866
  const responseText = agentConfig?.gate
1942
1867
  ? `${baseText}${await runConfiguredGate(agentConfig.gate, effectiveCwd, options.pi)}`
1943
1868
  : baseText;
1944
1869
  completed = true;
1945
- return { responseText, session, aborted, steered: softLimitReached, failure: collector.getFailure() };
1870
+ return { responseText, session, ...budget.outcome(), failure: collector.getFailure() };
1946
1871
  } finally {
1947
1872
  cleanupAbort();
1948
- // The manager takes ownership only after onSessionCreated is invoked. If
1949
- // setup or the first prompt fails before that hand-off, this runner owns
1950
- // the session and must close it instead of leaving a live session behind.
1873
+ // Until onSessionCreated hands ownership to the manager, the runner must
1874
+ // close a session whose setup/prompt failed.
1951
1875
  if (!handedOff && !completed) {
1952
1876
  try {
1953
1877
  if (!options.signal?.aborted) await session.abort();
@@ -1958,6 +1882,86 @@ export async function runAgent(
1958
1882
  }
1959
1883
  }
1960
1884
 
1885
+ /** One invocation's soft/hard budgets, shared by fresh and resumed prompts. */
1886
+ function createRunBudget(session: AgentSession, limits: RunBudgets, onTurnEnd?: (count: number) => void) {
1887
+ let turnCount = 0;
1888
+ const maxTurns = normalizeMaxTurns(limits.maxTurns);
1889
+ let softLimitReached = false;
1890
+ let aborted = false;
1891
+
1892
+ // Resource budgets, in the same shape as max_turns: a wrap-up steer at the
1893
+ // soft threshold, an abort at the hard one. They bound what a single agent
1894
+ // can spend on its own, which turn count does not — one turn can burn an
1895
+ // arbitrary number of tokens or tool calls.
1896
+ //
1897
+ // `0` means unlimited here, matching this package's existing convention for
1898
+ // `maxTurns`. That is the opposite of `maxSubagentSpawnsPerBranch`, and
1899
+ // deliberately: these are opt-in resource caps that ship off, while the
1900
+ // branch spawn budget is a safety valve that ships on.
1901
+ const tokenBudget = limits.maxTokens;
1902
+ const toolCallBudget = limits.maxToolCalls;
1903
+ let budgetTokens = 0;
1904
+ let budgetToolCalls = 0;
1905
+ let tokenSoftReached = false;
1906
+ let toolSoftReached = false;
1907
+
1908
+ /**
1909
+ * Apply one budget, returning the next soft-limit state.
1910
+ *
1911
+ * The soft threshold is `SOFT_BUDGET_FRACTION` of the budget so the agent has
1912
+ * room left to actually write its answer after being told to wrap up — a
1913
+ * steer sent at 100% would be asking for a final response with no allowance
1914
+ * to produce it.
1915
+ */
1916
+ const applyBudget = (used: number, budget: number | undefined, softReached: boolean, label: string): boolean => {
1917
+ if (budget == null) return softReached;
1918
+ // The hard limit is checked FIRST. One message can consume more than the
1919
+ // whole budget, and testing the soft threshold first would answer that with
1920
+ // a wrap-up steer and no abort — leaving an agent already over budget to
1921
+ // run on until its next event.
1922
+ if (used >= budget) {
1923
+ aborted = true;
1924
+ session.abort();
1925
+ return true;
1926
+ }
1927
+ if (!softReached && used >= budget * SOFT_BUDGET_FRACTION) {
1928
+ session.steer(
1929
+ `You are near your ${label} budget for this task. Wrap up immediately — provide your final answer now.`,
1930
+ );
1931
+ return true;
1932
+ }
1933
+ return softReached;
1934
+ };
1935
+
1936
+ return {
1937
+ outcome: () => ({ aborted, steered: softLimitReached }),
1938
+ onEvent: (event: AgentSessionEvent): void => {
1939
+ if (event.type === "turn_end") {
1940
+ turnCount++;
1941
+ if (maxTurns != null) {
1942
+ if (!softLimitReached && turnCount >= maxTurns) {
1943
+ softLimitReached = true;
1944
+ session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
1945
+ } else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
1946
+ aborted = true;
1947
+ session.abort();
1948
+ }
1949
+ }
1950
+ onTurnEnd?.(turnCount);
1951
+ }
1952
+ if (event.type === "tool_execution_end") {
1953
+ budgetToolCalls++;
1954
+ toolSoftReached = applyBudget(budgetToolCalls, toolCallBudget, toolSoftReached, "tool call");
1955
+ }
1956
+ if (event.type === "message_end" && event.message.role === "assistant" && event.message.usage) {
1957
+ const u = event.message.usage;
1958
+ budgetTokens += (u.input ?? 0) + (u.output ?? 0) + (u.cacheWrite ?? 0);
1959
+ tokenSoftReached = applyBudget(budgetTokens, tokenBudget, tokenSoftReached, "token");
1960
+ }
1961
+ },
1962
+ };
1963
+ }
1964
+
1961
1965
  /**
1962
1966
  * Send a new prompt to an existing session (resume).
1963
1967
  */
@@ -1965,34 +1969,48 @@ export async function resumeAgent(
1965
1969
  session: AgentSession,
1966
1970
  prompt: string,
1967
1971
  options: {
1972
+ maxTurns?: number;
1973
+ onTurnEnd?: (count: number) => void;
1974
+ onTextDelta?: (delta: string, fullText: string) => void;
1968
1975
  onToolActivity?: (activity: ToolActivity) => void;
1969
1976
  onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
1970
1977
  onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
1971
1978
  signal?: AbortSignal;
1972
1979
  } = {},
1973
- ): Promise<{ text: string; failure?: string }> {
1980
+ ): Promise<{ text: string; failure?: string; aborted: boolean; steered: boolean }> {
1974
1981
  throwIfAborted(options.signal);
1975
1982
  assertSafeChildSession(session);
1976
1983
  const collector = collectResponseText(session);
1977
1984
  const cleanupAbort = forwardAbortSignal(session, options.signal);
1978
1985
 
1979
- const unsubEvents = (options.onToolActivity || options.onAssistantUsage || options.onCompaction)
1980
- ? session.subscribe((event: AgentSessionEvent) => {
1981
- if (event.type === "tool_execution_start") options.onToolActivity?.({ type: "start", toolName: event.toolName });
1982
- if (event.type === "tool_execution_end") options.onToolActivity?.({ type: "end", toolName: event.toolName });
1983
- if (event.type === "message_end" && event.message.role === "assistant") {
1984
- const u = (event.message as any).usage;
1985
- if (u) options.onAssistantUsage?.({
1986
- input: u.input ?? 0,
1987
- output: u.output ?? 0,
1988
- cacheWrite: u.cacheWrite ?? 0,
1989
- });
1990
- }
1991
- if (event.type === "compaction_end" && !event.aborted && event.result) {
1992
- options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
1993
- }
1994
- })
1995
- : () => {};
1986
+ const inherited = sessionBudgets.get(session);
1987
+ const budget = createRunBudget(session, {
1988
+ maxTurns: selectResumeTurnPolicy(session, options.maxTurns),
1989
+ maxTokens: inherited ? inherited.maxTokens : normalizeMaxTurns(defaultMaxTokens),
1990
+ maxToolCalls: inherited ? inherited.maxToolCalls : normalizeMaxTurns(defaultMaxToolCalls),
1991
+ }, options.onTurnEnd);
1992
+ let currentMessageText = "";
1993
+ const unsubEvents = session.subscribe((event: AgentSessionEvent) => {
1994
+ budget.onEvent(event);
1995
+ if (event.type === "message_start") currentMessageText = "";
1996
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
1997
+ currentMessageText += event.assistantMessageEvent.delta;
1998
+ options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
1999
+ }
2000
+ if (event.type === "tool_execution_start") options.onToolActivity?.({ type: "start", toolName: event.toolName });
2001
+ if (event.type === "tool_execution_end") options.onToolActivity?.({ type: "end", toolName: event.toolName });
2002
+ if (event.type === "message_end" && event.message.role === "assistant") {
2003
+ const u = event.message.usage;
2004
+ if (u) options.onAssistantUsage?.({
2005
+ input: u.input ?? 0,
2006
+ output: u.output ?? 0,
2007
+ cacheWrite: u.cacheWrite ?? 0,
2008
+ });
2009
+ }
2010
+ if (event.type === "compaction_end" && !event.aborted && event.result) {
2011
+ options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
2012
+ }
2013
+ });
1996
2014
  // The child policy and its consent gate survive resumed turns. Replace the
1997
2015
  // original prompt's timer callback so a delayed approval cannot arm a stale
1998
2016
  // timer that the first prompt no longer clears.
@@ -2025,6 +2043,7 @@ export async function resumeAgent(
2025
2043
 
2026
2044
  return {
2027
2045
  text: collector.getText(),
2046
+ ...budget.outcome(),
2028
2047
  failure: collector.getFailure(),
2029
2048
  };
2030
2049
  }
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,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
+ }
@@ -556,7 +556,9 @@ export class FleetList {
556
556
  const color = status === "error" ? "error" : status === "stopped" ? "warning" : selected ? "accent" : "dim";
557
557
  const name = sanitizeDisplayText(run.name);
558
558
  const phase = run.currentPhase ? ` · ${sanitizeDisplayText(run.currentPhase)}` : "";
559
- const left = ` ${theme.fg(color, getAgentStatusMark(status))} ${theme.fg(selected ? "accent" : "muted", name)} · ${theme.fg(color, run.status)}${phase} · ${run.runningCalls}/${run.callCount} running · ${run.completedCalls} done · ${run.failedCalls} failed${run.restored ? " · restored" : ""}`;
559
+ // A workflow is an expandable group, not another agent. Keep its
560
+ // type mark distinct at every outcome; color/text still report status.
561
+ const left = ` ${theme.fg(color, "▸")} ${theme.fg(selected ? "accent" : "muted", name)} · ${theme.fg(color, run.status)}${phase} · ${run.runningCalls}/${run.callCount} running · ${run.completedCalls} done · ${run.failedCalls} failed${run.restored ? " · restored" : ""}`;
560
562
  const right = `${formatFleetElapsed(run.elapsedMs)}${run.tokenCount === undefined ? "" : ` · ${formatFleetTokens(run.tokenCount)}`}`;
561
563
  lines.push(rightAlign(left, theme.fg("dim", right), width));
562
564
  } else {
@@ -567,7 +569,7 @@ export class FleetList {
567
569
  }
568
570
  if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `${hiddenBelow} more below`), width));
569
571
 
570
- lines.push(""); // breathing room below the roster, including narrow terminals
572
+ // One separator belongs to Fleet; do not stack two empty rows before the footer.
571
573
  return [...lines.slice(0, height - 1), ""].map(line => truncateToWidth(line, Math.max(0, width)));
572
574
  }
573
575