pi-subagents 0.31.0 → 0.32.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 (42) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +170 -8
  3. package/package.json +1 -1
  4. package/skills/pi-subagents/SKILL.md +6 -1
  5. package/src/agents/agent-management.ts +6 -1
  6. package/src/agents/agents.ts +55 -11
  7. package/src/extension/companion-suggestions.ts +359 -0
  8. package/src/extension/config.ts +27 -4
  9. package/src/extension/doctor.ts +2 -0
  10. package/src/extension/fanout-child.ts +1 -0
  11. package/src/extension/index.ts +69 -4
  12. package/src/extension/schemas.ts +2 -2
  13. package/src/intercom/intercom-bridge.ts +25 -1
  14. package/src/profiles/profiles.ts +637 -0
  15. package/src/runs/background/async-execution.ts +138 -33
  16. package/src/runs/background/async-job-tracker.ts +77 -1
  17. package/src/runs/background/async-resume.ts +11 -13
  18. package/src/runs/background/async-status.ts +41 -9
  19. package/src/runs/background/chain-root-attachment.ts +34 -4
  20. package/src/runs/background/control-channel.ts +227 -0
  21. package/src/runs/background/run-status.ts +1 -0
  22. package/src/runs/background/stale-run-reconciler.ts +28 -1
  23. package/src/runs/background/subagent-runner.ts +459 -113
  24. package/src/runs/foreground/chain-execution.ts +29 -7
  25. package/src/runs/foreground/execution.ts +24 -6
  26. package/src/runs/foreground/subagent-executor.ts +240 -44
  27. package/src/runs/shared/acceptance.ts +45 -22
  28. package/src/runs/shared/dynamic-fanout.ts +1 -1
  29. package/src/runs/shared/model-fallback.ts +4 -0
  30. package/src/runs/shared/nested-events.ts +58 -0
  31. package/src/runs/shared/parallel-utils.ts +49 -1
  32. package/src/runs/shared/pi-args.ts +5 -3
  33. package/src/runs/shared/pi-spawn.ts +52 -20
  34. package/src/runs/shared/single-output.ts +2 -0
  35. package/src/runs/shared/subagent-prompt-runtime.ts +3 -2
  36. package/src/runs/shared/worktree.ts +28 -5
  37. package/src/shared/artifacts.ts +15 -1
  38. package/src/shared/fork-context.ts +133 -22
  39. package/src/shared/types.ts +82 -3
  40. package/src/shared/utils.ts +99 -14
  41. package/src/slash/slash-commands.ts +726 -40
  42. package/src/tui/render.ts +16 -4
@@ -16,7 +16,7 @@ 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 { resolveChildCwd } from "../../shared/utils.ts";
19
+ import { PI_CODING_AGENT_PACKAGE_ROOT_ENV, resolveChildCwd } from "../../shared/utils.ts";
20
20
  import { buildModelCandidates, resolveModelCandidate, resolveSubagentModelOverride, type AvailableModelInfo, type ParentModel } from "../shared/model-fallback.ts";
21
21
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
22
22
  import { resolveExpectedWorktreeAgentCwd } from "../shared/worktree.ts";
@@ -35,6 +35,7 @@ import {
35
35
  ASYNC_DIR,
36
36
  RESULTS_DIR,
37
37
  SUBAGENT_ASYNC_STARTED_EVENT,
38
+ SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
38
39
  TEMP_ROOT_DIR,
39
40
  getAsyncConfigPath,
40
41
  resolveChildMaxSubagentDepth,
@@ -117,16 +118,21 @@ interface AsyncChainParams {
117
118
  sessionRoot?: string;
118
119
  chainSkills?: string[];
119
120
  sessionFilesByFlatIndex?: (string | undefined)[];
121
+ thinkingOverridesByFlatIndex?: (AgentConfig["thinking"] | undefined)[];
120
122
  progressDir?: string;
121
123
  dynamicFanoutMaxItems?: number;
122
124
  maxSubagentDepth: number;
123
125
  worktreeSetupHook?: string;
124
126
  worktreeSetupHookTimeoutMs?: number;
127
+ worktreeBaseDir?: string;
125
128
  controlConfig?: ResolvedControlConfig;
126
129
  controlIntercomTarget?: string;
127
130
  childIntercomTarget?: (agent: string, index: number) => string | undefined;
128
131
  nestedRoute?: NestedRouteInfo;
129
132
  acceptance?: AcceptanceInput;
133
+ timeoutMs?: number;
134
+ /** Global cap on simultaneously-running subagent tasks within the async run. */
135
+ globalConcurrencyLimit?: number;
130
136
  }
131
137
 
132
138
  interface AsyncSingleParams {
@@ -144,16 +150,20 @@ interface AsyncSingleParams {
144
150
  skills?: string[];
145
151
  output?: string | boolean;
146
152
  outputMode?: "inline" | "file-only";
153
+ outputBaseDir?: string;
147
154
  modelOverride?: string;
155
+ thinkingOverride?: AgentConfig["thinking"];
148
156
  availableModels?: AvailableModelInfo[];
149
157
  maxSubagentDepth: number;
150
158
  worktreeSetupHook?: string;
151
159
  worktreeSetupHookTimeoutMs?: number;
160
+ worktreeBaseDir?: string;
152
161
  controlConfig?: ResolvedControlConfig;
153
162
  controlIntercomTarget?: string;
154
163
  childIntercomTarget?: (agent: string, index: number) => string | undefined;
155
164
  nestedRoute?: NestedRouteInfo;
156
165
  acceptance?: AcceptanceInput;
166
+ timeoutMs?: number;
157
167
  }
158
168
 
159
169
  interface AsyncExecutionResult {
@@ -173,10 +183,13 @@ export interface AsyncRunnerStepBuildParams {
173
183
  cwd?: string;
174
184
  chainSkills?: string[];
175
185
  sessionFilesByFlatIndex?: (string | undefined)[];
186
+ thinkingOverridesByFlatIndex?: (AgentConfig["thinking"] | undefined)[];
176
187
  progressDir?: string;
177
188
  dynamicFanoutMaxItems?: number;
178
189
  maxSubagentDepth: number;
190
+ worktreeBaseDir?: string;
179
191
  asyncDir: string;
192
+ outputBaseDir?: string;
180
193
  validateOutputBindings?: boolean;
181
194
  }
182
195
 
@@ -207,14 +220,47 @@ export function isAsyncAvailable(): boolean {
207
220
  return jitiCliPath !== undefined;
208
221
  }
209
222
 
223
+ function isNodeExecutableName(execPath: string): boolean {
224
+ const basename = path.basename(execPath).toLowerCase();
225
+ return basename === "node" || basename === "node.exe" || basename === "nodejs" || basename === "nodejs.exe";
226
+ }
227
+
228
+ function canUseCurrentNodeExecutable(execPath: string): boolean {
229
+ try {
230
+ fs.accessSync(execPath, process.platform === "win32" ? fs.constants.F_OK : fs.constants.X_OK);
231
+ return true;
232
+ } catch {
233
+ return false;
234
+ }
235
+ }
236
+
210
237
  function resolveAsyncRunnerNodeCommand(): string {
211
- const basename = path.basename(process.execPath).toLowerCase();
212
- if (basename === "node" || basename === "node.exe" || basename === "nodejs" || basename === "nodejs.exe") {
238
+ if (isNodeExecutableName(process.execPath) && canUseCurrentNodeExecutable(process.execPath)) {
213
239
  return process.execPath;
214
240
  }
215
241
  return process.platform === "win32" ? "node.exe" : "node";
216
242
  }
217
243
 
244
+ export function resolveAsyncRunnerLogPaths(cfg: object): { stdoutPath: string; stderrPath: string } | undefined {
245
+ const asyncDir = typeof (cfg as { asyncDir?: unknown }).asyncDir === "string"
246
+ ? (cfg as { asyncDir: string }).asyncDir
247
+ : undefined;
248
+ if (!asyncDir) return undefined;
249
+ return {
250
+ stdoutPath: path.join(asyncDir, "runner.stdout.log"),
251
+ stderrPath: path.join(asyncDir, "runner.stderr.log"),
252
+ };
253
+ }
254
+
255
+ function closeFd(fd: number | undefined): void {
256
+ if (fd === undefined) return;
257
+ try {
258
+ fs.closeSync(fd);
259
+ } catch {
260
+ // Best-effort cleanup; child process already owns its duplicated stdio fd.
261
+ }
262
+ }
263
+
218
264
  /**
219
265
  * Spawn the async runner process
220
266
  */
@@ -238,20 +284,40 @@ function spawnRunner(cfg: object, suffix: string, cwd: string): { pid?: number;
238
284
  const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
239
285
  const nodeCommand = resolveAsyncRunnerNodeCommand();
240
286
 
241
- const proc = spawn(nodeCommand, [jitiCliPath, runner, cfgPath], {
242
- cwd,
243
- detached: true,
244
- stdio: "ignore",
245
- windowsHide: true,
246
- });
247
- proc.on("error", (error) => {
248
- console.error(`[pi-subagents] async spawn failed: ${error.message}`);
249
- });
250
- if (typeof proc.pid !== "number") {
251
- return { error: `async runner did not produce a pid for cwd: ${cwd}` };
287
+ const logPaths = resolveAsyncRunnerLogPaths(cfg);
288
+ let stdoutFd: number | undefined;
289
+ let stderrFd: number | undefined;
290
+ try {
291
+ if (logPaths) {
292
+ fs.mkdirSync(path.dirname(logPaths.stdoutPath), { recursive: true });
293
+ stdoutFd = fs.openSync(logPaths.stdoutPath, "a");
294
+ stderrFd = fs.openSync(logPaths.stderrPath, "a");
295
+ }
296
+ const proc = spawn(nodeCommand, [jitiCliPath, runner, cfgPath], {
297
+ cwd,
298
+ detached: true,
299
+ stdio: ["ignore", stdoutFd ?? "ignore", stderrFd ?? "ignore"],
300
+ windowsHide: true,
301
+ env: {
302
+ ...process.env,
303
+ ...(piPackageRoot ? { [PI_CODING_AGENT_PACKAGE_ROOT_ENV]: piPackageRoot } : {}),
304
+ },
305
+ });
306
+ closeFd(stdoutFd);
307
+ closeFd(stderrFd);
308
+ proc.on("error", (error) => {
309
+ console.error(`[pi-subagents] async spawn failed: ${error.message}`);
310
+ });
311
+ if (typeof proc.pid !== "number") {
312
+ return { error: `async runner did not produce a pid for cwd: ${cwd}` };
313
+ }
314
+ proc.unref();
315
+ return { pid: proc.pid };
316
+ } catch (error) {
317
+ closeFd(stdoutFd);
318
+ closeFd(stderrFd);
319
+ return { error: error instanceof Error ? error.message : String(error) };
252
320
  }
253
- proc.unref();
254
- return { pid: proc.pid };
255
321
  }
256
322
 
257
323
  function formatAsyncStartError(mode: SubagentRunMode, message: string): AsyncExecutionResult {
@@ -274,9 +340,12 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
274
340
  ctx,
275
341
  cwd,
276
342
  sessionFilesByFlatIndex,
343
+ thinkingOverridesByFlatIndex,
277
344
  maxSubagentDepth,
345
+ worktreeBaseDir,
278
346
  asyncDir,
279
347
  } = params;
348
+ const outputBaseDir = params.outputBaseDir;
280
349
  const resultMode = params.resultMode ?? "chain";
281
350
  const chainSkills = params.chainSkills ?? [];
282
351
  const availableModels = params.availableModels;
@@ -333,7 +402,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
333
402
  ...(s.model ? { model: s.model } : {}),
334
403
  };
335
404
  };
336
- const buildSeqStep = (s: SequentialStep, sessionFile?: string, behaviorCwd?: string, progressPrecreated = false, resolvedBehavior?: ResolvedStepBehavior) => {
405
+ const buildSeqStep = (s: SequentialStep, sessionFile?: string, behaviorCwd?: string, progressPrecreated = false, resolvedBehavior?: ResolvedStepBehavior, flatIndex?: number) => {
337
406
  const a = agents.find((x) => x.name === s.agent)!;
338
407
  const stepCwd = resolveChildCwd(runnerCwd, s.cwd);
339
408
  const instructionCwd = behaviorCwd ?? stepCwd;
@@ -352,7 +421,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
352
421
  const isFirstProgressAgent = behavior.progress && !progressPrecreated && !progressInstructionCreated;
353
422
  if (behavior.progress) progressInstructionCreated = true;
354
423
  const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false }, progressDir, isFirstProgressAgent);
355
- const outputPath = resolveSingleOutputPath(behavior.output, ctx.cwd, instructionCwd);
424
+ const outputPath = resolveSingleOutputPath(behavior.output, ctx.cwd, instructionCwd, outputBaseDir);
356
425
  systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath);
357
426
  const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Async step (${s.agent})`);
358
427
  if (validationError) throw new AsyncStartValidationError(validationError);
@@ -363,7 +432,9 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
363
432
 
364
433
  const requestedModel = behavior.model ?? a.model;
365
434
  const primaryModel = resolveSubagentModelOverride(requestedModel, ctx.currentModel, availableModels, ctx.currentModelProvider);
366
- const model = applyThinkingSuffix(primaryModel, a.thinking);
435
+ const thinkingOverride = flatIndex === undefined ? undefined : thinkingOverridesByFlatIndex?.[flatIndex];
436
+ const effectiveThinking = thinkingOverride ?? a.thinking;
437
+ const model = applyThinkingSuffix(primaryModel, effectiveThinking, thinkingOverride !== undefined);
367
438
  return {
368
439
  parentSessionId: ctx.parentSessionId ?? ctx.currentSessionId,
369
440
  agent: s.agent,
@@ -374,9 +445,9 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
374
445
  structured: Boolean(s.outputSchema),
375
446
  cwd: stepCwd,
376
447
  model,
377
- thinking: resolveEffectiveThinking(model, a.thinking),
448
+ thinking: resolveEffectiveThinking(model, effectiveThinking),
378
449
  modelCandidates: buildModelCandidates(primaryModel, a.fallbackModels, availableModels, ctx.currentModelProvider).map((candidate) =>
379
- applyThinkingSuffix(candidate, a.thinking),
450
+ applyThinkingSuffix(candidate, effectiveThinking, thinkingOverride !== undefined),
380
451
  ),
381
452
  tools: a.tools,
382
453
  extensions: a.extensions,
@@ -406,10 +477,16 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
406
477
  };
407
478
 
408
479
  let flatStepIndex = 0;
409
- const nextSessionFile = (): string | undefined => {
480
+ const nextFlatStep = (): { index: number; sessionFile?: string; thinkingOverride?: AgentConfig["thinking"] } => {
481
+ const index = flatStepIndex;
410
482
  const sessionFile = sessionFilesByFlatIndex?.[flatStepIndex];
483
+ const thinkingOverride = thinkingOverridesByFlatIndex?.[flatStepIndex];
411
484
  flatStepIndex++;
412
- return sessionFile;
485
+ return {
486
+ index,
487
+ ...(sessionFile ? { sessionFile } : {}),
488
+ ...(thinkingOverride ? { thinkingOverride } : {}),
489
+ };
413
490
  };
414
491
 
415
492
  try {
@@ -429,12 +506,13 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
429
506
  let behaviorCwd: string | undefined;
430
507
  if (s.worktree) {
431
508
  try {
432
- behaviorCwd = resolveExpectedWorktreeAgentCwd(runnerCwd, `${id}-s${stepIndex}`, taskIndex);
509
+ behaviorCwd = resolveExpectedWorktreeAgentCwd(runnerCwd, `${id}-s${stepIndex}`, taskIndex, worktreeBaseDir);
433
510
  } catch {
434
511
  behaviorCwd = undefined;
435
512
  }
436
513
  }
437
- return buildSeqStep(t, nextSessionFile(), behaviorCwd, progressPrecreated, parallelBehaviors[taskIndex]);
514
+ const staticStep = nextFlatStep();
515
+ return buildSeqStep(t, staticStep.sessionFile, behaviorCwd, progressPrecreated, parallelBehaviors[taskIndex], staticStep.index);
438
516
  }),
439
517
  concurrency: s.concurrency,
440
518
  failFast: s.failFast,
@@ -449,6 +527,8 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
449
527
  writeInitialProgressFile(progressDir);
450
528
  progressInstructionCreated = true;
451
529
  }
530
+ const maxItems = s.expand.maxItems ?? params.dynamicFanoutMaxItems ?? 0;
531
+ const dynamicFlatSteps = Array.from({ length: maxItems }, () => nextFlatStep());
452
532
  return {
453
533
  expand: s.expand,
454
534
  parallel: buildSeqStep(s.parallel as SequentialStep, undefined, undefined, progressPrecreated, behavior),
@@ -457,6 +537,8 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
457
537
  failFast: s.failFast,
458
538
  phase: s.phase,
459
539
  label: s.label,
540
+ sessionFiles: dynamicFlatSteps.map((step) => step.sessionFile),
541
+ thinkingOverrides: dynamicFlatSteps.map((step) => step.thinkingOverride),
460
542
  effectiveAcceptance: resolveEffectiveAcceptance({
461
543
  explicit: s.acceptance,
462
544
  agentName: s.parallel.agent,
@@ -467,7 +549,8 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
467
549
  }),
468
550
  };
469
551
  }
470
- return buildSeqStep(s as SequentialStep, nextSessionFile());
552
+ const staticStep = nextFlatStep();
553
+ return buildSeqStep(s as SequentialStep, staticStep.sessionFile, undefined, false, undefined, staticStep.index);
471
554
  });
472
555
  const steps = params.attachRoot
473
556
  ? [{
@@ -510,9 +593,11 @@ export function executeAsyncChain(
510
593
  shareEnabled,
511
594
  sessionRoot,
512
595
  sessionFilesByFlatIndex,
596
+ thinkingOverridesByFlatIndex,
513
597
  maxSubagentDepth,
514
598
  worktreeSetupHook,
515
599
  worktreeSetupHookTimeoutMs,
600
+ worktreeBaseDir,
516
601
  controlConfig,
517
602
  controlIntercomTarget,
518
603
  childIntercomTarget,
@@ -546,9 +631,12 @@ export function executeAsyncChain(
546
631
  cwd,
547
632
  chainSkills: params.chainSkills,
548
633
  sessionFilesByFlatIndex,
549
- progressDir: params.progressDir ?? (resultMode === "parallel" ? path.join(asyncDir, "progress") : undefined),
634
+ thinkingOverridesByFlatIndex,
635
+ progressDir: params.progressDir ?? (artifactsDir ? path.join(artifactsDir, "progress", id) : resultMode === "parallel" ? path.join(asyncDir, "progress") : undefined),
636
+ outputBaseDir: artifactsDir ? path.join(artifactsDir, "outputs", id) : undefined,
550
637
  dynamicFanoutMaxItems: params.dynamicFanoutMaxItems,
551
638
  maxSubagentDepth,
639
+ worktreeBaseDir,
552
640
  asyncDir,
553
641
  });
554
642
  if ("error" in built) {
@@ -560,6 +648,7 @@ export function executeAsyncChain(
560
648
  return formatAsyncStartError(resultMode, built.error);
561
649
  }
562
650
  const { steps, runnerCwd, workflowGraph, eventChain } = built;
651
+ const deadlineAt = params.timeoutMs !== undefined ? Date.now() + params.timeoutMs : undefined;
563
652
  let childTargetIndex = 0;
564
653
  const childIntercomTargets = childIntercomTarget ? steps.flatMap((step) => {
565
654
  if (!("parallel" in step) && step.importAsyncRoot) {
@@ -596,11 +685,15 @@ export function executeAsyncChain(
596
685
  piArgv1: process.argv[1],
597
686
  worktreeSetupHook,
598
687
  worktreeSetupHookTimeoutMs,
688
+ worktreeBaseDir,
599
689
  controlConfig,
600
690
  controlIntercomTarget,
601
691
  childIntercomTargets,
602
692
  resultMode,
603
693
  dynamicFanoutMaxItems: params.dynamicFanoutMaxItems,
694
+ timeoutMs: params.timeoutMs,
695
+ deadlineAt,
696
+ globalConcurrencyLimit: params.globalConcurrencyLimit,
604
697
  workflowGraph,
605
698
  nestedRoute: nestedRoute ?? inheritedNestedRoute,
606
699
  nestedSelf: inheritedNestedRoute && nestedAddress ? {
@@ -673,6 +766,7 @@ export function executeAsyncChain(
673
766
  agents: flatAgents,
674
767
  chainStepCount: eventChain.length,
675
768
  parallelGroups,
769
+ ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}),
676
770
  startedAt: now,
677
771
  lastUpdate: now,
678
772
  },
@@ -682,6 +776,7 @@ export function executeAsyncChain(
682
776
  }
683
777
  }
684
778
  ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
779
+ lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
685
780
  id,
686
781
  pid: spawnResult.pid,
687
782
  sessionId: ctx.currentSessionId,
@@ -701,6 +796,7 @@ export function executeAsyncChain(
701
796
  workflowGraph,
702
797
  cwd: runnerCwd,
703
798
  asyncDir,
799
+ ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}),
704
800
  nestedRoute,
705
801
  });
706
802
  }
@@ -713,7 +809,7 @@ export function executeAsyncChain(
713
809
 
714
810
  return {
715
811
  content: [{ type: "text", text: formatAsyncStartedMessage(`Async ${resultMode}: ${chainDesc} [${id}]`) }],
716
- details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir, workflowGraph },
812
+ details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir, workflowGraph, ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}) },
717
813
  };
718
814
  }
719
815
 
@@ -738,6 +834,7 @@ export function executeAsyncSingle(
738
834
  maxSubagentDepth,
739
835
  worktreeSetupHook,
740
836
  worktreeSetupHookTimeoutMs,
837
+ worktreeBaseDir,
741
838
  controlConfig,
742
839
  controlIntercomTarget,
743
840
  childIntercomTarget,
@@ -772,7 +869,7 @@ export function executeAsyncSingle(
772
869
  }
773
870
 
774
871
  const effectiveOutput = normalizeSingleOutputOverride(params.output, agentConfig.output);
775
- const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, runnerCwd);
872
+ const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, runnerCwd, params.outputBaseDir ?? (artifactsDir ? path.join(artifactsDir, "outputs", id) : undefined));
776
873
  systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath);
777
874
  const outputMode = params.outputMode ?? "inline";
778
875
  const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
@@ -784,7 +881,9 @@ export function executeAsyncSingle(
784
881
  availableModels,
785
882
  ctx.currentModelProvider,
786
883
  );
787
- const model = applyThinkingSuffix(primaryModel, agentConfig.thinking);
884
+ const effectiveThinking = params.thinkingOverride ?? agentConfig.thinking;
885
+ const model = applyThinkingSuffix(primaryModel, effectiveThinking, params.thinkingOverride !== undefined);
886
+ const deadlineAt = params.timeoutMs !== undefined ? Date.now() + params.timeoutMs : undefined;
788
887
  let spawnResult: { pid?: number; error?: string } = {};
789
888
  try {
790
889
  spawnResult = spawnRunner(
@@ -797,9 +896,9 @@ export function executeAsyncSingle(
797
896
  task: taskWithOutputInstruction,
798
897
  cwd: runnerCwd,
799
898
  model,
800
- thinking: resolveEffectiveThinking(model, agentConfig.thinking),
899
+ thinking: resolveEffectiveThinking(model, effectiveThinking),
801
900
  modelCandidates: buildModelCandidates(primaryModel, agentConfig.fallbackModels, availableModels, ctx.currentModelProvider).map((candidate) =>
802
- applyThinkingSuffix(candidate, agentConfig.thinking),
901
+ applyThinkingSuffix(candidate, effectiveThinking, params.thinkingOverride !== undefined),
803
902
  ),
804
903
  tools: agentConfig.tools,
805
904
  extensions: agentConfig.extensions,
@@ -838,7 +937,10 @@ export function executeAsyncSingle(
838
937
  piArgv1: process.argv[1],
839
938
  worktreeSetupHook,
840
939
  worktreeSetupHookTimeoutMs,
940
+ worktreeBaseDir,
841
941
  controlConfig,
942
+ timeoutMs: params.timeoutMs,
943
+ deadlineAt,
842
944
  controlIntercomTarget,
843
945
  childIntercomTargets: childIntercomTarget ? [childIntercomTarget(agent, 0)] : undefined,
844
946
  resultMode: "single",
@@ -888,6 +990,7 @@ export function executeAsyncSingle(
888
990
  agent,
889
991
  agents: [agent],
890
992
  chainStepCount: 1,
993
+ ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}),
891
994
  startedAt: now,
892
995
  lastUpdate: now,
893
996
  },
@@ -897,6 +1000,7 @@ export function executeAsyncSingle(
897
1000
  }
898
1001
  }
899
1002
  ctx.pi.events.emit(SUBAGENT_ASYNC_STARTED_EVENT, {
1003
+ lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
900
1004
  id,
901
1005
  pid: spawnResult.pid,
902
1006
  sessionId: ctx.currentSessionId,
@@ -905,12 +1009,13 @@ export function executeAsyncSingle(
905
1009
  task: task?.slice(0, 50),
906
1010
  cwd: runnerCwd,
907
1011
  asyncDir,
1012
+ ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}),
908
1013
  nestedRoute,
909
1014
  });
910
1015
  }
911
1016
 
912
1017
  return {
913
1018
  content: [{ type: "text", text: formatAsyncStartedMessage(`Async: ${agent} [${id}]`) }],
914
- details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir },
1019
+ details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir, ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}) },
915
1020
  };
916
1021
  }
@@ -17,6 +17,7 @@ import { readStatus } from "../../shared/utils.ts";
17
17
  import { normalizeParallelGroups } from "./parallel-groups.ts";
18
18
  import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
19
19
  import { hasLiveNestedDescendants, updateAsyncJobNestedProjection } from "../shared/nested-events.ts";
20
+ import { listAsyncRuns, type AsyncRunSummary } from "./async-status.ts";
20
21
 
21
22
  interface AsyncJobTrackerOptions {
22
23
  completionRetentionMs?: number;
@@ -35,6 +36,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
35
36
  handleStarted: (data: unknown) => void;
36
37
  handleComplete: (data: unknown) => void;
37
38
  resetJobs: (ctx?: ExtensionContext) => void;
39
+ restoreActiveJobs: (ctx?: ExtensionContext) => void;
38
40
  } {
39
41
  const completionRetentionMs = options.completionRetentionMs ?? 10000;
40
42
  const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
@@ -43,6 +45,58 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
43
45
  renderWidget(ctx, jobs);
44
46
  ctx.ui.requestRender?.();
45
47
  };
48
+ const restoredControlEventCursor = (asyncDir: string) => {
49
+ try {
50
+ return fs.statSync(path.join(asyncDir, "events.jsonl")).size;
51
+ } catch (error) {
52
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0;
53
+ throw error;
54
+ }
55
+ };
56
+ const summaryToJob = (run: AsyncRunSummary): AsyncJobState => {
57
+ const groups = normalizeParallelGroups(run.parallelGroups, run.steps.length, run.chainStepCount ?? run.steps.length);
58
+ const activeGroup = run.currentStep !== undefined
59
+ ? groups.find((group) => run.currentStep! >= group.start && run.currentStep! < group.start + group.count)
60
+ : undefined;
61
+ const visibleSteps = activeGroup
62
+ ? run.steps.slice(activeGroup.start, activeGroup.start + activeGroup.count).map((step, index) => ({ ...step, index: activeGroup.start + index }))
63
+ : run.steps.map((step, index) => ({ ...step, index }));
64
+ return {
65
+ asyncId: run.id,
66
+ asyncDir: run.asyncDir,
67
+ status: run.state,
68
+ sessionId: run.sessionId,
69
+ activityState: run.activityState,
70
+ lastActivityAt: run.lastActivityAt,
71
+ currentTool: run.currentTool,
72
+ currentToolStartedAt: run.currentToolStartedAt,
73
+ currentPath: run.currentPath,
74
+ turnCount: run.turnCount,
75
+ toolCount: run.toolCount,
76
+ mode: run.mode,
77
+ agents: visibleSteps.map((step) => step.agent),
78
+ currentStep: run.currentStep,
79
+ chainStepCount: run.chainStepCount,
80
+ parallelGroups: groups,
81
+ steps: visibleSteps,
82
+ stepsTotal: visibleSteps.length,
83
+ runningSteps: visibleSteps.filter((step) => step.status === "running").length,
84
+ completedSteps: visibleSteps.filter((step) => step.status === "complete" || step.status === "completed").length,
85
+ hasParallelGroups: groups.length > 0,
86
+ activeParallelGroup: Boolean(activeGroup),
87
+ startedAt: run.startedAt,
88
+ updatedAt: run.lastUpdate ?? run.startedAt,
89
+ timeoutMs: run.timeoutMs,
90
+ deadlineAt: run.deadlineAt,
91
+ timedOut: run.timedOut,
92
+ sessionDir: run.sessionDir,
93
+ outputFile: run.outputFile,
94
+ totalTokens: run.totalTokens,
95
+ sessionFile: run.sessionFile,
96
+ controlEventCursor: restoredControlEventCursor(run.asyncDir),
97
+ nestedChildren: run.nestedChildren,
98
+ };
99
+ };
46
100
  const cancelCleanup = (asyncId: string) => {
47
101
  const existingTimer = state.cleanupTimers.get(asyncId);
48
102
  if (!existingTimer) return;
@@ -248,6 +302,9 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
248
302
  job.sessionDir = status.sessionDir ?? job.sessionDir;
249
303
  job.outputFile = status.outputFile ?? job.outputFile;
250
304
  job.totalTokens = status.totalTokens ?? job.totalTokens;
305
+ job.timeoutMs = status.timeoutMs ?? job.timeoutMs;
306
+ job.deadlineAt = status.deadlineAt ?? job.deadlineAt;
307
+ job.timedOut = status.timedOut ?? job.timedOut;
251
308
  job.sessionFile = status.sessionFile ?? job.sessionFile;
252
309
  if ((job.status === "complete" || job.status === "failed" || job.status === "paused") && !nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
253
310
  scheduleCleanup(job.asyncId);
@@ -305,6 +362,8 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
305
362
  activeParallelGroup: Boolean(firstGroupCount && firstGroupCount > 0),
306
363
  startedAt: now,
307
364
  updatedAt: now,
365
+ timeoutMs: info.timeoutMs,
366
+ deadlineAt: info.deadlineAt,
308
367
  controlEventCursor: 0,
309
368
  });
310
369
  ensurePoller();
@@ -351,5 +410,22 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
351
410
  }
352
411
  };
353
412
 
354
- return { ensurePoller, handleStarted, handleComplete, resetJobs };
413
+ const restoreActiveJobs = (ctx?: ExtensionContext) => {
414
+ if (ctx?.hasUI) state.lastUiContext = ctx;
415
+ let runs: AsyncRunSummary[];
416
+ try {
417
+ runs = listAsyncRuns(asyncDirRoot, { states: ["queued", "running"], resultsDir, kill: options.kill, now: options.now });
418
+ } catch (error) {
419
+ console.error(`Failed to restore active async jobs from '${asyncDirRoot}':`, error);
420
+ return;
421
+ }
422
+ for (const run of runs) {
423
+ state.asyncJobs.set(run.id, summaryToJob(run));
424
+ }
425
+ if (runs.length === 0) return;
426
+ ensurePoller();
427
+ if (state.lastUiContext?.hasUI) rerenderWidget(state.lastUiContext);
428
+ };
429
+
430
+ return { ensurePoller, handleStarted, handleComplete, resetJobs, restoreActiveJobs };
355
431
  }
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type SubagentState } from "../../shared/types.ts";
4
4
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
5
+ import { deliverInterruptRequest } from "./control-channel.ts";
5
6
  import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
6
7
 
7
8
  export const ASYNC_RESUME_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
@@ -38,33 +39,30 @@ export type AsyncResumeTarget = {
38
39
 
39
40
  type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => boolean;
40
41
 
41
- function readAsyncStatus(asyncDir: string): AsyncStatus | null {
42
- const statusPath = path.join(asyncDir, "status.json");
43
- try {
44
- return JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
45
- } catch (error) {
46
- const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined;
47
- if (code === "ENOENT") return null;
48
- throw error;
49
- }
50
- }
51
-
52
42
  export function interruptLiveAsyncResumeTarget(input: {
53
43
  target: AsyncResumeTarget & { kind: "live" };
54
44
  state?: Pick<SubagentState, "asyncJobs">;
55
45
  kill?: KillFn;
56
46
  now?: () => number;
47
+ resultsDir?: string;
57
48
  }): { ok: true; asyncId: string } | { ok: false; message: string } {
58
49
  const asyncId = input.target.runId;
59
50
  if (!input.target.asyncDir) {
60
51
  return { ok: false, message: `Async run ${asyncId} is live but does not have an async directory to interrupt.` };
61
52
  }
62
- const status = readAsyncStatus(input.target.asyncDir);
53
+ const status = reconcileAsyncRun(input.target.asyncDir, { resultsDir: input.resultsDir, kill: input.kill, now: input.now }).status;
63
54
  if (!status || status.state !== "running" || typeof status.pid !== "number") {
64
55
  return { ok: false, message: `Async run ${asyncId} is live but no interrupt-capable runner pid was found.` };
65
56
  }
66
57
  try {
67
- (input.kill ?? process.kill)(status.pid, ASYNC_RESUME_INTERRUPT_SIGNAL);
58
+ deliverInterruptRequest({
59
+ asyncDir: input.target.asyncDir,
60
+ pid: status.pid,
61
+ kill: input.kill,
62
+ signal: ASYNC_RESUME_INTERRUPT_SIGNAL,
63
+ now: input.now,
64
+ source: "async-resume",
65
+ });
68
66
  const tracked = input.state?.asyncJobs.get(asyncId);
69
67
  if (tracked) {
70
68
  tracked.activityState = undefined;