pi-subagents 0.29.0 → 0.30.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.
@@ -31,7 +31,9 @@ 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";
35
37
  import { createForkContextResolver } from "../../shared/fork-context.ts";
36
38
  import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
37
39
  import { applyIntercomBridgeToAgent, INTERCOM_BRIDGE_MARKER, resolveIntercomBridge, resolveIntercomSessionTarget, resolveSubagentIntercomTarget, type IntercomBridgeState } from "../../intercom/intercom-bridge.ts";
@@ -47,7 +49,8 @@ import {
47
49
  resolveSubagentResultStatus,
48
50
  stripDetailsOutputsForIntercomReceipt,
49
51
  } from "../../intercom/result-intercom.ts";
50
- import { buildRevivedAsyncTask, resolveAsyncResumeTarget } from "../background/async-resume.ts";
52
+ import { buildRevivedAsyncTask, interruptLiveAsyncResumeTarget, resolveAsyncResumeTarget } from "../background/async-resume.ts";
53
+ import { resolveAsyncRootResultPath } from "../background/chain-root-attachment.ts";
51
54
  import { createNestedRoute, readNestedControlResults, resolveInheritedNestedRouteFromEnv, resolveNestedAsyncDir, resolveNestedParentAddressFromEnv, updateForegroundNestedProjection, writeNestedControlRequest, writeNestedEvent, type NestedRunResolutionScope } from "../shared/nested-events.ts";
52
55
  import { resolveSubagentRunId, type ResolvedSubagentRunId } from "../background/run-id-resolver.ts";
53
56
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
@@ -79,7 +82,9 @@ import {
79
82
  type SingleResult,
80
83
  type SubagentRunMode,
81
84
  type SubagentState,
85
+ ASYNC_DIR,
82
86
  DEFAULT_ARTIFACT_CONFIG,
87
+ RESULTS_DIR,
83
88
  SUBAGENT_ACTIONS,
84
89
  SUBAGENT_CONTROL_EVENT,
85
90
  SUBAGENT_CONTROL_INTERCOM_EVENT,
@@ -150,6 +155,7 @@ interface ExecutorDeps {
150
155
  expandTilde: (p: string) => string;
151
156
  discoverAgents: (cwd: string, scope: AgentScope) => { agents: AgentConfig[] };
152
157
  allowMutatingManagementActions?: boolean;
158
+ kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
153
159
  }
154
160
 
155
161
  interface ExecutionContextData {
@@ -317,7 +323,7 @@ function isExactResumeError(error: unknown, source: "async" | "foreground", requ
317
323
  return new RegExp(`\\b${source} run '${escapeRegExp(requested)}'`, "i").test(error.message);
318
324
  }
319
325
 
320
- function resolveResumeTarget(params: SubagentParamsLike, state: SubagentState): ResumeSourceTarget {
326
+ function resolveResumeTarget(params: SubagentParamsLike, state: SubagentState, options: { asyncRequireSessionFile?: boolean } = {}): ResumeSourceTarget {
321
327
  const requested = (params.id ?? params.runId)?.trim() ?? "";
322
328
  let foregroundTarget: ForegroundResumeSourceTarget | undefined;
323
329
  let foregroundError: unknown;
@@ -331,7 +337,7 @@ function resolveResumeTarget(params: SubagentParamsLike, state: SubagentState):
331
337
  foregroundError = error;
332
338
  }
333
339
  try {
334
- asyncTarget = { source: "async", ...resolveAsyncResumeTarget(params) };
340
+ asyncTarget = { source: "async", ...resolveAsyncResumeTarget(params, {}, { requireSessionFile: options.asyncRequireSessionFile }) };
335
341
  } catch (error) {
336
342
  asyncError = error;
337
343
  }
@@ -402,7 +408,7 @@ function emitControlNotification(input: {
402
408
  }
403
409
  }
404
410
 
405
- function interruptAsyncRun(state: SubagentState, runId: string | undefined): AgentToolResult<Details> | null {
411
+ function interruptAsyncRun(state: SubagentState, runId: string | undefined, kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean): AgentToolResult<Details> | null {
406
412
  const target = getAsyncInterruptTarget(state, runId);
407
413
  if (!target) return null;
408
414
  const status = readStatus(target.asyncDir);
@@ -414,7 +420,7 @@ function interruptAsyncRun(state: SubagentState, runId: string | undefined): Age
414
420
  };
415
421
  }
416
422
  try {
417
- process.kill(status.pid, ASYNC_INTERRUPT_SIGNAL);
423
+ (kill ?? process.kill)(status.pid, ASYNC_INTERRUPT_SIGNAL);
418
424
  const tracked = state.asyncJobs.get(target.asyncId);
419
425
  if (tracked) {
420
426
  tracked.activityState = undefined;
@@ -434,6 +440,186 @@ function interruptAsyncRun(state: SubagentState, runId: string | undefined): Age
434
440
  }
435
441
  }
436
442
 
443
+ function duplicateNames(names: string[]): string[] {
444
+ const seen = new Set<string>();
445
+ const duplicates = new Set<string>();
446
+ for (const name of names) {
447
+ if (seen.has(name)) duplicates.add(name);
448
+ else seen.add(name);
449
+ }
450
+ return [...duplicates];
451
+ }
452
+
453
+ function appendStepToAsyncChain(input: {
454
+ params: SubagentParamsLike;
455
+ requestCwd: string;
456
+ ctx: ExtensionContext;
457
+ deps: ExecutorDeps;
458
+ }): AgentToolResult<Details> {
459
+ const targetRunId = input.params.id ?? input.params.runId;
460
+ if (!targetRunId) {
461
+ return {
462
+ content: [{ type: "text", text: "action='append-step' requires id." }],
463
+ isError: true,
464
+ details: { mode: "management", results: [] },
465
+ };
466
+ }
467
+ if (!input.params.chain || input.params.chain.length !== 1) {
468
+ return {
469
+ content: [{ type: "text", text: "action='append-step' requires chain with exactly one step." }],
470
+ isError: true,
471
+ details: { mode: "management", results: [] },
472
+ };
473
+ }
474
+
475
+ let resolved: ResolvedSubagentRunId | undefined;
476
+ try {
477
+ resolved = resolveSubagentRunId(targetRunId, { state: input.deps.state, nested: nestedResolutionScopeForExecutor(input.deps) });
478
+ } catch (error) {
479
+ const message = error instanceof Error ? error.message : String(error);
480
+ return { content: [{ type: "text", text: message }], isError: true, details: { mode: "management", results: [] } };
481
+ }
482
+ if (!resolved) {
483
+ return {
484
+ content: [{ type: "text", text: `No async chain run found for '${targetRunId}'.` }],
485
+ isError: true,
486
+ details: { mode: "management", results: [] },
487
+ };
488
+ }
489
+ if (resolved.kind !== "async" || !resolved.location.asyncDir) {
490
+ return {
491
+ content: [{ type: "text", text: `Run '${resolved.id}' is not an append-capable async chain run.` }],
492
+ isError: true,
493
+ details: { mode: "management", results: [] },
494
+ };
495
+ }
496
+
497
+ const status = readStatus(resolved.location.asyncDir);
498
+ if (!status) {
499
+ return {
500
+ content: [{ type: "text", text: `No async run status found for '${resolved.id}'.` }],
501
+ isError: true,
502
+ details: { mode: "management", results: [] },
503
+ };
504
+ }
505
+ if (status.mode !== "chain") {
506
+ return {
507
+ content: [{ type: "text", text: `Run '${resolved.id}' is ${status.mode}; only active chain runs accept appended steps.` }],
508
+ isError: true,
509
+ details: { mode: "management", results: [] },
510
+ };
511
+ }
512
+ if (status.state !== "running") {
513
+ return {
514
+ content: [{ type: "text", text: `Run '${resolved.id}' is ${status.state}; only running chain runs accept appended steps.` }],
515
+ isError: true,
516
+ details: { mode: "management", results: [] },
517
+ };
518
+ }
519
+ const stillInProgress = (status.steps ?? []).some((step) => step.status === "running" || step.status === "pending") || (status.pendingAppends ?? 0) > 0;
520
+ if (!stillInProgress) {
521
+ return {
522
+ content: [{ type: "text", text: `Run '${resolved.id}' has no running or pending chain steps left; append-step must target an in-progress chain.` }],
523
+ isError: true,
524
+ details: { mode: "management", results: [] },
525
+ };
526
+ }
527
+
528
+ const pendingAppendRequests = readPendingChainAppendRequests(resolved.location.asyncDir);
529
+ const reservedOutputNames = new Set<string>([
530
+ ...Object.keys(status.outputs ?? {}),
531
+ ...(status.steps ?? []).map((step) => step.outputName).filter((name): name is string => Boolean(name)),
532
+ ...pendingAppendRequests.flatMap((request) => runnerStepOutputNames(request.steps)),
533
+ ]);
534
+ try {
535
+ validateChainOutputBindingsWithContext(input.params.chain, { maxItems: input.deps.config.chain?.dynamicFanout?.maxItems }, {
536
+ priorOutputNames: reservedOutputNames,
537
+ startStepIndex: status.chainStepCount ?? status.steps?.length ?? 0,
538
+ });
539
+ } catch (error) {
540
+ if (!(error instanceof ChainOutputValidationError)) throw error;
541
+ return {
542
+ content: [{ type: "text", text: `Cannot append step to run '${resolved.id}': ${error.message}` }],
543
+ isError: true,
544
+ details: { mode: "management", results: [] },
545
+ };
546
+ }
547
+
548
+ const scope: AgentScope = resolveExecutionAgentScope(input.params.agentScope);
549
+ const agents = input.deps.discoverAgents(input.requestCwd, scope).agents;
550
+ const chainSkillInput = normalizeSkillInput(input.params.skill);
551
+ const chainSkills = chainSkillInput === false ? [] : (chainSkillInput ?? []);
552
+ const asyncCtx = {
553
+ pi: input.deps.pi,
554
+ cwd: input.ctx.cwd,
555
+ currentSessionId: resolveCurrentSessionId(input.ctx.sessionManager),
556
+ currentModelProvider: input.ctx.model?.provider,
557
+ currentModel: input.ctx.model,
558
+ };
559
+ const built = buildAsyncRunnerSteps(resolved.id, {
560
+ chain: wrapChainTasksForFork(input.params.chain, input.params.context),
561
+ task: input.params.task,
562
+ resultMode: "chain",
563
+ agents,
564
+ ctx: asyncCtx,
565
+ availableModels: input.ctx.modelRegistry.getAvailable().map(toModelInfo),
566
+ cwd: status.cwd ?? input.requestCwd,
567
+ chainSkills,
568
+ dynamicFanoutMaxItems: input.deps.config.chain?.dynamicFanout?.maxItems,
569
+ maxSubagentDepth: resolveCurrentMaxSubagentDepth(input.deps.config.maxSubagentDepth),
570
+ asyncDir: resolved.location.asyncDir,
571
+ validateOutputBindings: false,
572
+ });
573
+ if ("error" in built) {
574
+ return {
575
+ content: [{ type: "text", text: built.error }],
576
+ isError: true,
577
+ details: { mode: "management", results: [] },
578
+ };
579
+ }
580
+ const appendedOutputNames = runnerStepOutputNames(built.steps);
581
+ const duplicateAppendedOutputs = duplicateNames(appendedOutputNames);
582
+ if (duplicateAppendedOutputs.length > 0) {
583
+ return {
584
+ content: [{ type: "text", text: `Cannot append step to run '${resolved.id}': duplicate output name in appended step: ${duplicateAppendedOutputs.join(", ")}.` }],
585
+ isError: true,
586
+ details: { mode: "management", results: [] },
587
+ };
588
+ }
589
+ const pendingOutputNames = new Set(pendingAppendRequests.flatMap((request) => runnerStepOutputNames(request.steps)));
590
+ const pendingDuplicateOutputs = appendedOutputNames.filter((name) => pendingOutputNames.has(name));
591
+ if (pendingDuplicateOutputs.length > 0) {
592
+ return {
593
+ content: [{ type: "text", text: `Cannot append step to run '${resolved.id}': output name already belongs to a pending append: ${pendingDuplicateOutputs.join(", ")}.` }],
594
+ isError: true,
595
+ details: { mode: "management", results: [] },
596
+ };
597
+ }
598
+
599
+ try {
600
+ const result = enqueueChainAppendRequest({
601
+ asyncDir: resolved.location.asyncDir,
602
+ runId: resolved.id,
603
+ steps: built.steps,
604
+ });
605
+ const stepText = built.steps.length === 1 ? "step" : "steps";
606
+ return {
607
+ content: [{
608
+ type: "text",
609
+ 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}.`,
610
+ }],
611
+ details: { mode: "management", results: [], asyncId: resolved.id, asyncDir: resolved.location.asyncDir },
612
+ };
613
+ } catch (error) {
614
+ const message = error instanceof Error ? error.message : String(error);
615
+ return {
616
+ content: [{ type: "text", text: `Failed to append step to chain run ${resolved.id}: ${message}` }],
617
+ isError: true,
618
+ details: { mode: "management", results: [] },
619
+ };
620
+ }
621
+ }
622
+
437
623
  function nestedRunSessionFile(run: NestedRunSummary): string | undefined {
438
624
  return run.sessionFile ?? (run.steps?.length === 1 ? run.steps[0]?.sessionFile : undefined);
439
625
  }
@@ -554,7 +740,8 @@ async function resumeAsyncRun(input: {
554
740
  deps: ExecutorDeps;
555
741
  }): Promise<AgentToolResult<Details>> {
556
742
  const followUp = (input.params.message ?? input.params.task ?? "").trim();
557
- if (!followUp) {
743
+ const attachChain = (input.params.chain?.length ?? 0) > 0 ? input.params.chain as ChainStep[] : undefined;
744
+ if (!followUp && !attachChain) {
558
745
  return {
559
746
  content: [{ type: "text", text: "action='resume' requires message." }],
560
747
  isError: true,
@@ -568,6 +755,13 @@ async function resumeAsyncRun(input: {
568
755
  const requestedId = input.params.id ?? input.params.runId;
569
756
  const resolved = requestedId ? resolveSubagentRunId(requestedId, { state: input.deps.state, nested: nestedResolutionScopeForExecutor(input.deps) }) : undefined;
570
757
  if (resolved?.kind === "nested") {
758
+ if (attachChain) {
759
+ return {
760
+ content: [{ type: "text", text: "Attaching a running subagent as a chain root is currently available for top-level async runs only." }],
761
+ isError: true,
762
+ details: { mode: "management", results: [] },
763
+ };
764
+ }
571
765
  if (resolved.match.run.state === "running" || resolved.match.run.state === "queued") {
572
766
  return resumeLiveNestedRun({ target: resolved, message: followUp });
573
767
  }
@@ -577,14 +771,26 @@ async function resumeAsyncRun(input: {
577
771
  ];
578
772
  target = resolveNestedResumeTarget(resolved, trustedSessionRoots);
579
773
  } else {
580
- target = resolveResumeTarget(input.params, input.deps.state);
774
+ target = resolveResumeTarget(input.params, input.deps.state, { asyncRequireSessionFile: !attachChain });
581
775
  }
582
776
  } catch (error) {
583
777
  const message = error instanceof Error ? error.message : String(error);
584
778
  return { content: [{ type: "text", text: message }], isError: true, details: { mode: "management", results: [] } };
585
779
  }
586
780
 
587
- if (target.kind === "live") {
781
+ if (target.kind === "live" && !attachChain) {
782
+ const interrupt = interruptLiveAsyncResumeTarget({
783
+ target,
784
+ state: input.deps.state,
785
+ kill: input.deps.kill,
786
+ });
787
+ if (!interrupt.ok) {
788
+ return {
789
+ content: [{ type: "text", text: interrupt.message }],
790
+ isError: true,
791
+ details: { mode: "management", results: [] },
792
+ };
793
+ }
588
794
  const delivered = await deliverSubagentIntercomMessageEvent(
589
795
  input.deps.pi.events,
590
796
  target.intercomTarget,
@@ -594,7 +800,7 @@ async function resumeAsyncRun(input: {
594
800
  );
595
801
  if (delivered) {
596
802
  return {
597
- content: [{ type: "text", text: [`Delivered follow-up to live async child.`, `Run: ${target.runId}`, `Intercom target: ${target.intercomTarget}`].join("\n") }],
803
+ content: [{ type: "text", text: [`Interrupted live async child, then delivered follow-up.`, `Run: ${target.runId}`, `Intercom target: ${target.intercomTarget}`].join("\n") }],
598
804
  details: { mode: "management", results: [] },
599
805
  };
600
806
  }
@@ -637,6 +843,73 @@ async function resumeAsyncRun(input: {
637
843
  };
638
844
  }
639
845
 
846
+ if (attachChain) {
847
+ if (target.source !== "async") {
848
+ return {
849
+ content: [{ type: "text", text: "Attaching a running subagent as a chain root is currently available for async runs only." }],
850
+ isError: true,
851
+ details: { mode: "management", results: [] },
852
+ };
853
+ }
854
+ if (!isAsyncAvailable()) {
855
+ return {
856
+ 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." }],
857
+ isError: true,
858
+ details: { mode: "chain", results: [] },
859
+ };
860
+ }
861
+ const runId = randomUUID().slice(0, 8);
862
+ const artifactConfig: ArtifactConfig = { ...DEFAULT_ARTIFACT_CONFIG, enabled: input.params.artifacts !== false };
863
+ const availableModels = input.ctx.modelRegistry.getAvailable().map(toModelInfo);
864
+ const chain = wrapChainTasksForFork(attachChain, input.params.context);
865
+ const normalized = normalizeSkillInput(input.params.skill);
866
+ const result = executeAsyncChain(runId, {
867
+ chain,
868
+ task: (input.params.task ?? followUp) || undefined,
869
+ attachRoot: {
870
+ runId: target.runId,
871
+ asyncDir: target.asyncDir ?? path.join(ASYNC_DIR, target.runId),
872
+ resultPath: resolveAsyncRootResultPath(RESULTS_DIR, target.runId),
873
+ index: target.index,
874
+ agent: target.agent,
875
+ label: `Attached ${target.runId}`,
876
+ },
877
+ agents,
878
+ ctx: {
879
+ pi: input.deps.pi,
880
+ cwd: input.requestCwd,
881
+ currentSessionId: input.deps.state.currentSessionId,
882
+ currentModelProvider: input.ctx.model?.provider,
883
+ currentModel: input.ctx.model,
884
+ },
885
+ availableModels,
886
+ cwd: effectiveCwd,
887
+ maxOutput: input.params.maxOutput,
888
+ artifactsDir: input.deps.tempArtifactsDir,
889
+ artifactConfig,
890
+ shareEnabled: input.params.share === true,
891
+ sessionRoot: input.deps.getSubagentSessionRoot(parentSessionFile),
892
+ chainSkills: normalized === false ? [] : (normalized ?? []),
893
+ dynamicFanoutMaxItems: input.deps.config.chain?.dynamicFanout?.maxItems,
894
+ maxSubagentDepth: resolveCurrentMaxSubagentDepth(input.deps.config.maxSubagentDepth),
895
+ worktreeSetupHook: input.deps.config.worktreeSetupHook,
896
+ worktreeSetupHookTimeoutMs: input.deps.config.worktreeSetupHookTimeoutMs,
897
+ controlConfig: resolveControlConfig(input.deps.config.control, input.params.control),
898
+ controlIntercomTarget: intercomBridge.active ? intercomBridge.orchestratorTarget : undefined,
899
+ childIntercomTarget: intercomBridge.active ? (agent, index) => resolveSubagentIntercomTarget(runId, agent, index) : undefined,
900
+ });
901
+ if (result.isError) return result;
902
+ const attachedId = result.details.asyncId ?? runId;
903
+ const lines = [
904
+ `Attached async subagent ${target.runId} as the first step of a new chain.`,
905
+ `Chain run: ${attachedId}`,
906
+ `Root: ${target.agent} (step ${target.index + 1})`,
907
+ result.details.asyncDir ? `Async dir: ${result.details.asyncDir}` : undefined,
908
+ `Status if needed: subagent({ action: "status", id: "${attachedId}" })`,
909
+ ].filter((line): line is string => Boolean(line));
910
+ return { content: [{ type: "text", text: formatAsyncStartedMessage(lines.join("\n")) }], details: result.details };
911
+ }
912
+
640
913
  const runId = randomUUID().slice(0, 8);
641
914
  const artifactConfig: ArtifactConfig = { ...DEFAULT_ARTIFACT_CONFIG, enabled: input.params.artifacts !== false };
642
915
  const availableModels = input.ctx.modelRegistry.getAvailable().map(toModelInfo);
@@ -2268,6 +2541,9 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2268
2541
  if (params.action === "resume") {
2269
2542
  return resumeAsyncRun({ params: paramsWithResolvedCwd, requestCwd, ctx, deps });
2270
2543
  }
2544
+ if (params.action === "append-step") {
2545
+ return appendStepToAsyncChain({ params: paramsWithResolvedCwd, requestCwd, ctx, deps });
2546
+ }
2271
2547
  if (params.action === "interrupt") {
2272
2548
  const targetRunId = paramsWithResolvedCwd.runId ?? paramsWithResolvedCwd.id;
2273
2549
  let resolved: ResolvedSubagentRunId | undefined;
@@ -2297,7 +2573,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2297
2573
  details: { mode: "management", results: [] },
2298
2574
  };
2299
2575
  }
2300
- const asyncInterruptResult = interruptAsyncRun(deps.state, resolved?.kind === "async" ? resolved.id : targetRunId);
2576
+ const asyncInterruptResult = interruptAsyncRun(deps.state, resolved?.kind === "async" ? resolved.id : targetRunId, deps.kill);
2301
2577
  if (asyncInterruptResult) return asyncInterruptResult;
2302
2578
  return {
2303
2579
  content: [{ type: "text", text: "No interrupt-capable run found in this session." }],
@@ -2319,7 +2595,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
2319
2595
  details: { mode: "management" as const, results: [] },
2320
2596
  };
2321
2597
  }
2322
- return handleManagementAction(params.action, paramsWithResolvedCwd, { ...ctx, cwd: requestCwd });
2598
+ return handleManagementAction(params.action, paramsWithResolvedCwd, { ...ctx, cwd: requestCwd, config: deps.config });
2323
2599
  }
2324
2600
 
2325
2601
  const { blocked, depth, maxDepth } = checkSubagentDepth(deps.config.maxSubagentDepth);
@@ -8,6 +8,11 @@ const SAFE_OUTPUT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
8
8
 
9
9
  export class ChainOutputValidationError extends Error {}
10
10
 
11
+ export interface ChainOutputValidationContext {
12
+ priorOutputNames?: Iterable<string>;
13
+ startStepIndex?: number;
14
+ }
15
+
11
16
  function outputNamesForStep(step: ChainStep): string[] {
12
17
  if (isParallelStep(step)) return step.parallel.map((task) => task.as).filter((name): name is string => Boolean(name));
13
18
  if (isDynamicParallelStep(step)) return [step.collect.as];
@@ -22,27 +27,37 @@ function taskTemplatesForStep(step: ChainStep): string[] {
22
27
  }
23
28
 
24
29
  export function validateChainOutputBindings(steps: ChainStep[], dynamicFanoutConfig: DynamicFanoutConfig = {}): void {
25
- const available = new Set<string>();
26
- const seen = new Set<string>();
30
+ validateChainOutputBindingsWithContext(steps, dynamicFanoutConfig);
31
+ }
32
+
33
+ export function validateChainOutputBindingsWithContext(
34
+ steps: ChainStep[],
35
+ dynamicFanoutConfig: DynamicFanoutConfig = {},
36
+ context: ChainOutputValidationContext = {},
37
+ ): void {
38
+ const priorOutputNames = [...(context.priorOutputNames ?? [])];
39
+ const available = new Set<string>(priorOutputNames);
40
+ const seen = new Set<string>(priorOutputNames);
27
41
  for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
42
+ const displayStepIndex = (context.startStepIndex ?? 0) + stepIndex + 1;
28
43
  const step = steps[stepIndex]!;
29
44
  if (hasDynamicFanoutFields(step)) {
30
45
  if (!isDynamicParallelStep(step)) {
31
- throw new ChainOutputValidationError(`Dynamic chain step ${stepIndex + 1} requires expand, a single parallel template object, and collect; dynamic expand/collect cannot be mixed with static parallel arrays.`);
46
+ throw new ChainOutputValidationError(`Dynamic chain step ${displayStepIndex} requires expand, a single parallel template object, and collect; dynamic expand/collect cannot be mixed with static parallel arrays.`);
32
47
  }
33
48
  try {
34
- validateDynamicStepShape(step, stepIndex, dynamicFanoutConfig);
49
+ validateDynamicStepShape(step, displayStepIndex - 1, dynamicFanoutConfig);
35
50
  } catch (error) {
36
51
  if (error instanceof DynamicFanoutError) throw new ChainOutputValidationError(error.message);
37
52
  throw error;
38
53
  }
39
54
  if (!available.has(step.expand.from.output)) {
40
- throw new ChainOutputValidationError(`Dynamic chain step ${stepIndex + 1} references unknown output '${step.expand.from.output}'. Named outputs are only available after producing step/group completes.`);
55
+ throw new ChainOutputValidationError(`Dynamic chain step ${displayStepIndex} references unknown output '${step.expand.from.output}'. Named outputs are only available after producing step/group completes.`);
41
56
  }
42
57
  }
43
58
  for (const name of outputNamesForStep(step)) {
44
59
  if (!SAFE_OUTPUT_NAME_PATTERN.test(name)) {
45
- throw new ChainOutputValidationError(`Invalid chain output name '${name}' at step ${stepIndex + 1}. Use /^[A-Za-z_][A-Za-z0-9_]*$/.`);
60
+ throw new ChainOutputValidationError(`Invalid chain output name '${name}' at step ${displayStepIndex}. Use /^[A-Za-z_][A-Za-z0-9_]*$/.`);
46
61
  }
47
62
  if (seen.has(name)) {
48
63
  throw new ChainOutputValidationError(`Duplicate chain output name '${name}'. Each as name must be unique.`);
@@ -54,10 +69,10 @@ export function validateChainOutputBindings(steps: ChainStep[], dynamicFanoutCon
54
69
  const rawReference = match[0];
55
70
  const name = match[1]!;
56
71
  if (!SAFE_OUTPUT_NAME_PATTERN.test(name)) {
57
- throw new ChainOutputValidationError(`Invalid chain output reference '${rawReference}' at step ${stepIndex + 1}. Use {outputs.name} with /^[A-Za-z_][A-Za-z0-9_]*$/ names.`);
72
+ throw new ChainOutputValidationError(`Invalid chain output reference '${rawReference}' at step ${displayStepIndex}. Use {outputs.name} with /^[A-Za-z_][A-Za-z0-9_]*$/ names.`);
58
73
  }
59
74
  if (!available.has(name)) {
60
- throw new ChainOutputValidationError(`Unknown chain output reference '${rawReference}' at step ${stepIndex + 1}. Named outputs are only available after producing step/group completes.`);
75
+ throw new ChainOutputValidationError(`Unknown chain output reference '${rawReference}' at step ${displayStepIndex}. Named outputs are only available after producing step/group completes.`);
61
76
  }
62
77
  }
63
78
  }
@@ -48,7 +48,7 @@ const DYNAMIC_PARALLEL_KEYS = new Set(["agent", "task", "phase", "label", "outpu
48
48
  const RUNNER_DYNAMIC_PARALLEL_KEYS = new Set([
49
49
  ...DYNAMIC_PARALLEL_KEYS,
50
50
  "outputName", "structured", "inheritProjectContext", "inheritSkills", "skills", "outputPath", "maxSubagentDepth",
51
- "structuredOutput", "structuredOutputSchema", "tools", "extensions", "mcpDirectTools", "completionGuard", "systemPrompt",
51
+ "structuredOutput", "structuredOutputSchema", "tools", "extensions", "subagentOnlyExtensions", "mcpDirectTools", "completionGuard", "systemPrompt",
52
52
  "systemPromptMode", "thinking", "modelCandidates", "sessionFile", "effectiveAcceptance",
53
53
  ]);
54
54
  const DYNAMIC_COLLECT_KEYS = new Set(["as", "outputSchema"]);
@@ -1,6 +1,12 @@
1
1
  export interface RunnerSubagentStep {
2
2
  agent: string;
3
3
  task: string;
4
+ importAsyncRoot?: {
5
+ runId: string;
6
+ asyncDir: string;
7
+ resultPath: string;
8
+ index: number;
9
+ };
4
10
  phase?: string;
5
11
  label?: string;
6
12
  outputName?: string;
@@ -11,6 +17,7 @@ export interface RunnerSubagentStep {
11
17
  modelCandidates?: string[];
12
18
  tools?: string[];
13
19
  extensions?: string[];
20
+ subagentOnlyExtensions?: string[];
14
21
  mcpDirectTools?: string[];
15
22
  completionGuard?: boolean;
16
23
  systemPrompt?: string | null;
@@ -39,6 +39,7 @@ interface BuildPiArgsInput {
39
39
  inheritSkills: boolean;
40
40
  tools?: string[];
41
41
  extensions?: string[];
42
+ subagentOnlyExtensions?: string[];
42
43
  systemPrompt?: string | null;
43
44
  mcpDirectTools?: string[];
44
45
  cwd?: string;
@@ -120,11 +121,11 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
120
121
  : [PROMPT_RUNTIME_EXTENSION_PATH];
121
122
  if (input.extensions !== undefined) {
122
123
  args.push("--no-extensions");
123
- for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...input.extensions])]) {
124
+ for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...input.extensions, ...(input.subagentOnlyExtensions ?? [])])]) {
124
125
  args.push("--extension", extPath);
125
126
  }
126
127
  } else {
127
- for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths])]) {
128
+ for (const extPath of [...new Set([...runtimeExtensions, ...toolExtensionPaths, ...(input.subagentOnlyExtensions ?? [])])]) {
128
129
  args.push("--extension", extPath);
129
130
  }
130
131
  }
@@ -173,9 +173,8 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
173
173
  ].filter((line): line is string => Boolean(line)).join("\n");
174
174
  }
175
175
 
176
- const nudgeCommand = childIntercomTarget
177
- ? `intercom({ action: "send", to: "${childIntercomTarget}", message: "What are you blocked on? Reply with the smallest next step or ask for a decision." })`
178
- : undefined;
176
+ const nudgeMessage = "What are you blocked on? Reply with the smallest next step or ask for a decision.";
177
+ const nudgeCommand = `subagent({ action: "resume", id: "${runTarget}", ${event.index !== undefined ? `index: ${event.index}, ` : ""}message: "${nudgeMessage}" })`;
179
178
  if (event.type === "active_long_running") {
180
179
  const facts = formatLongRunningFacts(event);
181
180
  return [
@@ -183,10 +182,9 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
183
182
  `Run: ${runTarget}${event.index !== undefined ? ` step ${event.index + 1}` : ""}`,
184
183
  `Signal: ${event.message}`,
185
184
  facts ? `Facts: ${facts}` : undefined,
186
- "Hint: Inspect status, then nudge if the work seems stuck.",
187
- childIntercomTarget
188
- ? `Nudge: ${nudgeCommand}`
189
- : "Nudge: no child message route registered",
185
+ "Hint: Inspect status, then nudge if the work seems stuck. Live async nudges interrupt the child before sending the follow-up.",
186
+ `Nudge: ${nudgeCommand}`,
187
+ childIntercomTarget ? `Direct intercom target: ${childIntercomTarget}` : undefined,
190
188
  `Status: subagent({ action: "status", id: "${runTarget}" })`,
191
189
  `Interrupt: subagent({ action: "interrupt", id: "${runTarget}" })`,
192
190
  ].filter((line): line is string => Boolean(line)).join("\n");
@@ -197,10 +195,9 @@ export function formatControlNoticeMessage(event: ControlEvent, childIntercomTar
197
195
  `Run: ${runTarget}${event.index !== undefined ? ` step ${event.index + 1}` : ""}`,
198
196
  `Signal: ${event.message}`,
199
197
  event.recentFailureSummary ? `Recent failures: ${event.recentFailureSummary}` : undefined,
200
- "Hint: Inspect status first unless the run is clearly blocked.",
201
- childIntercomTarget
202
- ? `Nudge: ${nudgeCommand}`
203
- : "Nudge: no child message route registered",
198
+ "Hint: Inspect status first unless the run is clearly blocked. Live async nudges interrupt the child before sending the follow-up.",
199
+ `Nudge: ${nudgeCommand}`,
200
+ childIntercomTarget ? `Direct intercom target: ${childIntercomTarget}` : undefined,
204
201
  `Status: subagent({ action: "status", id: "${runTarget}" })`,
205
202
  `Interrupt: subagent({ action: "interrupt", id: "${runTarget}" })`,
206
203
  ].filter((line): line is string => Boolean(line)).join("\n");
@@ -579,6 +579,7 @@ export interface AsyncStatus {
579
579
  cwd?: string;
580
580
  currentStep?: number;
581
581
  chainStepCount?: number;
582
+ pendingAppends?: number;
582
583
  parallelGroups?: AsyncParallelGroupStatus[];
583
584
  workflowGraph?: WorkflowGraphSnapshot;
584
585
  steps?: Array<{
@@ -815,6 +816,13 @@ interface ExtensionChainConfig {
815
816
  };
816
817
  }
817
818
 
819
+ export interface ProactiveSkillSubagentsConfig {
820
+ enabled?: boolean;
821
+ minReferences?: number;
822
+ maxRecommendations?: number;
823
+ preferredAgent?: string;
824
+ }
825
+
818
826
  export interface ExtensionConfig {
819
827
  asyncByDefault?: boolean;
820
828
  forceTopLevelAsync?: boolean;
@@ -826,6 +834,7 @@ export interface ExtensionConfig {
826
834
  worktreeSetupHook?: string;
827
835
  worktreeSetupHookTimeoutMs?: number;
828
836
  intercomBridge?: IntercomBridgeConfig;
837
+ proactiveSkillSubagents?: ProactiveSkillSubagentsConfig | false;
829
838
  }
830
839
 
831
840
  // ============================================================================
@@ -916,7 +925,7 @@ export const SLASH_SUBAGENT_CANCEL_EVENT = "subagent:slash:cancel";
916
925
  export const POLL_INTERVAL_MS = 250;
917
926
  export const MAX_WIDGET_JOBS = 4;
918
927
  export const DEFAULT_SUBAGENT_MAX_DEPTH = 2;
919
- export const SUBAGENT_ACTIONS = ["list", "get", "create", "update", "delete", "status", "interrupt", "resume", "doctor"] as const;
928
+ export const SUBAGENT_ACTIONS = ["list", "get", "create", "update", "delete", "status", "interrupt", "resume", "append-step", "doctor"] as const;
920
929
 
921
930
  export const DEFAULT_FORK_PREAMBLE =
922
931
  "You are a delegated subagent running from a fork of the parent session. " +
@@ -13,6 +13,8 @@ import {
13
13
  interface SlashSubagentRequest {
14
14
  requestId: string;
15
15
  params: SubagentParamsLike;
16
+ /** Optional requester context for in-process extension bridge calls. */
17
+ ctx?: ExtensionContext;
16
18
  }
17
19
 
18
20
  export interface SlashSubagentResponse {
@@ -77,7 +79,7 @@ export function registerSlashSubagentBridge(options: SlashBridgeOptions): {
77
79
  if (typeof request.requestId !== "string" || !request.params) return;
78
80
  const { requestId, params } = request as SlashSubagentRequest;
79
81
 
80
- const ctx = options.getContext();
82
+ const ctx = request.ctx ?? options.getContext();
81
83
  if (!ctx) {
82
84
  const response: SlashSubagentResponse = {
83
85
  requestId,
@@ -245,7 +245,7 @@ async function requestSlashRun(
245
245
  next();
246
246
  };
247
247
 
248
- pi.events.emit(SLASH_SUBAGENT_REQUEST_EVENT, { requestId, params });
248
+ pi.events.emit(SLASH_SUBAGENT_REQUEST_EVENT, { requestId, params, ctx });
249
249
 
250
250
  // Bridge emits STARTED synchronously during REQUEST emit.
251
251
  // If not started, no bridge received the request.