pi-subagents 0.50.0 → 0.52.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 (109) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/agents/oracle.md +3 -1
  3. package/agents/reviewer.md +1 -0
  4. package/agents/scout.md +2 -2
  5. package/agents/worker.md +2 -1
  6. package/async-retention-discovery-worker.mjs +180 -0
  7. package/docs/agents.md +40 -2
  8. package/docs/configuration.md +30 -12
  9. package/docs/extension-api.md +42 -1
  10. package/docs/models.md +2 -0
  11. package/docs/observability.md +41 -5
  12. package/docs/tool-reference.md +38 -39
  13. package/docs/workflows.md +171 -3
  14. package/package.json +4 -2
  15. package/skills/pi-subagents/SKILL.md +6 -4
  16. package/skills/pi-subagents/references/constraints-and-recipes.md +12 -6
  17. package/skills/pi-subagents/references/execution-controls.md +29 -15
  18. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -3
  19. package/skills/pi-subagents/references/prompting-and-roles.md +4 -2
  20. package/src/agents/agent-management.ts +124 -351
  21. package/src/agents/agents.ts +157 -31
  22. package/src/agents/skills.ts +1 -1
  23. package/src/api/external-job-provider.ts +185 -0
  24. package/src/api/preflight.ts +36 -8
  25. package/src/api/shared-types.ts +2 -0
  26. package/src/extension/config.ts +3 -3
  27. package/src/extension/doctor.ts +3 -6
  28. package/src/extension/fanout-child.ts +2 -2
  29. package/src/extension/index.ts +166 -88
  30. package/src/extension/public-execution.ts +31 -2
  31. package/src/extension/schemas.ts +12 -35
  32. package/src/extension/tool-description.ts +35 -24
  33. package/src/inspectors/herdr/actions.ts +2 -2
  34. package/src/inspectors/herdr/inspector-runner.ts +2 -1
  35. package/src/inspectors/herdr/project-panes.ts +2 -2
  36. package/src/intercom/native-supervisor-channel.ts +32 -10
  37. package/src/missions/lifecycle.ts +6 -1
  38. package/src/missions/store.ts +4 -9
  39. package/src/missions/workflow-state.ts +2 -2
  40. package/src/profiles/profiles.ts +3 -1
  41. package/src/runs/background/active-run-index.ts +31 -8
  42. package/src/runs/background/async-execution.ts +68 -40
  43. package/src/runs/background/async-job-tracker.ts +20 -4
  44. package/src/runs/background/async-resume.ts +47 -15
  45. package/src/runs/background/async-retention.ts +886 -0
  46. package/src/runs/background/async-status.ts +39 -53
  47. package/src/runs/background/chain-append.ts +3 -33
  48. package/src/runs/background/completion-dedupe.ts +5 -1
  49. package/src/runs/background/completion-replay.ts +22 -12
  50. package/src/runs/background/control-channel.ts +14 -68
  51. package/src/runs/background/fleet-view.ts +68 -19
  52. package/src/runs/background/index-segment.ts +59 -0
  53. package/src/runs/background/inspect-rpc.ts +443 -0
  54. package/src/runs/background/notify.ts +31 -5
  55. package/src/runs/background/result-files.ts +158 -90
  56. package/src/runs/background/result-watcher.ts +116 -20
  57. package/src/runs/background/resume-guidance.ts +8 -5
  58. package/src/runs/background/retained-children.ts +13 -3
  59. package/src/runs/background/run-id-query.ts +7 -0
  60. package/src/runs/background/run-id-resolver.ts +11 -9
  61. package/src/runs/background/run-status.ts +27 -18
  62. package/src/runs/background/scheduled-runs.ts +24 -4
  63. package/src/runs/background/stale-run-reconciler.ts +6 -3
  64. package/src/runs/background/steering.ts +11 -1
  65. package/src/runs/background/subagent-runner.ts +253 -140
  66. package/src/runs/background/subagent-wait.ts +8 -8
  67. package/src/runs/background/terminal-run-index.ts +129 -0
  68. package/src/runs/background/wait-completions.ts +21 -4
  69. package/src/runs/background/wait-subscriptions.ts +81 -2
  70. package/src/runs/foreground/async-steering-action.ts +21 -14
  71. package/src/runs/foreground/execution.ts +3 -1
  72. package/src/runs/foreground/subagent-executor.ts +666 -1579
  73. package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
  74. package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
  75. package/src/runs/shared/acceptance.ts +4 -4
  76. package/src/runs/shared/chain-outputs.ts +1 -3
  77. package/src/runs/shared/external-job-bridge.ts +444 -0
  78. package/src/runs/shared/external-job-runner.ts +286 -0
  79. package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
  80. package/src/runs/shared/model-fallback.ts +98 -38
  81. package/src/runs/shared/orca-progress-tabs.ts +84 -22
  82. package/src/runs/shared/parallel-handoff.ts +46 -4
  83. package/src/runs/shared/parallel-utils.ts +4 -15
  84. package/src/runs/shared/permissions.ts +5 -1
  85. package/src/runs/shared/pi-args.ts +8 -1
  86. package/src/runs/shared/session-lease.ts +0 -6
  87. package/src/runs/shared/subagent-control.ts +26 -4
  88. package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
  89. package/src/runs/shared/workflow-graph.ts +1 -23
  90. package/src/runs/shared/worktree.ts +14 -2
  91. package/src/shared/atomic-json.ts +22 -2
  92. package/src/shared/capacity-resilient-json.ts +102 -0
  93. package/src/shared/completion-owner.ts +14 -0
  94. package/src/shared/file-system-retry.ts +49 -1
  95. package/src/shared/fork-context.ts +42 -0
  96. package/src/shared/prompt-resources.ts +0 -40
  97. package/src/shared/settings.ts +3 -27
  98. package/src/shared/types.ts +71 -26
  99. package/src/shared/utils.ts +8 -0
  100. package/src/shared/watch-strategy.ts +10 -0
  101. package/src/slash/slash-bridge.ts +2 -1
  102. package/src/slash/slash-commands.ts +29 -3
  103. package/src/tui/fleet.ts +63 -15
  104. package/src/watchdog/change-signature.ts +1 -1
  105. package/src/watchdog/lsp-diagnostics.ts +1 -0
  106. package/src/workflows/chat-progress.ts +2 -2
  107. package/src/workflows/scripted-workflow.ts +220 -67
  108. package/src/runs/foreground/chain-clarify.ts +0 -1354
  109. package/src/runs/foreground/chain-execution.ts +0 -1581
@@ -1,6 +1,10 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AsyncStatus } from "../../shared/types.ts";
4
+ import { readStatus } from "../../shared/utils.ts";
5
+ import { encodeIndexSegment } from "./index-segment.ts";
6
+ import { updateTerminalRunIndex } from "./terminal-run-index.ts";
7
+ import { isStorageCapacityError } from "../../shared/file-system-retry.ts";
4
8
 
5
9
  export const ACTIVE_RUN_INDEX_DIR = ".active-runs";
6
10
  export const DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS = 24 * 60 * 60 * 1000;
@@ -12,7 +16,7 @@ function indexDir(asyncDirRoot: string): string {
12
16
  }
13
17
 
14
18
  function toolCallIndexDir(asyncDirRoot: string, toolCallId: string): string {
15
- return path.join(indexDir(asyncDirRoot), TOOL_CALL_INDEX_DIR, encodeURIComponent(toolCallId));
19
+ return path.join(indexDir(asyncDirRoot), TOOL_CALL_INDEX_DIR, encodeIndexSegment(toolCallId));
16
20
  }
17
21
 
18
22
  function toolCallIndexPath(asyncDir: string, toolCallId: string): string {
@@ -45,8 +49,11 @@ function releaseToolCallAliases(asyncDir: string): void {
45
49
  try {
46
50
  entries = fs.readdirSync(root, { withFileTypes: true });
47
51
  } catch (error) {
48
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
49
- throw error;
52
+ const code = (error as NodeJS.ErrnoException).code;
53
+ if (code !== "ENOENT" && code !== "ENOTDIR") {
54
+ console.error(`Failed to inspect async active-run tool-call index root '${root}':`, error);
55
+ }
56
+ return;
50
57
  }
51
58
  for (const entry of entries) {
52
59
  if (!entry.isDirectory()) continue;
@@ -69,19 +76,34 @@ export function releaseActiveRunIndex(asyncDir: string): void {
69
76
  releaseToolCallAliases(asyncDir);
70
77
  }
71
78
 
72
- export function updateActiveRunIndex(asyncDir: string, state: AsyncStatus["state"], toolCallId?: string): void {
79
+ export function updateActiveRunIndex(asyncDir: string, state: AsyncStatus["state"], toolCallId?: string, options: { retryCapacityErrors?: boolean } = {}): void {
73
80
  const marker = markerPath(asyncDir);
74
81
  if (isActiveAsyncState(state)) {
75
82
  fs.mkdirSync(path.dirname(marker), { recursive: true });
76
83
  fs.writeFileSync(marker, "", { flag: "a" });
77
84
  if (toolCallId) {
78
- const toolCallMarker = toolCallIndexPath(asyncDir, toolCallId);
79
- fs.mkdirSync(path.dirname(toolCallMarker), { recursive: true });
80
- fs.writeFileSync(toolCallMarker, "", { flag: "a" });
85
+ try {
86
+ const toolCallMarker = toolCallIndexPath(asyncDir, toolCallId);
87
+ fs.mkdirSync(path.dirname(toolCallMarker), { recursive: true });
88
+ fs.writeFileSync(toolCallMarker, "", { flag: "a" });
89
+ } catch (error) {
90
+ // Tool-call aliases are optional. The authoritative active-run marker
91
+ // must keep the launch discoverable even when alias indexing fails.
92
+ console.error(`Failed to write async active-run tool-call index for '${asyncDir}':`, error);
93
+ }
81
94
  }
82
95
  return;
83
96
  }
84
97
  releaseActiveRunIndex(asyncDir);
98
+ const status = readStatus(asyncDir);
99
+ if (status && status.state === state) {
100
+ try {
101
+ updateTerminalRunIndex(asyncDir, status);
102
+ } catch (error) {
103
+ if (options.retryCapacityErrors && isStorageCapacityError(error)) throw error;
104
+ console.error(`Failed to write async terminal-run index for '${asyncDir}':`, error);
105
+ }
106
+ }
85
107
  }
86
108
 
87
109
  export function activeRunMarkerAgeMs(asyncDir: string, now = Date.now()): number | undefined {
@@ -110,7 +132,8 @@ export function readActiveRunToolCallIndex(asyncDirRoot: string, toolCallId: str
110
132
  .filter((entry) => entry.isFile())
111
133
  .map((entry) => entry.name);
112
134
  } catch (error) {
113
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
135
+ const code = (error as NodeJS.ErrnoException).code;
136
+ if (code === "ENOENT" || code === "ENOTDIR") return [];
114
137
  throw error;
115
138
  }
116
139
  }
@@ -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";
@@ -23,7 +24,7 @@ import { resolveNodeExecutable } from "../../shared/node-executable.ts";
23
24
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
24
25
  import { buildAgentMemoryInjection } from "../../agents/agent-memory.ts";
25
26
  import { PI_CODING_AGENT_PACKAGE_ROOT_ENV, PROMPT_REDACTED, resolveChildCwd } from "../../shared/utils.ts";
26
- import { buildModelCandidates, resolveEffectiveSubagentModel, resolveModelCandidate, resolveSubagentModelOverride, type AvailableModelInfo, type ParentModel } from "../shared/model-fallback.ts";
27
+ import { buildModelCandidates, inheritsParentModel, resolveEffectiveSubagentModel, resolveModelCandidate, resolveSubagentModelOverride, type AvailableModelInfo, type ParentModel } from "../shared/model-fallback.ts";
27
28
  import { resolveToolTimeoutMs, toolTimeoutFromEnv } from "../shared/tool-timeout.ts";
28
29
  import type { ModelScopeConfig } from "../shared/model-scope.ts";
29
30
  import { resolveEffectiveThinking } from "../../shared/model-info.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[];
@@ -218,6 +220,7 @@ interface AsyncSingleParams {
218
220
  agentContract?: AgentContract;
219
221
  structuredOutputSchema?: JsonSchemaObject;
220
222
  modelOverride?: string;
223
+ modelOverrideFromParent?: boolean;
221
224
  thinkingOverride?: AgentConfig["thinking"];
222
225
  availableModels?: AvailableModelInfo[];
223
226
  maxSubagentDepth: number;
@@ -225,6 +228,7 @@ interface AsyncSingleParams {
225
228
  worktreeSetupHook?: string;
226
229
  worktreeSetupHookTimeoutMs?: number;
227
230
  worktreeBaseDir?: string;
231
+ worktree?: boolean;
228
232
  controlConfig?: ResolvedControlConfig;
229
233
  intercomBridge?: IntercomBridgeConfig;
230
234
  controlIntercomTarget?: string;
@@ -428,7 +432,7 @@ function terminateRunnerBeforeProceed(pid: number): boolean {
428
432
  return !runnerIsAlive(pid);
429
433
  }
430
434
 
431
- function persistPreProceedStartupFailure(asyncDir: string, runId: string, runnerProcessInstanceId: string, sessionId: string | undefined, message: string): void {
435
+ function persistPreProceedStartupFailure(asyncDir: string, runId: string, runnerProcessInstanceId: string, sessionId: string | undefined, completionOwnerId: string | undefined, message: string): void {
432
436
  const now = Date.now();
433
437
  try {
434
438
  const statusPath = path.join(asyncDir, "status.json");
@@ -440,6 +444,7 @@ function persistPreProceedStartupFailure(asyncDir: string, runId: string, runner
440
444
  ...status,
441
445
  runId,
442
446
  ...(sessionId ? { sessionId } : {}),
447
+ ...(completionOwnerId ? { completionOwnerId } : {}),
443
448
  state: "failed",
444
449
  lastUpdate: now,
445
450
  error: message,
@@ -491,10 +496,11 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
491
496
  fs.writeFileSync(cfgPath, JSON.stringify(launchConfig));
492
497
  const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
493
498
  const nodeCommand = resolveNodeExecutable();
494
- const launchForStartup = launchConfig as typeof launchConfig & { asyncDir?: unknown; id?: unknown; sessionId?: unknown; revivalLease?: unknown };
499
+ const launchForStartup = launchConfig as typeof launchConfig & { asyncDir?: unknown; id?: unknown; sessionId?: unknown; completionOwnerId?: unknown; revivalLease?: unknown };
495
500
  const launchAsyncDir = typeof launchForStartup.asyncDir === "string" ? launchForStartup.asyncDir : undefined;
496
501
  const launchRunId = typeof launchForStartup.id === "string" ? launchForStartup.id : suffix;
497
502
  const launchSessionId = typeof launchForStartup.sessionId === "string" ? launchForStartup.sessionId : undefined;
503
+ const launchCompletionOwnerId = typeof launchForStartup.completionOwnerId === "string" ? launchForStartup.completionOwnerId : undefined;
498
504
  const startupPath = typeof launchForStartup.revivalLease === "object" && launchAsyncDir
499
505
  ? path.join(launchAsyncDir, "runner-startup.json")
500
506
  : undefined;
@@ -584,7 +590,7 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
584
590
  proc.unref();
585
591
  if (startupPath && startupAckPath && startupProceedPath) {
586
592
  const persistStartupFailure = (message: string) => {
587
- if (launchAsyncDir) persistPreProceedStartupFailure(launchAsyncDir, launchRunId, runnerProcessInstanceId, launchSessionId, message);
593
+ if (launchAsyncDir) persistPreProceedStartupFailure(launchAsyncDir, launchRunId, runnerProcessInstanceId, launchSessionId, launchCompletionOwnerId, message);
588
594
  };
589
595
  const ready = waitForRunnerStartup(startupPath, "ready", RUNNER_STARTUP_TIMEOUT_MS);
590
596
  if (ready.ok === false) {
@@ -669,13 +675,11 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
669
675
  : chain;
670
676
  const firstStep = chain[0];
671
677
  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)
678
+ ? (isParallelStep(firstStep)
679
+ ? firstStep.parallel[0]?.task
680
+ : isDynamicParallelStep(firstStep)
681
+ ? firstStep.parallel.task
682
+ : (firstStep as SequentialStep).task)
679
683
  : undefined);
680
684
  try {
681
685
  if (params.validateOutputBindings !== false) {
@@ -688,13 +692,11 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
688
692
  const workflowGraph = buildWorkflowGraphSnapshot({ runId: id, mode: resultMode, steps: graphChain });
689
693
 
690
694
  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];
695
+ const stepAgents = isParallelStep(s)
696
+ ? s.parallel.map((t) => t.agent)
697
+ : isDynamicParallelStep(s)
698
+ ? [s.parallel.agent]
699
+ : [(s as SequentialStep).agent];
698
700
  for (const agentName of stepAgents) {
699
701
  if (!agents.find((x) => x.name === agentName)) {
700
702
  return { error: `Unknown agent: ${agentName}` };
@@ -716,7 +718,8 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
716
718
  };
717
719
  const buildSeqStep = (s: SequentialStep, sessionFile?: string, behaviorCwd?: string, progressPrecreated = false, resolvedBehavior?: ResolvedStepBehavior, flatIndex?: number, parallelOutputNamespace?: { stepIndex: number; taskIndex?: number }, runFanoutPath?: string) => {
718
720
  const a = agents.find((x) => x.name === s.agent)!;
719
- const externalRunner = a.runner?.type === "external-cli";
721
+ const externalRunner = a.runner?.type === "external-cli" || a.runner?.type === "external-job";
722
+ const externalRunnerType = a.runner?.type;
720
723
  if (externalRunner) {
721
724
  const unsupported: string[] = [];
722
725
  if (s.model !== undefined) unsupported.push("model override");
@@ -724,7 +727,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
724
727
  if (s.acceptance !== undefined || params.agentContract !== undefined || s.agentContract !== undefined) unsupported.push("acceptance/agent contract");
725
728
  if (s.toolBudget !== undefined || params.toolBudget !== undefined || a.toolBudget !== undefined || params.configToolBudget !== undefined) unsupported.push("tool budget");
726
729
  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(", ")}.`);
730
+ if (unsupported.length > 0) throw new AsyncStartValidationError(`Agent '${a.name}' uses runner.type='${externalRunnerType}' and does not support: ${unsupported.join(", ")}.`);
728
731
  }
729
732
  try {
730
733
  assertAgentAllowedByCapabilityCeiling(a.name, intersectSubagentCapabilityCeilings(params.capabilityCeiling, decodeSubagentCapabilityCeiling(process.env[SUBAGENT_CAPABILITY_CEILING_ENV])));
@@ -815,7 +818,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
815
818
  const launchResolvedExtensions = externalRunner ? undefined : projectLaunchResolvedChildExtensions(toolPlan);
816
819
  const permissionRules = resolvePermissionRules(ctx.permissions, a.permissions);
817
820
  if (externalRunner && permissionRules) {
818
- throw new AsyncStartValidationError(`Agent '${a.name}' uses runner.type='external-cli', which cannot enforce native Pi child permission rules.`);
821
+ throw new AsyncStartValidationError(`Agent '${a.name}' uses runner.type='${externalRunnerType}', which cannot enforce native Pi child permission rules.`);
819
822
  }
820
823
  return {
821
824
  parentSessionId: ctx.parentSessionId ?? ctx.currentSessionId,
@@ -835,7 +838,10 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
835
838
  model,
836
839
  thinking: resolveEffectiveThinking(model, effectiveThinking),
837
840
  launchResolvedExtensions,
838
- modelCandidates: externalRunner ? undefined : buildModelCandidates(primaryModel, a.fallbackModels, availableModels, ctx.currentModelProvider, { scope: ctx.modelScope }).map((candidate) =>
841
+ modelCandidates: externalRunner ? undefined : buildModelCandidates(primaryModel, a.fallbackModels, availableModels, ctx.currentModelProvider, {
842
+ scope: ctx.modelScope,
843
+ primaryModelFromParent: inheritsParentModel(s.model, a.model, ctx.currentModel),
844
+ }).map((candidate) =>
839
845
  applyThinkingSuffix(candidate, effectiveThinking, thinkingOverride !== undefined),
840
846
  ),
841
847
  tools: a.tools,
@@ -872,6 +878,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
872
878
  ...(s.outputSchema ? { structuredOutputSchema: s.outputSchema } : {}),
873
879
  ...(s.outputSchema ? { structuredOutput: createStructuredOutputRuntime(s.outputSchema, path.join(asyncDir, "structured-output")) } : {}),
874
880
  ...(resolvedToolBudget.budget ? { toolBudget: resolvedToolBudget.budget } : {}),
881
+ ...(s.worktree ? { worktree: true } : {}),
875
882
  };
876
883
  };
877
884
 
@@ -890,9 +897,6 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
890
897
 
891
898
  try {
892
899
  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
900
  if (isParallelStep(s)) {
897
901
  const parallelBehaviors = s.parallel.map((task) => {
898
902
  const agent = agents.find((candidate) => candidate.name === task.agent)!;
@@ -958,8 +962,17 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
958
962
  ...(s.gateOn ? { gateOn: s.gateOn } : {}),
959
963
  };
960
964
  }
965
+ const sequential = s as SequentialStep;
966
+ let behaviorCwd: string | undefined;
967
+ if (sequential.worktree) {
968
+ try {
969
+ behaviorCwd = resolveExpectedWorktreeAgentCwd(runnerCwd, `${id}-s${stepIndex}`, 0, worktreeBaseDir);
970
+ } catch {
971
+ behaviorCwd = undefined;
972
+ }
973
+ }
961
974
  const staticStep = nextFlatStep();
962
- return buildSeqStep(s as SequentialStep, staticStep.sessionFile, undefined, false, undefined, staticStep.index, undefined, `chain[${stepIndex}]`);
975
+ return buildSeqStep(sequential, staticStep.sessionFile, behaviorCwd, false, undefined, staticStep.index, undefined, `chain[${stepIndex}]`);
963
976
  });
964
977
  const steps = params.attachRoot
965
978
  ? [{
@@ -1028,7 +1041,6 @@ export function executeAsyncChain(
1028
1041
  const resultMode = params.resultMode ?? "chain";
1029
1042
  const acceptanceErrors = validateExecutionAcceptance({
1030
1043
  chain: chain.map((step) => {
1031
- if (isCheckpointStep(step)) return {};
1032
1044
  if (isParallelStep(step)) return { parallel: step.parallel };
1033
1045
  if (isDynamicParallelStep(step)) return { acceptance: step.acceptance, parallel: step.parallel };
1034
1046
  return { acceptance: step.acceptance };
@@ -1127,6 +1139,7 @@ export function executeAsyncChain(
1127
1139
  sessionDir: sessionRoot ? path.join(sessionRoot, `async-${id}`) : undefined,
1128
1140
  asyncDir,
1129
1141
  sessionId: ctx.currentSessionId,
1142
+ completionOwnerId: ctx.completionOwnerId ?? currentCompletionOwnerId(),
1130
1143
  ...(capabilityCeiling ? { capabilityCeiling } : {}),
1131
1144
  piPackageRoot,
1132
1145
  piArgv1: process.argv[1],
@@ -1253,6 +1266,7 @@ export function executeAsyncChain(
1253
1266
  id,
1254
1267
  pid: spawnResult.pid,
1255
1268
  sessionId: ctx.currentSessionId,
1269
+ completionOwnerId: ctx.completionOwnerId ?? currentCompletionOwnerId(),
1256
1270
  mode: resultMode,
1257
1271
  agent: firstAgents[0],
1258
1272
  agents: flatAgents,
@@ -1323,7 +1337,8 @@ export function executeAsyncSingle(
1323
1337
  const task = params.task ?? "";
1324
1338
  const acceptanceErrors = validateAcceptanceInput(params.acceptance);
1325
1339
  if (acceptanceErrors.length > 0) return formatAsyncStartError("single", acceptanceErrors.join(" "));
1326
- const externalRunner = agentConfig.runner?.type === "external-cli";
1340
+ const externalRunner = agentConfig.runner?.type === "external-cli" || agentConfig.runner?.type === "external-job";
1341
+ const externalRunnerType = agentConfig.runner?.type;
1327
1342
  const permissionRules = resolvePermissionRules(ctx.permissions, agentConfig.permissions);
1328
1343
  if (externalRunner) {
1329
1344
  const unsupported: string[] = [];
@@ -1335,7 +1350,7 @@ export function executeAsyncSingle(
1335
1350
  if (params.context === "fork") unsupported.push("fork context");
1336
1351
  if ((params.skills?.length ?? 0) > 0) unsupported.push("skills");
1337
1352
  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(", ")}.`);
1353
+ if (unsupported.length > 0) return formatAsyncStartError("single", `Agent '${agentConfig.name}' uses runner.type='${externalRunnerType}' and does not support: ${unsupported.join(", ")}.`);
1339
1354
  }
1340
1355
  const capabilityCeiling = intersectSubagentCapabilityCeilings(params.capabilityCeiling ?? resolveCurrentSubagentCapabilityCeiling(ctx.currentSessionId), decodeSubagentCapabilityCeiling(process.env[SUBAGENT_CAPABILITY_CEILING_ENV]));
1341
1356
  try {
@@ -1344,6 +1359,10 @@ export function executeAsyncSingle(
1344
1359
  return formatAsyncStartError("single", error instanceof Error ? error.message : String(error));
1345
1360
  }
1346
1361
  const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
1362
+ const instructionCwd = params.worktree === true
1363
+ ? resolveExpectedWorktreeAgentCwd(runnerCwd, `${id}-s0`, 0, worktreeBaseDir)
1364
+ : runnerCwd;
1365
+ const readExistenceCwd = params.worktree === true ? runnerCwd : instructionCwd;
1347
1366
  const skillNames = params.skills ?? agentConfig.skills ?? [];
1348
1367
  const availableModels = params.availableModels;
1349
1368
  const { resolved: resolvedSkills, missing: missingSkills } = resolveSkillsWithFallback(
@@ -1385,7 +1404,7 @@ export function executeAsyncSingle(
1385
1404
  }
1386
1405
 
1387
1406
  const effectiveOutput = normalizeSingleOutputOverride(params.output, agentConfig.output);
1388
- const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, runnerCwd, params.outputBaseDir ?? (artifactsDir ? path.join(artifactsDir, "outputs", id) : undefined));
1407
+ const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, instructionCwd, params.outputBaseDir ?? (artifactsDir ? path.join(artifactsDir, "outputs", id) : undefined));
1389
1408
  systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath, agentConfig);
1390
1409
  const outputMode = params.outputMode ?? "inline";
1391
1410
  const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
@@ -1394,17 +1413,19 @@ export function executeAsyncSingle(
1394
1413
  // Reads: caller override > agent defaultReads > none. `~`/`~/` expand to home;
1395
1414
  // absolute paths pass through; relative paths resolve against the child cwd.
1396
1415
  const reads = params.reads !== undefined ? params.reads : agentConfig.defaultReads ?? false;
1397
- const readPaths = Array.isArray(reads) ? resolveExistingReadPaths(reads, runnerCwd) : [];
1416
+ const readPaths = Array.isArray(reads) ? resolveExistingReadPaths(reads, readExistenceCwd) : [];
1398
1417
  const readsInstruction = readPaths.length > 0
1399
1418
  ? `[Read from: ${readPaths.join(", ")}]\n\n`
1400
1419
  : "";
1401
1420
  const taskText = readsInstruction + taskWithOutputInstruction;
1402
- const primaryModel = externalRunner ? undefined : resolveSubagentModelOverride(
1403
- params.modelOverride ?? agentConfig.model,
1404
- ctx.currentModel,
1405
- availableModels,
1406
- ctx.currentModelProvider,
1407
- );
1421
+ const primaryModel = externalRunner ? undefined : params.modelOverrideFromParent
1422
+ ? params.modelOverride
1423
+ : resolveSubagentModelOverride(
1424
+ params.modelOverride ?? agentConfig.model,
1425
+ ctx.currentModel,
1426
+ availableModels,
1427
+ ctx.currentModelProvider,
1428
+ );
1408
1429
  const effectiveThinking = externalRunner ? undefined : params.thinkingOverride ?? agentConfig.thinking;
1409
1430
  const model = externalRunner ? undefined : applyThinkingSuffix(primaryModel, effectiveThinking, params.thinkingOverride !== undefined);
1410
1431
  const toolBudgetInput = params.toolBudget ?? agentConfig.toolBudget ?? params.configToolBudget;
@@ -1431,7 +1452,10 @@ export function executeAsyncSingle(
1431
1452
  : undefined;
1432
1453
  const modelCandidates = externalRunner
1433
1454
  ? []
1434
- : buildModelCandidates(primaryModel, agentConfig.fallbackModels, availableModels, ctx.currentModelProvider, { scope: ctx.modelScope })
1455
+ : buildModelCandidates(primaryModel, agentConfig.fallbackModels, availableModels, ctx.currentModelProvider, {
1456
+ scope: ctx.modelScope,
1457
+ primaryModelFromParent: params.modelOverrideFromParent,
1458
+ })
1435
1459
  .flatMap((candidate) => {
1436
1460
  const resolved = applyThinkingSuffix(candidate, effectiveThinking, params.thinkingOverride !== undefined);
1437
1461
  return resolved ? [resolved] : [];
@@ -1489,6 +1513,7 @@ export function executeAsyncSingle(
1489
1513
  ...(sessionFile ? { sessionFile } : {}),
1490
1514
  cwd: runnerCwd,
1491
1515
  ...(model ? { model } : {}),
1516
+ ...(params.modelOverrideFromParent ? { modelOverrideFromParent: true } : {}),
1492
1517
  ...(recoveryAgentConfig.fallbackModels ? { fallbackModels: [...recoveryAgentConfig.fallbackModels] } : {}),
1493
1518
  ...(effectiveThinking ? { thinking: resolveEffectiveThinking(model, effectiveThinking) } : {}),
1494
1519
  ...(recoveryAgentConfig.tools ? { tools: [...recoveryAgentConfig.tools] } : {}),
@@ -1570,6 +1595,7 @@ export function executeAsyncSingle(
1570
1595
  ...(structuredOutput ? { structuredOutput } : {}),
1571
1596
  ...(params.structuredOutputSchema ? { structuredOutputSchema: params.structuredOutputSchema } : {}),
1572
1597
  ...(resolvedToolBudget.budget ? { toolBudget: resolvedToolBudget.budget } : {}),
1598
+ ...(params.worktree === true ? { worktree: true } : {}),
1573
1599
  },
1574
1600
  ],
1575
1601
  resultPath: params.parentWorkflowRunId !== undefined && params.revivalLease !== undefined
@@ -1584,6 +1610,7 @@ export function executeAsyncSingle(
1584
1610
  sessionDir: resolvedSessionDir,
1585
1611
  asyncDir,
1586
1612
  sessionId: ctx.currentSessionId,
1613
+ completionOwnerId: ctx.completionOwnerId ?? currentCompletionOwnerId(),
1587
1614
  ...(capabilityCeiling ? { capabilityCeiling } : {}),
1588
1615
  piPackageRoot,
1589
1616
  piArgv1: process.argv[1],
@@ -1677,6 +1704,7 @@ export function executeAsyncSingle(
1677
1704
  id,
1678
1705
  pid: spawnResult.pid,
1679
1706
  sessionId: ctx.currentSessionId,
1707
+ completionOwnerId: ctx.completionOwnerId ?? currentCompletionOwnerId(),
1680
1708
  mode: "single",
1681
1709
  agent,
1682
1710
  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,