pi-subagents 0.32.0 → 0.33.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +30 -3
  2. package/README.md +147 -58
  3. package/install.mjs +2 -1
  4. package/package.json +3 -2
  5. package/skills/pi-subagents/SKILL.md +70 -14
  6. package/src/agents/agent-management.ts +177 -5
  7. package/src/agents/agent-memory.ts +254 -0
  8. package/src/agents/agent-serializer.ts +11 -0
  9. package/src/agents/agents.ts +142 -12
  10. package/src/agents/chain-serializer.ts +27 -2
  11. package/src/extension/doctor.ts +1 -9
  12. package/src/extension/fanout-child.ts +2 -2
  13. package/src/extension/index.ts +65 -90
  14. package/src/extension/rpc.ts +369 -0
  15. package/src/extension/schemas.ts +52 -8
  16. package/src/extension/tool-description.ts +200 -0
  17. package/src/intercom/intercom-bridge.ts +21 -253
  18. package/src/intercom/native-supervisor-channel.ts +510 -0
  19. package/src/runs/background/async-execution.ts +51 -7
  20. package/src/runs/background/async-job-tracker.ts +12 -2
  21. package/src/runs/background/async-status.ts +27 -2
  22. package/src/runs/background/completion-batcher.ts +166 -0
  23. package/src/runs/background/control-channel.ts +106 -1
  24. package/src/runs/background/fleet-view.ts +515 -0
  25. package/src/runs/background/notify.ts +161 -44
  26. package/src/runs/background/result-watcher.ts +1 -2
  27. package/src/runs/background/run-id-resolver.ts +3 -2
  28. package/src/runs/background/run-status.ts +166 -6
  29. package/src/runs/background/scheduled-runs.ts +514 -0
  30. package/src/runs/background/subagent-runner.ts +409 -35
  31. package/src/runs/background/wait.ts +353 -0
  32. package/src/runs/foreground/chain-execution.ts +95 -21
  33. package/src/runs/foreground/execution.ts +150 -21
  34. package/src/runs/foreground/subagent-executor.ts +378 -64
  35. package/src/runs/shared/dynamic-fanout.ts +1 -1
  36. package/src/runs/shared/model-fallback.ts +167 -20
  37. package/src/runs/shared/model-scope.ts +128 -0
  38. package/src/runs/shared/nested-events.ts +31 -0
  39. package/src/runs/shared/parallel-utils.ts +1 -0
  40. package/src/runs/shared/pi-args.ts +30 -1
  41. package/src/runs/shared/subagent-prompt-runtime.ts +108 -2
  42. package/src/runs/shared/tool-budget.ts +74 -0
  43. package/src/runs/shared/turn-budget.ts +52 -0
  44. package/src/shared/artifacts.ts +1 -0
  45. package/src/shared/atomic-json.ts +15 -2
  46. package/src/shared/child-transcript.ts +212 -0
  47. package/src/shared/settings.ts +3 -1
  48. package/src/shared/types.ts +134 -19
  49. package/src/slash/prompt-workflows.ts +330 -0
  50. package/src/slash/slash-commands.ts +16 -2
  51. package/src/tui/render.ts +16 -8
  52. package/src/extension/companion-suggestions.ts +0 -359
@@ -16,8 +16,10 @@ import { buildChainInstructions, isDynamicParallelStep, isParallelStep, resolveS
16
16
  import type { RunnerStep } from "../shared/parallel-utils.ts";
17
17
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
18
18
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
19
+ import { buildAgentMemoryInjection } from "../../agents/agent-memory.ts";
19
20
  import { PI_CODING_AGENT_PACKAGE_ROOT_ENV, resolveChildCwd } from "../../shared/utils.ts";
20
21
  import { buildModelCandidates, resolveModelCandidate, resolveSubagentModelOverride, type AvailableModelInfo, type ParentModel } from "../shared/model-fallback.ts";
22
+ import type { ModelScopeConfig } from "../shared/model-scope.ts";
21
23
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
22
24
  import { resolveExpectedWorktreeAgentCwd } from "../shared/worktree.ts";
23
25
  import { buildWorkflowGraphSnapshot } from "../shared/workflow-graph.ts";
@@ -31,6 +33,8 @@ import {
31
33
  type MaxOutputConfig,
32
34
  type NestedRouteInfo,
33
35
  type ResolvedControlConfig,
36
+ type ResolvedTurnBudget,
37
+ type ResolvedToolBudget,
34
38
  type SubagentRunMode,
35
39
  ASYNC_DIR,
36
40
  RESULTS_DIR,
@@ -41,6 +45,8 @@ import {
41
45
  resolveChildMaxSubagentDepth,
42
46
  } from "../../shared/types.ts";
43
47
  import { nestedResultsPath, resolveInheritedNestedRouteFromEnv, resolveNestedParentAddressFromEnv, writeNestedEvent } from "../shared/nested-events.ts";
48
+ import { initialTurnBudgetState } from "../shared/turn-budget.ts";
49
+ import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
44
50
  import type { ImportedAsyncRoot } from "./chain-root-attachment.ts";
45
51
 
46
52
  const require = createRequire(import.meta.url);
@@ -100,6 +106,8 @@ interface AsyncExecutionContext {
100
106
  parentSessionId?: string;
101
107
  currentModelProvider?: string;
102
108
  currentModel?: ParentModel;
109
+ /** Optional model-scope enforcement resolved from subagent settings. */
110
+ modelScope?: ModelScopeConfig;
103
111
  }
104
112
 
105
113
  interface AsyncChainParams {
@@ -131,6 +139,9 @@ interface AsyncChainParams {
131
139
  nestedRoute?: NestedRouteInfo;
132
140
  acceptance?: AcceptanceInput;
133
141
  timeoutMs?: number;
142
+ turnBudget?: ResolvedTurnBudget;
143
+ toolBudget?: ResolvedToolBudget;
144
+ configToolBudget?: ResolvedToolBudget;
134
145
  /** Global cap on simultaneously-running subagent tasks within the async run. */
135
146
  globalConcurrencyLimit?: number;
136
147
  }
@@ -164,6 +175,9 @@ interface AsyncSingleParams {
164
175
  nestedRoute?: NestedRouteInfo;
165
176
  acceptance?: AcceptanceInput;
166
177
  timeoutMs?: number;
178
+ turnBudget?: ResolvedTurnBudget;
179
+ toolBudget?: ResolvedToolBudget;
180
+ configToolBudget?: ResolvedToolBudget;
167
181
  }
168
182
 
169
183
  interface AsyncExecutionResult {
@@ -191,6 +205,8 @@ export interface AsyncRunnerStepBuildParams {
191
205
  asyncDir: string;
192
206
  outputBaseDir?: string;
193
207
  validateOutputBindings?: boolean;
208
+ toolBudget?: ResolvedToolBudget;
209
+ configToolBudget?: ResolvedToolBudget;
194
210
  }
195
211
 
196
212
  export type AsyncRunnerStepBuildResult =
@@ -208,8 +224,8 @@ export function formatAsyncStartedMessage(headline: string): string {
208
224
  headline,
209
225
  "",
210
226
  "The async run is detached. Do not run sleep timers or polling loops just to wait for it.",
211
- "If you have independent work, continue that work. If you have nothing else to do until the async result arrives, end your turn now; Pi will deliver the completion when the run finishes.",
212
- "Use subagent({ action: \"status\", id: \"...\" }) when you need the current status/result, or to inspect a blocked/stale run. Do not poll just to wait.",
227
+ "If you have independent work, continue that work. When you have nothing left to do until the async result arrives, call wait() it blocks until the run finishes and delivers the completion here. Only if you are certain you will get another turn (an interactive session where the user will prompt you again) can you instead stop and let Pi wake you; inside a skill that must run to completion, or in a non-interactive run, there is no next turn, so use wait().",
228
+ "Use subagent({ action: \"status\", id: \"...\" }) when you need a one-shot status/result or to inspect a blocked/stale run. To block until completion, prefer wait(). Do not poll in a loop just to wait.",
213
229
  ].join("\n");
214
230
  }
215
231
 
@@ -404,6 +420,9 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
404
420
  };
405
421
  const buildSeqStep = (s: SequentialStep, sessionFile?: string, behaviorCwd?: string, progressPrecreated = false, resolvedBehavior?: ResolvedStepBehavior, flatIndex?: number) => {
406
422
  const a = agents.find((x) => x.name === s.agent)!;
423
+ const toolBudgetInput = s.toolBudget ?? params.toolBudget ?? a.toolBudget ?? params.configToolBudget;
424
+ const resolvedToolBudget = validateToolBudgetConfig(toolBudgetInput, s.toolBudget ? "toolBudget" : a.toolBudget ? "agent.toolBudget" : "config.toolBudget");
425
+ if (resolvedToolBudget.error) throw new AsyncStartValidationError(resolvedToolBudget.error);
407
426
  const stepCwd = resolveChildCwd(runnerCwd, s.cwd);
408
427
  const instructionCwd = behaviorCwd ?? stepCwd;
409
428
  const behavior = suppressProgressForReadOnlyTask(resolvedBehavior ?? resolveStepBehavior(a, buildStepOverrides(s), chainSkills), s.task, originalTask);
@@ -416,6 +435,10 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
416
435
  const injection = buildSkillInjection(resolvedSkills);
417
436
  systemPrompt = systemPrompt ? `${systemPrompt}\n\n${injection}` : injection;
418
437
  }
438
+ const memoryInjection = buildAgentMemoryInjection(a, stepCwd);
439
+ if (memoryInjection) {
440
+ systemPrompt = systemPrompt ? `${systemPrompt}\n\n${memoryInjection}` : memoryInjection;
441
+ }
419
442
 
420
443
  const readInstructions = buildChainInstructions({ ...behavior, output: false, progress: false }, instructionCwd, false);
421
444
  const isFirstProgressAgent = behavior.progress && !progressPrecreated && !progressInstructionCreated;
@@ -431,7 +454,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
431
454
  const task = injectSingleOutputInstruction(`${readInstructions.prefix}${taskTemplate}${progressInstructions.suffix}`, outputPath);
432
455
 
433
456
  const requestedModel = behavior.model ?? a.model;
434
- const primaryModel = resolveSubagentModelOverride(requestedModel, ctx.currentModel, availableModels, ctx.currentModelProvider);
457
+ const primaryModel = resolveSubagentModelOverride(requestedModel, ctx.currentModel, availableModels, ctx.currentModelProvider, { scope: ctx.modelScope, source: behavior.model ? "explicit" : "inherited" });
435
458
  const thinkingOverride = flatIndex === undefined ? undefined : thinkingOverridesByFlatIndex?.[flatIndex];
436
459
  const effectiveThinking = thinkingOverride ?? a.thinking;
437
460
  const model = applyThinkingSuffix(primaryModel, effectiveThinking, thinkingOverride !== undefined);
@@ -446,7 +469,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
446
469
  cwd: stepCwd,
447
470
  model,
448
471
  thinking: resolveEffectiveThinking(model, effectiveThinking),
449
- modelCandidates: buildModelCandidates(primaryModel, a.fallbackModels, availableModels, ctx.currentModelProvider).map((candidate) =>
472
+ modelCandidates: buildModelCandidates(primaryModel, a.fallbackModels, availableModels, ctx.currentModelProvider, { scope: ctx.modelScope }).map((candidate) =>
450
473
  applyThinkingSuffix(candidate, effectiveThinking, thinkingOverride !== undefined),
451
474
  ),
452
475
  tools: a.tools,
@@ -473,6 +496,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
473
496
  }),
474
497
  ...(s.outputSchema ? { structuredOutputSchema: s.outputSchema } : {}),
475
498
  ...(s.outputSchema ? { structuredOutput: createStructuredOutputRuntime(s.outputSchema, path.join(asyncDir, "structured-output")) } : {}),
499
+ ...(resolvedToolBudget.budget ? { toolBudget: resolvedToolBudget.budget } : {}),
476
500
  };
477
501
  };
478
502
 
@@ -638,6 +662,8 @@ export function executeAsyncChain(
638
662
  maxSubagentDepth,
639
663
  worktreeBaseDir,
640
664
  asyncDir,
665
+ toolBudget: params.toolBudget,
666
+ configToolBudget: params.configToolBudget,
641
667
  });
642
668
  if ("error" in built) {
643
669
  try {
@@ -649,6 +675,7 @@ export function executeAsyncChain(
649
675
  }
650
676
  const { steps, runnerCwd, workflowGraph, eventChain } = built;
651
677
  const deadlineAt = params.timeoutMs !== undefined ? Date.now() + params.timeoutMs : undefined;
678
+ const initialTurnBudget = params.turnBudget ? initialTurnBudgetState(params.turnBudget) : undefined;
652
679
  let childTargetIndex = 0;
653
680
  const childIntercomTargets = childIntercomTarget ? steps.flatMap((step) => {
654
681
  if (!("parallel" in step) && step.importAsyncRoot) {
@@ -687,6 +714,8 @@ export function executeAsyncChain(
687
714
  worktreeSetupHookTimeoutMs,
688
715
  worktreeBaseDir,
689
716
  controlConfig,
717
+ turnBudget: params.turnBudget,
718
+ toolBudget: params.toolBudget,
690
719
  controlIntercomTarget,
691
720
  childIntercomTargets,
692
721
  resultMode,
@@ -767,6 +796,7 @@ export function executeAsyncChain(
767
796
  chainStepCount: eventChain.length,
768
797
  parallelGroups,
769
798
  ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}),
799
+ ...(initialTurnBudget ? { turnBudget: initialTurnBudget } : {}),
770
800
  startedAt: now,
771
801
  lastUpdate: now,
772
802
  },
@@ -797,6 +827,7 @@ export function executeAsyncChain(
797
827
  cwd: runnerCwd,
798
828
  asyncDir,
799
829
  ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}),
830
+ ...(initialTurnBudget ? { turnBudget: initialTurnBudget } : {}),
800
831
  nestedRoute,
801
832
  });
802
833
  }
@@ -809,7 +840,7 @@ export function executeAsyncChain(
809
840
 
810
841
  return {
811
842
  content: [{ type: "text", text: formatAsyncStartedMessage(`Async ${resultMode}: ${chainDesc} [${id}]`) }],
812
- details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir, workflowGraph, ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}) },
843
+ details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir, workflowGraph, ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}), ...(params.turnBudget ? { turnBudget: params.turnBudget } : {}), ...(params.toolBudget ? { toolBudget: params.toolBudget } : {}) },
813
844
  };
814
845
  }
815
846
 
@@ -851,6 +882,10 @@ export function executeAsyncSingle(
851
882
  const injection = buildSkillInjection(resolvedSkills);
852
883
  systemPrompt = systemPrompt ? `${systemPrompt}\n\n${injection}` : injection;
853
884
  }
885
+ const memoryInjection = buildAgentMemoryInjection(agentConfig, runnerCwd);
886
+ if (memoryInjection) {
887
+ systemPrompt = systemPrompt ? `${systemPrompt}\n\n${memoryInjection}` : memoryInjection;
888
+ }
854
889
 
855
890
  const inheritedNestedRoute = resolveInheritedNestedRouteFromEnv();
856
891
  const nestedAddress = inheritedNestedRoute ? resolveNestedParentAddressFromEnv() : undefined;
@@ -883,7 +918,11 @@ export function executeAsyncSingle(
883
918
  );
884
919
  const effectiveThinking = params.thinkingOverride ?? agentConfig.thinking;
885
920
  const model = applyThinkingSuffix(primaryModel, effectiveThinking, params.thinkingOverride !== undefined);
921
+ const toolBudgetInput = params.toolBudget ?? agentConfig.toolBudget ?? params.configToolBudget;
922
+ const resolvedToolBudget = validateToolBudgetConfig(toolBudgetInput, params.toolBudget ? "toolBudget" : agentConfig.toolBudget ? "agent.toolBudget" : "config.toolBudget");
923
+ if (resolvedToolBudget.error) return formatAsyncStartError("single", resolvedToolBudget.error);
886
924
  const deadlineAt = params.timeoutMs !== undefined ? Date.now() + params.timeoutMs : undefined;
925
+ const initialTurnBudget = params.turnBudget ? initialTurnBudgetState(params.turnBudget) : undefined;
887
926
  let spawnResult: { pid?: number; error?: string } = {};
888
927
  try {
889
928
  spawnResult = spawnRunner(
@@ -897,7 +936,7 @@ export function executeAsyncSingle(
897
936
  cwd: runnerCwd,
898
937
  model,
899
938
  thinking: resolveEffectiveThinking(model, effectiveThinking),
900
- modelCandidates: buildModelCandidates(primaryModel, agentConfig.fallbackModels, availableModels, ctx.currentModelProvider).map((candidate) =>
939
+ modelCandidates: buildModelCandidates(primaryModel, agentConfig.fallbackModels, availableModels, ctx.currentModelProvider, { scope: ctx.modelScope }).map((candidate) =>
901
940
  applyThinkingSuffix(candidate, effectiveThinking, params.thinkingOverride !== undefined),
902
941
  ),
903
942
  tools: agentConfig.tools,
@@ -921,6 +960,7 @@ export function executeAsyncSingle(
921
960
  mode: "single",
922
961
  async: true,
923
962
  }),
963
+ ...(resolvedToolBudget.budget ? { toolBudget: resolvedToolBudget.budget } : {}),
924
964
  },
925
965
  ],
926
966
  resultPath: inheritedNestedRoute ? nestedResultsPath(inheritedNestedRoute.rootRunId, id) : path.join(RESULTS_DIR, `${id}.json`),
@@ -941,6 +981,8 @@ export function executeAsyncSingle(
941
981
  controlConfig,
942
982
  timeoutMs: params.timeoutMs,
943
983
  deadlineAt,
984
+ turnBudget: params.turnBudget,
985
+ toolBudget: params.toolBudget,
944
986
  controlIntercomTarget,
945
987
  childIntercomTargets: childIntercomTarget ? [childIntercomTarget(agent, 0)] : undefined,
946
988
  resultMode: "single",
@@ -991,6 +1033,7 @@ export function executeAsyncSingle(
991
1033
  agents: [agent],
992
1034
  chainStepCount: 1,
993
1035
  ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}),
1036
+ ...(initialTurnBudget ? { turnBudget: initialTurnBudget } : {}),
994
1037
  startedAt: now,
995
1038
  lastUpdate: now,
996
1039
  },
@@ -1010,12 +1053,13 @@ export function executeAsyncSingle(
1010
1053
  cwd: runnerCwd,
1011
1054
  asyncDir,
1012
1055
  ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}),
1056
+ ...(initialTurnBudget ? { turnBudget: initialTurnBudget } : {}),
1013
1057
  nestedRoute,
1014
1058
  });
1015
1059
  }
1016
1060
 
1017
1061
  return {
1018
1062
  content: [{ type: "text", text: formatAsyncStartedMessage(`Async: ${agent} [${id}]`) }],
1019
- details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir, ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}) },
1063
+ details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir, ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}), ...(params.turnBudget ? { turnBudget: params.turnBudget } : {}), ...(params.toolBudget ? { toolBudget: params.toolBudget } : {}) },
1020
1064
  };
1021
1065
  }
@@ -89,6 +89,9 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
89
89
  timeoutMs: run.timeoutMs,
90
90
  deadlineAt: run.deadlineAt,
91
91
  timedOut: run.timedOut,
92
+ turnBudget: run.turnBudget,
93
+ turnBudgetExceeded: run.turnBudgetExceeded,
94
+ wrapUpRequested: run.wrapUpRequested,
92
95
  sessionDir: run.sessionDir,
93
96
  outputFile: run.outputFile,
94
97
  totalTokens: run.totalTokens,
@@ -305,6 +308,9 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
305
308
  job.timeoutMs = status.timeoutMs ?? job.timeoutMs;
306
309
  job.deadlineAt = status.deadlineAt ?? job.deadlineAt;
307
310
  job.timedOut = status.timedOut ?? job.timedOut;
311
+ job.turnBudget = status.turnBudget ?? job.turnBudget;
312
+ job.turnBudgetExceeded = status.turnBudgetExceeded ?? job.turnBudgetExceeded;
313
+ job.wrapUpRequested = status.wrapUpRequested ?? job.wrapUpRequested;
308
314
  job.sessionFile = status.sessionFile ?? job.sessionFile;
309
315
  if ((job.status === "complete" || job.status === "failed" || job.status === "paused") && !nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
310
316
  scheduleCleanup(job.asyncId);
@@ -337,6 +343,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
337
343
  const handleStarted = (data: unknown) => {
338
344
  const info = data as AsyncStartedEvent;
339
345
  if (!info.id) return;
346
+ if (typeof state.currentSessionId === "string" && info.sessionId !== state.currentSessionId) return;
340
347
  const now = Date.now();
341
348
  const asyncDir = info.asyncDir ?? path.join(asyncDirRoot, info.id);
342
349
  const rawAgents = info.agents?.length ? info.agents : info.chain && info.chain.length > 0 ? info.chain : info.agent ? [info.agent] : undefined;
@@ -364,6 +371,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
364
371
  updatedAt: now,
365
372
  timeoutMs: info.timeoutMs,
366
373
  deadlineAt: info.deadlineAt,
374
+ turnBudget: info.turnBudget,
367
375
  controlEventCursor: 0,
368
376
  });
369
377
  ensurePoller();
@@ -373,7 +381,8 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
373
381
  };
374
382
 
375
383
  const handleComplete = (data: unknown) => {
376
- const result = data as { id?: string; success?: boolean; asyncDir?: string };
384
+ const result = data as { id?: string; success?: boolean; asyncDir?: string; sessionId?: string };
385
+ if (typeof state.currentSessionId === "string" && result.sessionId !== state.currentSessionId) return;
377
386
  const asyncId = result.id;
378
387
  if (!asyncId) return;
379
388
  const job = state.asyncJobs.get(asyncId);
@@ -412,9 +421,10 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
412
421
 
413
422
  const restoreActiveJobs = (ctx?: ExtensionContext) => {
414
423
  if (ctx?.hasUI) state.lastUiContext = ctx;
424
+ if (!state.currentSessionId) return;
415
425
  let runs: AsyncRunSummary[];
416
426
  try {
417
- runs = listAsyncRuns(asyncDirRoot, { states: ["queued", "running"], resultsDir, kill: options.kill, now: options.now });
427
+ runs = listAsyncRuns(asyncDirRoot, { states: ["queued", "running"], sessionId: state.currentSessionId, resultsDir, kill: options.kill, now: options.now });
418
428
  } catch (error) {
419
429
  console.error(`Failed to restore active async jobs from '${asyncDirRoot}':`, error);
420
430
  return;
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
4
4
  import { formatActivityLabel, formatParallelOutcome } from "../../shared/status-format.ts";
5
- import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type CostSummary, type NestedRunSummary, type SubagentRunMode, type TokenUsage } from "../../shared/types.ts";
5
+ import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type CostSummary, type NestedRunSummary, type SubagentRunMode, type TokenUsage, type TurnBudgetState } from "../../shared/types.ts";
6
6
  import { readStatus } from "../../shared/utils.ts";
7
7
  import { attachRootChildrenToSteps, buildNestedRouteIndex, type NestedRoute, projectNestedEvents } from "../shared/nested-events.ts";
8
8
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
@@ -27,6 +27,8 @@ interface AsyncRunStepSummary {
27
27
  recentOutput?: string[];
28
28
  turnCount?: number;
29
29
  toolCount?: number;
30
+ steerCount?: number;
31
+ lastSteerAt?: number;
30
32
  durationMs?: number;
31
33
  tokens?: TokenUsage;
32
34
  totalCost?: CostSummary;
@@ -36,6 +38,9 @@ interface AsyncRunStepSummary {
36
38
  attemptedModels?: string[];
37
39
  error?: string;
38
40
  timedOut?: boolean;
41
+ turnBudget?: TurnBudgetState;
42
+ turnBudgetExceeded?: boolean;
43
+ wrapUpRequested?: boolean;
39
44
  children?: NestedRunSummary[];
40
45
  }
41
46
 
@@ -52,6 +57,8 @@ export interface AsyncRunSummary {
52
57
  currentPath?: string;
53
58
  turnCount?: number;
54
59
  toolCount?: number;
60
+ steerCount?: number;
61
+ lastSteerAt?: number;
55
62
  mode: SubagentRunMode;
56
63
  cwd?: string;
57
64
  startedAt: number;
@@ -60,6 +67,9 @@ export interface AsyncRunSummary {
60
67
  timeoutMs?: number;
61
68
  deadlineAt?: number;
62
69
  timedOut?: boolean;
70
+ turnBudget?: TurnBudgetState;
71
+ turnBudgetExceeded?: boolean;
72
+ wrapUpRequested?: boolean;
63
73
  currentStep?: number;
64
74
  chainStepCount?: number;
65
75
  pendingAppends?: number;
@@ -168,6 +178,8 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
168
178
  ...(step.recentOutput ? { recentOutput: [...step.recentOutput] } : {}),
169
179
  ...(step.turnCount !== undefined ? { turnCount: step.turnCount } : {}),
170
180
  ...(step.toolCount !== undefined ? { toolCount: step.toolCount } : {}),
181
+ ...(step.steerCount !== undefined ? { steerCount: step.steerCount } : {}),
182
+ ...(step.lastSteerAt !== undefined ? { lastSteerAt: step.lastSteerAt } : {}),
171
183
  ...(step.durationMs !== undefined ? { durationMs: step.durationMs } : {}),
172
184
  ...(step.tokens ? { tokens: step.tokens } : {}),
173
185
  ...(step.totalCost ? { totalCost: step.totalCost } : {}),
@@ -177,6 +189,9 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
177
189
  ...(step.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
178
190
  ...(step.error ? { error: step.error } : {}),
179
191
  ...(step.timedOut !== undefined ? { timedOut: step.timedOut } : {}),
192
+ ...(step.turnBudget ? { turnBudget: step.turnBudget } : {}),
193
+ ...(step.turnBudgetExceeded !== undefined ? { turnBudgetExceeded: step.turnBudgetExceeded } : {}),
194
+ ...(step.wrapUpRequested !== undefined ? { wrapUpRequested: step.wrapUpRequested } : {}),
180
195
  ...(step.children?.length ? { children: step.children } : {}),
181
196
  };
182
197
  });
@@ -194,6 +209,8 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
194
209
  currentPath: status.currentPath,
195
210
  turnCount: status.turnCount,
196
211
  toolCount: status.toolCount,
212
+ steerCount: status.steerCount,
213
+ lastSteerAt: status.lastSteerAt,
197
214
  mode: status.mode,
198
215
  cwd: status.cwd,
199
216
  startedAt: status.startedAt,
@@ -202,6 +219,9 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
202
219
  ...(status.timeoutMs !== undefined ? { timeoutMs: status.timeoutMs } : {}),
203
220
  ...(status.deadlineAt !== undefined ? { deadlineAt: status.deadlineAt } : {}),
204
221
  ...(status.timedOut !== undefined ? { timedOut: status.timedOut } : {}),
222
+ ...(status.turnBudget ? { turnBudget: status.turnBudget } : {}),
223
+ ...(status.turnBudgetExceeded !== undefined ? { turnBudgetExceeded: status.turnBudgetExceeded } : {}),
224
+ ...(status.wrapUpRequested !== undefined ? { wrapUpRequested: status.wrapUpRequested } : {}),
205
225
  currentStep: status.currentStep,
206
226
  ...(status.chainStepCount !== undefined ? { chainStepCount: status.chainStepCount } : {}),
207
227
  ...(status.pendingAppends !== undefined ? { pendingAppends: status.pendingAppends } : {}),
@@ -288,13 +308,18 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
288
308
  return options.limit !== undefined ? sorted.slice(0, options.limit) : sorted;
289
309
  }
290
310
 
291
- function formatActivityFacts(input: { activityState?: ActivityState; lastActivityAt?: number; currentTool?: string; currentToolStartedAt?: number; currentPath?: string; turnCount?: number; toolCount?: number }): string | undefined {
311
+ function formatActivityFacts(input: { activityState?: ActivityState; lastActivityAt?: number; currentTool?: string; currentToolStartedAt?: number; currentPath?: string; turnCount?: number; toolCount?: number; steerCount?: number; lastSteerAt?: number; turnBudget?: TurnBudgetState; turnBudgetExceeded?: boolean; wrapUpRequested?: boolean }): string | undefined {
292
312
  const facts: string[] = [];
293
313
  if (input.currentTool && input.currentToolStartedAt !== undefined) facts.push(`tool ${input.currentTool} ${formatDuration(Math.max(0, Date.now() - input.currentToolStartedAt))}`);
294
314
  else if (input.currentTool) facts.push(`tool ${input.currentTool}`);
295
315
  if (input.currentPath) facts.push(shortenPath(input.currentPath));
296
316
  if (input.turnCount !== undefined) facts.push(`${input.turnCount} turns`);
317
+ if (input.turnBudgetExceeded && input.turnBudget) facts.push(`turn budget exceeded ${input.turnBudget.turnCount}/${input.turnBudget.maxTurns}+${input.turnBudget.graceTurns}`);
318
+ else if (input.wrapUpRequested && input.turnBudget) facts.push(`wrap-up requested ${input.turnBudget.turnCount}/${input.turnBudget.maxTurns}`);
319
+ else if (input.turnBudget) facts.push(`turn budget ${input.turnBudget.turnCount}/${input.turnBudget.maxTurns}+${input.turnBudget.graceTurns}`);
297
320
  if (input.toolCount !== undefined) facts.push(`${input.toolCount} tools`);
321
+ if (input.steerCount !== undefined) facts.push(`${input.steerCount} steers`);
322
+ if (typeof input.lastSteerAt === "number" && Number.isFinite(input.lastSteerAt)) facts.push(`last steer ${new Date(input.lastSteerAt).toISOString()}`);
298
323
  const activity = formatActivityLabel(input.lastActivityAt, input.activityState);
299
324
  return activity || facts.length ? [activity, ...facts].filter(Boolean).join(" | ") : undefined;
300
325
  }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Smart completion batching with straggler handling.
3
+ *
4
+ * Holds successful async-completion notifications briefly so sibling jobs that
5
+ * finish within a short window arrive as a single grouped message. A hard
6
+ * max-wait cap (measured from the first item in a group) prevents holding
7
+ * notifications indefinitely. After a group is emitted, late-finishing
8
+ * siblings that arrive within the straggler window join a shorter "straggler"
9
+ * group with reduced debounce and max-wait timers.
10
+ *
11
+ * Failure and attention signals bypass this batcher entirely. Callers must
12
+ * flush() held items and emit those signals immediately so failures and
13
+ * needs-attention notices are never delayed.
14
+ */
15
+
16
+ import type { CompletionBatchConfig } from "../../shared/types.ts";
17
+
18
+ export type { CompletionBatchConfig };
19
+
20
+ export interface ResolvedCompletionBatchConfig {
21
+ enabled: boolean;
22
+ debounceMs: number;
23
+ maxWaitMs: number;
24
+ stragglerDebounceMs: number;
25
+ stragglerMaxWaitMs: number;
26
+ stragglerWindowMs: number;
27
+ }
28
+
29
+ export const DEFAULT_COMPLETION_BATCH_CONFIG: ResolvedCompletionBatchConfig = {
30
+ enabled: true,
31
+ debounceMs: 150,
32
+ maxWaitMs: 1000,
33
+ stragglerDebounceMs: 75,
34
+ stragglerMaxWaitMs: 400,
35
+ stragglerWindowMs: 2000,
36
+ };
37
+
38
+ function parsePositiveInt(value: unknown): number | undefined {
39
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < 1) return undefined;
40
+ return value;
41
+ }
42
+
43
+ export function resolveCompletionBatchConfig(
44
+ globalConfig?: CompletionBatchConfig,
45
+ override?: CompletionBatchConfig,
46
+ ): ResolvedCompletionBatchConfig {
47
+ const enabled = typeof override?.enabled === "boolean"
48
+ ? override.enabled
49
+ : typeof globalConfig?.enabled === "boolean"
50
+ ? globalConfig.enabled
51
+ : DEFAULT_COMPLETION_BATCH_CONFIG.enabled;
52
+ return {
53
+ enabled,
54
+ debounceMs: parsePositiveInt(override?.debounceMs) ?? parsePositiveInt(globalConfig?.debounceMs) ?? DEFAULT_COMPLETION_BATCH_CONFIG.debounceMs,
55
+ maxWaitMs: parsePositiveInt(override?.maxWaitMs) ?? parsePositiveInt(globalConfig?.maxWaitMs) ?? DEFAULT_COMPLETION_BATCH_CONFIG.maxWaitMs,
56
+ stragglerDebounceMs: parsePositiveInt(override?.stragglerDebounceMs) ?? parsePositiveInt(globalConfig?.stragglerDebounceMs) ?? DEFAULT_COMPLETION_BATCH_CONFIG.stragglerDebounceMs,
57
+ stragglerMaxWaitMs: parsePositiveInt(override?.stragglerMaxWaitMs) ?? parsePositiveInt(globalConfig?.stragglerMaxWaitMs) ?? DEFAULT_COMPLETION_BATCH_CONFIG.stragglerMaxWaitMs,
58
+ stragglerWindowMs: parsePositiveInt(override?.stragglerWindowMs) ?? parsePositiveInt(globalConfig?.stragglerWindowMs) ?? DEFAULT_COMPLETION_BATCH_CONFIG.stragglerWindowMs,
59
+ };
60
+ }
61
+
62
+ type TimerHandle = unknown;
63
+
64
+ interface TimerApi {
65
+ setTimeout(handler: () => void, delayMs: number): TimerHandle;
66
+ clearTimeout(handle: TimerHandle): void;
67
+ }
68
+
69
+ const defaultTimers: TimerApi = {
70
+ setTimeout: (handler, delayMs) => setTimeout(handler, delayMs),
71
+ clearTimeout: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
72
+ };
73
+
74
+ function unrefHandle(handle: TimerHandle): void {
75
+ if (handle && typeof handle === "object" && "unref" in handle && typeof (handle as { unref: unknown }).unref === "function") {
76
+ (handle as { unref: () => void }).unref();
77
+ }
78
+ }
79
+
80
+ export interface CompletionBatcherOptions<T> {
81
+ config: ResolvedCompletionBatchConfig;
82
+ emit: (items: T[]) => void;
83
+ timers?: TimerApi;
84
+ now?: () => number;
85
+ }
86
+
87
+ export interface CompletionBatcher<T> {
88
+ /** Add a batchable item. Emits immediately when batching is disabled. */
89
+ push(item: T): void;
90
+ /** Emit any held items immediately as a single group. */
91
+ flush(): void;
92
+ /** Clear timers without emitting. */
93
+ dispose(): void;
94
+ }
95
+
96
+ /**
97
+ * Create a completion batcher. The batcher is single-use per registration: it
98
+ * holds at most one open group. `flush` forces emission; `dispose` tears down
99
+ * timers for reload/shutdown without emitting.
100
+ */
101
+ export function createCompletionBatcher<T>(options: CompletionBatcherOptions<T>): CompletionBatcher<T> {
102
+ const timers = options.timers ?? defaultTimers;
103
+ const now = options.now ?? Date.now;
104
+ const config = options.config;
105
+
106
+ if (!config.enabled) {
107
+ return {
108
+ push(item: T) {
109
+ options.emit([item]);
110
+ },
111
+ flush() {},
112
+ dispose() {},
113
+ };
114
+ }
115
+
116
+ let pending: T[] = [];
117
+ let debounceTimer: TimerHandle | null = null;
118
+ let maxWaitTimer: TimerHandle | null = null;
119
+ let straggler = false;
120
+ let lastEmitAt: number | null = null;
121
+
122
+ const clearTimers = () => {
123
+ if (debounceTimer) {
124
+ timers.clearTimeout(debounceTimer);
125
+ debounceTimer = null;
126
+ }
127
+ if (maxWaitTimer) {
128
+ timers.clearTimeout(maxWaitTimer);
129
+ maxWaitTimer = null;
130
+ }
131
+ };
132
+
133
+ const emitGroup = () => {
134
+ clearTimers();
135
+ if (pending.length === 0) return;
136
+ const items = pending;
137
+ pending = [];
138
+ lastEmitAt = now();
139
+ options.emit(items);
140
+ };
141
+
142
+ return {
143
+ push(item: T) {
144
+ if (pending.length === 0) {
145
+ straggler = lastEmitAt !== null && (now() - lastEmitAt) < config.stragglerWindowMs;
146
+ }
147
+ pending.push(item);
148
+
149
+ if (debounceTimer) timers.clearTimeout(debounceTimer);
150
+ const debounceDelay = straggler ? config.stragglerDebounceMs : config.debounceMs;
151
+ debounceTimer = timers.setTimeout(emitGroup, debounceDelay);
152
+ unrefHandle(debounceTimer);
153
+
154
+ if (!maxWaitTimer) {
155
+ const maxWaitDelay = straggler ? config.stragglerMaxWaitMs : config.maxWaitMs;
156
+ maxWaitTimer = timers.setTimeout(emitGroup, maxWaitDelay);
157
+ unrefHandle(maxWaitTimer);
158
+ }
159
+ },
160
+ flush: emitGroup,
161
+ dispose() {
162
+ clearTimers();
163
+ pending = [];
164
+ },
165
+ };
166
+ }