pi-subagents 0.29.0 → 0.31.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 (48) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +125 -19
  3. package/agents/context-builder.md +3 -3
  4. package/agents/planner.md +1 -1
  5. package/agents/researcher.md +1 -1
  6. package/agents/scout.md +1 -1
  7. package/package.json +7 -7
  8. package/skills/pi-subagents/SKILL.md +30 -0
  9. package/src/agents/agent-management.ts +189 -8
  10. package/src/agents/agent-serializer.ts +35 -12
  11. package/src/agents/agents.ts +243 -24
  12. package/src/agents/frontmatter.ts +66 -2
  13. package/src/agents/proactive-skills.ts +191 -0
  14. package/src/agents/skills.ts +117 -20
  15. package/src/extension/doctor.ts +20 -0
  16. package/src/extension/fanout-child.ts +2 -1
  17. package/src/extension/index.ts +50 -5
  18. package/src/extension/schemas.ts +40 -79
  19. package/src/intercom/intercom-bridge.ts +2 -3
  20. package/src/runs/background/async-execution.ts +180 -67
  21. package/src/runs/background/async-job-tracker.ts +56 -11
  22. package/src/runs/background/async-resume.ts +53 -5
  23. package/src/runs/background/async-status.ts +4 -1
  24. package/src/runs/background/chain-append.ts +282 -0
  25. package/src/runs/background/chain-root-attachment.ts +161 -0
  26. package/src/runs/background/result-watcher.ts +11 -2
  27. package/src/runs/background/run-status.ts +1 -0
  28. package/src/runs/background/stale-run-reconciler.ts +9 -4
  29. package/src/runs/background/subagent-runner.ts +158 -11
  30. package/src/runs/foreground/chain-execution.ts +26 -2
  31. package/src/runs/foreground/execution.ts +114 -8
  32. package/src/runs/foreground/subagent-executor.ts +611 -87
  33. package/src/runs/shared/acceptance.ts +285 -34
  34. package/src/runs/shared/chain-outputs.ts +23 -8
  35. package/src/runs/shared/completion-guard.ts +1 -1
  36. package/src/runs/shared/dynamic-fanout.ts +5 -3
  37. package/src/runs/shared/mcp-direct-tool-allowlist.ts +2 -2
  38. package/src/runs/shared/parallel-utils.ts +13 -1
  39. package/src/runs/shared/pi-args.ts +12 -3
  40. package/src/runs/shared/single-output.ts +15 -1
  41. package/src/runs/shared/subagent-control.ts +8 -11
  42. package/src/shared/settings.ts +1 -0
  43. package/src/shared/types.ts +17 -2
  44. package/src/shared/utils.ts +19 -1
  45. package/src/slash/prompt-template-bridge.ts +26 -3
  46. package/src/slash/slash-bridge.ts +3 -1
  47. package/src/slash/slash-commands.ts +34 -4
  48. package/src/tui/render.ts +265 -13
@@ -31,7 +31,10 @@ import {
31
31
  type StepOverrides,
32
32
  } from "../../shared/settings.ts";
33
33
  import { discoverAvailableSkills, normalizeSkillInput } from "../../agents/skills.ts";
34
- import { executeAsyncChain, executeAsyncSingle, formatAsyncStartedMessage, isAsyncAvailable } from "../background/async-execution.ts";
34
+ import { buildAsyncRunnerSteps, executeAsyncChain, executeAsyncSingle, formatAsyncStartedMessage, isAsyncAvailable } from "../background/async-execution.ts";
35
+ import { enqueueChainAppendRequest, readPendingChainAppendRequests, runnerStepOutputNames } from "../background/chain-append.ts";
36
+ import { ChainOutputValidationError, validateChainOutputBindingsWithContext } from "../shared/chain-outputs.ts";
37
+ import { validateAcceptanceInput } from "../shared/acceptance.ts";
35
38
  import { createForkContextResolver } from "../../shared/fork-context.ts";
36
39
  import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
37
40
  import { applyIntercomBridgeToAgent, INTERCOM_BRIDGE_MARKER, resolveIntercomBridge, resolveIntercomSessionTarget, resolveSubagentIntercomTarget, type IntercomBridgeState } from "../../intercom/intercom-bridge.ts";
@@ -47,7 +50,8 @@ import {
47
50
  resolveSubagentResultStatus,
48
51
  stripDetailsOutputsForIntercomReceipt,
49
52
  } from "../../intercom/result-intercom.ts";
50
- import { buildRevivedAsyncTask, resolveAsyncResumeTarget } from "../background/async-resume.ts";
53
+ import { buildRevivedAsyncTask, interruptLiveAsyncResumeTarget, resolveAsyncResumeTarget } from "../background/async-resume.ts";
54
+ import { resolveAsyncRootResultPath } from "../background/chain-root-attachment.ts";
51
55
  import { createNestedRoute, readNestedControlResults, resolveInheritedNestedRouteFromEnv, resolveNestedAsyncDir, resolveNestedParentAddressFromEnv, updateForegroundNestedProjection, writeNestedControlRequest, writeNestedEvent, type NestedRunResolutionScope } from "../shared/nested-events.ts";
52
56
  import { resolveSubagentRunId, type ResolvedSubagentRunId } from "../background/run-id-resolver.ts";
53
57
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
@@ -79,7 +83,9 @@ import {
79
83
  type SingleResult,
80
84
  type SubagentRunMode,
81
85
  type SubagentState,
86
+ ASYNC_DIR,
82
87
  DEFAULT_ARTIFACT_CONFIG,
88
+ RESULTS_DIR,
83
89
  SUBAGENT_ACTIONS,
84
90
  SUBAGENT_CONTROL_EVENT,
85
91
  SUBAGENT_CONTROL_INTERCOM_EVENT,
@@ -123,6 +129,8 @@ export interface SubagentParamsLike {
123
129
  worktree?: boolean;
124
130
  context?: "fresh" | "fork";
125
131
  async?: boolean;
132
+ timeoutMs?: number;
133
+ maxRuntimeMs?: number;
126
134
  clarify?: boolean;
127
135
  share?: boolean;
128
136
  control?: ControlConfig;
@@ -150,6 +158,7 @@ interface ExecutorDeps {
150
158
  expandTilde: (p: string) => string;
151
159
  discoverAgents: (cwd: string, scope: AgentScope) => { agents: AgentConfig[] };
152
160
  allowMutatingManagementActions?: boolean;
161
+ kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
153
162
  }
154
163
 
155
164
  interface ExecutionContextData {
@@ -164,6 +173,7 @@ interface ExecutionContextData {
164
173
  sessionRoot: string;
165
174
  sessionDirForIndex: (idx?: number) => string;
166
175
  sessionFileForIndex: (idx?: number) => string | undefined;
176
+ sessionFileForTask: (agentName: string, idx?: number) => string | undefined;
167
177
  artifactConfig: ArtifactConfig;
168
178
  artifactsDir: string;
169
179
  backgroundRequestedWhileClarifying: boolean;
@@ -171,6 +181,9 @@ interface ExecutionContextData {
171
181
  controlConfig: ResolvedControlConfig;
172
182
  intercomBridge: IntercomBridgeState;
173
183
  nestedRoute?: NestedRouteInfo;
184
+ timeoutMs?: number;
185
+ deadlineAt?: number;
186
+ contextPolicy: AgentDefaultContextPolicy;
174
187
  }
175
188
 
176
189
  function resolveRequestedCwd(runtimeCwd: string, requestedCwd: string | undefined): string {
@@ -317,7 +330,7 @@ function isExactResumeError(error: unknown, source: "async" | "foreground", requ
317
330
  return new RegExp(`\\b${source} run '${escapeRegExp(requested)}'`, "i").test(error.message);
318
331
  }
319
332
 
320
- function resolveResumeTarget(params: SubagentParamsLike, state: SubagentState): ResumeSourceTarget {
333
+ function resolveResumeTarget(params: SubagentParamsLike, state: SubagentState, options: { asyncRequireSessionFile?: boolean } = {}): ResumeSourceTarget {
321
334
  const requested = (params.id ?? params.runId)?.trim() ?? "";
322
335
  let foregroundTarget: ForegroundResumeSourceTarget | undefined;
323
336
  let foregroundError: unknown;
@@ -331,7 +344,7 @@ function resolveResumeTarget(params: SubagentParamsLike, state: SubagentState):
331
344
  foregroundError = error;
332
345
  }
333
346
  try {
334
- asyncTarget = { source: "async", ...resolveAsyncResumeTarget(params) };
347
+ asyncTarget = { source: "async", ...resolveAsyncResumeTarget(params, {}, { requireSessionFile: options.asyncRequireSessionFile }) };
335
348
  } catch (error) {
336
349
  asyncError = error;
337
350
  }
@@ -402,7 +415,7 @@ function emitControlNotification(input: {
402
415
  }
403
416
  }
404
417
 
405
- function interruptAsyncRun(state: SubagentState, runId: string | undefined): AgentToolResult<Details> | null {
418
+ function interruptAsyncRun(state: SubagentState, runId: string | undefined, kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean): AgentToolResult<Details> | null {
406
419
  const target = getAsyncInterruptTarget(state, runId);
407
420
  if (!target) return null;
408
421
  const status = readStatus(target.asyncDir);
@@ -414,7 +427,7 @@ function interruptAsyncRun(state: SubagentState, runId: string | undefined): Age
414
427
  };
415
428
  }
416
429
  try {
417
- process.kill(status.pid, ASYNC_INTERRUPT_SIGNAL);
430
+ (kill ?? process.kill)(status.pid, ASYNC_INTERRUPT_SIGNAL);
418
431
  const tracked = state.asyncJobs.get(target.asyncId);
419
432
  if (tracked) {
420
433
  tracked.activityState = undefined;
@@ -434,6 +447,196 @@ function interruptAsyncRun(state: SubagentState, runId: string | undefined): Age
434
447
  }
435
448
  }
436
449
 
450
+ function duplicateNames(names: string[]): string[] {
451
+ const seen = new Set<string>();
452
+ const duplicates = new Set<string>();
453
+ for (const name of names) {
454
+ if (seen.has(name)) duplicates.add(name);
455
+ else seen.add(name);
456
+ }
457
+ return [...duplicates];
458
+ }
459
+
460
+ function appendStepToAsyncChain(input: {
461
+ params: SubagentParamsLike;
462
+ requestCwd: string;
463
+ ctx: ExtensionContext;
464
+ deps: ExecutorDeps;
465
+ }): AgentToolResult<Details> {
466
+ const targetRunId = input.params.id ?? input.params.runId;
467
+ if (!targetRunId) {
468
+ return {
469
+ content: [{ type: "text", text: "action='append-step' requires id." }],
470
+ isError: true,
471
+ details: { mode: "management", results: [] },
472
+ };
473
+ }
474
+ if (!input.params.chain || input.params.chain.length !== 1) {
475
+ return {
476
+ content: [{ type: "text", text: "action='append-step' requires chain with exactly one step." }],
477
+ isError: true,
478
+ details: { mode: "management", results: [] },
479
+ };
480
+ }
481
+ const acceptanceErrors = validateExecutionAcceptance(input.params);
482
+ if (acceptanceErrors.length > 0) {
483
+ return {
484
+ content: [{ type: "text", text: `Cannot append step: ${acceptanceErrors.join(" ")}` }],
485
+ isError: true,
486
+ details: { mode: "management", results: [] },
487
+ };
488
+ }
489
+
490
+ let resolved: ResolvedSubagentRunId | undefined;
491
+ try {
492
+ resolved = resolveSubagentRunId(targetRunId, { state: input.deps.state, nested: nestedResolutionScopeForExecutor(input.deps) });
493
+ } catch (error) {
494
+ const message = error instanceof Error ? error.message : String(error);
495
+ return { content: [{ type: "text", text: message }], isError: true, details: { mode: "management", results: [] } };
496
+ }
497
+ if (!resolved) {
498
+ return {
499
+ content: [{ type: "text", text: `No async chain run found for '${targetRunId}'.` }],
500
+ isError: true,
501
+ details: { mode: "management", results: [] },
502
+ };
503
+ }
504
+ if (resolved.kind !== "async" || !resolved.location.asyncDir) {
505
+ return {
506
+ content: [{ type: "text", text: `Run '${resolved.id}' is not an append-capable async chain run.` }],
507
+ isError: true,
508
+ details: { mode: "management", results: [] },
509
+ };
510
+ }
511
+
512
+ const status = readStatus(resolved.location.asyncDir);
513
+ if (!status) {
514
+ return {
515
+ content: [{ type: "text", text: `No async run status found for '${resolved.id}'.` }],
516
+ isError: true,
517
+ details: { mode: "management", results: [] },
518
+ };
519
+ }
520
+ if (status.mode !== "chain") {
521
+ return {
522
+ content: [{ type: "text", text: `Run '${resolved.id}' is ${status.mode}; only active chain runs accept appended steps.` }],
523
+ isError: true,
524
+ details: { mode: "management", results: [] },
525
+ };
526
+ }
527
+ if (status.state !== "running") {
528
+ return {
529
+ content: [{ type: "text", text: `Run '${resolved.id}' is ${status.state}; only running chain runs accept appended steps.` }],
530
+ isError: true,
531
+ details: { mode: "management", results: [] },
532
+ };
533
+ }
534
+ const stillInProgress = (status.steps ?? []).some((step) => step.status === "running" || step.status === "pending") || (status.pendingAppends ?? 0) > 0;
535
+ if (!stillInProgress) {
536
+ return {
537
+ content: [{ type: "text", text: `Run '${resolved.id}' has no running or pending chain steps left; append-step must target an in-progress chain.` }],
538
+ isError: true,
539
+ details: { mode: "management", results: [] },
540
+ };
541
+ }
542
+
543
+ const pendingAppendRequests = readPendingChainAppendRequests(resolved.location.asyncDir);
544
+ const reservedOutputNames = new Set<string>([
545
+ ...Object.keys(status.outputs ?? {}),
546
+ ...(status.steps ?? []).map((step) => step.outputName).filter((name): name is string => Boolean(name)),
547
+ ...pendingAppendRequests.flatMap((request) => runnerStepOutputNames(request.steps)),
548
+ ]);
549
+ try {
550
+ validateChainOutputBindingsWithContext(input.params.chain, { maxItems: input.deps.config.chain?.dynamicFanout?.maxItems }, {
551
+ priorOutputNames: reservedOutputNames,
552
+ startStepIndex: status.chainStepCount ?? status.steps?.length ?? 0,
553
+ });
554
+ } catch (error) {
555
+ if (!(error instanceof ChainOutputValidationError)) throw error;
556
+ return {
557
+ content: [{ type: "text", text: `Cannot append step to run '${resolved.id}': ${error.message}` }],
558
+ isError: true,
559
+ details: { mode: "management", results: [] },
560
+ };
561
+ }
562
+
563
+ const scope: AgentScope = resolveExecutionAgentScope(input.params.agentScope);
564
+ const agents = input.deps.discoverAgents(input.requestCwd, scope).agents;
565
+ const contextPolicy = resolveExplicitContextPolicy(input.params);
566
+ const chainSkillInput = normalizeSkillInput(input.params.skill);
567
+ const chainSkills = chainSkillInput === false ? [] : (chainSkillInput ?? []);
568
+ const asyncCtx = {
569
+ pi: input.deps.pi,
570
+ cwd: input.ctx.cwd,
571
+ currentSessionId: resolveCurrentSessionId(input.ctx.sessionManager),
572
+ parentSessionId: input.ctx.sessionManager.getSessionId() ?? undefined,
573
+ currentModelProvider: input.ctx.model?.provider,
574
+ currentModel: input.ctx.model,
575
+ };
576
+ const built = buildAsyncRunnerSteps(resolved.id, {
577
+ chain: wrapChainTasksForFork(input.params.chain, contextPolicy),
578
+ task: input.params.task,
579
+ resultMode: "chain",
580
+ agents,
581
+ ctx: asyncCtx,
582
+ availableModels: input.ctx.modelRegistry.getAvailable().map(toModelInfo),
583
+ cwd: status.cwd ?? input.requestCwd,
584
+ chainSkills,
585
+ dynamicFanoutMaxItems: input.deps.config.chain?.dynamicFanout?.maxItems,
586
+ maxSubagentDepth: resolveCurrentMaxSubagentDepth(input.deps.config.maxSubagentDepth),
587
+ asyncDir: resolved.location.asyncDir,
588
+ validateOutputBindings: false,
589
+ });
590
+ if ("error" in built) {
591
+ return {
592
+ content: [{ type: "text", text: built.error }],
593
+ isError: true,
594
+ details: { mode: "management", results: [] },
595
+ };
596
+ }
597
+ const appendedOutputNames = runnerStepOutputNames(built.steps);
598
+ const duplicateAppendedOutputs = duplicateNames(appendedOutputNames);
599
+ if (duplicateAppendedOutputs.length > 0) {
600
+ return {
601
+ content: [{ type: "text", text: `Cannot append step to run '${resolved.id}': duplicate output name in appended step: ${duplicateAppendedOutputs.join(", ")}.` }],
602
+ isError: true,
603
+ details: { mode: "management", results: [] },
604
+ };
605
+ }
606
+ const pendingOutputNames = new Set(pendingAppendRequests.flatMap((request) => runnerStepOutputNames(request.steps)));
607
+ const pendingDuplicateOutputs = appendedOutputNames.filter((name) => pendingOutputNames.has(name));
608
+ if (pendingDuplicateOutputs.length > 0) {
609
+ return {
610
+ content: [{ type: "text", text: `Cannot append step to run '${resolved.id}': output name already belongs to a pending append: ${pendingDuplicateOutputs.join(", ")}.` }],
611
+ isError: true,
612
+ details: { mode: "management", results: [] },
613
+ };
614
+ }
615
+
616
+ try {
617
+ const result = enqueueChainAppendRequest({
618
+ asyncDir: resolved.location.asyncDir,
619
+ runId: resolved.id,
620
+ steps: built.steps,
621
+ });
622
+ const stepText = built.steps.length === 1 ? "step" : "steps";
623
+ return {
624
+ content: [{
625
+ type: "text",
626
+ text: `Append queued for chain run ${resolved.id}: ${built.steps.length} ${stepText}. It becomes eligible after the chain's already-queued steps finish. Pending appends: ${result.pendingCount}.`,
627
+ }],
628
+ details: { mode: "management", results: [], asyncId: resolved.id, asyncDir: resolved.location.asyncDir },
629
+ };
630
+ } catch (error) {
631
+ const message = error instanceof Error ? error.message : String(error);
632
+ return {
633
+ content: [{ type: "text", text: `Failed to append step to chain run ${resolved.id}: ${message}` }],
634
+ isError: true,
635
+ details: { mode: "management", results: [] },
636
+ };
637
+ }
638
+ }
639
+
437
640
  function nestedRunSessionFile(run: NestedRunSummary): string | undefined {
438
641
  return run.sessionFile ?? (run.steps?.length === 1 ? run.steps[0]?.sessionFile : undefined);
439
642
  }
@@ -554,7 +757,8 @@ async function resumeAsyncRun(input: {
554
757
  deps: ExecutorDeps;
555
758
  }): Promise<AgentToolResult<Details>> {
556
759
  const followUp = (input.params.message ?? input.params.task ?? "").trim();
557
- if (!followUp) {
760
+ const attachChain = (input.params.chain?.length ?? 0) > 0 ? input.params.chain as ChainStep[] : undefined;
761
+ if (!followUp && !attachChain) {
558
762
  return {
559
763
  content: [{ type: "text", text: "action='resume' requires message." }],
560
764
  isError: true,
@@ -568,6 +772,13 @@ async function resumeAsyncRun(input: {
568
772
  const requestedId = input.params.id ?? input.params.runId;
569
773
  const resolved = requestedId ? resolveSubagentRunId(requestedId, { state: input.deps.state, nested: nestedResolutionScopeForExecutor(input.deps) }) : undefined;
570
774
  if (resolved?.kind === "nested") {
775
+ if (attachChain) {
776
+ return {
777
+ content: [{ type: "text", text: "Attaching a running subagent as a chain root is currently available for top-level async runs only." }],
778
+ isError: true,
779
+ details: { mode: "management", results: [] },
780
+ };
781
+ }
571
782
  if (resolved.match.run.state === "running" || resolved.match.run.state === "queued") {
572
783
  return resumeLiveNestedRun({ target: resolved, message: followUp });
573
784
  }
@@ -577,14 +788,26 @@ async function resumeAsyncRun(input: {
577
788
  ];
578
789
  target = resolveNestedResumeTarget(resolved, trustedSessionRoots);
579
790
  } else {
580
- target = resolveResumeTarget(input.params, input.deps.state);
791
+ target = resolveResumeTarget(input.params, input.deps.state, { asyncRequireSessionFile: !attachChain });
581
792
  }
582
793
  } catch (error) {
583
794
  const message = error instanceof Error ? error.message : String(error);
584
795
  return { content: [{ type: "text", text: message }], isError: true, details: { mode: "management", results: [] } };
585
796
  }
586
797
 
587
- if (target.kind === "live") {
798
+ if (target.kind === "live" && !attachChain) {
799
+ const interrupt = interruptLiveAsyncResumeTarget({
800
+ target,
801
+ state: input.deps.state,
802
+ kill: input.deps.kill,
803
+ });
804
+ if (!interrupt.ok) {
805
+ return {
806
+ content: [{ type: "text", text: interrupt.message }],
807
+ isError: true,
808
+ details: { mode: "management", results: [] },
809
+ };
810
+ }
588
811
  const delivered = await deliverSubagentIntercomMessageEvent(
589
812
  input.deps.pi.events,
590
813
  target.intercomTarget,
@@ -594,7 +817,7 @@ async function resumeAsyncRun(input: {
594
817
  );
595
818
  if (delivered) {
596
819
  return {
597
- content: [{ type: "text", text: [`Delivered follow-up to live async child.`, `Run: ${target.runId}`, `Intercom target: ${target.intercomTarget}`].join("\n") }],
820
+ content: [{ type: "text", text: [`Interrupted live async child, then delivered follow-up.`, `Run: ${target.runId}`, `Intercom target: ${target.intercomTarget}`].join("\n") }],
598
821
  details: { mode: "management", results: [] },
599
822
  };
600
823
  }
@@ -637,6 +860,75 @@ async function resumeAsyncRun(input: {
637
860
  };
638
861
  }
639
862
 
863
+ if (attachChain) {
864
+ if (target.source !== "async") {
865
+ return {
866
+ content: [{ type: "text", text: "Attaching a running subagent as a chain root is currently available for async runs only." }],
867
+ isError: true,
868
+ details: { mode: "management", results: [] },
869
+ };
870
+ }
871
+ if (!isAsyncAvailable()) {
872
+ return {
873
+ content: [{ type: "text", text: "Async mode requires upstream jiti for TypeScript execution but it could not be found. Ensure the pi-subagents package dependencies are installed." }],
874
+ isError: true,
875
+ details: { mode: "chain", results: [] },
876
+ };
877
+ }
878
+ const runId = randomUUID().slice(0, 8);
879
+ const artifactConfig: ArtifactConfig = { ...DEFAULT_ARTIFACT_CONFIG, enabled: input.params.artifacts !== false };
880
+ const availableModels = input.ctx.modelRegistry.getAvailable().map(toModelInfo);
881
+ const contextPolicy = resolveExplicitContextPolicy(input.params);
882
+ const chain = wrapChainTasksForFork(attachChain, contextPolicy);
883
+ const normalized = normalizeSkillInput(input.params.skill);
884
+ const result = executeAsyncChain(runId, {
885
+ chain,
886
+ task: (input.params.task ?? followUp) || undefined,
887
+ attachRoot: {
888
+ runId: target.runId,
889
+ asyncDir: target.asyncDir ?? path.join(ASYNC_DIR, target.runId),
890
+ resultPath: resolveAsyncRootResultPath(RESULTS_DIR, target.runId),
891
+ index: target.index,
892
+ agent: target.agent,
893
+ label: `Attached ${target.runId}`,
894
+ },
895
+ agents,
896
+ ctx: {
897
+ pi: input.deps.pi,
898
+ cwd: input.requestCwd,
899
+ currentSessionId: input.deps.state.currentSessionId,
900
+ parentSessionId: input.ctx.sessionManager.getSessionId() ?? undefined,
901
+ currentModelProvider: input.ctx.model?.provider,
902
+ currentModel: input.ctx.model,
903
+ },
904
+ availableModels,
905
+ cwd: effectiveCwd,
906
+ maxOutput: input.params.maxOutput,
907
+ artifactsDir: input.deps.tempArtifactsDir,
908
+ artifactConfig,
909
+ shareEnabled: input.params.share === true,
910
+ sessionRoot: input.deps.getSubagentSessionRoot(parentSessionFile),
911
+ chainSkills: normalized === false ? [] : (normalized ?? []),
912
+ dynamicFanoutMaxItems: input.deps.config.chain?.dynamicFanout?.maxItems,
913
+ maxSubagentDepth: resolveCurrentMaxSubagentDepth(input.deps.config.maxSubagentDepth),
914
+ worktreeSetupHook: input.deps.config.worktreeSetupHook,
915
+ worktreeSetupHookTimeoutMs: input.deps.config.worktreeSetupHookTimeoutMs,
916
+ controlConfig: resolveControlConfig(input.deps.config.control, input.params.control),
917
+ controlIntercomTarget: intercomBridge.active ? intercomBridge.orchestratorTarget : undefined,
918
+ childIntercomTarget: intercomBridge.active ? (agent, index) => resolveSubagentIntercomTarget(runId, agent, index) : undefined,
919
+ });
920
+ if (result.isError) return result;
921
+ const attachedId = result.details.asyncId ?? runId;
922
+ const lines = [
923
+ `Attached async subagent ${target.runId} as the first step of a new chain.`,
924
+ `Chain run: ${attachedId}`,
925
+ `Root: ${target.agent} (step ${target.index + 1})`,
926
+ result.details.asyncDir ? `Async dir: ${result.details.asyncDir}` : undefined,
927
+ `Status if needed: subagent({ action: "status", id: "${attachedId}" })`,
928
+ ].filter((line): line is string => Boolean(line));
929
+ return { content: [{ type: "text", text: formatAsyncStartedMessage(lines.join("\n")) }], details: result.details };
930
+ }
931
+
640
932
  const runId = randomUUID().slice(0, 8);
641
933
  const artifactConfig: ArtifactConfig = { ...DEFAULT_ARTIFACT_CONFIG, enabled: input.params.artifacts !== false };
642
934
  const availableModels = input.ctx.modelRegistry.getAvailable().map(toModelInfo);
@@ -648,6 +940,7 @@ async function resumeAsyncRun(input: {
648
940
  pi: input.deps.pi,
649
941
  cwd: input.requestCwd,
650
942
  currentSessionId: input.deps.state.currentSessionId,
943
+ parentSessionId: input.ctx.sessionManager.getSessionId() ?? undefined,
651
944
  currentModelProvider: input.ctx.model?.provider,
652
945
  currentModel: input.ctx.model,
653
946
  },
@@ -795,6 +1088,15 @@ function validateExecutionInput(
795
1088
  };
796
1089
  }
797
1090
 
1091
+ const acceptanceErrors = validateExecutionAcceptance(params);
1092
+ if (acceptanceErrors.length > 0) {
1093
+ return {
1094
+ content: [{ type: "text", text: acceptanceErrors.join(" ") }],
1095
+ isError: true,
1096
+ details: { mode: getRequestedModeLabel(params), results: [] },
1097
+ };
1098
+ }
1099
+
798
1100
  if (hasSingle && params.agent && !agents.find((agent) => agent.name === params.agent)) {
799
1101
  return {
800
1102
  content: [{ type: "text", text: `Unknown agent: ${params.agent}` }],
@@ -872,6 +1174,42 @@ function validateExecutionInput(
872
1174
  return null;
873
1175
  }
874
1176
 
1177
+ function validateExecutionChainBindings(params: SubagentParamsLike, dynamicFanoutMaxItems?: number): AgentToolResult<Details> | null {
1178
+ if ((params.chain?.length ?? 0) === 0) return null;
1179
+ try {
1180
+ validateChainOutputBindingsWithContext(params.chain as ChainStep[], { maxItems: dynamicFanoutMaxItems });
1181
+ } catch (error) {
1182
+ if (error instanceof ChainOutputValidationError) {
1183
+ return {
1184
+ content: [{ type: "text", text: error.message }],
1185
+ isError: true,
1186
+ details: { mode: "chain" as const, results: [] },
1187
+ };
1188
+ }
1189
+ throw error;
1190
+ }
1191
+ return null;
1192
+ }
1193
+
1194
+ function validateExecutionAcceptance(params: SubagentParamsLike): string[] {
1195
+ const errors: string[] = [];
1196
+ errors.push(...validateAcceptanceInput(params.acceptance, "acceptance"));
1197
+ for (const [index, task] of (params.tasks ?? []).entries()) {
1198
+ errors.push(...validateAcceptanceInput(task.acceptance, `tasks[${index}].acceptance`));
1199
+ }
1200
+ for (const [stepIndex, step] of (params.chain ?? []).entries()) {
1201
+ errors.push(...validateAcceptanceInput((step as { acceptance?: unknown }).acceptance, `chain[${stepIndex}].acceptance`));
1202
+ if (isParallelStep(step)) {
1203
+ for (const [taskIndex, task] of step.parallel.entries()) {
1204
+ errors.push(...validateAcceptanceInput(task.acceptance, `chain[${stepIndex}].parallel[${taskIndex}].acceptance`));
1205
+ }
1206
+ } else if (isDynamicParallelStep(step)) {
1207
+ errors.push(...validateAcceptanceInput(step.parallel.acceptance, `chain[${stepIndex}].parallel.acceptance`));
1208
+ }
1209
+ }
1210
+ return errors;
1211
+ }
1212
+
875
1213
  function getRequestedModeLabel(params: SubagentParamsLike): Details["mode"] {
876
1214
  if ((params.chain?.length ?? 0) > 0) return "chain";
877
1215
  if ((params.tasks?.length ?? 0) > 0) return "parallel";
@@ -879,16 +1217,46 @@ function getRequestedModeLabel(params: SubagentParamsLike): Details["mode"] {
879
1217
  return "single";
880
1218
  }
881
1219
 
882
- function applyAgentDefaultContext(params: SubagentParamsLike, agents: AgentConfig[]): SubagentParamsLike {
883
- if (params.context !== undefined) return params;
1220
+ interface AgentDefaultContextPolicy {
1221
+ params: SubagentParamsLike;
1222
+ contextForAgent(agentName: string): "fresh" | "fork";
1223
+ usesFork: boolean;
1224
+ }
1225
+
1226
+ function resolveAgentDefaultContextPolicy(params: SubagentParamsLike, agents: AgentConfig[]): AgentDefaultContextPolicy {
1227
+ if (params.context !== undefined) {
1228
+ return resolveExplicitContextPolicy(params);
1229
+ }
884
1230
  const byName = new Map(agents.map((agent) => [agent.name, agent]));
1231
+ const contextForAgent = (agentName: string): "fresh" | "fork" =>
1232
+ byName.get(agentName)?.defaultContext === "fork" ? "fork" : "fresh";
1233
+ const usesFork = collectRequestedAgentNames(params).some((name) => contextForAgent(name) === "fork");
1234
+ return {
1235
+ params: usesFork ? { ...params, context: "fork" } : params,
1236
+ contextForAgent,
1237
+ usesFork,
1238
+ };
1239
+ }
1240
+
1241
+ function resolveExplicitContextPolicy(params: SubagentParamsLike): AgentDefaultContextPolicy {
1242
+ const context = params.context === "fork" ? "fork" : "fresh";
1243
+ return {
1244
+ params,
1245
+ contextForAgent: () => context,
1246
+ usesFork: context === "fork",
1247
+ };
1248
+ }
1249
+
1250
+ function collectRequestedAgentNames(params: SubagentParamsLike): string[] {
885
1251
  const names: string[] = [];
886
1252
  if (params.agent) names.push(params.agent);
887
1253
  for (const task of params.tasks ?? []) names.push(task.agent);
888
1254
  for (const step of params.chain ?? []) names.push(...getStepAgents(step));
889
- return names.some((name) => byName.get(name)?.defaultContext === "fork")
890
- ? { ...params, context: "fork" }
891
- : params;
1255
+ return names;
1256
+ }
1257
+
1258
+ function shouldForkAgent(contextPolicy: AgentDefaultContextPolicy, agentName: string): boolean {
1259
+ return contextPolicy.contextForAgent(agentName) === "fork";
892
1260
  }
893
1261
 
894
1262
  function buildRequestedModeError(params: SubagentParamsLike, message: string): AgentToolResult<Details> {
@@ -902,6 +1270,22 @@ function buildRequestedModeError(params: SubagentParamsLike, message: string): A
902
1270
  );
903
1271
  }
904
1272
 
1273
+ function resolveForegroundTimeout(params: SubagentParamsLike): { timeoutMs?: number; error?: string } {
1274
+ const rawTimeout = params.timeoutMs;
1275
+ const rawMaxRuntime = params.maxRuntimeMs;
1276
+ if (rawTimeout === undefined && rawMaxRuntime === undefined) return {};
1277
+ for (const [name, value] of [["timeoutMs", rawTimeout], ["maxRuntimeMs", rawMaxRuntime]] as const) {
1278
+ if (value === undefined) continue;
1279
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
1280
+ return { error: `${name} must be a positive integer.` };
1281
+ }
1282
+ }
1283
+ if (rawTimeout !== undefined && rawMaxRuntime !== undefined && rawTimeout !== rawMaxRuntime) {
1284
+ return { error: "timeoutMs and maxRuntimeMs are aliases; provide only one value or use the same value for both." };
1285
+ }
1286
+ return { timeoutMs: rawTimeout ?? rawMaxRuntime };
1287
+ }
1288
+
905
1289
  function expandTopLevelTaskCounts(tasks: TaskParam[]): { tasks?: TaskParam[]; error?: string } {
906
1290
  const expanded: TaskParam[] = [];
907
1291
  for (let taskIndex = 0; taskIndex < tasks.length; taskIndex++) {
@@ -989,14 +1373,14 @@ function toExecutionErrorResult(params: SubagentParamsLike, error: unknown): Age
989
1373
 
990
1374
  function collectChainSessionFiles(
991
1375
  chain: ChainStep[],
992
- sessionFileForIndex: (idx?: number) => string | undefined,
1376
+ sessionFileForTask: (agentName: string, idx?: number) => string | undefined,
993
1377
  ): (string | undefined)[] {
994
1378
  const sessionFiles: (string | undefined)[] = [];
995
1379
  let flatIndex = 0;
996
1380
  for (const step of chain) {
997
1381
  if (isParallelStep(step)) {
998
- for (let i = 0; i < step.parallel.length; i++) {
999
- sessionFiles.push(sessionFileForIndex(flatIndex));
1382
+ for (const task of step.parallel) {
1383
+ sessionFiles.push(sessionFileForTask(task.agent, flatIndex));
1000
1384
  flatIndex++;
1001
1385
  }
1002
1386
  continue;
@@ -1005,21 +1389,22 @@ function collectChainSessionFiles(
1005
1389
  sessionFiles.push(undefined);
1006
1390
  continue;
1007
1391
  }
1008
- sessionFiles.push(sessionFileForIndex(flatIndex));
1392
+ sessionFiles.push(sessionFileForTask((step as SequentialStep).agent, flatIndex));
1009
1393
  flatIndex++;
1010
1394
  }
1011
1395
  return sessionFiles;
1012
1396
  }
1013
1397
 
1014
- function wrapChainTasksForFork(chain: ChainStep[], context: SubagentParamsLike["context"]): ChainStep[] {
1015
- if (context !== "fork") return chain;
1398
+ function wrapChainTasksForFork(chain: ChainStep[], contextPolicy: AgentDefaultContextPolicy): ChainStep[] {
1016
1399
  return chain.map((step, stepIndex) => {
1017
1400
  if (isParallelStep(step)) {
1018
1401
  return {
1019
1402
  ...step,
1020
1403
  parallel: step.parallel.map((task) => ({
1021
1404
  ...task,
1022
- task: wrapForkTask(task.task ?? "{previous}"),
1405
+ task: shouldForkAgent(contextPolicy, task.agent)
1406
+ ? wrapForkTask(task.task ?? "{previous}")
1407
+ : task.task,
1023
1408
  })),
1024
1409
  };
1025
1410
  }
@@ -1028,18 +1413,59 @@ function wrapChainTasksForFork(chain: ChainStep[], context: SubagentParamsLike["
1028
1413
  ...step,
1029
1414
  parallel: {
1030
1415
  ...step.parallel,
1031
- task: wrapForkTask(step.parallel.task ?? "{previous}"),
1416
+ task: shouldForkAgent(contextPolicy, step.parallel.agent)
1417
+ ? wrapForkTask(step.parallel.task ?? "{previous}")
1418
+ : step.parallel.task,
1032
1419
  },
1033
1420
  };
1034
1421
  }
1035
1422
  const sequential = step as SequentialStep;
1036
1423
  return {
1037
1424
  ...sequential,
1038
- task: wrapForkTask(sequential.task ?? (stepIndex === 0 ? "{task}" : "{previous}")),
1425
+ task: shouldForkAgent(contextPolicy, sequential.agent)
1426
+ ? wrapForkTask(sequential.task ?? (stepIndex === 0 ? "{task}" : "{previous}"))
1427
+ : sequential.task,
1039
1428
  };
1040
1429
  });
1041
1430
  }
1042
1431
 
1432
+ function preflightForkSessionsForStaticTasks(
1433
+ params: SubagentParamsLike,
1434
+ contextPolicy: AgentDefaultContextPolicy,
1435
+ sessionFileForTask: (agentName: string, idx?: number) => string | undefined,
1436
+ ): void {
1437
+ if (!contextPolicy.usesFork) return;
1438
+ if (params.agent) {
1439
+ if (shouldForkAgent(contextPolicy, params.agent)) sessionFileForTask(params.agent, 0);
1440
+ return;
1441
+ }
1442
+ if (params.tasks) {
1443
+ params.tasks.forEach((task, index) => {
1444
+ if (shouldForkAgent(contextPolicy, task.agent)) sessionFileForTask(task.agent, index);
1445
+ });
1446
+ return;
1447
+ }
1448
+ if (!params.chain?.length) return;
1449
+ let flatIndex = 0;
1450
+ for (const step of params.chain) {
1451
+ if (isParallelStep(step)) {
1452
+ for (const task of step.parallel) {
1453
+ if (shouldForkAgent(contextPolicy, task.agent)) sessionFileForTask(task.agent, flatIndex);
1454
+ flatIndex++;
1455
+ }
1456
+ continue;
1457
+ }
1458
+ if (isDynamicParallelStep(step)) {
1459
+ if (shouldForkAgent(contextPolicy, step.parallel.agent)) sessionFileForTask(step.parallel.agent, flatIndex);
1460
+ flatIndex++;
1461
+ continue;
1462
+ }
1463
+ const sequential = step as SequentialStep;
1464
+ if (shouldForkAgent(contextPolicy, sequential.agent)) sessionFileForTask(sequential.agent, flatIndex);
1465
+ flatIndex++;
1466
+ }
1467
+ }
1468
+
1043
1469
  function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentToolResult<Details> | null {
1044
1470
  const {
1045
1471
  params,
@@ -1049,12 +1475,14 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1049
1475
  shareEnabled,
1050
1476
  sessionRoot,
1051
1477
  sessionFileForIndex,
1478
+ sessionFileForTask,
1052
1479
  artifactConfig,
1053
1480
  artifactsDir,
1054
1481
  effectiveAsync,
1055
1482
  controlConfig,
1056
1483
  intercomBridge,
1057
1484
  nestedRoute,
1485
+ contextPolicy,
1058
1486
  } = data;
1059
1487
  const hasChain = (params.chain?.length ?? 0) > 0;
1060
1488
  const hasTasks = (params.tasks?.length ?? 0) > 0;
@@ -1095,6 +1523,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1095
1523
  pi: deps.pi,
1096
1524
  cwd: ctx.cwd,
1097
1525
  currentSessionId: deps.state.currentSessionId!,
1526
+ parentSessionId: ctx.sessionManager.getSessionId() ?? undefined,
1098
1527
  currentModelProvider: ctx.model?.provider,
1099
1528
  currentModel: ctx.model,
1100
1529
  };
@@ -1112,7 +1541,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1112
1541
  const skillOverrides = params.tasks.map((task) => normalizeSkillInput(task.skill));
1113
1542
  const parallelTasks = params.tasks.map((task, index) => ({
1114
1543
  agent: task.agent,
1115
- task: params.context === "fork" ? wrapForkTask(task.task) : task.task,
1544
+ task: shouldForkAgent(contextPolicy, task.agent) ? wrapForkTask(task.task) : task.task,
1116
1545
  cwd: task.cwd,
1117
1546
  ...(modelOverrides[index] ? { model: modelOverrides[index] } : {}),
1118
1547
  ...(skillOverrides[index] !== undefined ? { skill: skillOverrides[index] } : {}),
@@ -1139,7 +1568,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1139
1568
  shareEnabled,
1140
1569
  sessionRoot,
1141
1570
  chainSkills: [],
1142
- sessionFilesByFlatIndex: params.tasks.map((_, index) => sessionFileForIndex(index)),
1571
+ sessionFilesByFlatIndex: params.tasks.map((task, index) => sessionFileForTask(task.agent, index)),
1143
1572
  maxSubagentDepth: currentMaxSubagentDepth,
1144
1573
  worktreeSetupHook: deps.config.worktreeSetupHook,
1145
1574
  worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs,
@@ -1153,7 +1582,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1153
1582
  if (hasChain && params.chain) {
1154
1583
  const normalized = normalizeSkillInput(params.skill);
1155
1584
  const chainSkills = normalized === false ? [] : (normalized ?? []);
1156
- const chain = wrapChainTasksForFork(params.chain as ChainStep[], params.context);
1585
+ const chain = wrapChainTasksForFork(params.chain as ChainStep[], contextPolicy);
1157
1586
  return executeAsyncChain(id, {
1158
1587
  chain,
1159
1588
  task: params.task,
@@ -1167,7 +1596,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1167
1596
  shareEnabled,
1168
1597
  sessionRoot,
1169
1598
  chainSkills,
1170
- sessionFilesByFlatIndex: collectChainSessionFiles(chain, sessionFileForIndex),
1599
+ sessionFilesByFlatIndex: collectChainSessionFiles(chain, sessionFileForTask),
1171
1600
  dynamicFanoutMaxItems: deps.config.chain?.dynamicFanout?.maxItems,
1172
1601
  maxSubagentDepth: currentMaxSubagentDepth,
1173
1602
  worktreeSetupHook: deps.config.worktreeSetupHook,
@@ -1197,7 +1626,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1197
1626
  const modelOverride = resolveSubagentModelOverride((params.model as string | undefined) ?? a.model, ctx.model, availableModels, currentProvider);
1198
1627
  return executeAsyncSingle(id, {
1199
1628
  agent: params.agent!,
1200
- task: params.context === "fork" ? wrapForkTask(params.task ?? "") : (params.task ?? ""),
1629
+ task: shouldForkAgent(contextPolicy, params.agent!) ? wrapForkTask(params.task ?? "") : (params.task ?? ""),
1201
1630
  agentConfig: a,
1202
1631
  ctx: asyncCtx,
1203
1632
  availableModels,
@@ -1207,7 +1636,7 @@ function runAsyncPath(data: ExecutionContextData, deps: ExecutorDeps): AgentTool
1207
1636
  artifactConfig,
1208
1637
  shareEnabled,
1209
1638
  sessionRoot,
1210
- sessionFile: sessionFileForIndex(0),
1639
+ sessionFile: sessionFileForTask(params.agent!, 0),
1211
1640
  skills,
1212
1641
  output: effectiveOutput,
1213
1642
  outputMode: effectiveOutputMode,
@@ -1237,18 +1666,20 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
1237
1666
  shareEnabled,
1238
1667
  sessionDirForIndex,
1239
1668
  sessionFileForIndex,
1669
+ sessionFileForTask,
1240
1670
  artifactsDir,
1241
1671
  artifactConfig,
1242
1672
  onUpdate,
1243
1673
  sessionRoot,
1244
1674
  controlConfig,
1675
+ contextPolicy,
1245
1676
  } = data;
1246
1677
  const onControlEvent = createForegroundControlNotifier(data, deps);
1247
1678
  const childIntercomTarget = data.intercomBridge.active ? resolveSubagentIntercomTarget : undefined;
1248
1679
  const foregroundControl = deps.state.foregroundControls.get(runId);
1249
1680
  const normalized = normalizeSkillInput(params.skill);
1250
1681
  const chainSkills = normalized === false ? [] : (normalized ?? []);
1251
- const chain = wrapChainTasksForFork(params.chain as ChainStep[], params.context);
1682
+ const chain = wrapChainTasksForFork(params.chain as ChainStep[], contextPolicy);
1252
1683
  const currentMaxSubagentDepth = resolveCurrentMaxSubagentDepth(deps.config.maxSubagentDepth);
1253
1684
  const chainResult = await executeChain({
1254
1685
  chain,
@@ -1262,6 +1693,7 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
1262
1693
  shareEnabled,
1263
1694
  sessionDirForIndex,
1264
1695
  sessionFileForIndex,
1696
+ sessionFileForTask,
1265
1697
  artifactsDir,
1266
1698
  artifactConfig,
1267
1699
  includeProgress: params.includeProgress,
@@ -1279,9 +1711,14 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
1279
1711
  maxSubagentDepth: currentMaxSubagentDepth,
1280
1712
  worktreeSetupHook: deps.config.worktreeSetupHook,
1281
1713
  worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs,
1714
+ timeoutMs: data.timeoutMs,
1715
+ deadlineAt: data.deadlineAt,
1282
1716
  });
1283
1717
 
1284
1718
  if (chainResult.requestedAsync) {
1719
+ if (data.timeoutMs !== undefined) {
1720
+ return buildRequestedModeError(params, "timeoutMs/maxRuntimeMs are only supported for foreground runs; background launch from clarify cannot preserve the timeout.");
1721
+ }
1285
1722
  if (!isAsyncAvailable()) {
1286
1723
  return {
1287
1724
  content: [{ type: "text", text: "Background mode requires upstream jiti for TypeScript execution but it could not be found. Ensure the pi-subagents package dependencies are installed." }],
@@ -1294,10 +1731,11 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
1294
1731
  pi: deps.pi,
1295
1732
  cwd: ctx.cwd,
1296
1733
  currentSessionId: deps.state.currentSessionId!,
1734
+ parentSessionId: ctx.sessionManager.getSessionId() ?? undefined,
1297
1735
  currentModelProvider: ctx.model?.provider,
1298
1736
  currentModel: ctx.model,
1299
1737
  };
1300
- const asyncChain = wrapChainTasksForFork(chainResult.requestedAsync.chain, params.context);
1738
+ const asyncChain = wrapChainTasksForFork(chainResult.requestedAsync.chain, contextPolicy);
1301
1739
  return executeAsyncChain(id, {
1302
1740
  chain: asyncChain,
1303
1741
  task: params.task,
@@ -1311,7 +1749,7 @@ async function runChainPath(data: ExecutionContextData, deps: ExecutorDeps): Pro
1311
1749
  shareEnabled,
1312
1750
  sessionRoot,
1313
1751
  chainSkills: chainResult.requestedAsync.chainSkills,
1314
- sessionFilesByFlatIndex: collectChainSessionFiles(asyncChain, sessionFileForIndex),
1752
+ sessionFilesByFlatIndex: collectChainSessionFiles(asyncChain, sessionFileForTask),
1315
1753
  dynamicFanoutMaxItems: deps.config.chain?.dynamicFanout?.maxItems,
1316
1754
  maxSubagentDepth: currentMaxSubagentDepth,
1317
1755
  worktreeSetupHook: deps.config.worktreeSetupHook,
@@ -1357,11 +1795,13 @@ interface ForegroundParallelRunInput {
1357
1795
  runId: string;
1358
1796
  sessionDirForIndex: (idx?: number) => string | undefined;
1359
1797
  sessionFileForIndex: (idx?: number) => string | undefined;
1798
+ sessionFileForTask: (agentName: string, idx?: number) => string | undefined;
1360
1799
  shareEnabled: boolean;
1361
1800
  artifactConfig: ArtifactConfig;
1362
1801
  artifactsDir: string;
1363
1802
  maxOutput?: MaxOutputConfig;
1364
1803
  paramsCwd: string;
1804
+ progressDir: string;
1365
1805
  maxSubagentDepths: number[];
1366
1806
  availableModels: ModelInfo[];
1367
1807
  modelOverrides: (string | undefined)[];
@@ -1377,6 +1817,8 @@ interface ForegroundParallelRunInput {
1377
1817
  liveProgress: (AgentProgress | undefined)[];
1378
1818
  onUpdate?: (r: AgentToolResult<Details>) => void;
1379
1819
  worktreeSetup?: WorktreeSetup;
1820
+ timeoutMs?: number;
1821
+ deadlineAt?: number;
1380
1822
  }
1381
1823
 
1382
1824
  function buildParallelModeError(message: string): AgentToolResult<Details> {
@@ -1487,7 +1929,7 @@ async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Pr
1487
1929
  ? buildChainInstructions({ ...behavior, output: false, progress: false }, taskCwd, false)
1488
1930
  : { prefix: "", suffix: "" };
1489
1931
  const progressInstructions = behavior
1490
- ? buildChainInstructions({ ...behavior, output: false, reads: false }, input.paramsCwd, index === input.firstProgressIndex)
1932
+ ? buildChainInstructions({ ...behavior, output: false, reads: false }, input.progressDir, index === input.firstProgressIndex)
1491
1933
  : { prefix: "", suffix: "" };
1492
1934
  const outputPath = resolveSingleOutputPath(behavior?.output, input.ctx.cwd, taskCwd);
1493
1935
  const taskText = injectSingleOutputInstruction(
@@ -1510,6 +1952,7 @@ async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Pr
1510
1952
  }
1511
1953
  const agentConfig = input.agents.find((agent) => agent.name === task.agent);
1512
1954
  return runSync(input.ctx.cwd, input.agents, task.agent, taskText, {
1955
+ parentSessionId: input.ctx.sessionManager.getSessionId() ?? undefined,
1513
1956
  cwd: taskCwd,
1514
1957
  signal: input.signal,
1515
1958
  interruptSignal: interruptController.signal,
@@ -1518,7 +1961,7 @@ async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Pr
1518
1961
  runId: input.runId,
1519
1962
  index,
1520
1963
  sessionDir: input.sessionDirForIndex(index),
1521
- sessionFile: input.sessionFileForIndex(index),
1964
+ sessionFile: input.sessionFileForTask(task.agent, index),
1522
1965
  share: input.shareEnabled,
1523
1966
  artifactsDir: input.artifactConfig.enabled ? input.artifactsDir : undefined,
1524
1967
  artifactConfig: input.artifactConfig,
@@ -1537,39 +1980,41 @@ async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Pr
1537
1980
  skills: effectiveSkills === false ? [] : effectiveSkills,
1538
1981
  acceptance: task.acceptance,
1539
1982
  acceptanceContext: { mode: "parallel" },
1540
- onUpdate: input.onUpdate
1541
- ? (progressUpdate) => {
1542
- const stepResults = progressUpdate.details?.results || [];
1543
- const stepProgress = progressUpdate.details?.progress || [];
1544
- if (input.foregroundControl && stepProgress.length > 0) {
1545
- const current = stepProgress[0];
1546
- input.foregroundControl.currentAgent = task.agent;
1547
- input.foregroundControl.currentIndex = index;
1548
- input.foregroundControl.currentActivityState = current?.activityState;
1549
- input.foregroundControl.lastActivityAt = current?.lastActivityAt;
1550
- input.foregroundControl.currentTool = current?.currentTool;
1551
- input.foregroundControl.currentToolStartedAt = current?.currentToolStartedAt;
1552
- input.foregroundControl.currentPath = current?.currentPath;
1553
- input.foregroundControl.turnCount = current?.turnCount;
1554
- input.foregroundControl.tokens = current?.tokens;
1555
- input.foregroundControl.toolCount = current?.toolCount;
1556
- input.foregroundControl.updatedAt = Date.now();
1557
- }
1558
- if (stepResults.length > 0) input.liveResults[index] = stepResults[0];
1559
- if (stepProgress.length > 0) input.liveProgress[index] = stepProgress[0];
1560
- const mergedResults = input.liveResults.filter((result): result is SingleResult => result !== undefined);
1561
- const mergedProgress = input.liveProgress.filter((progress): progress is AgentProgress => progress !== undefined);
1562
- input.onUpdate?.({
1563
- content: progressUpdate.content,
1564
- details: {
1565
- mode: "parallel",
1566
- results: mergedResults,
1567
- progress: mergedProgress,
1568
- controlEvents: progressUpdate.details?.controlEvents,
1569
- totalSteps: input.tasks.length,
1570
- },
1571
- });
1983
+ timeoutMs: input.timeoutMs,
1984
+ deadlineAt: input.deadlineAt,
1985
+ onUpdate: input.onUpdate
1986
+ ? (progressUpdate) => {
1987
+ const stepResults = progressUpdate.details?.results || [];
1988
+ const stepProgress = progressUpdate.details?.progress || [];
1989
+ if (input.foregroundControl && stepProgress.length > 0) {
1990
+ const current = stepProgress[0];
1991
+ input.foregroundControl.currentAgent = task.agent;
1992
+ input.foregroundControl.currentIndex = index;
1993
+ input.foregroundControl.currentActivityState = current?.activityState;
1994
+ input.foregroundControl.lastActivityAt = current?.lastActivityAt;
1995
+ input.foregroundControl.currentTool = current?.currentTool;
1996
+ input.foregroundControl.currentToolStartedAt = current?.currentToolStartedAt;
1997
+ input.foregroundControl.currentPath = current?.currentPath;
1998
+ input.foregroundControl.turnCount = current?.turnCount;
1999
+ input.foregroundControl.tokens = current?.tokens;
2000
+ input.foregroundControl.toolCount = current?.toolCount;
2001
+ input.foregroundControl.updatedAt = Date.now();
1572
2002
  }
2003
+ if (stepResults.length > 0) input.liveResults[index] = stepResults[0];
2004
+ if (stepProgress.length > 0) input.liveProgress[index] = stepProgress[0];
2005
+ const mergedResults = input.liveResults.filter((result): result is SingleResult => result !== undefined);
2006
+ const mergedProgress = input.liveProgress.filter((progress): progress is AgentProgress => progress !== undefined);
2007
+ input.onUpdate?.({
2008
+ content: progressUpdate.content,
2009
+ details: {
2010
+ mode: "parallel",
2011
+ results: mergedResults,
2012
+ progress: mergedProgress,
2013
+ controlEvents: progressUpdate.details?.controlEvents,
2014
+ totalSteps: input.tasks.length,
2015
+ },
2016
+ });
2017
+ }
1573
2018
  : undefined,
1574
2019
  }).finally(() => {
1575
2020
  if (input.foregroundControl?.currentIndex === index) {
@@ -1590,6 +2035,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1590
2035
  runId,
1591
2036
  sessionDirForIndex,
1592
2037
  sessionFileForIndex,
2038
+ sessionFileForTask,
1593
2039
  shareEnabled,
1594
2040
  artifactConfig,
1595
2041
  artifactsDir,
@@ -1597,6 +2043,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1597
2043
  onUpdate,
1598
2044
  sessionRoot,
1599
2045
  controlConfig,
2046
+ contextPolicy,
1600
2047
  } = data;
1601
2048
  const onControlEvent = createForegroundControlNotifier(data, deps);
1602
2049
  const childIntercomTarget = data.intercomBridge.active ? resolveSubagentIntercomTarget : undefined;
@@ -1699,6 +2146,9 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1699
2146
  }
1700
2147
 
1701
2148
  if (result.runInBackground) {
2149
+ if (data.timeoutMs !== undefined) {
2150
+ return buildRequestedModeError(params, "timeoutMs/maxRuntimeMs are only supported for foreground runs; background launch from clarify cannot preserve the timeout.");
2151
+ }
1702
2152
  if (!isAsyncAvailable()) {
1703
2153
  return {
1704
2154
  content: [{ type: "text", text: "Background mode requires upstream jiti for TypeScript execution but it could not be found. Ensure the pi-subagents package dependencies are installed." }],
@@ -1711,11 +2161,12 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1711
2161
  pi: deps.pi,
1712
2162
  cwd: ctx.cwd,
1713
2163
  currentSessionId: deps.state.currentSessionId!,
2164
+ parentSessionId: ctx.sessionManager.getSessionId() ?? undefined,
1714
2165
  currentModelProvider: ctx.model?.provider,
1715
2166
  currentModel: ctx.model,
1716
2167
  };
1717
2168
  const parallelTasks = tasks.map((t, i) => {
1718
- const taskText = params.context === "fork" ? wrapForkTask(taskTexts[i]!) : taskTexts[i]!;
2169
+ const taskText = shouldForkAgent(contextPolicy, t.agent) ? wrapForkTask(taskTexts[i]!) : taskTexts[i]!;
1719
2170
  const progress = taskDisallowsFileUpdates(taskText) ? false : behaviorOverrides[i]?.progress;
1720
2171
  return {
1721
2172
  agent: t.agent,
@@ -1743,7 +2194,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1743
2194
  shareEnabled,
1744
2195
  sessionRoot,
1745
2196
  chainSkills: [],
1746
- sessionFilesByFlatIndex: tasks.map((_, index) => sessionFileForIndex(index)),
2197
+ sessionFilesByFlatIndex: tasks.map((task, index) => sessionFileForTask(task.agent, index)),
1747
2198
  maxSubagentDepth: currentMaxSubagentDepth,
1748
2199
  worktreeSetupHook: deps.config.worktreeSetupHook,
1749
2200
  worktreeSetupHookTimeoutMs: deps.config.worktreeSetupHookTimeoutMs,
@@ -1786,14 +2237,14 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1786
2237
  }
1787
2238
 
1788
2239
  const parallelProgressPrecreated = firstProgressIndex !== -1;
1789
- if (parallelProgressPrecreated) writeInitialProgressFile(effectiveCwd);
2240
+ const parallelProgressDir = path.join(artifactsDir, "progress", runId);
2241
+ if (parallelProgressPrecreated) writeInitialProgressFile(parallelProgressDir);
1790
2242
 
1791
- if (params.context === "fork") {
1792
- for (let i = 0; i < taskTexts.length; i++) {
1793
- taskTexts[i] = wrapForkTask(taskTexts[i]!);
1794
- }
2243
+ for (let i = 0; i < taskTexts.length; i++) {
2244
+ if (shouldForkAgent(contextPolicy, tasks[i]!.agent)) taskTexts[i] = wrapForkTask(taskTexts[i]!);
1795
2245
  }
1796
2246
 
2247
+ const deadlineAt = data.deadlineAt ?? (data.timeoutMs !== undefined ? Date.now() + data.timeoutMs : undefined);
1797
2248
  const results = await runForegroundParallelTasks({
1798
2249
  tasks,
1799
2250
  taskTexts,
@@ -1804,11 +2255,13 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1804
2255
  runId,
1805
2256
  sessionDirForIndex,
1806
2257
  sessionFileForIndex,
2258
+ sessionFileForTask,
1807
2259
  shareEnabled,
1808
2260
  artifactConfig,
1809
2261
  artifactsDir,
1810
2262
  maxOutput: params.maxOutput,
1811
2263
  paramsCwd: effectiveCwd,
2264
+ progressDir: parallelProgressDir,
1812
2265
  availableModels,
1813
2266
  modelOverrides,
1814
2267
  behaviors,
@@ -1824,6 +2277,8 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1824
2277
  liveProgress,
1825
2278
  onUpdate,
1826
2279
  worktreeSetup,
2280
+ timeoutMs: data.timeoutMs,
2281
+ deadlineAt,
1827
2282
  });
1828
2283
  for (let i = 0; i < results.length; i++) {
1829
2284
  const run = results[i]!;
@@ -1884,6 +2339,7 @@ async function runParallelPath(data: ExecutionContextData, deps: ExecutorDeps):
1884
2339
  output: result.truncation?.text || getSingleResultOutput(result),
1885
2340
  exitCode: result.exitCode,
1886
2341
  error: result.error,
2342
+ timedOut: result.timedOut,
1887
2343
  })),
1888
2344
  (i, agent) => `=== Task ${i + 1}: ${agent} ===`,
1889
2345
  );
@@ -1911,13 +2367,14 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
1911
2367
  signal,
1912
2368
  runId,
1913
2369
  sessionDirForIndex,
1914
- sessionFileForIndex,
2370
+ sessionFileForTask,
1915
2371
  shareEnabled,
1916
2372
  artifactConfig,
1917
2373
  artifactsDir,
1918
2374
  onUpdate,
1919
2375
  sessionRoot,
1920
2376
  controlConfig,
2377
+ contextPolicy,
1921
2378
  } = data;
1922
2379
  const onControlEvent = createForegroundControlNotifier(data, deps);
1923
2380
  const childIntercomTarget = data.intercomBridge.active ? resolveSubagentIntercomTarget(runId, params.agent!, 0) : undefined;
@@ -1981,6 +2438,9 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
1981
2438
  if (override?.skills !== undefined) skillOverride = override.skills;
1982
2439
 
1983
2440
  if (result.runInBackground) {
2441
+ if (data.timeoutMs !== undefined) {
2442
+ return buildRequestedModeError(params, "timeoutMs/maxRuntimeMs are only supported for foreground runs; background launch from clarify cannot preserve the timeout.");
2443
+ }
1984
2444
  if (!isAsyncAvailable()) {
1985
2445
  return {
1986
2446
  content: [{ type: "text", text: "Background mode requires upstream jiti for TypeScript execution but it could not be found. Ensure the pi-subagents package dependencies are installed." }],
@@ -1993,12 +2453,13 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
1993
2453
  pi: deps.pi,
1994
2454
  cwd: ctx.cwd,
1995
2455
  currentSessionId: deps.state.currentSessionId!,
2456
+ parentSessionId: ctx.sessionManager.getSessionId() ?? undefined,
1996
2457
  currentModelProvider: ctx.model?.provider,
1997
2458
  currentModel: ctx.model,
1998
2459
  };
1999
2460
  return executeAsyncSingle(id, {
2000
2461
  agent: params.agent!,
2001
- task: params.context === "fork" ? wrapForkTask(task) : task,
2462
+ task: shouldForkAgent(contextPolicy, params.agent!) ? wrapForkTask(task) : task,
2002
2463
  agentConfig,
2003
2464
  ctx: asyncCtx,
2004
2465
  availableModels,
@@ -2008,7 +2469,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2008
2469
  artifactConfig,
2009
2470
  shareEnabled,
2010
2471
  sessionRoot,
2011
- sessionFile: sessionFileForIndex(0),
2472
+ sessionFile: sessionFileForTask(params.agent!, 0),
2012
2473
  skills: skillOverride === false ? [] : skillOverride,
2013
2474
  output: effectiveOutput,
2014
2475
  outputMode: effectiveOutputMode,
@@ -2023,7 +2484,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2023
2484
  }
2024
2485
  }
2025
2486
 
2026
- if (params.context === "fork") {
2487
+ if (shouldForkAgent(contextPolicy, params.agent!)) {
2027
2488
  task = wrapForkTask(task);
2028
2489
  }
2029
2490
  const cleanTask = task;
@@ -2076,7 +2537,9 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2076
2537
  }
2077
2538
  : undefined;
2078
2539
 
2540
+ const deadlineAt = data.deadlineAt ?? (data.timeoutMs !== undefined ? Date.now() + data.timeoutMs : undefined);
2079
2541
  const r = await runSync(ctx.cwd, agents, params.agent!, task, {
2542
+ parentSessionId: ctx.sessionManager.getSessionId() ?? undefined,
2080
2543
  cwd: effectiveCwd,
2081
2544
  signal,
2082
2545
  interruptSignal: interruptController.signal,
@@ -2084,7 +2547,7 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2084
2547
  intercomEvents: deps.pi.events,
2085
2548
  runId,
2086
2549
  sessionDir: sessionDirForIndex(0),
2087
- sessionFile: sessionFileForIndex(0),
2550
+ sessionFile: sessionFileForTask(params.agent!, 0),
2088
2551
  share: shareEnabled,
2089
2552
  artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
2090
2553
  artifactConfig,
@@ -2105,6 +2568,8 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2105
2568
  skills: effectiveSkills,
2106
2569
  acceptance: params.acceptance,
2107
2570
  acceptanceContext: { mode: "single" },
2571
+ timeoutMs: data.timeoutMs,
2572
+ deadlineAt,
2108
2573
  });
2109
2574
  if (foregroundControl?.currentIndex === 0) {
2110
2575
  foregroundControl.interrupt = undefined;
@@ -2189,6 +2654,23 @@ async function runSinglePath(data: ExecutionContextData, deps: ExecutorDeps): Pr
2189
2654
  };
2190
2655
  }
2191
2656
 
2657
+ function inferExecutionMode(params: SubagentParamsLike): SubagentRunMode {
2658
+ if ((params.chain?.length ?? 0) > 0) return "chain";
2659
+ if ((params.tasks?.length ?? 0) > 0) return "parallel";
2660
+ return "single";
2661
+ }
2662
+
2663
+ function duplicateSubagentCallResult(params: SubagentParamsLike): AgentToolResult<Details> {
2664
+ return {
2665
+ content: [{
2666
+ type: "text",
2667
+ text: "Rejected: a subagent call is already in progress. Issue exactly ONE subagent call per turn.",
2668
+ }],
2669
+ isError: true,
2670
+ details: { mode: inferExecutionMode(params), results: [] },
2671
+ };
2672
+ }
2673
+
2192
2674
  export function createSubagentExecutor(deps: ExecutorDeps): {
2193
2675
  execute: (
2194
2676
  id: string,
@@ -2225,7 +2707,9 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2225
2707
  let orchestratorTarget: string | undefined;
2226
2708
  try {
2227
2709
  orchestratorTarget = resolveIntercomSessionTarget(deps.pi.getSessionName(), ctx.sessionManager.getSessionId());
2228
- } catch {}
2710
+ } catch (error) {
2711
+ if (!sessionError) sessionError = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
2712
+ }
2229
2713
  return {
2230
2714
  content: [{
2231
2715
  type: "text",
@@ -2268,6 +2752,9 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2268
2752
  if (params.action === "resume") {
2269
2753
  return resumeAsyncRun({ params: paramsWithResolvedCwd, requestCwd, ctx, deps });
2270
2754
  }
2755
+ if (params.action === "append-step") {
2756
+ return appendStepToAsyncChain({ params: paramsWithResolvedCwd, requestCwd, ctx, deps });
2757
+ }
2271
2758
  if (params.action === "interrupt") {
2272
2759
  const targetRunId = paramsWithResolvedCwd.runId ?? paramsWithResolvedCwd.id;
2273
2760
  let resolved: ResolvedSubagentRunId | undefined;
@@ -2297,7 +2784,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2297
2784
  details: { mode: "management", results: [] },
2298
2785
  };
2299
2786
  }
2300
- const asyncInterruptResult = interruptAsyncRun(deps.state, resolved?.kind === "async" ? resolved.id : targetRunId);
2787
+ const asyncInterruptResult = interruptAsyncRun(deps.state, resolved?.kind === "async" ? resolved.id : targetRunId, deps.kill);
2301
2788
  if (asyncInterruptResult) return asyncInterruptResult;
2302
2789
  return {
2303
2790
  content: [{ type: "text", text: "No interrupt-capable run found in this session." }],
@@ -2319,7 +2806,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2319
2806
  details: { mode: "management" as const, results: [] },
2320
2807
  };
2321
2808
  }
2322
- return handleManagementAction(params.action, paramsWithResolvedCwd, { ...ctx, cwd: requestCwd });
2809
+ return handleManagementAction(params.action, paramsWithResolvedCwd, { ...ctx, cwd: requestCwd, config: deps.config });
2323
2810
  }
2324
2811
 
2325
2812
  const { blocked, depth, maxDepth } = checkSubagentDepth(deps.config.maxSubagentDepth);
@@ -2348,13 +2835,16 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2348
2835
  depth,
2349
2836
  deps.config.forceTopLevelAsync === true,
2350
2837
  );
2838
+ const foregroundTimeout = resolveForegroundTimeout(effectiveParams);
2839
+ if (foregroundTimeout.error) return buildRequestedModeError(effectiveParams, foregroundTimeout.error);
2351
2840
 
2352
2841
  const scope: AgentScope = resolveExecutionAgentScope(effectiveParams.agentScope);
2353
2842
  const effectiveCwd = effectiveParams.cwd ?? ctx.cwd;
2354
2843
  const parentSessionFile = ctx.sessionManager.getSessionFile() ?? null;
2355
2844
  deps.state.currentSessionId = resolveCurrentSessionId(ctx.sessionManager);
2356
2845
  const discoveredAgents = deps.discoverAgents(effectiveCwd, scope).agents;
2357
- effectiveParams = applyAgentDefaultContext(effectiveParams, discoveredAgents);
2846
+ const contextPolicy = resolveAgentDefaultContextPolicy(effectiveParams, discoveredAgents);
2847
+ effectiveParams = contextPolicy.params;
2358
2848
  const sessionName = resolveIntercomSessionTarget(deps.pi.getSessionName(), ctx.sessionManager.getSessionId());
2359
2849
  const intercomBridge = resolveIntercomBridge({
2360
2850
  config: deps.config.intercomBridge,
@@ -2388,15 +2878,18 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2388
2878
  );
2389
2879
  if (validationError) return validationError;
2390
2880
 
2391
- let sessionFileForIndex: (idx?: number) => string | undefined = () => undefined;
2881
+ let forkSessionFileForIndex: (idx?: number) => string | undefined = () => undefined;
2392
2882
  try {
2393
- sessionFileForIndex = createForkContextResolver(ctx.sessionManager, effectiveParams.context).sessionFileForIndex;
2883
+ forkSessionFileForIndex = createForkContextResolver(ctx.sessionManager, contextPolicy.usesFork ? "fork" : undefined).sessionFileForIndex;
2394
2884
  } catch (error) {
2395
2885
  return toExecutionErrorResult(effectiveParams, error);
2396
2886
  }
2397
2887
  const requestedAsync = effectiveParams.async ?? deps.asyncByDefault;
2398
2888
  const backgroundRequestedWhileClarifying = (hasChain || hasTasks) && requestedAsync && effectiveParams.clarify === true;
2399
2889
  const effectiveAsync = requestedAsync && effectiveParams.clarify !== true;
2890
+ if (foregroundTimeout.timeoutMs !== undefined && effectiveAsync) {
2891
+ return buildRequestedModeError(effectiveParams, "timeoutMs/maxRuntimeMs are only supported for foreground runs; set async: false or omit the timeout for background runs.");
2892
+ }
2400
2893
  const controlConfig = resolveControlConfig(deps.config.control, effectiveParams.control);
2401
2894
 
2402
2895
  const artifactConfig: ArtifactConfig = {
@@ -2425,8 +2918,19 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2425
2918
  }
2426
2919
  const sessionDirForIndex = (idx?: number) =>
2427
2920
  path.join(sessionRoot, `run-${idx ?? 0}`);
2921
+ const forkSessionFileForTask = (agentName: string, idx?: number) =>
2922
+ shouldForkAgent(contextPolicy, agentName) ? forkSessionFileForIndex(idx) : undefined;
2923
+ const childSessionFileForTask = (agentName: string, idx?: number) =>
2924
+ forkSessionFileForTask(agentName, idx) ?? path.join(sessionDirForIndex(idx), "session.jsonl");
2428
2925
  const childSessionFileForIndex = (idx?: number) =>
2429
- sessionFileForIndex(idx) ?? path.join(sessionDirForIndex(idx), "session.jsonl");
2926
+ path.join(sessionDirForIndex(idx), "session.jsonl");
2927
+ try {
2928
+ preflightForkSessionsForStaticTasks(effectiveParams, contextPolicy, forkSessionFileForTask);
2929
+ } catch (error) {
2930
+ return toExecutionErrorResult(effectiveParams, error);
2931
+ }
2932
+ const chainBindingsError = validateExecutionChainBindings(effectiveParams, deps.config.chain?.dynamicFanout?.maxItems);
2933
+ if (chainBindingsError) return chainBindingsError;
2430
2934
 
2431
2935
  const onUpdateWithContext = onUpdate
2432
2936
  ? (r: AgentToolResult<Details>) => onUpdate(withForkContext(r, effectiveParams.context))
@@ -2444,6 +2948,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2444
2948
  sessionRoot,
2445
2949
  sessionDirForIndex,
2446
2950
  sessionFileForIndex: childSessionFileForIndex,
2951
+ sessionFileForTask: childSessionFileForTask,
2447
2952
  artifactConfig,
2448
2953
  artifactsDir,
2449
2954
  backgroundRequestedWhileClarifying,
@@ -2451,6 +2956,8 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2451
2956
  controlConfig,
2452
2957
  intercomBridge,
2453
2958
  nestedRoute,
2959
+ timeoutMs: foregroundTimeout.timeoutMs,
2960
+ contextPolicy,
2454
2961
  };
2455
2962
 
2456
2963
  const foregroundMode: "single" | "parallel" | "chain" = hasChain ? "chain" : hasTasks ? "parallel" : "single";
@@ -2575,5 +3082,22 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2575
3082
  }, effectiveParams.context);
2576
3083
  };
2577
3084
 
2578
- return { execute };
3085
+ const executeWithSingleDispatchGuard = async (
3086
+ id: string,
3087
+ params: SubagentParamsLike,
3088
+ signal: AbortSignal,
3089
+ onUpdate: ((r: AgentToolResult<Details>) => void) | undefined,
3090
+ ctx: ExtensionContext,
3091
+ ): Promise<AgentToolResult<Details>> => {
3092
+ if (params.action) return execute(id, params, signal, onUpdate, ctx);
3093
+ if (deps.state.subagentInProgress === true) return duplicateSubagentCallResult(params);
3094
+ deps.state.subagentInProgress = true;
3095
+ try {
3096
+ return await execute(id, params, signal, onUpdate, ctx);
3097
+ } finally {
3098
+ deps.state.subagentInProgress = false;
3099
+ }
3100
+ };
3101
+
3102
+ return { execute: executeWithSingleDispatchGuard };
2579
3103
  }