pi-subagents 0.35.0 → 0.36.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 (76) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +132 -24
  3. package/agents/advisor.md +73 -0
  4. package/package.json +8 -12
  5. package/skills/pi-subagents/SKILL.md +22 -9
  6. package/src/agents/agents.ts +22 -5
  7. package/src/api/delegation.ts +125 -0
  8. package/src/extension/config.ts +7 -1
  9. package/src/extension/index.ts +50 -38
  10. package/src/extension/rpc.ts +27 -2
  11. package/src/extension/schemas.ts +22 -2
  12. package/src/extension/tool-description.ts +2 -2
  13. package/src/intercom/intercom-bridge.ts +1 -1
  14. package/src/intercom/native-supervisor-channel.ts +45 -6
  15. package/src/intercom/result-intercom.ts +7 -0
  16. package/src/runs/background/async-execution.ts +34 -4
  17. package/src/runs/background/async-job-tracker.ts +4 -0
  18. package/src/runs/background/async-resume.ts +27 -5
  19. package/src/runs/background/async-status.ts +76 -3
  20. package/src/runs/background/chain-append.ts +2 -0
  21. package/src/runs/background/completion-batcher.ts +6 -4
  22. package/src/runs/background/completion-dedupe.ts +2 -11
  23. package/src/runs/background/fleet-view.ts +9 -4
  24. package/src/runs/background/notify.ts +132 -120
  25. package/src/runs/background/result-watcher.ts +138 -78
  26. package/src/runs/background/run-status.ts +3 -1
  27. package/src/runs/background/subagent-runner.ts +225 -43
  28. package/src/runs/background/subagent-wait.ts +130 -4
  29. package/src/runs/background/wait-tool.ts +2 -2
  30. package/src/runs/foreground/chain-execution.ts +176 -111
  31. package/src/runs/foreground/execution.ts +90 -36
  32. package/src/runs/foreground/foreground-control.ts +90 -0
  33. package/src/runs/foreground/subagent-executor.ts +394 -163
  34. package/src/runs/shared/acceptance.ts +55 -13
  35. package/src/runs/shared/agent-contract.ts +38 -0
  36. package/src/runs/shared/child-protocol.ts +1 -1
  37. package/src/runs/shared/completion-guard.ts +36 -5
  38. package/src/runs/shared/context-mode.ts +44 -0
  39. package/src/runs/shared/dynamic-fanout.ts +4 -4
  40. package/src/runs/shared/long-running-guard.ts +4 -0
  41. package/src/runs/shared/nested-events.ts +27 -2
  42. package/src/runs/shared/parallel-handoff.ts +154 -0
  43. package/src/runs/shared/parallel-utils.ts +6 -0
  44. package/src/runs/shared/pi-args.ts +23 -14
  45. package/src/runs/shared/run-history.ts +90 -5
  46. package/src/runs/shared/structured-output.ts +112 -7
  47. package/src/runs/shared/subagent-control.ts +4 -0
  48. package/src/runs/shared/subagent-prompt-runtime.ts +17 -18
  49. package/src/runs/shared/task-intent.ts +10 -5
  50. package/src/runs/shared/tool-availability.ts +3 -1
  51. package/src/runs/shared/tool-budget.ts +11 -5
  52. package/src/runs/shared/turn-budget.ts +2 -1
  53. package/src/runs/shared/worktree.ts +63 -14
  54. package/src/shared/accessible-dir.ts +25 -0
  55. package/src/shared/artifacts.ts +37 -7
  56. package/src/shared/atomic-json.ts +14 -42
  57. package/src/shared/child-transcript.ts +52 -0
  58. package/src/shared/file-system-retry.ts +47 -0
  59. package/src/shared/settings.ts +9 -1
  60. package/src/shared/types.ts +211 -22
  61. package/src/slash/delegation-adapters.ts +152 -5
  62. package/src/slash/delegation-json.ts +108 -0
  63. package/src/slash/delegation-request.ts +182 -36
  64. package/src/slash/prompt-template-bridge.ts +222 -37
  65. package/src/slash/selector.ts +147 -0
  66. package/src/slash/slash-commands.ts +14 -5
  67. package/src/slash/slash-live-state.ts +2 -2
  68. package/src/slash/subagents-admin.ts +42 -42
  69. package/src/tui/fleet-status.ts +362 -0
  70. package/src/tui/fleet-transcript.ts +472 -0
  71. package/src/tui/fleet.ts +318 -59
  72. package/src/tui/render.ts +25 -15
  73. package/src/watchdog/change-signature.ts +105 -12
  74. package/src/watchdog/review.ts +7 -2
  75. package/src/watchdog/runtime.ts +5 -3
  76. package/src/slash/subagents-editor.ts +0 -86
@@ -447,6 +447,43 @@ function requestMatchesContext(request: SupervisorRequest, state: Pick<SubagentS
447
447
  return Boolean(currentSessionId && request.orchestratorSessionId === currentSessionId);
448
448
  }
449
449
 
450
+ function rememberedForegroundChild(request: SupervisorRequest, state: SubagentState) {
451
+ const run = state.foregroundRuns?.get(request.runId);
452
+ const child = run?.children.find((candidate) => candidate.index === request.childIndex && candidate.agent === request.agent)
453
+ ?? run?.children[request.childIndex];
454
+ return run && child ? { run, child } : undefined;
455
+ }
456
+
457
+ function markForegroundSupervisorAttention(request: SupervisorRequest, state: SubagentState): void {
458
+ const remembered = rememberedForegroundChild(request, state);
459
+ if (!remembered || remembered.child.status !== "detached") return;
460
+ const updatedAt = Date.now();
461
+ remembered.run.updatedAt = updatedAt;
462
+ remembered.child.activityState = "needs_attention";
463
+ remembered.child.lastActivityAt = request.createdAt;
464
+ remembered.child.currentTool = "contact_supervisor";
465
+ remembered.child.currentToolStartedAt = request.createdAt;
466
+ remembered.child.updatedAt = updatedAt;
467
+ }
468
+
469
+ function clearForegroundSupervisorAttention(request: SupervisorRequest, pending: Map<string, PendingSupervisorRequest>, state: SubagentState): void {
470
+ if ([...pending.values()].some((candidate) =>
471
+ candidate.expectsReply
472
+ && candidate.runId === request.runId
473
+ && candidate.agent === request.agent
474
+ && candidate.childIndex === request.childIndex
475
+ )) return;
476
+ const remembered = rememberedForegroundChild(request, state);
477
+ if (!remembered || remembered.child.status !== "detached" || remembered.child.currentTool !== "contact_supervisor") return;
478
+ const updatedAt = Date.now();
479
+ remembered.run.updatedAt = updatedAt;
480
+ remembered.child.activityState = undefined;
481
+ remembered.child.lastActivityAt = updatedAt;
482
+ remembered.child.currentTool = undefined;
483
+ remembered.child.currentToolStartedAt = undefined;
484
+ remembered.child.updatedAt = updatedAt;
485
+ }
486
+
450
487
  function removeRequestFile(file: string): void {
451
488
  try {
452
489
  fs.rmSync(file, { force: true });
@@ -465,10 +502,8 @@ function requestExpiresAt(request: SupervisorRequest, now: number): number {
465
502
 
466
503
  function requestRunInactive(request: SupervisorRequest, state: SubagentState): boolean {
467
504
  if (state.foregroundControls.has(request.runId)) return false;
468
- const foregroundRun = state.foregroundRuns?.get(request.runId);
469
- const foregroundChild = foregroundRun?.children.find((child) => child.index === request.childIndex && child.agent === request.agent)
470
- ?? foregroundRun?.children[request.childIndex];
471
- if (foregroundChild) return foregroundChild.status !== "detached";
505
+ const foreground = rememberedForegroundChild(request, state);
506
+ if (foreground) return foreground.child.status !== "detached";
472
507
 
473
508
  const asyncJob = state.asyncJobs.get(request.runId);
474
509
  if (!asyncJob) return false;
@@ -580,6 +615,7 @@ function buildParentIntercomTool(pending: Map<string, PendingSupervisorRequest>,
580
615
  const request = resolvePendingRequest(pending, input);
581
616
  writeReply(request, input.message ?? "");
582
617
  pending.delete(request.id);
618
+ clearForegroundSupervisorAttention(request, pending, state);
583
619
  return { content: [{ type: "text", text: `Replied to supervisor request ${request.id}.` }], details: { replyTo: request.id, runId: request.runId, agent: request.agent } };
584
620
  }
585
621
  if (input.action === "send" || input.action === "ask") {
@@ -629,7 +665,10 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
629
665
  continue;
630
666
  }
631
667
  seenFiles.add(file);
632
- if (request.expectsReply) pending.set(request.id, request);
668
+ if (request.expectsReply) {
669
+ pending.set(request.id, request);
670
+ markForegroundSupervisorAttention(request, state);
671
+ }
633
672
  else {
634
673
  removeRequestFile(request.requestFile);
635
674
  }
@@ -645,7 +684,7 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
645
684
  agent: request.agent,
646
685
  childIndex: request.childIndex,
647
686
  },
648
- });
687
+ }, { triggerTurn: true });
649
688
  if (request.expectsReply) {
650
689
  (pi as { events?: IntercomEventBus }).events?.emit(INTERCOM_DETACH_REQUEST_EVENT, {
651
690
  requestId: request.id,
@@ -5,6 +5,7 @@ import {
5
5
  type IntercomEventBus,
6
6
  type NestedRunSummary,
7
7
  type PublicNestedRunSummary,
8
+ type ParallelHandoffReference,
8
9
  type SingleResult,
9
10
  type SubagentResultIntercomChild,
10
11
  type SubagentResultIntercomPayload,
@@ -179,6 +180,7 @@ interface GroupedResultIntercomMessageInput {
179
180
  asyncId?: string;
180
181
  asyncDir?: string;
181
182
  chainSteps?: number;
183
+ parallelHandoff?: ParallelHandoffReference;
182
184
  }
183
185
 
184
186
  function asyncResumeGuidance(input: {
@@ -207,6 +209,7 @@ function formatSubagentResultIntercomMessage(input: {
207
209
  asyncId?: string;
208
210
  asyncDir?: string;
209
211
  chainSteps?: number;
212
+ parallelHandoff?: ParallelHandoffReference;
210
213
  }): string {
211
214
  const counts = countStatuses(input.children);
212
215
  const lines: string[] = [
@@ -222,6 +225,7 @@ function formatSubagentResultIntercomMessage(input: {
222
225
  }
223
226
  if (input.asyncId) lines.push(`Async id: ${input.asyncId}`);
224
227
  if (input.asyncDir) lines.push(`Async dir: ${input.asyncDir}`);
228
+ if (input.parallelHandoff) lines.push(`Parallel handoff: ${input.parallelHandoff.path}`);
225
229
  const resumeGuidance = asyncResumeGuidance(input);
226
230
  if (resumeGuidance) lines.push(resumeGuidance);
227
231
  if (input.children.some((child) => child.intercomTarget)) {
@@ -266,6 +270,7 @@ export function buildSubagentResultIntercomPayload(input: GroupedResultIntercomM
266
270
  ...(input.asyncId ? { asyncId: input.asyncId } : {}),
267
271
  ...(input.asyncDir ? { asyncDir: input.asyncDir } : {}),
268
272
  ...(typeof input.chainSteps === "number" ? { chainSteps: input.chainSteps } : {}),
273
+ ...(input.parallelHandoff ? { parallelHandoff: input.parallelHandoff } : {}),
269
274
  ...(firstChild?.agent ? { agent: firstChild.agent } : {}),
270
275
  ...(firstChild?.index !== undefined ? { index: firstChild.index } : {}),
271
276
  ...(firstChild?.artifactPath ? { artifactPath: firstChild.artifactPath } : {}),
@@ -352,6 +357,8 @@ export function formatSubagentResultReceipt(input: {
352
357
  `Children: ${formatStatusCounts(counts)}`,
353
358
  ];
354
359
 
360
+ if (input.payload.parallelHandoff) lines.push(`Parallel handoff: ${input.payload.parallelHandoff.path}`);
361
+
355
362
  const artifacts = input.payload.children.filter((child) => typeof child.artifactPath === "string");
356
363
  if (artifacts.length > 0) {
357
364
  lines.push("Artifacts:");
@@ -15,6 +15,7 @@ import { applyThinkingSuffix } from "../shared/pi-args.ts";
15
15
  import { injectOutputPathSystemPrompt, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
16
16
  import { buildChainInstructions, isDynamicParallelStep, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
17
17
  import type { RunnerStep } from "../shared/parallel-utils.ts";
18
+ import type { ContextMode } from "../shared/context-mode.ts";
18
19
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
19
20
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
20
21
  import { buildAgentMemoryInjection } from "../../agents/agent-memory.ts";
@@ -29,8 +30,10 @@ import { createStructuredOutputRuntime } from "../shared/structured-output.ts";
29
30
  import { resolveEffectiveAcceptance } from "../shared/acceptance.ts";
30
31
  import {
31
32
  type AcceptanceInput,
33
+ type AgentContract,
32
34
  type ArtifactConfig,
33
35
  type Details,
36
+ type JsonSchemaObject,
34
37
  type MaxOutputConfig,
35
38
  type NestedRouteInfo,
36
39
  type ResolvedControlConfig,
@@ -131,9 +134,11 @@ interface AsyncChainParams {
131
134
  artifactConfig: ArtifactConfig;
132
135
  shareEnabled: boolean;
133
136
  sessionRoot?: string;
137
+ agentContract?: AgentContract;
134
138
  chainSkills?: string[];
135
139
  sessionFilesByFlatIndex?: (string | undefined)[];
136
140
  thinkingOverridesByFlatIndex?: (AgentConfig["thinking"] | undefined)[];
141
+ contextForAgent?: (agentName: string) => ContextMode;
137
142
  progressDir?: string;
138
143
  dynamicFanoutMaxItems?: number;
139
144
  maxSubagentDepth: number;
@@ -170,10 +175,13 @@ interface AsyncSingleParams {
170
175
  sessionDir?: string;
171
176
  sessionFile?: string;
172
177
  revivalLease?: SessionLeaseRequest;
178
+ context?: ContextMode;
173
179
  skills?: string[];
174
180
  output?: string | boolean;
175
181
  outputMode?: "inline" | "file-only";
176
182
  outputBaseDir?: string;
183
+ agentContract?: AgentContract;
184
+ structuredOutputSchema?: JsonSchemaObject;
177
185
  modelOverride?: string;
178
186
  thinkingOverride?: AgentConfig["thinking"];
179
187
  availableModels?: AvailableModelInfo[];
@@ -212,7 +220,9 @@ export interface AsyncRunnerStepBuildParams {
212
220
  chainSkills?: string[];
213
221
  sessionFilesByFlatIndex?: (string | undefined)[];
214
222
  thinkingOverridesByFlatIndex?: (AgentConfig["thinking"] | undefined)[];
223
+ contextForAgent?: (agentName: string) => ContextMode;
215
224
  progressDir?: string;
225
+ agentContract?: AgentContract;
216
226
  dynamicFanoutMaxItems?: number;
217
227
  maxSubagentDepth: number;
218
228
  waitToolEnabled?: boolean;
@@ -624,10 +634,13 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
624
634
  const thinkingOverride = flatIndex === undefined ? undefined : thinkingOverridesByFlatIndex?.[flatIndex];
625
635
  const effectiveThinking = thinkingOverride ?? a.thinking;
626
636
  const model = applyThinkingSuffix(primaryModel, effectiveThinking, thinkingOverride !== undefined);
637
+ const agentContract = s.agentContract ?? params.agentContract;
627
638
  return {
628
639
  parentSessionId: ctx.parentSessionId ?? ctx.currentSessionId,
629
640
  agent: s.agent,
630
641
  task,
642
+ ...(params.contextForAgent ? { context: params.contextForAgent(s.agent) } : {}),
643
+ ...(agentContract ? { agentContract } : {}),
631
644
  phase: s.phase,
632
645
  label: s.label,
633
646
  outputName: s.as,
@@ -662,9 +675,11 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
662
675
  mode: resultMode,
663
676
  async: true,
664
677
  dynamic: false,
678
+ agentContract,
665
679
  }),
666
680
  acceptanceInput: s.acceptance,
667
681
  acceptanceRole: a.acceptanceRole,
682
+ ...(s.gateOn ? { gateOn: s.gateOn } : {}),
668
683
  ...(s.outputSchema ? { structuredOutputSchema: s.outputSchema } : {}),
669
684
  ...(s.outputSchema ? { structuredOutput: createStructuredOutputRuntime(s.outputSchema, path.join(asyncDir, "structured-output")) } : {}),
670
685
  ...(resolvedToolBudget.budget ? { toolBudget: resolvedToolBudget.budget } : {}),
@@ -707,7 +722,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
707
722
  }
708
723
  }
709
724
  const staticStep = nextFlatStep();
710
- return buildSeqStep(t, staticStep.sessionFile, behaviorCwd, progressPrecreated, parallelBehaviors[taskIndex], staticStep.index, { stepIndex, taskIndex });
725
+ return buildSeqStep({ ...t, agentContract: t.agentContract ?? s.agentContract, gateOn: t.gateOn ?? s.gateOn }, staticStep.sessionFile, behaviorCwd, progressPrecreated, parallelBehaviors[taskIndex], staticStep.index, { stepIndex, taskIndex });
711
726
  }),
712
727
  concurrency: s.concurrency,
713
728
  failFast: s.failFast,
@@ -724,7 +739,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
724
739
  }
725
740
  const maxItems = s.expand.maxItems ?? params.dynamicFanoutMaxItems ?? 0;
726
741
  const dynamicFlatSteps = Array.from({ length: maxItems }, () => nextFlatStep());
727
- const parallel = buildSeqStep(s.parallel as SequentialStep, undefined, undefined, progressPrecreated, behavior, undefined, { stepIndex });
742
+ const parallel = buildSeqStep({ ...(s.parallel as SequentialStep), agentContract: s.parallel.agentContract ?? s.agentContract, gateOn: s.parallel.gateOn ?? s.gateOn }, undefined, undefined, progressPrecreated, behavior, undefined, { stepIndex });
728
743
  return {
729
744
  expand: s.expand,
730
745
  parallel,
@@ -743,9 +758,12 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
743
758
  mode: resultMode,
744
759
  async: true,
745
760
  dynamicGroup: true,
761
+ agentContract: s.agentContract ?? params.agentContract,
746
762
  }),
747
763
  acceptanceInput: s.acceptance,
748
764
  acceptanceRole: agent.acceptanceRole,
765
+ ...(s.agentContract ?? params.agentContract ? { agentContract: s.agentContract ?? params.agentContract } : {}),
766
+ ...(s.gateOn ? { gateOn: s.gateOn } : {}),
749
767
  };
750
768
  }
751
769
  const staticStep = nextFlatStep();
@@ -844,7 +862,9 @@ export function executeAsyncChain(
844
862
  chainSkills: params.chainSkills,
845
863
  sessionFilesByFlatIndex,
846
864
  thinkingOverridesByFlatIndex,
865
+ contextForAgent: params.contextForAgent,
847
866
  progressDir: params.progressDir ?? (artifactsDir ? path.join(artifactsDir, "progress", id) : resultMode === "parallel" ? path.join(asyncDir, "progress") : undefined),
867
+ agentContract: params.agentContract,
848
868
  outputBaseDir: artifactsDir ? path.join(artifactsDir, "outputs", id) : undefined,
849
869
  dynamicFanoutMaxItems: params.dynamicFanoutMaxItems,
850
870
  maxSubagentDepth,
@@ -1126,6 +1146,9 @@ export function executeAsyncSingle(
1126
1146
  if (timeoutMs !== undefined && timeoutMs <= 0) return formatAsyncStartError("single", "The source run's absolute deadline expired before recovery could launch.");
1127
1147
  const initialTurnBudget = params.turnBudget ? initialTurnBudgetState(params.turnBudget) : undefined;
1128
1148
  const resolvedSessionDir = params.sessionDir ?? (sessionRoot ? path.join(sessionRoot, `async-${id}`) : undefined);
1149
+ const structuredOutput = params.structuredOutputSchema
1150
+ ? createStructuredOutputRuntime(params.structuredOutputSchema, path.join(asyncDir, "structured-output"))
1151
+ : undefined;
1129
1152
  const resolvedAcceptance = resolveEffectiveAcceptance({
1130
1153
  explicit: params.acceptance,
1131
1154
  agentName: agent,
@@ -1133,10 +1156,12 @@ export function executeAsyncSingle(
1133
1156
  task,
1134
1157
  mode: "single",
1135
1158
  async: true,
1159
+ agentContract: params.agentContract,
1136
1160
  });
1137
1161
  const recoveryDescriptor: SteeringRecoveryDescriptor = {
1138
1162
  version: 1,
1139
1163
  sourceRunId: id,
1164
+ ...(params.agentContract ? { agentContract: params.agentContract } : {}),
1140
1165
  agent,
1141
1166
  ...(sessionFile ? { sessionFile } : {}),
1142
1167
  cwd: runnerCwd,
@@ -1158,7 +1183,8 @@ export function executeAsyncSingle(
1158
1183
  ...(agentConfig.memory ? { memory: { ...agentConfig.memory } } : {}),
1159
1184
  ...(outputPath ? { outputPath } : {}),
1160
1185
  outputMode,
1161
- ...(resolvedAcceptance ? { acceptance: resolvedAcceptance } : {}),
1186
+ ...(params.structuredOutputSchema ? { structuredOutputSchema: params.structuredOutputSchema } : {}),
1187
+ ...(params.acceptance !== undefined ? { acceptance: params.acceptance } : {}),
1162
1188
  ...(controlConfig ? { controlConfig } : {}),
1163
1189
  ...(deadlineAt !== undefined ? { absoluteDeadlineAt: deadlineAt } : {}),
1164
1190
  ...(params.turnBudget ? { initialTurnBudget: params.turnBudget } : {}),
@@ -1185,6 +1211,7 @@ export function executeAsyncSingle(
1185
1211
  parentSessionId: ctx.parentSessionId ?? ctx.currentSessionId,
1186
1212
  agent,
1187
1213
  task: taskWithOutputInstruction,
1214
+ ...(params.context ? { context: params.context } : {}),
1188
1215
  cwd: runnerCwd,
1189
1216
  model,
1190
1217
  thinking: resolveEffectiveThinking(model, effectiveThinking),
@@ -1206,7 +1233,10 @@ export function executeAsyncSingle(
1206
1233
  sessionFile,
1207
1234
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, agentConfig.maxSubagentDepth),
1208
1235
  waitToolEnabled: params.waitToolEnabled,
1236
+ ...(params.agentContract ? { agentContract: params.agentContract } : {}),
1209
1237
  effectiveAcceptance: resolvedAcceptance,
1238
+ ...(structuredOutput ? { structuredOutput } : {}),
1239
+ ...(params.structuredOutputSchema ? { structuredOutputSchema: params.structuredOutputSchema } : {}),
1210
1240
  ...(resolvedToolBudget.budget ? { toolBudget: resolvedToolBudget.budget } : {}),
1211
1241
  },
1212
1242
  ],
@@ -1309,6 +1339,6 @@ export function executeAsyncSingle(
1309
1339
 
1310
1340
  return {
1311
1341
  content: [{ type: "text", text: formatAsyncStartedMessage(`Async: ${agent} [${id}]`, ctx.interactive === true) }],
1312
- details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir, ...(timeoutMs !== undefined ? { timeoutMs, deadlineAt } : {}), ...(params.turnBudget ? { turnBudget: params.turnBudget } : {}), ...(params.toolBudget ? { toolBudget: params.toolBudget } : {}) },
1342
+ details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir, ...(params.context ? { context: params.context } : {}), ...(timeoutMs !== undefined ? { timeoutMs, deadlineAt } : {}), ...(params.turnBudget ? { turnBudget: params.turnBudget } : {}), ...(params.toolBudget ? { toolBudget: params.toolBudget } : {}) },
1313
1343
  };
1314
1344
  }
@@ -91,6 +91,8 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
91
91
  toolCount: run.toolCount,
92
92
  steering: run.steering,
93
93
  mode: run.mode,
94
+ context: run.context,
95
+ cwd: run.cwd,
94
96
  agents: visibleSteps.map((step) => step.agent),
95
97
  currentStep: run.currentStep,
96
98
  chainStepCount: run.chainStepCount,
@@ -397,10 +399,12 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
397
399
  state.asyncJobs.set(info.id, {
398
400
  asyncId: info.id,
399
401
  asyncDir,
402
+ ...(typeof info.cwd === "string" ? { cwd: path.resolve(info.cwd) } : {}),
400
403
  status: "queued",
401
404
  pid: typeof info.pid === "number" ? info.pid : undefined,
402
405
  ...(typeof info.sessionId === "string" ? { sessionId: info.sessionId } : {}),
403
406
  mode: info.mode ?? (info.chain ? "chain" : "single"),
407
+ description: info.goal ?? info.task,
404
408
  agents,
405
409
  chainStepCount: info.chainStepCount,
406
410
  parallelGroups: validParallelGroups,
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type SteeringRecoveryDescriptor } from "../../shared/types.ts";
3
+ import { ASYNC_DIR, RESULTS_DIR, type AcceptanceInput, type AsyncStatus, type SteeringRecoveryDescriptor } from "../../shared/types.ts";
4
4
  import type { AgentConfig } from "../../agents/agents.ts";
5
5
  import { validateAcceptanceInput } from "../shared/acceptance.ts";
6
6
  import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
@@ -250,6 +250,21 @@ function validateStatusForResume(status: AsyncStatus | null, source: string): vo
250
250
  }
251
251
  }
252
252
 
253
+ function normalizeRecoveryAcceptance(value: unknown, descriptorPath: string): AcceptanceInput | undefined {
254
+ if (value && typeof value === "object" && !Array.isArray(value) && ("explicit" in value || "inferredReason" in value)) {
255
+ const { explicit, inferredReason: _inferredReason, ...publicAcceptance } = value as Record<string, unknown>;
256
+ if (explicit === false) return undefined;
257
+ if (publicAcceptance.level === "reviewed") {
258
+ publicAcceptance.level = "verified";
259
+ delete publicAcceptance.review;
260
+ }
261
+ value = publicAcceptance;
262
+ }
263
+ const errors = validateAcceptanceInput(value, "recoveryDescriptor.acceptance");
264
+ if (errors.length) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${errors.join(" ")}`);
265
+ return value as AcceptanceInput;
266
+ }
267
+
253
268
  export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): SteeringRecoveryDescriptor | undefined {
254
269
  if (!asyncDir) return undefined;
255
270
  const descriptorPath = path.join(asyncDir, "recovery-descriptor.json");
@@ -263,9 +278,9 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
263
278
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
264
279
  const parsed = value as Record<string, unknown>;
265
280
  const allowedFields = new Set([
266
- "version", "sourceRunId", "agent", "sessionFile", "cwd", "model", "fallbackModels", "thinking", "tools", "extensions",
281
+ "version", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "fallbackModels", "thinking", "tools", "extensions",
267
282
  "subagentOnlyExtensions", "mcpDirectTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritSkills", "skills",
268
- "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "acceptance", "sessionDir", "artifactConfig",
283
+ "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
269
284
  "artifactsDir", "maxOutput", "controlConfig", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share",
270
285
  ]);
271
286
  for (const field of Object.keys(parsed)) {
@@ -276,6 +291,11 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
276
291
  if (typeof parsed[field] !== "string" || !(parsed[field] as string).trim()) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a non-empty string.`);
277
292
  }
278
293
  if (parsed.version !== 1) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': version must be 1.`);
294
+ if (parsed.agentContract !== undefined) {
295
+ if (!parsed.agentContract || typeof parsed.agentContract !== "object" || Array.isArray(parsed.agentContract)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': agentContract must be an object.`);
296
+ const contract = parsed.agentContract as Record<string, unknown>;
297
+ if (contract.version !== 1 || Object.keys(contract).some((key) => key !== "version")) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': agentContract must be { version: 1 }.`);
298
+ }
279
299
  if (parsed.systemPromptMode !== "append" && parsed.systemPromptMode !== "replace") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': systemPromptMode is invalid.`);
280
300
  if (parsed.outputMode !== "inline" && parsed.outputMode !== "file-only") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': outputMode is invalid.`);
281
301
  for (const field of ["inheritProjectContext", "inheritSkills", "share"] as const) {
@@ -291,6 +311,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
291
311
  if (parsed[field] !== undefined && (typeof parsed[field] !== "string" || !(parsed[field] as string).trim())) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a non-empty string.`);
292
312
  }
293
313
  if (parsed.completionGuard !== undefined && typeof parsed.completionGuard !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': completionGuard must be a boolean.`);
314
+ if (parsed.structuredOutputSchema !== undefined && (!parsed.structuredOutputSchema || typeof parsed.structuredOutputSchema !== "object" || Array.isArray(parsed.structuredOutputSchema))) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': structuredOutputSchema must be an object.`);
294
315
  if (parsed.memory !== undefined) {
295
316
  if (!parsed.memory || typeof parsed.memory !== "object" || Array.isArray(parsed.memory)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': memory must be an object.`);
296
317
  const memory = parsed.memory as Record<string, unknown>;
@@ -335,8 +356,9 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
335
356
  if (!Array.isArray(control.notifyChannels) || control.notifyChannels.some((item) => item !== "event" && item !== "async" && item !== "intercom")) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': controlConfig.notifyChannels is invalid.`);
336
357
  }
337
358
  if (parsed.acceptance !== undefined) {
338
- const errors = validateAcceptanceInput(parsed.acceptance, "recoveryDescriptor.acceptance");
339
- if (errors.length) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${errors.join(" ")}`);
359
+ const acceptance = normalizeRecoveryAcceptance(parsed.acceptance, descriptorPath);
360
+ if (acceptance === undefined) delete parsed.acceptance;
361
+ else parsed.acceptance = acceptance;
340
362
  }
341
363
  return parsed as unknown as SteeringRecoveryDescriptor;
342
364
  }
@@ -7,11 +7,13 @@ import { readStatus } from "../../shared/utils.ts";
7
7
  import { attachRootChildrenToSteps, buildNestedRouteIndex, type NestedRoute, projectNestedEvents } from "../shared/nested-events.ts";
8
8
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
9
9
  import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
10
+ import { contextModeLabel, summarizeContextModes, type ContextMode, type ContextSummary } from "../shared/context-mode.ts";
10
11
  import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
11
12
 
12
13
  interface AsyncRunStepSummary {
13
14
  index: number;
14
15
  agent: string;
16
+ context?: ContextMode;
15
17
  label?: string;
16
18
  phase?: string;
17
19
  outputName?: string;
@@ -35,12 +37,19 @@ interface AsyncRunStepSummary {
35
37
  model?: string;
36
38
  thinking?: string;
37
39
  attemptedModels?: string[];
40
+ sessionFile?: string;
41
+ transcriptPath?: string;
38
42
  error?: string;
39
43
  timedOut?: boolean;
40
44
  stopped?: boolean;
41
45
  turnBudget?: TurnBudgetState;
42
46
  turnBudgetExceeded?: boolean;
43
47
  wrapUpRequested?: boolean;
48
+ acceptance?: AsyncJobStep["acceptance"];
49
+ agentContract?: AsyncJobStep["agentContract"];
50
+ execution?: AsyncJobStep["execution"];
51
+ review?: AsyncJobStep["review"];
52
+ effects?: AsyncJobStep["effects"];
44
53
  children?: NestedRunSummary[];
45
54
  }
46
55
 
@@ -59,6 +68,7 @@ export interface AsyncRunSummary {
59
68
  toolCount?: number;
60
69
  steering?: SteeringStatus;
61
70
  mode: SubagentRunMode;
71
+ context?: ContextSummary;
62
72
  cwd?: string;
63
73
  startedAt: number;
64
74
  lastUpdate?: number;
@@ -92,6 +102,7 @@ interface AsyncRunListOptions {
92
102
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
93
103
  now?: () => number;
94
104
  reconcile?: boolean;
105
+ runId?: string;
95
106
  }
96
107
 
97
108
  function getErrorMessage(error: unknown): string {
@@ -117,6 +128,45 @@ function isAsyncRunDir(root: string, entry: string): boolean {
117
128
  }
118
129
  }
119
130
 
131
+ type TargetedAsyncRunResolution =
132
+ | { kind: "exact"; id: string }
133
+ | { kind: "scan" }
134
+ | { kind: "reject" };
135
+
136
+ /**
137
+ * Resolve an exact targeted run without following a run-directory symlink or
138
+ * accepting a path whose canonical location escaped the async root.
139
+ */
140
+ export function resolveTargetedAsyncRun(asyncDirRoot: string, id: string, sessionId?: string): TargetedAsyncRunResolution {
141
+ if (!id || id === "." || id === ".." || path.basename(id) !== id) return { kind: "reject" };
142
+ const asyncDir = path.join(asyncDirRoot, id);
143
+ let entryStat: fs.Stats;
144
+ try {
145
+ entryStat = fs.lstatSync(asyncDir);
146
+ } catch (error) {
147
+ if (isNotFoundError(error)) return { kind: "scan" };
148
+ throw new Error(`Failed to inspect async run path '${asyncDir}': ${getErrorMessage(error)}`, {
149
+ cause: error instanceof Error ? error : undefined,
150
+ });
151
+ }
152
+ if (!entryStat.isDirectory() || entryStat.isSymbolicLink()) return { kind: "reject" };
153
+ try {
154
+ const canonicalRoot = fs.realpathSync(asyncDirRoot);
155
+ const canonicalDir = fs.realpathSync(asyncDir);
156
+ if (canonicalDir !== canonicalRoot && !canonicalDir.startsWith(`${canonicalRoot}${path.sep}`)) return { kind: "reject" };
157
+ } catch (error) {
158
+ if (isNotFoundError(error)) return { kind: "reject" };
159
+ throw new Error(`Failed to resolve async run path '${asyncDir}': ${getErrorMessage(error)}`, {
160
+ cause: error instanceof Error ? error : undefined,
161
+ });
162
+ }
163
+ if (sessionId !== undefined) {
164
+ const status = readStatus(asyncDir);
165
+ if (status?.sessionId !== sessionId) return { kind: "scan" };
166
+ }
167
+ return { kind: "exact", id };
168
+ }
169
+
120
170
  function outputFileMtime(outputFile: string | undefined): number | undefined {
121
171
  if (!outputFile) return undefined;
122
172
  try {
@@ -163,6 +213,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
163
213
  return {
164
214
  index,
165
215
  agent: step.agent,
216
+ ...(step.context ? { context: step.context } : {}),
166
217
  ...(step.label ? { label: step.label } : {}),
167
218
  ...(step.phase ? { phase: step.phase } : {}),
168
219
  ...(step.outputName ? { outputName: step.outputName } : {}),
@@ -186,12 +237,19 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
186
237
  ...(step.model ? { model: step.model } : {}),
187
238
  ...(step.thinking ? { thinking: step.thinking } : {}),
188
239
  ...(step.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
240
+ ...(step.sessionFile ? { sessionFile: step.sessionFile } : {}),
241
+ ...(step.transcriptPath ? { transcriptPath: step.transcriptPath } : {}),
189
242
  ...(step.error ? { error: step.error } : {}),
190
243
  ...(step.timedOut !== undefined ? { timedOut: step.timedOut } : {}),
191
244
  ...(step.stopped !== undefined ? { stopped: step.stopped } : {}),
192
245
  ...(step.turnBudget ? { turnBudget: step.turnBudget } : {}),
193
246
  ...(step.turnBudgetExceeded !== undefined ? { turnBudgetExceeded: step.turnBudgetExceeded } : {}),
194
247
  ...(step.wrapUpRequested !== undefined ? { wrapUpRequested: step.wrapUpRequested } : {}),
248
+ ...(step.acceptance ? { acceptance: step.acceptance } : {}),
249
+ ...(step.agentContract ? { agentContract: step.agentContract } : {}),
250
+ ...(step.execution ? { execution: step.execution } : {}),
251
+ ...(step.review ? { review: step.review } : {}),
252
+ ...(step.effects ? { effects: step.effects } : {}),
195
253
  ...(step.children?.length ? { children: step.children } : {}),
196
254
  };
197
255
  });
@@ -211,6 +269,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
211
269
  toolCount: status.toolCount,
212
270
  steering: status.steering,
213
271
  mode: status.mode,
272
+ ...(summarizeContextModes(summarizedSteps.map((step) => step.context)) ? { context: summarizeContextModes(summarizedSteps.map((step) => step.context)) } : {}),
214
273
  cwd: status.cwd,
215
274
  startedAt: status.startedAt,
216
275
  lastUpdate: status.lastUpdate,
@@ -264,7 +323,19 @@ function sortRuns(runs: AsyncRunSummary[]): AsyncRunSummary[] {
264
323
  export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions = {}): AsyncRunSummary[] {
265
324
  let entries: string[];
266
325
  try {
267
- entries = fs.readdirSync(asyncDirRoot).filter((entry) => isAsyncRunDir(asyncDirRoot, entry));
326
+ if (options.runId !== undefined) {
327
+ const resolution = resolveTargetedAsyncRun(asyncDirRoot, options.runId, options.sessionId);
328
+ entries = resolution.kind === "exact"
329
+ ? [resolution.id]
330
+ : resolution.kind === "scan"
331
+ ? fs.readdirSync(asyncDirRoot).filter((entry) =>
332
+ (entry === options.runId || entry.startsWith(options.runId!))
333
+ && resolveTargetedAsyncRun(asyncDirRoot, entry, options.sessionId).kind === "exact"
334
+ )
335
+ : [];
336
+ } else {
337
+ entries = fs.readdirSync(asyncDirRoot).filter((entry) => isAsyncRunDir(asyncDirRoot, entry));
338
+ }
268
339
  } catch (error) {
269
340
  if (isNotFoundError(error)) return [];
270
341
  throw new Error(`Failed to list async runs in '${asyncDirRoot}': ${getErrorMessage(error)}`, {
@@ -333,8 +404,9 @@ function formatActivityFacts(input: { activityState?: ActivityState; lastActivit
333
404
 
334
405
  function formatStepLine(step: AsyncRunStepSummary): string {
335
406
  const display = step.label ? `${step.label} (${step.agent})` : step.agent;
407
+ const context = contextModeLabel(step.context);
336
408
  const phase = step.phase ? `[${step.phase}] ` : "";
337
- const parts = [`${step.index + 1}. ${phase}${display}`, step.status];
409
+ const parts = [`${step.index + 1}. ${phase}${display}${context ? ` ${context}` : ""}`, step.status];
338
410
  const activity = formatActivityFacts(step);
339
411
  if (activity) parts.push(activity);
340
412
  const modelThinking = formatModelThinking(step.model, step.thinking);
@@ -375,7 +447,8 @@ function formatRunHeader(run: AsyncRunSummary): string {
375
447
  const cwd = run.cwd ? shortenPath(run.cwd) : shortenPath(run.asyncDir);
376
448
  const activity = formatActivityFacts(run);
377
449
  const pending = run.pendingAppends ? ` | ${run.pendingAppends} pending append${run.pendingAppends === 1 ? "" : "s"}` : "";
378
- return `${run.id} | ${run.state}${activity ? ` | ${activity}` : ""} | ${run.mode} | ${stepLabel}${pending} | ${cwd}`;
450
+ const context = contextModeLabel(run.context);
451
+ return `${run.id} | ${run.state}${activity ? ` | ${activity}` : ""} | ${run.mode}${context ? ` ${context}` : ""} | ${stepLabel}${pending} | ${cwd}`;
379
452
  }
380
453
 
381
454
  export function formatAsyncRunList(runs: AsyncRunSummary[], heading = "Active async runs"): string {
@@ -131,6 +131,7 @@ export function consumeChainAppendRequests(asyncDir: string): ChainAppendRequest
131
131
  function statusStepForTask(task: RunnerSubagentStep): StatusStep {
132
132
  return {
133
133
  agent: task.agent,
134
+ ...(task.context ? { context: task.context } : {}),
134
135
  phase: task.phase,
135
136
  label: task.label,
136
137
  outputName: task.outputName,
@@ -151,6 +152,7 @@ function statusStepsForRunnerStep(step: RunnerStep): StatusStep[] {
151
152
  if (isDynamicRunnerGroup(step)) {
152
153
  return [{
153
154
  agent: `expand:${step.parallel.agent}`,
155
+ ...(step.parallel.context ? { context: step.parallel.context } : {}),
154
156
  phase: step.phase ?? step.parallel.phase,
155
157
  label: step.label ?? step.parallel.label ?? `Dynamic fanout (${step.collect.as})`,
156
158
  outputName: step.collect.as,
@@ -89,8 +89,8 @@ export interface CompletionBatcher<T> {
89
89
  push(item: T): void;
90
90
  /** Emit any held items immediately as a single group. */
91
91
  flush(): void;
92
- /** Clear timers without emitting. */
93
- dispose(): void;
92
+ /** Clear timers and return items that were never emitted. */
93
+ dispose(): T[];
94
94
  }
95
95
 
96
96
  /**
@@ -109,7 +109,7 @@ export function createCompletionBatcher<T>(options: CompletionBatcherOptions<T>)
109
109
  options.emit([item]);
110
110
  },
111
111
  flush() {},
112
- dispose() {},
112
+ dispose() { return []; },
113
113
  };
114
114
  }
115
115
 
@@ -160,7 +160,9 @@ export function createCompletionBatcher<T>(options: CompletionBatcherOptions<T>)
160
160
  flush: emitGroup,
161
161
  dispose() {
162
162
  clearTimers();
163
+ const abandoned = pending;
163
164
  pending = [];
164
- },
165
+ return abandoned;
166
+ }
165
167
  };
166
168
  }
@@ -20,9 +20,9 @@ function asFiniteNumber(value: unknown): number | undefined {
20
20
  }
21
21
 
22
22
  export function buildCompletionKey(data: CompletionDataLike, fallback: string): string {
23
- const id = asNonEmptyString(data.id);
24
- if (id) return `id:${id}`;
25
23
  const sessionId = asNonEmptyString(data.sessionId) ?? "no-session";
24
+ const id = asNonEmptyString(data.id);
25
+ if (id) return `session:${sessionId}:id:${id}`;
26
26
  const agent = asNonEmptyString(data.agent) ?? "unknown";
27
27
  const timestamp = asFiniteNumber(data.timestamp);
28
28
  const taskIndex = asFiniteNumber(data.taskIndex);
@@ -52,12 +52,3 @@ export function markSeenWithTtl(seen: Map<string, number>, key: string, now: num
52
52
  seen.set(key, now);
53
53
  return false;
54
54
  }
55
-
56
- export function getGlobalSeenMap(storeKey: string): Map<string, number> {
57
- const globalStore = globalThis as Record<string, unknown>;
58
- const existing = globalStore[storeKey];
59
- if (existing instanceof Map) return existing as Map<string, number>;
60
- const map = new Map<string, number>();
61
- globalStore[storeKey] = map;
62
- return map;
63
- }