pi-subagents 0.50.0 → 0.51.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 (92) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/agents/gpt-pro.md +17 -0
  3. package/async-retention-discovery-worker.mjs +180 -0
  4. package/docs/agents.md +35 -2
  5. package/docs/configuration.md +30 -12
  6. package/docs/extension-api.md +42 -1
  7. package/docs/observability.md +4 -4
  8. package/docs/tool-reference.md +38 -39
  9. package/docs/workflows.md +169 -3
  10. package/package.json +4 -2
  11. package/skills/pi-subagents/SKILL.md +5 -4
  12. package/skills/pi-subagents/references/constraints-and-recipes.md +6 -4
  13. package/skills/pi-subagents/references/execution-controls.md +18 -15
  14. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -3
  15. package/skills/pi-subagents/references/prompting-and-roles.md +2 -2
  16. package/src/agents/agent-management.ts +100 -345
  17. package/src/agents/agents.ts +125 -25
  18. package/src/api/external-job-provider.ts +185 -0
  19. package/src/api/preflight.ts +31 -6
  20. package/src/api/shared-types.ts +2 -0
  21. package/src/extension/config.ts +3 -3
  22. package/src/extension/doctor.ts +3 -6
  23. package/src/extension/fanout-child.ts +2 -2
  24. package/src/extension/index.ts +165 -88
  25. package/src/extension/public-execution.ts +28 -1
  26. package/src/extension/schemas.ts +12 -35
  27. package/src/extension/tool-description.ts +35 -24
  28. package/src/inspectors/herdr/actions.ts +2 -2
  29. package/src/inspectors/herdr/inspector-runner.ts +2 -1
  30. package/src/inspectors/herdr/project-panes.ts +2 -2
  31. package/src/intercom/native-supervisor-channel.ts +30 -9
  32. package/src/missions/lifecycle.ts +6 -1
  33. package/src/missions/store.ts +4 -9
  34. package/src/profiles/profiles.ts +3 -1
  35. package/src/runs/background/active-run-index.ts +31 -8
  36. package/src/runs/background/async-execution.ts +49 -31
  37. package/src/runs/background/async-job-tracker.ts +20 -4
  38. package/src/runs/background/async-resume.ts +28 -13
  39. package/src/runs/background/async-retention.ts +888 -0
  40. package/src/runs/background/async-status.ts +39 -53
  41. package/src/runs/background/chain-append.ts +3 -33
  42. package/src/runs/background/control-channel.ts +14 -68
  43. package/src/runs/background/index-segment.ts +59 -0
  44. package/src/runs/background/notify.ts +3 -1
  45. package/src/runs/background/result-files.ts +158 -90
  46. package/src/runs/background/result-watcher.ts +72 -20
  47. package/src/runs/background/retained-children.ts +13 -3
  48. package/src/runs/background/run-id-query.ts +7 -0
  49. package/src/runs/background/run-id-resolver.ts +11 -9
  50. package/src/runs/background/run-status.ts +27 -18
  51. package/src/runs/background/scheduled-runs.ts +22 -4
  52. package/src/runs/background/stale-run-reconciler.ts +6 -3
  53. package/src/runs/background/steering.ts +11 -1
  54. package/src/runs/background/subagent-runner.ts +250 -138
  55. package/src/runs/background/subagent-wait.ts +7 -7
  56. package/src/runs/background/terminal-run-index.ts +129 -0
  57. package/src/runs/background/wait-completions.ts +21 -4
  58. package/src/runs/background/wait-subscriptions.ts +80 -1
  59. package/src/runs/foreground/async-steering-action.ts +21 -14
  60. package/src/runs/foreground/execution.ts +2 -0
  61. package/src/runs/foreground/subagent-executor.ts +460 -1536
  62. package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
  63. package/src/runs/shared/chain-outputs.ts +1 -3
  64. package/src/runs/shared/external-job-bridge.ts +450 -0
  65. package/src/runs/shared/external-job-runner.ts +286 -0
  66. package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
  67. package/src/runs/shared/model-fallback.ts +22 -5
  68. package/src/runs/shared/orca-progress-tabs.ts +84 -22
  69. package/src/runs/shared/parallel-handoff.ts +46 -4
  70. package/src/runs/shared/parallel-utils.ts +4 -15
  71. package/src/runs/shared/permissions.ts +5 -1
  72. package/src/runs/shared/pi-args.ts +8 -1
  73. package/src/runs/shared/subagent-control.ts +26 -4
  74. package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
  75. package/src/runs/shared/workflow-graph.ts +1 -23
  76. package/src/runs/shared/worktree.ts +12 -1
  77. package/src/shared/atomic-json.ts +22 -2
  78. package/src/shared/capacity-resilient-json.ts +102 -0
  79. package/src/shared/completion-owner.ts +14 -0
  80. package/src/shared/file-system-retry.ts +49 -1
  81. package/src/shared/fork-context.ts +42 -0
  82. package/src/shared/prompt-resources.ts +0 -40
  83. package/src/shared/settings.ts +3 -27
  84. package/src/shared/types.ts +60 -26
  85. package/src/shared/utils.ts +8 -0
  86. package/src/shared/watch-strategy.ts +10 -0
  87. package/src/slash/slash-commands.ts +11 -3
  88. package/src/tui/fleet.ts +63 -15
  89. package/src/workflows/chat-progress.ts +1 -1
  90. package/src/workflows/scripted-workflow.ts +207 -65
  91. package/src/runs/foreground/chain-clarify.ts +0 -1354
  92. package/src/runs/foreground/chain-execution.ts +0 -1581
@@ -13,9 +13,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import type { AgentConfig } from "../../agents/agents.ts";
14
14
  import { appendAgentRefinementOverlay } from "../../agents/agent-refinements.ts";
15
15
  import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
16
+ import { currentCompletionOwnerId } from "../../shared/completion-owner.ts";
16
17
  import { applyThinkingSuffix, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/pi-args.ts";
17
18
  import { injectOutputPathSystemPrompt, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
18
- import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isParallelStep, resolveChainPath, resolveExistingReadPaths, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
19
+ import { buildChainInstructions, isDynamicParallelStep, isParallelStep, resolveChainPath, resolveExistingReadPaths, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
19
20
  import type { RunnerStep } from "../shared/parallel-utils.ts";
20
21
  import type { ContextMode } from "../shared/context-mode.ts";
21
22
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
@@ -125,6 +126,7 @@ interface AsyncExecutionContext {
125
126
  pi: ExtensionAPI;
126
127
  cwd: string;
127
128
  currentSessionId: string;
129
+ completionOwnerId?: string;
128
130
  /** Parent session id used by permission-system ask forwarding. */
129
131
  parentSessionId?: string;
130
132
  permissions?: PermissionConfig;
@@ -144,7 +146,7 @@ interface AsyncChainParams {
144
146
  /** Raw caller-facing goal used only by the started event. */
145
147
  goal?: string;
146
148
  attachRoot?: ImportedAsyncRoot & { agent: string; outputName?: string; label?: string };
147
- resultMode?: Exclude<SubagentRunMode, "single">;
149
+ resultMode?: SubagentRunMode;
148
150
  agents: AgentConfig[];
149
151
  ctx: AsyncExecutionContext;
150
152
  availableModels?: AvailableModelInfo[];
@@ -225,6 +227,7 @@ interface AsyncSingleParams {
225
227
  worktreeSetupHook?: string;
226
228
  worktreeSetupHookTimeoutMs?: number;
227
229
  worktreeBaseDir?: string;
230
+ worktree?: boolean;
228
231
  controlConfig?: ResolvedControlConfig;
229
232
  intercomBridge?: IntercomBridgeConfig;
230
233
  controlIntercomTarget?: string;
@@ -428,7 +431,7 @@ function terminateRunnerBeforeProceed(pid: number): boolean {
428
431
  return !runnerIsAlive(pid);
429
432
  }
430
433
 
431
- function persistPreProceedStartupFailure(asyncDir: string, runId: string, runnerProcessInstanceId: string, sessionId: string | undefined, message: string): void {
434
+ function persistPreProceedStartupFailure(asyncDir: string, runId: string, runnerProcessInstanceId: string, sessionId: string | undefined, completionOwnerId: string | undefined, message: string): void {
432
435
  const now = Date.now();
433
436
  try {
434
437
  const statusPath = path.join(asyncDir, "status.json");
@@ -440,6 +443,7 @@ function persistPreProceedStartupFailure(asyncDir: string, runId: string, runner
440
443
  ...status,
441
444
  runId,
442
445
  ...(sessionId ? { sessionId } : {}),
446
+ ...(completionOwnerId ? { completionOwnerId } : {}),
443
447
  state: "failed",
444
448
  lastUpdate: now,
445
449
  error: message,
@@ -491,10 +495,11 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
491
495
  fs.writeFileSync(cfgPath, JSON.stringify(launchConfig));
492
496
  const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
493
497
  const nodeCommand = resolveNodeExecutable();
494
- const launchForStartup = launchConfig as typeof launchConfig & { asyncDir?: unknown; id?: unknown; sessionId?: unknown; revivalLease?: unknown };
498
+ const launchForStartup = launchConfig as typeof launchConfig & { asyncDir?: unknown; id?: unknown; sessionId?: unknown; completionOwnerId?: unknown; revivalLease?: unknown };
495
499
  const launchAsyncDir = typeof launchForStartup.asyncDir === "string" ? launchForStartup.asyncDir : undefined;
496
500
  const launchRunId = typeof launchForStartup.id === "string" ? launchForStartup.id : suffix;
497
501
  const launchSessionId = typeof launchForStartup.sessionId === "string" ? launchForStartup.sessionId : undefined;
502
+ const launchCompletionOwnerId = typeof launchForStartup.completionOwnerId === "string" ? launchForStartup.completionOwnerId : undefined;
498
503
  const startupPath = typeof launchForStartup.revivalLease === "object" && launchAsyncDir
499
504
  ? path.join(launchAsyncDir, "runner-startup.json")
500
505
  : undefined;
@@ -584,7 +589,7 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
584
589
  proc.unref();
585
590
  if (startupPath && startupAckPath && startupProceedPath) {
586
591
  const persistStartupFailure = (message: string) => {
587
- if (launchAsyncDir) persistPreProceedStartupFailure(launchAsyncDir, launchRunId, runnerProcessInstanceId, launchSessionId, message);
592
+ if (launchAsyncDir) persistPreProceedStartupFailure(launchAsyncDir, launchRunId, runnerProcessInstanceId, launchSessionId, launchCompletionOwnerId, message);
588
593
  };
589
594
  const ready = waitForRunnerStartup(startupPath, "ready", RUNNER_STARTUP_TIMEOUT_MS);
590
595
  if (ready.ok === false) {
@@ -669,13 +674,11 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
669
674
  : chain;
670
675
  const firstStep = chain[0];
671
676
  const originalTask = params.task ?? (firstStep
672
- ? (isCheckpointStep(firstStep)
673
- ? undefined
674
- : isParallelStep(firstStep)
675
- ? firstStep.parallel[0]?.task
676
- : isDynamicParallelStep(firstStep)
677
- ? firstStep.parallel.task
678
- : (firstStep as SequentialStep).task)
677
+ ? (isParallelStep(firstStep)
678
+ ? firstStep.parallel[0]?.task
679
+ : isDynamicParallelStep(firstStep)
680
+ ? firstStep.parallel.task
681
+ : (firstStep as SequentialStep).task)
679
682
  : undefined);
680
683
  try {
681
684
  if (params.validateOutputBindings !== false) {
@@ -688,13 +691,11 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
688
691
  const workflowGraph = buildWorkflowGraphSnapshot({ runId: id, mode: resultMode, steps: graphChain });
689
692
 
690
693
  for (const s of chain) {
691
- const stepAgents = isCheckpointStep(s)
692
- ? []
693
- : isParallelStep(s)
694
- ? s.parallel.map((t) => t.agent)
695
- : isDynamicParallelStep(s)
696
- ? [s.parallel.agent]
697
- : [(s as SequentialStep).agent];
694
+ const stepAgents = isParallelStep(s)
695
+ ? s.parallel.map((t) => t.agent)
696
+ : isDynamicParallelStep(s)
697
+ ? [s.parallel.agent]
698
+ : [(s as SequentialStep).agent];
698
699
  for (const agentName of stepAgents) {
699
700
  if (!agents.find((x) => x.name === agentName)) {
700
701
  return { error: `Unknown agent: ${agentName}` };
@@ -716,7 +717,8 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
716
717
  };
717
718
  const buildSeqStep = (s: SequentialStep, sessionFile?: string, behaviorCwd?: string, progressPrecreated = false, resolvedBehavior?: ResolvedStepBehavior, flatIndex?: number, parallelOutputNamespace?: { stepIndex: number; taskIndex?: number }, runFanoutPath?: string) => {
718
719
  const a = agents.find((x) => x.name === s.agent)!;
719
- const externalRunner = a.runner?.type === "external-cli";
720
+ const externalRunner = a.runner?.type === "external-cli" || a.runner?.type === "external-job";
721
+ const externalRunnerType = a.runner?.type;
720
722
  if (externalRunner) {
721
723
  const unsupported: string[] = [];
722
724
  if (s.model !== undefined) unsupported.push("model override");
@@ -724,7 +726,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
724
726
  if (s.acceptance !== undefined || params.agentContract !== undefined || s.agentContract !== undefined) unsupported.push("acceptance/agent contract");
725
727
  if (s.toolBudget !== undefined || params.toolBudget !== undefined || a.toolBudget !== undefined || params.configToolBudget !== undefined) unsupported.push("tool budget");
726
728
  if (params.contextForAgent?.(s.agent) === "fork") unsupported.push("fork context");
727
- if (unsupported.length > 0) throw new AsyncStartValidationError(`Agent '${a.name}' uses runner.type='external-cli' and does not support: ${unsupported.join(", ")}.`);
729
+ if (unsupported.length > 0) throw new AsyncStartValidationError(`Agent '${a.name}' uses runner.type='${externalRunnerType}' and does not support: ${unsupported.join(", ")}.`);
728
730
  }
729
731
  try {
730
732
  assertAgentAllowedByCapabilityCeiling(a.name, intersectSubagentCapabilityCeilings(params.capabilityCeiling, decodeSubagentCapabilityCeiling(process.env[SUBAGENT_CAPABILITY_CEILING_ENV])));
@@ -815,7 +817,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
815
817
  const launchResolvedExtensions = externalRunner ? undefined : projectLaunchResolvedChildExtensions(toolPlan);
816
818
  const permissionRules = resolvePermissionRules(ctx.permissions, a.permissions);
817
819
  if (externalRunner && permissionRules) {
818
- throw new AsyncStartValidationError(`Agent '${a.name}' uses runner.type='external-cli', which cannot enforce native Pi child permission rules.`);
820
+ throw new AsyncStartValidationError(`Agent '${a.name}' uses runner.type='${externalRunnerType}', which cannot enforce native Pi child permission rules.`);
819
821
  }
820
822
  return {
821
823
  parentSessionId: ctx.parentSessionId ?? ctx.currentSessionId,
@@ -872,6 +874,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
872
874
  ...(s.outputSchema ? { structuredOutputSchema: s.outputSchema } : {}),
873
875
  ...(s.outputSchema ? { structuredOutput: createStructuredOutputRuntime(s.outputSchema, path.join(asyncDir, "structured-output")) } : {}),
874
876
  ...(resolvedToolBudget.budget ? { toolBudget: resolvedToolBudget.budget } : {}),
877
+ ...(s.worktree ? { worktree: true } : {}),
875
878
  };
876
879
  };
877
880
 
@@ -890,9 +893,6 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
890
893
 
891
894
  try {
892
895
  const builtSteps = chain.map((s, stepIndex) => {
893
- if (isCheckpointStep(s)) {
894
- return { checkpoint: s.checkpoint, ...(s.message ? { message: s.message } : {}), phase: s.phase, label: s.label };
895
- }
896
896
  if (isParallelStep(s)) {
897
897
  const parallelBehaviors = s.parallel.map((task) => {
898
898
  const agent = agents.find((candidate) => candidate.name === task.agent)!;
@@ -958,8 +958,17 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
958
958
  ...(s.gateOn ? { gateOn: s.gateOn } : {}),
959
959
  };
960
960
  }
961
+ const sequential = s as SequentialStep;
962
+ let behaviorCwd: string | undefined;
963
+ if (sequential.worktree) {
964
+ try {
965
+ behaviorCwd = resolveExpectedWorktreeAgentCwd(runnerCwd, `${id}-s${stepIndex}`, 0, worktreeBaseDir);
966
+ } catch {
967
+ behaviorCwd = undefined;
968
+ }
969
+ }
961
970
  const staticStep = nextFlatStep();
962
- return buildSeqStep(s as SequentialStep, staticStep.sessionFile, undefined, false, undefined, staticStep.index, undefined, `chain[${stepIndex}]`);
971
+ return buildSeqStep(sequential, staticStep.sessionFile, behaviorCwd, false, undefined, staticStep.index, undefined, `chain[${stepIndex}]`);
963
972
  });
964
973
  const steps = params.attachRoot
965
974
  ? [{
@@ -1028,7 +1037,6 @@ export function executeAsyncChain(
1028
1037
  const resultMode = params.resultMode ?? "chain";
1029
1038
  const acceptanceErrors = validateExecutionAcceptance({
1030
1039
  chain: chain.map((step) => {
1031
- if (isCheckpointStep(step)) return {};
1032
1040
  if (isParallelStep(step)) return { parallel: step.parallel };
1033
1041
  if (isDynamicParallelStep(step)) return { acceptance: step.acceptance, parallel: step.parallel };
1034
1042
  return { acceptance: step.acceptance };
@@ -1127,6 +1135,7 @@ export function executeAsyncChain(
1127
1135
  sessionDir: sessionRoot ? path.join(sessionRoot, `async-${id}`) : undefined,
1128
1136
  asyncDir,
1129
1137
  sessionId: ctx.currentSessionId,
1138
+ completionOwnerId: ctx.completionOwnerId ?? currentCompletionOwnerId(),
1130
1139
  ...(capabilityCeiling ? { capabilityCeiling } : {}),
1131
1140
  piPackageRoot,
1132
1141
  piArgv1: process.argv[1],
@@ -1253,6 +1262,7 @@ export function executeAsyncChain(
1253
1262
  id,
1254
1263
  pid: spawnResult.pid,
1255
1264
  sessionId: ctx.currentSessionId,
1265
+ completionOwnerId: ctx.completionOwnerId ?? currentCompletionOwnerId(),
1256
1266
  mode: resultMode,
1257
1267
  agent: firstAgents[0],
1258
1268
  agents: flatAgents,
@@ -1323,7 +1333,8 @@ export function executeAsyncSingle(
1323
1333
  const task = params.task ?? "";
1324
1334
  const acceptanceErrors = validateAcceptanceInput(params.acceptance);
1325
1335
  if (acceptanceErrors.length > 0) return formatAsyncStartError("single", acceptanceErrors.join(" "));
1326
- const externalRunner = agentConfig.runner?.type === "external-cli";
1336
+ const externalRunner = agentConfig.runner?.type === "external-cli" || agentConfig.runner?.type === "external-job";
1337
+ const externalRunnerType = agentConfig.runner?.type;
1327
1338
  const permissionRules = resolvePermissionRules(ctx.permissions, agentConfig.permissions);
1328
1339
  if (externalRunner) {
1329
1340
  const unsupported: string[] = [];
@@ -1335,7 +1346,7 @@ export function executeAsyncSingle(
1335
1346
  if (params.context === "fork") unsupported.push("fork context");
1336
1347
  if ((params.skills?.length ?? 0) > 0) unsupported.push("skills");
1337
1348
  if (permissionRules) unsupported.push("native Pi child permissions");
1338
- if (unsupported.length > 0) return formatAsyncStartError("single", `Agent '${agentConfig.name}' uses runner.type='external-cli' and does not support: ${unsupported.join(", ")}.`);
1349
+ if (unsupported.length > 0) return formatAsyncStartError("single", `Agent '${agentConfig.name}' uses runner.type='${externalRunnerType}' and does not support: ${unsupported.join(", ")}.`);
1339
1350
  }
1340
1351
  const capabilityCeiling = intersectSubagentCapabilityCeilings(params.capabilityCeiling ?? resolveCurrentSubagentCapabilityCeiling(ctx.currentSessionId), decodeSubagentCapabilityCeiling(process.env[SUBAGENT_CAPABILITY_CEILING_ENV]));
1341
1352
  try {
@@ -1344,6 +1355,10 @@ export function executeAsyncSingle(
1344
1355
  return formatAsyncStartError("single", error instanceof Error ? error.message : String(error));
1345
1356
  }
1346
1357
  const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
1358
+ const instructionCwd = params.worktree === true
1359
+ ? resolveExpectedWorktreeAgentCwd(runnerCwd, `${id}-s0`, 0, worktreeBaseDir)
1360
+ : runnerCwd;
1361
+ const readExistenceCwd = params.worktree === true ? runnerCwd : instructionCwd;
1347
1362
  const skillNames = params.skills ?? agentConfig.skills ?? [];
1348
1363
  const availableModels = params.availableModels;
1349
1364
  const { resolved: resolvedSkills, missing: missingSkills } = resolveSkillsWithFallback(
@@ -1385,7 +1400,7 @@ export function executeAsyncSingle(
1385
1400
  }
1386
1401
 
1387
1402
  const effectiveOutput = normalizeSingleOutputOverride(params.output, agentConfig.output);
1388
- const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, runnerCwd, params.outputBaseDir ?? (artifactsDir ? path.join(artifactsDir, "outputs", id) : undefined));
1403
+ const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, instructionCwd, params.outputBaseDir ?? (artifactsDir ? path.join(artifactsDir, "outputs", id) : undefined));
1389
1404
  systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath, agentConfig);
1390
1405
  const outputMode = params.outputMode ?? "inline";
1391
1406
  const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
@@ -1394,7 +1409,7 @@ export function executeAsyncSingle(
1394
1409
  // Reads: caller override > agent defaultReads > none. `~`/`~/` expand to home;
1395
1410
  // absolute paths pass through; relative paths resolve against the child cwd.
1396
1411
  const reads = params.reads !== undefined ? params.reads : agentConfig.defaultReads ?? false;
1397
- const readPaths = Array.isArray(reads) ? resolveExistingReadPaths(reads, runnerCwd) : [];
1412
+ const readPaths = Array.isArray(reads) ? resolveExistingReadPaths(reads, readExistenceCwd) : [];
1398
1413
  const readsInstruction = readPaths.length > 0
1399
1414
  ? `[Read from: ${readPaths.join(", ")}]\n\n`
1400
1415
  : "";
@@ -1570,6 +1585,7 @@ export function executeAsyncSingle(
1570
1585
  ...(structuredOutput ? { structuredOutput } : {}),
1571
1586
  ...(params.structuredOutputSchema ? { structuredOutputSchema: params.structuredOutputSchema } : {}),
1572
1587
  ...(resolvedToolBudget.budget ? { toolBudget: resolvedToolBudget.budget } : {}),
1588
+ ...(params.worktree === true ? { worktree: true } : {}),
1573
1589
  },
1574
1590
  ],
1575
1591
  resultPath: params.parentWorkflowRunId !== undefined && params.revivalLease !== undefined
@@ -1584,6 +1600,7 @@ export function executeAsyncSingle(
1584
1600
  sessionDir: resolvedSessionDir,
1585
1601
  asyncDir,
1586
1602
  sessionId: ctx.currentSessionId,
1603
+ completionOwnerId: ctx.completionOwnerId ?? currentCompletionOwnerId(),
1587
1604
  ...(capabilityCeiling ? { capabilityCeiling } : {}),
1588
1605
  piPackageRoot,
1589
1606
  piArgv1: process.argv[1],
@@ -1677,6 +1694,7 @@ export function executeAsyncSingle(
1677
1694
  id,
1678
1695
  pid: spawnResult.pid,
1679
1696
  sessionId: ctx.currentSessionId,
1697
+ completionOwnerId: ctx.completionOwnerId ?? currentCompletionOwnerId(),
1680
1698
  mode: "single",
1681
1699
  agent,
1682
1700
  task: task?.trim() ? PROMPT_REDACTED : undefined,
@@ -20,6 +20,8 @@ import { normalizeParallelGroups } from "./parallel-groups.ts";
20
20
  import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
21
21
  import { findNestedRouteForRootId, hasLiveNestedDescendants, updateAsyncJobNestedProjection } from "../shared/nested-events.ts";
22
22
  import { listAsyncRuns, type AsyncRunSummary } from "./async-status.ts";
23
+ import { EXTERNAL_JOB_BRIDGE_REQUEST_DIR, serviceExternalJobBridgeRequests } from "../shared/external-job-bridge.ts";
24
+ import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
23
25
 
24
26
  interface AsyncJobTrackerOptions {
25
27
  completionRetentionMs?: number;
@@ -27,6 +29,9 @@ interface AsyncJobTrackerOptions {
27
29
  pollIntervalMs?: number;
28
30
  resultsDir?: string;
29
31
  widgetEnabled?: boolean;
32
+ platform?: NodeJS.Platform;
33
+ onJobTerminal?: () => void;
34
+ watch?: typeof fs.watch;
30
35
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
31
36
  now?: () => number;
32
37
  }
@@ -66,6 +71,9 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
66
71
  const runningJobIds = new Set<string>();
67
72
  let rootWatcher: fs.FSWatcher | undefined;
68
73
  let nextLivenessAt = Date.now() + livenessIntervalMs;
74
+ const watch = options.watch ?? fs.watch;
75
+ const useNativeWatcher = () => shouldUseNativeFsWatch("async-job-tracker", options.platform);
76
+ const terminalStatus = (status: string) => status === "complete" || status === "failed" || status === "paused" || status === "stopped";
69
77
  const rerenderWidget = (ctx: ExtensionContext, jobs = Array.from(state.asyncJobs.values())) => {
70
78
  if (state.widgetsSuspended) return;
71
79
  renderWidget(ctx, options.widgetEnabled === false ? [] : jobs);
@@ -139,6 +147,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
139
147
  mode: run.mode,
140
148
  context: run.context,
141
149
  cwd: run.cwd,
150
+ sessionRoot: run.sessionRoot,
142
151
  agents: visibleSteps.map((step) => step.agent),
143
152
  currentStep: run.currentStep,
144
153
  chainStepCount: run.chainStepCount,
@@ -327,6 +336,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
327
336
  };
328
337
  try {
329
338
  emitNewControlEvents(job);
339
+ serviceExternalJobBridgeRequests(job.asyncDir);
330
340
  try {
331
341
  if (job.nestedRoute) reconcileNestedAsyncDescendants(job.nestedRoute, { resultsDir, kill: options.kill, now: options.now });
332
342
  } catch (error) {
@@ -342,6 +352,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
342
352
  runId: job.asyncId,
343
353
  pid: job.pid,
344
354
  sessionId: job.sessionId,
355
+ completionOwnerId: job.completionOwnerId,
345
356
  mode: job.mode,
346
357
  agents: job.agents,
347
358
  chainStepCount: job.chainStepCount,
@@ -404,7 +415,8 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
404
415
  job.turnBudgetExceeded = status.turnBudgetExceeded ?? job.turnBudgetExceeded;
405
416
  job.wrapUpRequested = status.wrapUpRequested ?? job.wrapUpRequested;
406
417
  job.sessionFile = status.sessionFile ?? job.sessionFile;
407
- if (job.status === "complete" || job.status === "failed" || job.status === "paused" || job.status === "stopped") {
418
+ if (terminalStatus(job.status)) {
419
+ if (!terminalStatus(previousStatus)) options.onJobTerminal?.();
408
420
  rememberFleetJob(state, job);
409
421
  if (!nestedRefreshFailed && !hasLiveNestedDescendants(job.nestedChildren) && (previousStatus !== job.status || !state.cleanupTimers.has(job.asyncId))) {
410
422
  scheduleCleanup(job.asyncId);
@@ -442,6 +454,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
442
454
  };
443
455
 
444
456
  const watchJob = (job: AsyncJobState) => {
457
+ if (!useNativeWatcher()) return;
445
458
  const watched = jobWatchers.get(job.asyncId) ?? { watchers: new Map<string, fs.FSWatcher>() };
446
459
  const active = job.status === "queued" || job.status === "running";
447
460
  const watchPath = (watchPath: string): boolean => {
@@ -449,11 +462,11 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
449
462
  const nestedEventSink = job.nestedRoute?.eventSink === watchPath;
450
463
  let watcher: fs.FSWatcher;
451
464
  try {
452
- watcher = fs.watch(resolveWatchPath(watchPath), (_event, file) => {
465
+ watcher = watch(resolveWatchPath(watchPath), (_event, file) => {
453
466
  const rawFileName = file?.toString();
454
467
  const fileName = rawFileName ?? path.basename(watchPath);
455
468
  const nestedEventFile = nestedEventSink && (!rawFileName || rawFileName.endsWith(".json") || rawFileName.endsWith(".jsonl"));
456
- if (fileName === "status.json" || fileName === "events.jsonl" || nestedEventFile) {
469
+ if (fileName === "status.json" || fileName === "events.jsonl" || fileName === EXTERNAL_JOB_BRIDGE_REQUEST_DIR || fileName.endsWith(".json") || nestedEventFile) {
457
470
  scheduleJobRefresh(job.asyncId);
458
471
  }
459
472
  if (watchPath !== job.asyncDir && !nestedEventSink) {
@@ -480,6 +493,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
480
493
  const statusWatched = watchPath(statusPath);
481
494
  if (statusWatched && !hadStatusWatcher) scheduleJobRefresh(job.asyncId);
482
495
  watchPath(path.join(job.asyncDir, "events.jsonl"));
496
+ watchPath(path.join(job.asyncDir, EXTERNAL_JOB_BRIDGE_REQUEST_DIR));
483
497
  if (job.nestedRoute) watchPath(job.nestedRoute.eventSink);
484
498
  if (!statusWatched && active && !watched.retryTimer) {
485
499
  watched.retryTimer = setTimeout(() => {
@@ -497,9 +511,10 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
497
511
  };
498
512
 
499
513
  const watchAsyncRoot = () => {
514
+ if (!useNativeWatcher()) return;
500
515
  if (rootWatcher) return;
501
516
  try {
502
- rootWatcher = fs.watch(resolveWatchPath(asyncDirRoot), (_event, file) => {
517
+ rootWatcher = watch(resolveWatchPath(asyncDirRoot), (_event, file) => {
503
518
  const runDirName = file?.toString();
504
519
  for (const job of state.asyncJobs.values()) {
505
520
  if (jobWatchers.has(job.asyncId) || (runDirName && path.basename(job.asyncDir) !== runDirName)) continue;
@@ -568,6 +583,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
568
583
  status: "queued",
569
584
  pid: typeof info.pid === "number" ? info.pid : undefined,
570
585
  ...(typeof info.sessionId === "string" ? { sessionId: info.sessionId } : {}),
586
+ ...(typeof info.completionOwnerId === "string" ? { completionOwnerId: info.completionOwnerId } : {}),
571
587
  mode: info.mode ?? (info.chain ? "chain" : "single"),
572
588
  description: info.goal ?? info.task,
573
589
  agents,
@@ -8,7 +8,9 @@ import { intersectSubagentCapabilityCeilings, parseSubagentCapabilityCeiling, ty
8
8
  import { validateRunFanoutBudgetDescriptor } from "../shared/run-fanout-budget.ts";
9
9
  import { resolveTurnBudgetConfig } from "../shared/turn-budget.ts";
10
10
  import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
11
- import { resultFilePath } from "./result-files.ts";
11
+ import { resultFilePath, resultPayloadPathForIndexedRun } from "./result-files.ts";
12
+ import { canScanAsyncRunPrefix, MIN_SAFE_ASYNC_RUN_PREFIX_LENGTH } from "./run-id-query.ts";
13
+ import { parallelHandoffPath, resolveRetainedWorktreeCwd } from "../shared/parallel-handoff.ts";
12
14
 
13
15
  export interface AsyncResumeParams {
14
16
  id?: string;
@@ -173,6 +175,8 @@ function prefixedRunIds(dir: string, prefix: string, suffix = ""): string[] {
173
175
  }
174
176
 
175
177
  function exactResultPath(resultsDir: string, runId: string): string | null {
178
+ const indexed = resultPayloadPathForIndexedRun(resultsDir, runId);
179
+ if (indexed) return indexed;
176
180
  const resultPath = resultFilePath(resultsDir, runId);
177
181
  assertInsideRoot(resultsDir, resultPath, "Async result file");
178
182
  return fs.existsSync(resultPath) ? resultPath : null;
@@ -181,6 +185,7 @@ function exactResultPath(resultsDir: string, runId: string): string | null {
181
185
  export function findAsyncRunPrefixMatches(prefix: string, asyncDirRoot: string, resultsDir: string): Array<{ id: string; location: AsyncRunLocation }> {
182
186
  const requestedId = assertRunId(prefix, "id");
183
187
  if (!requestedId) return [];
188
+ if (!canScanAsyncRunPrefix(requestedId)) return [];
184
189
  const asyncRoot = path.resolve(asyncDirRoot);
185
190
  const resultRoot = path.resolve(resultsDir);
186
191
  const matchingIds = prefixedRunIds(asyncRoot, requestedId).sort();
@@ -223,6 +228,10 @@ export function resolveAsyncRunLocation(params: AsyncResumeParams, asyncDirRoot:
223
228
  resolvedId: requestedId,
224
229
  };
225
230
  }
231
+ if (requestedId.length < MIN_SAFE_ASYNC_RUN_PREFIX_LENGTH) {
232
+ throw new Error(`Async run id prefix '${requestedId}' is too short. Provide at least ${MIN_SAFE_ASYNC_RUN_PREFIX_LENGTH} characters.`);
233
+ }
234
+ if (!canScanAsyncRunPrefix(requestedId)) return { asyncDir: null, resultPath: null, resolvedId: requestedId };
226
235
 
227
236
  const matching = findAsyncRunPrefixMatches(requestedId, asyncRoot, resultRoot);
228
237
  if (matching.length === 0) return { asyncDir: null, resultPath: null, resolvedId: requestedId };
@@ -261,12 +270,7 @@ function validateStatusForResume(status: AsyncStatus | null, source: string): vo
261
270
  }
262
271
  }
263
272
 
264
- function normalizeRecoveryAcceptance(value: unknown, descriptorPath: string): AcceptanceInput | undefined {
265
- if (value && typeof value === "object" && !Array.isArray(value) && ("explicit" in value || "inferredReason" in value)) {
266
- const { explicit, inferredReason: _inferredReason, ...publicAcceptance } = value as Record<string, unknown>;
267
- if (explicit === false) return undefined;
268
- value = publicAcceptance;
269
- }
273
+ function normalizeRecoveryAcceptance(value: unknown, descriptorPath: string): AcceptanceInput {
270
274
  const errors = validateAcceptanceInput(value, "recoveryDescriptor.acceptance");
271
275
  if (errors.length) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${errors.join(" ")}`);
272
276
  return value as AcceptanceInput;
@@ -395,11 +399,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
395
399
  if (!Array.isArray(control.notifyOn) || control.notifyOn.some((item) => item !== "active_long_running" && item !== "needs_attention")) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': controlConfig.notifyOn is invalid.`);
396
400
  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.`);
397
401
  }
398
- if (parsed.acceptance !== undefined) {
399
- const acceptance = normalizeRecoveryAcceptance(parsed.acceptance, descriptorPath);
400
- if (acceptance === undefined) delete parsed.acceptance;
401
- else parsed.acceptance = acceptance;
402
- }
402
+ if (parsed.acceptance !== undefined) parsed.acceptance = normalizeRecoveryAcceptance(parsed.acceptance, descriptorPath);
403
403
  return parsed as unknown as SteeringRecoveryDescriptor;
404
404
  }
405
405
 
@@ -410,6 +410,17 @@ function validateResumeSessionFile(runId: string, sessionFile: string): string {
410
410
  return resolved;
411
411
  }
412
412
 
413
+ function validateResumeCwd(runId: string, cwd: string | undefined): string | undefined {
414
+ if (!cwd) return undefined;
415
+ const resolved = path.resolve(cwd);
416
+ try {
417
+ if (!fs.statSync(resolved).isDirectory()) throw new Error("path is not a directory");
418
+ } catch (error) {
419
+ throw new Error(`Async run '${runId}' required cwd does not exist: ${cwd}`, { cause: error instanceof Error ? error : undefined });
420
+ }
421
+ return resolved;
422
+ }
423
+
413
424
  export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncResumeDeps = {}, options: AsyncResumeOptions = {}): AsyncResumeTarget {
414
425
  const asyncDirRoot = deps.asyncDirRoot ?? DIRS.async;
415
426
  const resultsDir = deps.resultsDir ?? DIRS.results;
@@ -510,6 +521,10 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
510
521
  const stepModel = statusSteps[index]?.model ?? resultSteps[index]?.model ?? (stepCount === 1 ? result?.model : undefined);
511
522
  const stepThinking = statusSteps[index]?.thinking ?? resultSteps[index]?.thinking ?? (stepCount === 1 ? result?.thinking : undefined);
512
523
  const capabilityCeiling = intersectSubagentCapabilityCeilings(status?.capabilityCeiling, statusSteps[index]?.capabilityCeiling, result?.capabilityCeiling, resultSteps[index]?.capabilityCeiling);
524
+ const managedWorktreeCwd = location.asyncDir
525
+ ? resolveRetainedWorktreeCwd(parallelHandoffPath(location.asyncDir), runId, index)
526
+ : undefined;
527
+ const resumeCwd = validateResumeCwd(runId, managedWorktreeCwd ?? status?.cwd ?? result?.cwd ?? recoveryDescriptor?.cwd);
513
528
 
514
529
  return {
515
530
  kind: "revive",
@@ -518,7 +533,7 @@ export function resolveAsyncResumeTarget(params: AsyncResumeParams, deps: AsyncR
518
533
  state,
519
534
  agent,
520
535
  index,
521
- cwd: status?.cwd ?? result?.cwd,
536
+ ...(resumeCwd ? { cwd: resumeCwd } : {}),
522
537
  ...(resolvedSessionFile ? { sessionFile: resolvedSessionFile } : {}),
523
538
  ...(stepModel ? { model: stepModel } : {}),
524
539
  ...(stepThinking ? { thinking: stepThinking } : {}),