pi-subagents 0.48.0 → 0.49.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 (42) hide show
  1. package/CHANGELOG.md +47 -19
  2. package/docs/agents.md +1 -0
  3. package/docs/configuration.md +28 -5
  4. package/docs/missions.md +4 -2
  5. package/docs/observability.md +26 -2
  6. package/package.json +1 -1
  7. package/src/agents/agents.ts +27 -12
  8. package/src/api/preflight.ts +1 -1
  9. package/src/extension/config.ts +20 -0
  10. package/src/extension/index.ts +18 -8
  11. package/src/extension/public-execution.ts +30 -4
  12. package/src/extension/schemas.ts +8 -7
  13. package/src/extension/tool-description.ts +10 -8
  14. package/src/inspectors/herdr/actions.ts +11 -2
  15. package/src/inspectors/herdr/inspector-runner.ts +16 -3
  16. package/src/intercom/intercom-bridge.ts +3 -1
  17. package/src/missions/store.ts +8 -3
  18. package/src/runs/background/active-async-capacity.ts +82 -25
  19. package/src/runs/background/async-execution.ts +24 -36
  20. package/src/runs/background/async-job-tracker.ts +4 -0
  21. package/src/runs/background/async-resume.ts +11 -1
  22. package/src/runs/background/fleet-view.ts +18 -5
  23. package/src/runs/background/resume-guidance.ts +27 -7
  24. package/src/runs/background/retained-children.ts +14 -6
  25. package/src/runs/background/run-status.ts +96 -4
  26. package/src/runs/background/subagent-runner.ts +4 -1
  27. package/src/runs/foreground/prompt-audit.ts +4 -3
  28. package/src/runs/foreground/subagent-executor.ts +219 -20
  29. package/src/runs/shared/completion-guard.ts +17 -1
  30. package/src/runs/shared/llm-intent-arbiter.ts +39 -23
  31. package/src/shared/agent-stream-options.ts +5 -0
  32. package/src/shared/artifacts.ts +2 -6
  33. package/src/shared/display-text.ts +50 -0
  34. package/src/shared/node-executable.ts +21 -0
  35. package/src/shared/types.ts +23 -4
  36. package/src/tui/fleet-transcript.ts +1 -48
  37. package/src/tui/fleet.ts +3 -1
  38. package/src/tui/render.ts +86 -39
  39. package/src/watchdog/permission-arbiter.ts +2 -1
  40. package/src/watchdog/review.ts +4 -3
  41. package/src/workflows/chat-progress.ts +8 -2
  42. package/src/workflows/scripted-workflow.ts +46 -7
@@ -8,24 +8,25 @@ const CUSTOM_TOOL_DESCRIPTION_MAX_BYTES = 50 * 1024;
8
8
 
9
9
  export const SUBAGENT_SAFETY_GUIDANCE = `SAFETY-CRITICAL SUBAGENT GUIDANCE:
10
10
  • Use { action: "list" } before execution and only run executable/non-disabled agents.
11
- • Keep execution and management separate: omit action for workflowScript execution; use action only for management/control.
12
- • Async/background runs are the default. Use async:false only when a blocking foreground result is needed. Do not sleep or poll status just to wait; use subagent_wait only when the current request must finish in this turn.
11
+ • Keep execution and management separate: omit action for structured single-child or workflowScript execution; use action only for management/control.
12
+ • Async/background runs are the default. Use async:false only when a blocking foreground result is needed. After an async launch, continue independent work only until its next dependency barrier; consume the result before work that depends on it. Do not sleep or poll status just to wait; use subagent_wait only when the current request must finish in this turn.
13
13
  • Ordinary child subagents are not orchestrators. Only explicitly configured fanout children may use the child-safe subagent tool, still bounded by depth/session limits.
14
14
  • Keep one writer for the same cwd/worktree. Use fresh-context read-only reviewers for independent review, then have the parent synthesize and apply fixes.
15
- • Async runs expose asyncId/asyncDir with status.json, events.jsonl, output logs, and status via { action: "status", id }. Include output paths and residual risks when reporting results.`;
15
+ • Async runs expose asyncId/asyncDir with status.json, events.jsonl, output logs, status via { action: "status", id }, and lifecycle diagnostics via { action: "debug.run", id }. Include output paths and residual risks when reporting results.`;
16
16
 
17
- export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { workflowScript }; omit action. Use action only for management/control actions.
17
+ export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Run one child with { agent, task? }; use { workflowScript } for orchestration. Omit action for execution. Use action only for management/control actions.
18
18
 
19
19
  EXECUTION:
20
20
  • Before executing, use { action: "list" } and run only executable/non-disabled configured agents.
21
- • WORKFLOW SCRIPT: { workflowScript: "return runs.run('main', {agent:'worker', task:'...'})" }. Every execution is a workflow. Use stable-key runs.run for one child and runs.all for parallel children; ordinary JavaScript provides sequence, branching, filtering, retries, and aggregation. workflowScript is an ordinary JavaScript statement body, so use an explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. Scripts start asynchronously by default; pass async:false only for a small foreground run. Same-repo foreground workflows default to a live in-chat card; set chatProgress to auto, off, or live-card to control that projection. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Use await prompts.render("package:name" | "user:name" | "project:name", vars?) for reusable plain task text, then pass the result explicitly as task. Use {action:"children.list"} to list up to 10 completed retained children from this parent session, then continue one with runs.run(key, {resume:"run-id", task:"follow-up"}); resume and agent are mutually exclusive, resume keeps the stored agent/model/tool contract, workflow resumes wait for completed output, and loops must continue from each latest returned runId. For repository mutation lanes, set worktree:true on the workflow or individual runs.run/runs.all item for managed isolation; each parallel child gets a separate worktree and handoff artifact. A workflow usageBudget is enforced once across the workflow. Available globals are runs.run, runs.all, runs.status, runs.ref/refs, prompts.render, emit, console, and standard JavaScript only. Workflows get async state.get(key) and state.set(key, JSONValue) through their automatic or explicit mission; mission:false workflows do not have a state global. Scripts cannot access filesystem, shell, arbitrary Pi tools, or host globals.
21
+ • SINGLE CHILD: { agent:"worker", task:"..." }. This structured form starts exactly one child through the workflow runtime. Workflow-level fields such as model, context, cwd, worktree, output, budgets, acceptance, and async remain defaults for that child. Do not combine agent/task with action or workflowScript.
22
+ • WORKFLOW SCRIPT: { workflowScript: "return runs.run('main', {agent:'worker', task:'...'})" }. Use stable-key runs.run for one child and runs.all for parallel children; ordinary JavaScript provides sequence, branching, filtering, retries, and aggregation. workflowScript is an ordinary JavaScript statement body, so use an explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. Scripts start asynchronously by default; pass async:false only for a small foreground run. Same-repo foreground workflows default to a live in-chat card; set chatProgress to auto, off, or live-card to control that projection. Workflow-level child controls default onto each runs.run launch, and explicit child fields override them. Use await prompts.render("package:name" | "user:name" | "project:name", vars?) for reusable plain task text, then pass the result explicitly as task. Use {action:"children.list"} to list up to 10 completed retained children from this parent session, then continue one with runs.run(key, {resume:"run-id", task:"follow-up"}); resume and agent are mutually exclusive, resume keeps the stored agent/model/tool contract, workflow resumes wait for completed output, and loops must continue from each latest returned runId. For repository mutation lanes, set worktree:true on the workflow or individual runs.run/runs.all item for managed isolation; each parallel child gets a separate worktree and handoff artifact. A workflow usageBudget is enforced once across the workflow. Available globals are runs.run, runs.all, runs.status, runs.ref/refs, prompts.render, emit, console, and standard JavaScript only. Workflows get async state.get(key) and state.set(key, JSONValue) through their automatic or explicit mission; mission:false workflows do not have a state global. Scripts cannot access filesystem, shell, arbitrary Pi tools, or host globals.
22
23
  • Sequential example: { workflowScript: "const a = await runs.run('analyze', {agent:'agent-a', task:'Analyze the request'}); return (await runs.run('plan', {agent:'agent-b', task:'Plan from: '+a.output})).output" }
23
24
  • Parallel example: { workflowScript: "const [a,b] = await runs.all([{key:'correctness',agent:'agent-a',task:'Review correctness'},{key:'tests',agent:'agent-b',task:'Review tests'}]); return {correctness:a.output,tests:b.output}" }
24
25
  • Optional context is "fresh" or "fork". timeoutMs/maxRuntimeMs apply to foreground and async workflows; foreground workflows default to 30 minutes and async workflows have no default timeout. Omit acceptance for reviewer/read-only calls; evidence levels end at verified, and acceptance.review.required requests independent writer review.
25
26
  • Durable mission attachment is automatic by default. Use missionId to attach an existing mission, mission:{...} to override auto-create, or mission:false for ephemeral work. A mission object needs exactly one non-empty title or summary; objective and labels are optional. goal may only be true and requires budget:{tokens}.
26
27
 
27
28
  MANAGEMENT / CONTROL (use action; omit execution fields):
28
- • list, get, models, guide, children.list, create, update, delete, eject, disable, enable, reset, doctor, grant-spawn-budget, worktree.discard, refine/refine.show/refine.rollback, mission.create/list/show/update/resolve-decision/attach-run/close, inspector.open/status/close, project.open/status/close, and watchdog actions remain available. Use {action:"guide", topic:"overview"} for packaged current-version help; topics are overview, workflows, agents, missions, observability, tool-reference, configuration, models, watchdog, and extension-api.
29
+ • list, get, models, guide, children.list, create, update, delete, eject, disable, enable, reset, status, debug.run, doctor, grant-spawn-budget, worktree.discard, refine/refine.show/refine.rollback, mission.create/list/show/update/resolve-decision/attach-run/close, inspector.open/status/close, project.open/status/close, and watchdog actions remain available. Use {action:"guide", topic:"overview"} for packaged current-version help; topics are overview, workflows, agents, missions, observability, tool-reference, configuration, models, watchdog, and extension-api.
29
30
  • status, interrupt, stop, resume, and steer manage live or persisted runs. Use status view:"fleet" for an overview or view:"transcript" with id and optional index to tail output.
30
31
  • { action: "append-step", id: "...", step: {agent:"agent-c", task:"Use {previous}"} } appends one step to an already-running durable legacy chain. step is control-only, not an execution mode.
31
32
  • approve-checkpoint and reject-checkpoint decide a paused durable legacy chain checkpoint.
@@ -33,10 +34,11 @@ MANAGEMENT / CONTROL (use action; omit execution fields):
33
34
 
34
35
  ${SUBAGENT_SAFETY_GUIDANCE}`;
35
36
 
36
- export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { workflowScript }; omit action. Use action only for management/control actions.
37
+ export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Run one child with { agent, task? }; use { workflowScript } for orchestration. Omit action for execution. Use action only for management/control actions.
37
38
 
38
39
  EXECUTE:
39
40
  • Call { action:"list" } first and use only executable/non-disabled agents.
41
+ • SINGLE {agent:"worker",task:"..."} starts exactly one child through the workflow runtime. Workflow-level fields remain child defaults. Do not combine agent/task with action or workflowScript.
40
42
  • SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and runs.all for parallel work. Use await prompts.render("package:name" | "user:name" | "project:name", vars?) for reusable task text and pass it explicitly to runs.run. Use {action:"children.list"} for the last 10 retained children in this parent session, then runs.run(key,{resume:"run-id",task:"follow-up"}) to continue one with its stored contract; workflow resumes wait for completion and loops continue from the latest returned runId. Workflows get async state.get/state.set through their automatic or explicit mission; mission:false does not. Scripts are ordinary JavaScript statement bodies; use explicit return for a useful result. For task text with Markdown fences or shell blocks, build quoted lines instead of nesting raw template literals: \`const task=["Run:","\`\`\`bash","npm test","\`\`\`"].join("\\n")\`. Use JavaScript for sequence, branching, retries, and aggregation. For repository mutation lanes, use worktree:true on the workflow or runs.run/runs.all item for managed isolation. Scripts start async by default; async:false is the foreground escape hatch and auto-enables a same-repo live chat card unless chatProgress is off.
41
43
  • Example: {workflowScript:"const [a,b]=await runs.all([{key:'a',agent:'agent-a',task:'Implement A',worktree:true},{key:'b',agent:'agent-b',task:'Implement B',worktree:true}]); return [a.output,b.output]"}
42
44
  • context can be fresh or fork. timeoutMs/maxRuntimeMs apply to foreground and async workflows; foreground workflows default to 30 minutes and async workflows have no default timeout. Omit acceptance for reviewer/read-only calls.
@@ -47,7 +49,7 @@ MANAGE / CONTROL:
47
49
  • A mission object needs exactly one non-empty title or summary; objective and labels are optional. goal may only be true and requires budget:{tokens}.
48
50
 
49
51
  ASYNC / SAFETY:
50
- • Omitted async detaches background work. Do not sleep or poll merely to wait; use subagent_wait only when this turn must receive results.
52
+ • Omitted async detaches background work. Continue independent work only until its next dependency barrier; consume the result before work that depends on it. Do not sleep or poll merely to wait; use subagent_wait only when this turn must receive results.
51
53
  • Ordinary children are not orchestrators. Keep one writer per cwd/worktree and use fresh read-only reviewers for independent checks.
52
54
  • Status and artifacts live under asyncId/asyncDir with status.json, events.jsonl, output logs, and {action:"status",id:"..."}.`;
53
55
 
@@ -10,6 +10,7 @@ import { writeAtomicJson } from "../../shared/atomic-json.ts";
10
10
  import { DIRS, type Details, type SubagentState } from "../../shared/types.ts";
11
11
  import { readStatus } from "../../shared/utils.ts";
12
12
  import { resolveSubagentRunId } from "../../runs/background/run-id-resolver.ts";
13
+ import { resolveNodeExecutable } from "../../shared/node-executable.ts";
13
14
  import { createHerdrClient, detectHerdr, type HerdrClient, type HerdrErrorCode, type HerdrResult } from "./client.ts";
14
15
 
15
16
  export const HERDR_INSPECTOR_ACTIONS = ["inspector.open", "inspector.status", "inspector.close"] as const;
@@ -45,6 +46,7 @@ interface InspectorDeps {
45
46
  client?: HerdrClient;
46
47
  missions?: MissionStoreConfig;
47
48
  authorityPolicy?: AuthorityPolicyConfig;
49
+ sessionRoots?: string[];
48
50
  cwd: string;
49
51
  signal?: AbortSignal;
50
52
  now?: () => Date;
@@ -89,8 +91,8 @@ function shellQuote(value: string): string {
89
91
  return `'${value.replaceAll("'", "'\\''")}'`;
90
92
  }
91
93
 
92
- function inspectorCommand(input: { runnerPath: string; asyncDir: string; runId: string; index?: number; missionPath?: string; allowSteer: boolean; allowStop: boolean }): string {
93
- const args = [process.execPath, input.runnerPath, "--async-dir", input.asyncDir, "--run-id", input.runId, "--allow-steer", String(input.allowSteer), "--allow-stop", String(input.allowStop)];
94
+ function inspectorCommand(input: { runnerPath: string; asyncDir: string; runId: string; index?: number; missionPath?: string; allowSteer: boolean; allowStop: boolean; sessionRoots: string[] }): string {
95
+ const args = [resolveNodeExecutable(), input.runnerPath, "--async-dir", input.asyncDir, "--run-id", input.runId, "--allow-steer", String(input.allowSteer), "--allow-stop", String(input.allowStop), "--session-roots", JSON.stringify(input.sessionRoots)];
94
96
  if (input.index !== undefined) args.push("--index", String(input.index));
95
97
  if (input.missionPath) args.push("--mission-path", input.missionPath);
96
98
  return `${process.platform === "win32" ? "& " : ""}${args.map(shellQuote).join(" ")}`;
@@ -114,6 +116,12 @@ function pathWithin(base: string, candidate: string): boolean {
114
116
  return resolvedCandidate === resolvedBase || resolvedCandidate.startsWith(`${resolvedBase}${path.sep}`);
115
117
  }
116
118
 
119
+ function herdrSessionRoots(target: { runId: string }, deps: InspectorDeps): string[] {
120
+ const roots = deps.sessionRoots ?? deps.state?.trustedSessionRoots ?? [];
121
+ const job = deps.state?.asyncJobs.get(target.runId) ?? deps.state?.fleetJobs?.get(target.runId);
122
+ return [...new Set([...roots, ...(job?.sessionRoot ? [job.sessionRoot] : [])])];
123
+ }
124
+
117
125
  function isTrustedAsyncDir(asyncDir: string, deps: InspectorDeps): boolean {
118
126
  try {
119
127
  if (fs.lstatSync(asyncDir).isSymbolicLink() || !fs.statSync(asyncDir).isDirectory()) return false;
@@ -204,6 +212,7 @@ export async function handleHerdrInspectorAction(action: HerdrInspectorAction, p
204
212
  missionPath: mission?.path,
205
213
  allowSteer: resolveAuthorityDecision({ action: "steerRun", policy: deps.authorityPolicy }) === "auto",
206
214
  allowStop: resolveAuthorityDecision({ action: "stopRun", policy: deps.authorityPolicy }) === "auto",
215
+ sessionRoots: herdrSessionRoots(target, deps),
207
216
  });
208
217
  const started = await client.run(["pane", "run", paneId, command], { timeoutMs: 15_000, signal: deps.signal });
209
218
  if (started.ok === false) {
@@ -17,6 +17,7 @@ export interface RunnerOptions {
17
17
  refreshMs: number;
18
18
  allowSteer?: boolean;
19
19
  allowStop?: boolean;
20
+ sessionRoots: string[];
20
21
  }
21
22
 
22
23
  function readMission(filePath: string | undefined): MissionRecord | undefined {
@@ -24,7 +25,7 @@ function readMission(filePath: string | undefined): MissionRecord | undefined {
24
25
  try { return parseMissionRecord(JSON.parse(fs.readFileSync(filePath, "utf-8")), filePath); } catch { return undefined; }
25
26
  }
26
27
 
27
- export function formatInspectorDashboard(input: { status: AsyncStatus; asyncDir: string; index?: number; mission?: MissionRecord; allowSteer?: boolean; allowStop?: boolean }): string {
28
+ export function formatInspectorDashboard(input: { status: AsyncStatus; asyncDir: string; index?: number; mission?: MissionRecord; allowSteer?: boolean; allowStop?: boolean; sessionRoots?: string[] }): string {
28
29
  const { status, asyncDir, mission } = input;
29
30
  const lines = [
30
31
  `pi-subagents inspector for ${status.runId}`,
@@ -37,7 +38,7 @@ export function formatInspectorDashboard(input: { status: AsyncStatus; asyncDir:
37
38
  if (open.length) lines.push(`Open decisions: ${open.map((decision) => `${decision.id}: ${decision.title}`).join(" | ")}`);
38
39
  lines.push("");
39
40
  }
40
- lines.push(formatAsyncRunTranscript(status, asyncDir, { index: input.index, lines: 60 }));
41
+ lines.push(formatAsyncRunTranscript(status, asyncDir, { index: input.index, lines: 60, sessionRoots: input.sessionRoots }));
41
42
  const controls = [input.allowSteer === false ? undefined : "steer <message>", input.allowStop === false ? undefined : "stop", "status"].filter(Boolean);
42
43
  lines.push("", `Controls: ${controls.join(" | ")}`, "Supervisor replies remain in the parent Pi session (subagent_supervisor/intercom).");
43
44
  return lines.join("\n");
@@ -57,6 +58,17 @@ function parseArgs(argv: string[]): RunnerOptions {
57
58
  const indexRaw = values.get("--index");
58
59
  const childIndex = indexRaw === undefined ? undefined : Number(indexRaw);
59
60
  if (childIndex !== undefined && (!Number.isInteger(childIndex) || childIndex < 0)) throw new Error("--index must be a non-negative integer.");
61
+ const sessionRootsRaw = values.get("--session-roots");
62
+ let sessionRoots: string[] = [];
63
+ if (sessionRootsRaw !== undefined) {
64
+ try {
65
+ const parsed = JSON.parse(sessionRootsRaw) as unknown;
66
+ if (!Array.isArray(parsed) || parsed.some((root) => typeof root !== "string")) throw new Error();
67
+ sessionRoots = parsed;
68
+ } catch {
69
+ throw new Error("--session-roots must be a JSON array of strings.");
70
+ }
71
+ }
60
72
  const refreshRaw = values.get("--refresh-ms");
61
73
  const refreshMs = refreshRaw === undefined ? 1_500 : Number(refreshRaw);
62
74
  if (!Number.isInteger(refreshMs) || refreshMs < 250) throw new Error("--refresh-ms must be an integer >= 250.");
@@ -65,6 +77,7 @@ function parseArgs(argv: string[]): RunnerOptions {
65
77
  runId,
66
78
  ...(childIndex !== undefined ? { index: childIndex } : {}),
67
79
  ...(values.get("--mission-path") ? { missionPath: path.resolve(values.get("--mission-path")!) } : {}),
80
+ sessionRoots,
68
81
  refreshMs,
69
82
  allowSteer: values.get("--allow-steer") !== "false",
70
83
  allowStop: values.get("--allow-stop") !== "false",
@@ -115,7 +128,7 @@ export function runInspector(argv = process.argv.slice(2)): void {
115
128
  process.stdout.write(`\x1b[2J\x1b[Hpi-subagents inspector\n\nLifecycle status for ${options.runId} is unavailable.\n`);
116
129
  return;
117
130
  }
118
- process.stdout.write(`\x1b[2J\x1b[H${formatInspectorDashboard({ status, asyncDir: options.asyncDir, index: options.index, mission: readMission(options.missionPath), allowSteer: options.allowSteer, allowStop: options.allowStop })}${notice ? `\n\n${notice}` : ""}\n> `);
131
+ process.stdout.write(`\x1b[2J\x1b[H${formatInspectorDashboard({ status, asyncDir: options.asyncDir, index: options.index, mission: readMission(options.missionPath), allowSteer: options.allowSteer, allowStop: options.allowStop, sessionRoots: options.sessionRoots })}${notice ? `\n\n${notice}` : ""}\n> `);
119
132
  if (isTerminal(status) && timer) {
120
133
  clearInterval(timer);
121
134
  timer = undefined;
@@ -51,6 +51,8 @@ export interface IntercomBridgeDiagnostic {
51
51
 
52
52
  interface ResolveIntercomBridgeInput {
53
53
  config: ExtensionConfig["intercomBridge"];
54
+ /** Per-run config replaces the global config when supplied. */
55
+ override?: IntercomBridgeConfig;
54
56
  context: "fresh" | "fork" | undefined;
55
57
  orchestratorTarget?: string;
56
58
  cwd?: string;
@@ -143,7 +145,7 @@ export function diagnoseIntercomBridge(input: ResolveIntercomBridgeInput): Inter
143
145
  }
144
146
 
145
147
  export function resolveIntercomBridge(input: ResolveIntercomBridgeInput): IntercomBridgeState {
146
- const config = resolveIntercomBridgeConfig(input.config);
148
+ const config = resolveIntercomBridgeConfig(input.override !== undefined ? input.override : input.config);
147
149
  const mode = config.mode;
148
150
  const orchestratorTarget = input.orchestratorTarget?.trim();
149
151
  const agentDir = path.resolve(input.agentDir ?? defaultAgentDir());
@@ -2,7 +2,6 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
- import { getProjectSubagentsDir } from "../shared/artifacts.ts";
6
5
  import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
7
6
  import { getAgentDir } from "../shared/utils.ts";
8
7
  import {
@@ -259,6 +258,11 @@ function expandConfiguredPath(value: string, projectRoot: string): string {
259
258
  return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(projectRoot, expanded);
260
259
  }
261
260
 
261
+ function projectMissionDirectory(agentDir: string, projectRoot: string): string {
262
+ const projectKey = createHash("sha256").update(projectRoot).digest("hex");
263
+ return path.join(agentDir, "missions", "projects", projectKey);
264
+ }
265
+
262
266
  export function validateMissionStoreConfig(value: unknown, label = "config.missions"): MissionStoreConfig | undefined {
263
267
  if (value === undefined) return undefined;
264
268
  const input = asObject(value, label);
@@ -289,12 +293,13 @@ export function resolveMissionStoreLocation(input: {
289
293
  agentDir?: string;
290
294
  }): MissionStoreLocation {
291
295
  const projectRoot = path.resolve(input.projectRoot);
296
+ const agentDir = input.agentDir ?? getAgentDir();
292
297
  const missionDir = input.config?.directory
293
298
  ? expandConfiguredPath(input.config.directory, projectRoot)
294
- : path.join(getProjectSubagentsDir(projectRoot), "missions");
299
+ : projectMissionDirectory(agentDir, projectRoot);
295
300
  const globalIndexDir = input.config?.globalIndexDir
296
301
  ? expandConfiguredPath(input.config.globalIndexDir, projectRoot)
297
- : path.join(input.agentDir ?? getAgentDir(), "missions", "index");
302
+ : path.join(agentDir, "missions", "index");
298
303
  return {
299
304
  projectRoot,
300
305
  missionDir,
@@ -39,6 +39,18 @@ interface CapacityOptions {
39
39
  afterSlotRename?: (releasedDir: string) => void;
40
40
  }
41
41
 
42
+ export type ActiveAsyncCapacityReleaseVerdict =
43
+ | { state: "releasable"; reason: string }
44
+ | { state: "retained"; reason: string }
45
+ | { state: "not-owned"; reason: string };
46
+
47
+ export interface ActiveAsyncCapacityInspection {
48
+ owner?: ActiveAsyncCapacityOwnerV1;
49
+ relation: "current" | "source" | "none";
50
+ slotDir?: string;
51
+ release: ActiveAsyncCapacityReleaseVerdict;
52
+ }
53
+
42
54
  export class ActiveAsyncCapacityError extends Error {
43
55
  readonly snapshot: ActiveAsyncCapacitySnapshot;
44
56
 
@@ -160,55 +172,100 @@ function terminalState(state: AsyncStatus["state"]): boolean {
160
172
  return state !== "queued" && state !== "running" && state !== "paused";
161
173
  }
162
174
 
163
- function runnerCanRelease(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus): boolean {
164
- if (!owner.runnerProcessInstanceId
165
- || status.sessionId !== owner.ownerSessionId
166
- || status.runId !== owner.runId
167
- || !terminalState(status.state)) return false;
175
+ function runnerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null): ActiveAsyncCapacityReleaseVerdict {
176
+ if (!status) return { state: "retained", reason: "status file is missing or unreadable" };
177
+ if (!owner.runnerProcessInstanceId) return { state: "retained", reason: "runner process identity has not been recorded" };
178
+ if (status.sessionId !== owner.ownerSessionId) return { state: "retained", reason: `status session ${status.sessionId ?? "unknown"} does not match owner session ${owner.ownerSessionId}` };
179
+ if (status.runId !== owner.runId) return { state: "retained", reason: `status run ${status.runId} does not match owner run ${owner.runId}` };
180
+ if (!terminalState(status.state)) return { state: "retained", reason: `run is still ${status.state}` };
168
181
  if (status.processTerminal?.state === "not-started"
169
182
  && status.processTerminal.runId === owner.runId
170
183
  && status.processTerminal.runnerProcessInstanceId === owner.runnerProcessInstanceId
171
184
  && typeof status.error === "string"
172
- && status.error) return true;
185
+ && status.error) return { state: "releasable", reason: "run failed before child startup completed" };
173
186
  const proof = readProcessTerminal(owner.asyncDir, {
174
187
  runId: owner.runId,
175
188
  runnerProcessInstanceId: owner.runnerProcessInstanceId,
176
189
  });
177
190
  return proof?.state === "observed"
178
191
  && proof.runId === owner.runId
179
- && proof.runnerProcessInstanceId === owner.runnerProcessInstanceId;
192
+ && proof.runnerProcessInstanceId === owner.runnerProcessInstanceId
193
+ ? { state: "releasable", reason: "matching observed process-terminal proof is present" }
194
+ : { state: "retained", reason: `process-terminal proof is ${proof?.state ?? "missing"}` };
180
195
  }
181
196
 
182
- function workflowCanRelease(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus, liveWorkflowRunIds: ReadonlySet<string>): boolean {
183
- if (status.sessionId !== owner.ownerSessionId
184
- || status.runId !== owner.runId
185
- || status.mode !== "workflow"
186
- || !terminalState(status.state)
187
- || liveWorkflowRunIds.has(owner.runId)) return false;
197
+ function workflowReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, status: AsyncStatus | null, liveWorkflowRunIds: ReadonlySet<string>): ActiveAsyncCapacityReleaseVerdict {
198
+ if (!status) return { state: "retained", reason: "status file is missing or unreadable" };
199
+ if (status.sessionId !== owner.ownerSessionId) return { state: "retained", reason: `status session ${status.sessionId ?? "unknown"} does not match owner session ${owner.ownerSessionId}` };
200
+ if (status.runId !== owner.runId) return { state: "retained", reason: `status run ${status.runId} does not match owner run ${owner.runId}` };
201
+ if (status.mode !== "workflow") return { state: "retained", reason: `status mode is ${status.mode}, not workflow` };
202
+ if (!terminalState(status.state)) return { state: "retained", reason: `workflow is still ${status.state}` };
203
+ if (liveWorkflowRunIds.has(owner.runId)) return { state: "retained", reason: "workflow controller is still live" };
188
204
  for (const step of status.steps ?? []) {
189
- if (step.status === "pending" || step.status === "running" || step.status === "paused") return false;
190
- if (typeof step.async !== "boolean") return false;
205
+ const label = step.workflowKey ?? step.agent;
206
+ if (step.status === "pending" || step.status === "running" || step.status === "paused") return { state: "retained", reason: `workflow child ${label} is still ${step.status}` };
207
+ if (typeof step.async !== "boolean") return { state: "retained", reason: `workflow child ${label} is missing async classification` };
191
208
  if (!step.async) continue;
192
- if (!step.runId) return false;
209
+ if (!step.runId) return { state: "retained", reason: `async workflow child ${label} is missing run id` };
193
210
  const childDir = path.join(path.dirname(owner.asyncDir), step.runId);
194
- if (!fs.existsSync(childDir)) return false;
211
+ if (!fs.existsSync(childDir)) return { state: "retained", reason: `async workflow child ${label} directory is missing` };
195
212
  const childStatus = readStatus(childDir);
196
- if (!childStatus || !terminalState(childStatus.state) || !childStatus.processTerminal?.runnerProcessInstanceId) return false;
213
+ if (!childStatus) return { state: "retained", reason: `async workflow child ${label} status is missing or unreadable` };
214
+ if (!terminalState(childStatus.state)) return { state: "retained", reason: `async workflow child ${label} is still ${childStatus.state}` };
215
+ if (!childStatus.processTerminal?.runnerProcessInstanceId) return { state: "retained", reason: `async workflow child ${label} has no runner process identity` };
197
216
  const proof = readProcessTerminal(childDir, {
198
217
  runId: step.runId,
199
218
  runnerProcessInstanceId: childStatus.processTerminal.runnerProcessInstanceId,
200
219
  });
201
- if (proof?.state !== "observed" || proof.runId !== step.runId) return false;
220
+ if (proof?.state !== "observed" || proof.runId !== step.runId) return { state: "retained", reason: `async workflow child ${label} process-terminal proof is ${proof?.state ?? "missing"}` };
202
221
  }
203
- return true;
222
+ return { state: "releasable", reason: "workflow is terminal, controller is gone, and async children have observed proof" };
204
223
  }
205
224
 
206
- function ownerCanRelease(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string>): boolean {
225
+ function ownerReleaseVerdict(owner: ActiveAsyncCapacityOwnerV1, liveWorkflowRunIds: ReadonlySet<string>): ActiveAsyncCapacityReleaseVerdict {
207
226
  const status = readStatus(owner.asyncDir);
208
- if (!status) return false;
209
227
  return owner.kind === "runner"
210
- ? runnerCanRelease(owner, status)
211
- : workflowCanRelease(owner, status, liveWorkflowRunIds);
228
+ ? runnerReleaseVerdict(owner, status)
229
+ : workflowReleaseVerdict(owner, status, liveWorkflowRunIds);
230
+ }
231
+
232
+ function capacitySessionDirs(rootDir: string, sessionId?: string): string[] {
233
+ if (sessionId) return [sessionDir(sessionId, rootDir)];
234
+ try {
235
+ return fs.readdirSync(rootDir, { withFileTypes: true })
236
+ .filter((entry) => entry.isDirectory())
237
+ .map((entry) => path.join(rootDir, entry.name));
238
+ } catch (error) {
239
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
240
+ throw error;
241
+ }
242
+ }
243
+
244
+ export function inspectActiveAsyncCapacityOwner(
245
+ input: { runId: string; sessionId?: string; asyncDir?: string },
246
+ options: CapacityOptions & { liveWorkflowRunIds?: ReadonlySet<string> } = {},
247
+ ): ActiveAsyncCapacityInspection {
248
+ const rootDir = options.rootDir ?? ACTIVE_ASYNC_CAPACITY_DIR;
249
+ const liveWorkflowRunIds = options.liveWorkflowRunIds ?? new Set<string>();
250
+ for (const poolDir of capacitySessionDirs(rootDir, input.sessionId)) {
251
+ for (const dir of occupiedSlots(poolDir)) {
252
+ const owner = readOwner(dir);
253
+ if (!owner) continue;
254
+ const sameRun = owner.runId === input.runId || (input.asyncDir !== undefined && path.resolve(owner.asyncDir) === path.resolve(input.asyncDir));
255
+ const sourceRun = owner.sourceRunId === input.runId;
256
+ if (!sameRun && !sourceRun) continue;
257
+ if (sourceRun && !sameRun) {
258
+ return {
259
+ owner,
260
+ relation: "source",
261
+ slotDir: dir,
262
+ release: { state: "not-owned", reason: `slot was transferred to ${owner.runId}` },
263
+ };
264
+ }
265
+ return { owner, relation: "current", slotDir: dir, release: ownerReleaseVerdict(owner, liveWorkflowRunIds) };
266
+ }
267
+ }
268
+ return { relation: "none", release: { state: "not-owned", reason: "no active-capacity slot records this run" } };
212
269
  }
213
270
 
214
271
  export function reconcileActiveAsyncCapacity(
@@ -225,7 +282,7 @@ export function reconcileActiveAsyncCapacity(
225
282
  || owner.ownerSessionId !== sessionId
226
283
  || owner.ownerSessionKey !== activeAsyncCapacitySessionKey(sessionId)
227
284
  || path.basename(dir) !== `slot-${owner.slot}`
228
- || !ownerCanRelease(owner, liveWorkflowRunIds)) continue;
285
+ || ownerReleaseVerdict(owner, liveWorkflowRunIds).state !== "releasable") continue;
229
286
  removeOwnedSlot(dir, owner, options);
230
287
  }
231
288
  return snapshotFor(sessionId, limit, rootDir);
@@ -19,6 +19,7 @@ import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isPara
19
19
  import type { RunnerStep } from "../shared/parallel-utils.ts";
20
20
  import type { ContextMode } from "../shared/context-mode.ts";
21
21
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
22
+ import { resolveNodeExecutable } from "../../shared/node-executable.ts";
22
23
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
23
24
  import { buildAgentMemoryInjection } from "../../agents/agent-memory.ts";
24
25
  import { PI_CODING_AGENT_PACKAGE_ROOT_ENV, PROMPT_REDACTED, resolveChildCwd } from "../../shared/utils.ts";
@@ -37,6 +38,7 @@ import {
37
38
  type AsyncStatus,
38
39
  type ArtifactConfig,
39
40
  type Details,
41
+ type IntercomBridgeConfig,
40
42
  type JsonSchemaObject,
41
43
  type MaxOutputConfig,
42
44
  type NestedRouteInfo,
@@ -187,6 +189,8 @@ interface AsyncSingleParams {
187
189
  /** Raw caller-facing goal used only by the started event. */
188
190
  goal?: string;
189
191
  agentConfig: AgentConfig;
192
+ /** Agent contract before per-run bridge injection, used only for recovery persistence. */
193
+ recoveryAgentConfig?: AgentConfig;
190
194
  ctx: AsyncExecutionContext;
191
195
  cwd?: string;
192
196
  maxOutput?: MaxOutputConfig;
@@ -214,6 +218,7 @@ interface AsyncSingleParams {
214
218
  worktreeSetupHookTimeoutMs?: number;
215
219
  worktreeBaseDir?: string;
216
220
  controlConfig?: ResolvedControlConfig;
221
+ intercomBridge?: IntercomBridgeConfig;
217
222
  controlIntercomTarget?: string;
218
223
  childIntercomTarget?: (agent: string, index: number) => string | undefined;
219
224
  nestedRoute?: NestedRouteInfo;
@@ -299,27 +304,6 @@ export function isAsyncAvailable(): boolean {
299
304
  return jitiCliPath !== undefined;
300
305
  }
301
306
 
302
- function isNodeExecutableName(execPath: string): boolean {
303
- const basename = path.basename(execPath).toLowerCase();
304
- return basename === "node" || basename === "node.exe" || basename === "nodejs" || basename === "nodejs.exe";
305
- }
306
-
307
- function canUseCurrentNodeExecutable(execPath: string): boolean {
308
- try {
309
- fs.accessSync(execPath, process.platform === "win32" ? fs.constants.F_OK : fs.constants.X_OK);
310
- return true;
311
- } catch {
312
- return false;
313
- }
314
- }
315
-
316
- function resolveAsyncRunnerNodeCommand(): string {
317
- if (isNodeExecutableName(process.execPath) && canUseCurrentNodeExecutable(process.execPath)) {
318
- return process.execPath;
319
- }
320
- return process.platform === "win32" ? "node.exe" : "node";
321
- }
322
-
323
307
  export function resolveAsyncRunnerLogPaths(cfg: object): { stdoutPath: string; stderrPath: string } | undefined {
324
308
  const asyncDir = typeof (cfg as { asyncDir?: unknown }).asyncDir === "string"
325
309
  ? (cfg as { asyncDir: string }).asyncDir
@@ -486,7 +470,7 @@ function spawnRunner(cfg: object, suffix: string, cwd: string, onProcessTerminal
486
470
  const launchConfig = { ...cfg, runnerProcessInstanceId };
487
471
  fs.writeFileSync(cfgPath, JSON.stringify(launchConfig));
488
472
  const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
489
- const nodeCommand = resolveAsyncRunnerNodeCommand();
473
+ const nodeCommand = resolveNodeExecutable();
490
474
  const launchForStartup = launchConfig as typeof launchConfig & { asyncDir?: unknown; id?: unknown; sessionId?: unknown; revivalLease?: unknown };
491
475
  const launchAsyncDir = typeof launchForStartup.asyncDir === "string" ? launchForStartup.asyncDir : undefined;
492
476
  const launchRunId = typeof launchForStartup.id === "string" ? launchForStartup.id : suffix;
@@ -1251,6 +1235,7 @@ export function executeAsyncChain(
1251
1235
  workflowGraph,
1252
1236
  cwd: runnerCwd,
1253
1237
  asyncDir,
1238
+ ...(sessionRoot ? { sessionRoot } : {}),
1254
1239
  ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}),
1255
1240
  ...(initialTurnBudget ? { turnBudget: initialTurnBudget } : {}),
1256
1241
  ...(initialUsageBudget ? { usageBudget: initialUsageBudget } : {}),
@@ -1451,6 +1436,7 @@ export function executeAsyncSingle(
1451
1436
  async: true,
1452
1437
  agentContract: params.agentContract,
1453
1438
  });
1439
+ const recoveryAgentConfig = params.recoveryAgentConfig ?? agentConfig;
1454
1440
  const recoveryDescriptor: SteeringRecoveryDescriptor = {
1455
1441
  version: 1,
1456
1442
  launchContractDigest,
@@ -1462,30 +1448,31 @@ export function executeAsyncSingle(
1462
1448
  ...(sessionFile ? { sessionFile } : {}),
1463
1449
  cwd: runnerCwd,
1464
1450
  ...(model ? { model } : {}),
1465
- ...(agentConfig.fallbackModels ? { fallbackModels: [...agentConfig.fallbackModels] } : {}),
1451
+ ...(recoveryAgentConfig.fallbackModels ? { fallbackModels: [...recoveryAgentConfig.fallbackModels] } : {}),
1466
1452
  ...(effectiveThinking ? { thinking: resolveEffectiveThinking(model, effectiveThinking) } : {}),
1467
- ...(agentConfig.tools ? { tools: [...agentConfig.tools] } : {}),
1468
- ...(agentConfig.extensions ? { extensions: [...agentConfig.extensions] } : {}),
1469
- ...(agentConfig.subagentOnlyExtensions ? { subagentOnlyExtensions: [...agentConfig.subagentOnlyExtensions] } : {}),
1470
- ...(agentConfig.mcpDirectTools ? { mcpDirectTools: [...agentConfig.mcpDirectTools] } : {}),
1471
- ...(agentConfig.systemPrompt ? { systemPrompt: agentConfig.systemPrompt } : {}),
1472
- systemPromptMode: agentConfig.systemPromptMode,
1473
- inheritProjectContext: agentConfig.inheritProjectContext,
1474
- inheritSkills: agentConfig.inheritSkills,
1453
+ ...(recoveryAgentConfig.tools ? { tools: [...recoveryAgentConfig.tools] } : {}),
1454
+ ...(recoveryAgentConfig.extensions ? { extensions: [...recoveryAgentConfig.extensions] } : {}),
1455
+ ...(recoveryAgentConfig.subagentOnlyExtensions ? { subagentOnlyExtensions: [...recoveryAgentConfig.subagentOnlyExtensions] } : {}),
1456
+ ...(recoveryAgentConfig.mcpDirectTools ? { mcpDirectTools: [...recoveryAgentConfig.mcpDirectTools] } : {}),
1457
+ ...(recoveryAgentConfig.systemPrompt ? { systemPrompt: recoveryAgentConfig.systemPrompt } : {}),
1458
+ systemPromptMode: recoveryAgentConfig.systemPromptMode,
1459
+ inheritProjectContext: recoveryAgentConfig.inheritProjectContext,
1460
+ inheritSkills: recoveryAgentConfig.inheritSkills,
1475
1461
  ...(resolvedSkills.length ? { skills: resolvedSkills.map((skill) => skill.name) } : {}),
1476
- ...(agentConfig.skillPath ? { skillPath: [...agentConfig.skillPath] } : {}),
1477
- ...(agentConfig.filePath ? { agentFilePath: agentConfig.filePath } : {}),
1478
- ...(agentConfig.completionGuard !== undefined ? { completionGuard: agentConfig.completionGuard } : {}),
1479
- ...(agentConfig.memory ? { memory: { ...agentConfig.memory } } : {}),
1462
+ ...(recoveryAgentConfig.skillPath ? { skillPath: [...recoveryAgentConfig.skillPath] } : {}),
1463
+ ...(recoveryAgentConfig.filePath ? { agentFilePath: recoveryAgentConfig.filePath } : {}),
1464
+ ...(recoveryAgentConfig.completionGuard !== undefined ? { completionGuard: recoveryAgentConfig.completionGuard } : {}),
1465
+ ...(recoveryAgentConfig.memory ? { memory: { ...recoveryAgentConfig.memory } } : {}),
1480
1466
  ...(outputPath ? { outputPath } : {}),
1481
1467
  outputMode,
1482
1468
  ...(params.structuredOutputSchema ? { structuredOutputSchema: params.structuredOutputSchema } : {}),
1483
1469
  ...(params.acceptance !== undefined ? { acceptance: params.acceptance } : {}),
1484
1470
  ...(controlConfig ? { controlConfig } : {}),
1471
+ ...(params.intercomBridge !== undefined ? { intercomBridge: params.intercomBridge } : {}),
1485
1472
  ...(deadlineAt !== undefined ? { absoluteDeadlineAt: deadlineAt } : {}),
1486
1473
  ...(initialTurnBudget ? { initialTurnBudget: { maxTurns: initialTurnBudget.maxTurns, graceTurns: initialTurnBudget.graceTurns } } : {}),
1487
1474
  ...(resolvedToolBudget.budget ? { initialToolBudget: resolvedToolBudget.budget } : {}),
1488
- maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, agentConfig.maxSubagentDepth),
1475
+ maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, recoveryAgentConfig.maxSubagentDepth),
1489
1476
  ...(maxOutput ? { maxOutput } : {}),
1490
1477
  share: shareEnabled,
1491
1478
  ...(resolvedSessionDir ? { sessionDir: resolvedSessionDir } : {}),
@@ -1654,6 +1641,7 @@ export function executeAsyncSingle(
1654
1641
  goal: (params.goal ?? task).trim() ? PROMPT_REDACTED : undefined,
1655
1642
  cwd: runnerCwd,
1656
1643
  asyncDir,
1644
+ ...(sessionRoot ? { sessionRoot } : {}),
1657
1645
  launchContractDigest,
1658
1646
  launchResolvedExtensions,
1659
1647
  ...(params.parentWorkflowRunId ? { parentWorkflowRunId: params.parentWorkflowRunId } : {}),
@@ -557,10 +557,13 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
557
557
  const agents = firstGroupCount && firstGroupCount > 0
558
558
  ? rawAgents?.slice(0, firstGroupCount)
559
559
  : rawAgents;
560
+ const sessionRoot = state.liveAsyncSessionRoots?.get(info.id);
561
+ state.liveAsyncSessionRoots?.delete(info.id);
560
562
  state.asyncJobs.set(info.id, {
561
563
  asyncId: info.id,
562
564
  asyncDir,
563
565
  ...(typeof info.cwd === "string" ? { cwd: path.resolve(info.cwd) } : {}),
566
+ ...(sessionRoot ? { sessionRoot } : {}),
564
567
  status: "queued",
565
568
  pid: typeof info.pid === "number" ? info.pid : undefined,
566
569
  ...(typeof info.sessionId === "string" ? { sessionId: info.sessionId } : {}),
@@ -637,6 +640,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
637
640
  state.asyncJobs.clear();
638
641
  state.fleetJobs?.clear();
639
642
  state.foregroundControls?.clear();
643
+ state.liveAsyncSessionRoots?.clear();
640
644
  state.lastForegroundControlId = null;
641
645
  state.resultFileCoalescer.clear();
642
646
  if (ctx?.hasUI) {
@@ -307,7 +307,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
307
307
  "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "fallbackModels", "thinking", "tools", "extensions",
308
308
  "subagentOnlyExtensions", "mcpDirectTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritSkills", "skills",
309
309
  "skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
310
- "artifactsDir", "maxOutput", "controlConfig", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
310
+ "artifactsDir", "maxOutput", "controlConfig", "intercomBridge", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
311
311
  "launchResolvedExtensions", "runFanoutBudget",
312
312
  ]);
313
313
  for (const field of Object.keys(parsed)) {
@@ -374,6 +374,16 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
374
374
  if (artifact.includeTranscript !== undefined && typeof artifact.includeTranscript !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': artifactConfig.includeTranscript must be a boolean.`);
375
375
  if (!Number.isInteger(artifact.cleanupDays) || (artifact.cleanupDays as number) < 0) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': artifactConfig.cleanupDays must be a non-negative integer.`);
376
376
  }
377
+ if (parsed.intercomBridge !== undefined) {
378
+ if (!parsed.intercomBridge || typeof parsed.intercomBridge !== "object" || Array.isArray(parsed.intercomBridge)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': intercomBridge must be an object.`);
379
+ const bridge = parsed.intercomBridge as Record<string, unknown>;
380
+ for (const field of Object.keys(bridge)) {
381
+ if (field !== "mode" && field !== "instructionFile" && field !== "resultDelivery") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': intercomBridge.${field} is not supported.`);
382
+ }
383
+ if (bridge.mode !== undefined && bridge.mode !== "off" && bridge.mode !== "fork-only" && bridge.mode !== "always") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': intercomBridge.mode is invalid.`);
384
+ if (bridge.instructionFile !== undefined && typeof bridge.instructionFile !== "string") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': intercomBridge.instructionFile must be a string.`);
385
+ if (bridge.resultDelivery !== undefined && typeof bridge.resultDelivery !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': intercomBridge.resultDelivery must be a boolean.`);
386
+ }
377
387
  if (parsed.controlConfig !== undefined) {
378
388
  if (!parsed.controlConfig || typeof parsed.controlConfig !== "object" || Array.isArray(parsed.controlConfig)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': controlConfig must be an object.`);
379
389
  const control = parsed.controlConfig as Record<string, unknown>;