pi-subagents 0.34.0 → 0.35.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 (98) hide show
  1. package/CHANGELOG.md +78 -9
  2. package/README.md +213 -32
  3. package/index.ts +1 -0
  4. package/install.mjs +1 -1
  5. package/package.json +23 -8
  6. package/prompts/review-loop.md +3 -1
  7. package/skills/pi-subagents/SKILL.md +87 -25
  8. package/src/agents/agent-management.ts +82 -15
  9. package/src/agents/agent-serializer.ts +19 -0
  10. package/src/agents/agents.ts +91 -49
  11. package/src/agents/frontmatter.ts +67 -13
  12. package/src/agents/skills.ts +25 -12
  13. package/src/api/background-work.ts +197 -0
  14. package/src/api/delegation.ts +158 -0
  15. package/src/extension/chain-validation.ts +165 -0
  16. package/src/extension/doctor.ts +15 -0
  17. package/src/extension/fanout-child.ts +3 -1
  18. package/src/extension/index.ts +65 -124
  19. package/src/extension/rpc.ts +10 -2
  20. package/src/extension/schemas.ts +18 -14
  21. package/src/extension/steering-notices.ts +35 -0
  22. package/src/extension/tool-description.ts +20 -9
  23. package/src/intercom/intercom-bridge.ts +3 -2
  24. package/src/intercom/native-supervisor-channel.ts +9 -1
  25. package/src/intercom/result-intercom.ts +4 -0
  26. package/src/runs/background/async-execution.ts +293 -44
  27. package/src/runs/background/async-job-tracker.ts +56 -9
  28. package/src/runs/background/async-resume.ts +159 -52
  29. package/src/runs/background/async-status.ts +25 -18
  30. package/src/runs/background/auto-drain.ts +67 -0
  31. package/src/runs/background/chain-root-attachment.ts +16 -8
  32. package/src/runs/background/control-channel.ts +260 -13
  33. package/src/runs/background/fleet-view.ts +23 -2
  34. package/src/runs/background/notify.ts +79 -10
  35. package/src/runs/background/result-watcher.ts +12 -9
  36. package/src/runs/background/run-id-resolver.ts +14 -2
  37. package/src/runs/background/run-status.ts +23 -15
  38. package/src/runs/background/scheduled-runs.ts +3 -0
  39. package/src/runs/background/stale-run-reconciler.ts +32 -10
  40. package/src/runs/background/steering.ts +237 -0
  41. package/src/runs/background/subagent-runner.ts +898 -236
  42. package/src/runs/background/subagent-wait.ts +484 -0
  43. package/src/runs/background/top-level-async.ts +2 -1
  44. package/src/runs/background/wait-config.ts +36 -0
  45. package/src/runs/background/wait-tool.ts +26 -0
  46. package/src/runs/foreground/async-steering-action.ts +230 -0
  47. package/src/runs/foreground/chain-clarify.ts +22 -6
  48. package/src/runs/foreground/chain-execution.ts +50 -32
  49. package/src/runs/foreground/execution.ts +308 -94
  50. package/src/runs/foreground/subagent-executor.ts +592 -268
  51. package/src/runs/shared/acceptance.ts +355 -97
  52. package/src/runs/shared/child-protocol.ts +121 -0
  53. package/src/runs/shared/completion-guard.ts +8 -127
  54. package/src/runs/shared/dynamic-fanout.ts +6 -4
  55. package/src/runs/shared/model-fallback.ts +36 -0
  56. package/src/runs/shared/nested-events.ts +9 -4
  57. package/src/runs/shared/nested-render.ts +4 -1
  58. package/src/runs/shared/parallel-utils.ts +7 -0
  59. package/src/runs/shared/pi-args.ts +34 -7
  60. package/src/runs/shared/pi-spawn.ts +18 -12
  61. package/src/runs/shared/session-lease.ts +279 -0
  62. package/src/runs/shared/single-output.ts +61 -6
  63. package/src/runs/shared/spawn-budget.ts +128 -0
  64. package/src/runs/shared/subagent-control.ts +10 -6
  65. package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
  66. package/src/runs/shared/task-intent.ts +176 -0
  67. package/src/runs/shared/tool-availability.ts +65 -0
  68. package/src/runs/shared/turn-budget.ts +49 -4
  69. package/src/shared/atomic-json.ts +4 -1
  70. package/src/shared/fork-context.ts +28 -3
  71. package/src/shared/model-info.ts +7 -4
  72. package/src/shared/status-format.ts +7 -1
  73. package/src/shared/types.ts +203 -25
  74. package/src/shared/utils.ts +35 -7
  75. package/src/slash/delegation-adapters.ts +457 -0
  76. package/src/slash/delegation-request.ts +103 -0
  77. package/src/slash/prompt-template-bridge.ts +167 -344
  78. package/src/slash/slash-commands.ts +239 -6
  79. package/src/slash/subagents-admin.ts +428 -0
  80. package/src/slash/subagents-editor.ts +86 -0
  81. package/src/tui/fleet.ts +405 -0
  82. package/src/tui/render.ts +90 -16
  83. package/src/watchdog/change-signature.ts +127 -0
  84. package/src/watchdog/child-status.ts +205 -0
  85. package/src/watchdog/emission-guard.ts +123 -0
  86. package/src/watchdog/lsp-diagnostics.ts +532 -0
  87. package/src/watchdog/model-selection.ts +167 -0
  88. package/src/watchdog/register-child.ts +117 -0
  89. package/src/watchdog/register-main.ts +433 -0
  90. package/src/watchdog/render.ts +54 -0
  91. package/src/watchdog/review.ts +293 -0
  92. package/src/watchdog/runtime.ts +712 -0
  93. package/src/watchdog/settings.ts +528 -0
  94. package/src/watchdog/tool-actions.ts +155 -0
  95. package/src/watchdog/turn-delta.ts +161 -0
  96. package/src/watchdog/types.ts +188 -0
  97. package/src/watchdog/warning-format.ts +73 -0
  98. package/src/runs/background/wait.ts +0 -394
@@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url";
10
10
  import { createRequire } from "node:module";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import type { AgentConfig } from "../../agents/agents.ts";
13
+ import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
13
14
  import { applyThinkingSuffix } from "../shared/pi-args.ts";
14
15
  import { injectOutputPathSystemPrompt, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
15
16
  import { buildChainInstructions, isDynamicParallelStep, isParallelStep, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
@@ -18,7 +19,7 @@ import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
18
19
  import { buildSkillInjection, normalizeSkillInput, resolveSkillsWithFallback } from "../../agents/skills.ts";
19
20
  import { buildAgentMemoryInjection } from "../../agents/agent-memory.ts";
20
21
  import { PI_CODING_AGENT_PACKAGE_ROOT_ENV, resolveChildCwd } from "../../shared/utils.ts";
21
- import { buildModelCandidates, resolveModelCandidate, resolveSubagentModelOverride, type AvailableModelInfo, type ParentModel } from "../shared/model-fallback.ts";
22
+ import { buildModelCandidates, resolveEffectiveSubagentModel, resolveModelCandidate, resolveSubagentModelOverride, type AvailableModelInfo, type ParentModel } from "../shared/model-fallback.ts";
22
23
  import type { ModelScopeConfig } from "../shared/model-scope.ts";
23
24
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
24
25
  import { resolveExpectedWorktreeAgentCwd } from "../shared/worktree.ts";
@@ -36,6 +37,7 @@ import {
36
37
  type ResolvedTurnBudget,
37
38
  type ResolvedToolBudget,
38
39
  type SubagentRunMode,
40
+ type SteeringRecoveryDescriptor,
39
41
  ASYNC_DIR,
40
42
  RESULTS_DIR,
41
43
  SUBAGENT_ASYNC_STARTED_EVENT,
@@ -48,6 +50,7 @@ import { nestedResultsPath, resolveInheritedNestedRouteFromEnv, resolveNestedPar
48
50
  import { initialTurnBudgetState } from "../shared/turn-budget.ts";
49
51
  import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
50
52
  import type { ImportedAsyncRoot } from "./chain-root-attachment.ts";
53
+ import type { SessionLeaseRequest } from "../shared/session-lease.ts";
51
54
 
52
55
  const require = createRequire(import.meta.url);
53
56
  const piPackageRoot = resolvePiPackageRoot();
@@ -108,11 +111,15 @@ interface AsyncExecutionContext {
108
111
  currentModel?: ParentModel;
109
112
  /** Optional model-scope enforcement resolved from subagent settings. */
110
113
  modelScope?: ModelScopeConfig;
114
+ /** Whether the parent session has an interactive UI. */
115
+ interactive?: boolean;
111
116
  }
112
117
 
113
118
  interface AsyncChainParams {
114
119
  chain: ChainStep[];
115
120
  task?: string;
121
+ /** Raw caller-facing goal used only by the started event. */
122
+ goal?: string;
116
123
  attachRoot?: ImportedAsyncRoot & { agent: string; outputName?: string; label?: string };
117
124
  resultMode?: Exclude<SubagentRunMode, "single">;
118
125
  agents: AgentConfig[];
@@ -130,6 +137,7 @@ interface AsyncChainParams {
130
137
  progressDir?: string;
131
138
  dynamicFanoutMaxItems?: number;
132
139
  maxSubagentDepth: number;
140
+ waitToolEnabled?: boolean;
133
141
  worktreeSetupHook?: string;
134
142
  worktreeSetupHookTimeoutMs?: number;
135
143
  worktreeBaseDir?: string;
@@ -149,6 +157,8 @@ interface AsyncChainParams {
149
157
  interface AsyncSingleParams {
150
158
  agent: string;
151
159
  task?: string;
160
+ /** Raw caller-facing goal used only by the started event. */
161
+ goal?: string;
152
162
  agentConfig: AgentConfig;
153
163
  ctx: AsyncExecutionContext;
154
164
  cwd?: string;
@@ -157,7 +167,9 @@ interface AsyncSingleParams {
157
167
  artifactConfig: ArtifactConfig;
158
168
  shareEnabled: boolean;
159
169
  sessionRoot?: string;
170
+ sessionDir?: string;
160
171
  sessionFile?: string;
172
+ revivalLease?: SessionLeaseRequest;
161
173
  skills?: string[];
162
174
  output?: string | boolean;
163
175
  outputMode?: "inline" | "file-only";
@@ -166,6 +178,7 @@ interface AsyncSingleParams {
166
178
  thinkingOverride?: AgentConfig["thinking"];
167
179
  availableModels?: AvailableModelInfo[];
168
180
  maxSubagentDepth: number;
181
+ waitToolEnabled?: boolean;
169
182
  worktreeSetupHook?: string;
170
183
  worktreeSetupHookTimeoutMs?: number;
171
184
  worktreeBaseDir?: string;
@@ -175,6 +188,7 @@ interface AsyncSingleParams {
175
188
  nestedRoute?: NestedRouteInfo;
176
189
  acceptance?: AcceptanceInput;
177
190
  timeoutMs?: number;
191
+ absoluteDeadlineAt?: number;
178
192
  turnBudget?: ResolvedTurnBudget;
179
193
  toolBudget?: ResolvedToolBudget;
180
194
  configToolBudget?: ResolvedToolBudget;
@@ -201,6 +215,7 @@ export interface AsyncRunnerStepBuildParams {
201
215
  progressDir?: string;
202
216
  dynamicFanoutMaxItems?: number;
203
217
  maxSubagentDepth: number;
218
+ waitToolEnabled?: boolean;
204
219
  worktreeBaseDir?: string;
205
220
  asyncDir: string;
206
221
  outputBaseDir?: string;
@@ -219,14 +234,20 @@ export type AsyncRunnerStepBuildResult =
219
234
  }
220
235
  | { error: string };
221
236
 
222
- export function formatAsyncStartedMessage(headline: string): string {
223
- return [
224
- headline,
225
- "",
226
- "The async run is detached. Do not run sleep timers or polling loops just to wait for it.",
227
- "If you have independent work, continue that work. When you have nothing left to do until the async result arrives, call wait() — it blocks until the run finishes and delivers the completion here. Only if you are certain you will get another turn (an interactive session where the user will prompt you again) can you instead stop and let Pi wake you; inside a skill that must run to completion, or in a non-interactive run, there is no next turn, so use wait().",
228
- "Use subagent({ action: \"status\", id: \"...\" }) when you need a one-shot status/result or to inspect a blocked/stale run. To block until completion, prefer wait(). Do not poll in a loop just to wait.",
229
- ].join("\n");
237
+ export function formatAsyncStartedMessage(headline: string, interactive: boolean): string {
238
+ const guidance = interactive
239
+ ? [
240
+ "The async run is detached and running in the background.",
241
+ "You are in an interactive session. By default, return control to the user now; Pi will wake you on completion when the run finishes or needs attention. Do NOT call subagent_wait() merely to wait, and do not run sleep/polling loops to wait for it.",
242
+ "Override that default and call subagent_wait() before ending the turn only when the current request is run-to-completion — for example, the user asked you to report results back here before continuing, or a skill must finish in one turn. In that case, call subagent_wait() to block until the run completes so its results are delivered in this turn instead of deferred.",
243
+ "Otherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.",
244
+ ]
245
+ : [
246
+ "The async run is detached. Do not run sleep timers or polling loops just to wait for it.",
247
+ "This is a non-interactive run: Pi auto-drains current-session background work at agent_end so detached children are not abandoned; call subagent_wait() when this turn must receive the run's results before it ends, otherwise let the headless auto-drain finish the work.",
248
+ "Use subagent({ action: \"status\", id: \"...\" }) when you need a one-shot status/result or to inspect a blocked/stale run. To block until completion, use subagent_wait() — do not poll in a loop.",
249
+ ];
250
+ return [headline, "", ...guidance].join("\n");
230
251
  }
231
252
 
232
253
  /**
@@ -280,6 +301,92 @@ function closeFd(fd: number | undefined): void {
280
301
  /**
281
302
  * Spawn the async runner process
282
303
  */
304
+ const RUNNER_STARTUP_TIMEOUT_MS = 10_000;
305
+ const RUNNER_STARTUP_WAIT_BUFFER = typeof SharedArrayBuffer !== "undefined" ? new SharedArrayBuffer(4) : undefined;
306
+ const RUNNER_STARTUP_WAIT_VIEW = RUNNER_STARTUP_WAIT_BUFFER ? new Int32Array(RUNNER_STARTUP_WAIT_BUFFER) : undefined;
307
+
308
+ type RunnerStartupState = "ready" | "acknowledged";
309
+
310
+ type RunnerStartupWaitResult =
311
+ | { ok: true; token: string }
312
+ | { ok: false; error: string };
313
+
314
+ function waitForStartupInterval(delayMs = 20): void {
315
+ if (RUNNER_STARTUP_WAIT_VIEW) {
316
+ Atomics.wait(RUNNER_STARTUP_WAIT_VIEW, 0, 0, delayMs);
317
+ return;
318
+ }
319
+ const waitUntil = Date.now() + delayMs;
320
+ while (Date.now() < waitUntil) {
321
+ // Startup handshakes are synchronous so resume rejects before reporting a run as started.
322
+ }
323
+ }
324
+
325
+ function readRunnerStartup(startupPath: string, expectedState: RunnerStartupState, expectedToken?: string): RunnerStartupWaitResult | undefined {
326
+ if (!fs.existsSync(startupPath)) return undefined;
327
+ try {
328
+ const payload = JSON.parse(fs.readFileSync(startupPath, "utf-8")) as { state?: unknown; token?: unknown; error?: unknown };
329
+ if (payload.state === "error" && typeof payload.error === "string") return { ok: false, error: payload.error };
330
+ if (payload.state !== expectedState) return undefined;
331
+ if (typeof payload.token !== "string" || (expectedToken !== undefined && payload.token !== expectedToken)) {
332
+ return { ok: false, error: `Async runner wrote an invalid ${expectedState} startup handshake: ${startupPath}` };
333
+ }
334
+ return { ok: true, token: payload.token };
335
+ } catch (error) {
336
+ return { ok: false, error: `Failed to read async runner startup handshake '${startupPath}': ${error instanceof Error ? error.message : String(error)}` };
337
+ }
338
+ }
339
+
340
+ function waitForRunnerStartup(startupPath: string, expectedState: RunnerStartupState, timeoutMs: number, expectedToken?: string): RunnerStartupWaitResult {
341
+ const deadline = Date.now() + timeoutMs;
342
+ for (;;) {
343
+ const result = readRunnerStartup(startupPath, expectedState, expectedToken);
344
+ if (result) return result;
345
+ if (Date.now() >= deadline) break;
346
+ waitForStartupInterval(Math.min(20, Math.max(1, deadline - Date.now())));
347
+ }
348
+ const finalResult = readRunnerStartup(startupPath, expectedState, expectedToken);
349
+ if (finalResult) return finalResult;
350
+ return { ok: false, error: `Timed out after ${timeoutMs}ms waiting for the async runner startup state '${expectedState}'.` };
351
+ }
352
+
353
+ function writeRunnerStartupControl(filePath: string, payload: { action: "ack" | "proceed"; token: string }): void {
354
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
355
+ try {
356
+ fs.writeFileSync(tempPath, JSON.stringify(payload), { encoding: "utf-8", mode: 0o600 });
357
+ fs.renameSync(tempPath, filePath);
358
+ } catch (error) {
359
+ try {
360
+ fs.rmSync(tempPath, { force: true });
361
+ } catch {
362
+ // Preserve the startup-control write error.
363
+ }
364
+ throw error;
365
+ }
366
+ }
367
+
368
+ function runnerIsAlive(pid: number): boolean {
369
+ try {
370
+ process.kill(pid, 0);
371
+ return true;
372
+ } catch (error) {
373
+ return (error as NodeJS.ErrnoException).code === "EPERM";
374
+ }
375
+ }
376
+
377
+ function terminateRunnerBeforeProceed(pid: number): void {
378
+ for (const signal of ["SIGTERM", "SIGKILL"] as const) {
379
+ if (!runnerIsAlive(pid)) return;
380
+ try {
381
+ process.kill(pid, signal);
382
+ } catch {
383
+ if (!runnerIsAlive(pid)) return;
384
+ }
385
+ const deadline = Date.now() + 1000;
386
+ while (runnerIsAlive(pid) && Date.now() < deadline) waitForStartupInterval();
387
+ }
388
+ }
389
+
283
390
  function spawnRunner(cfg: object, suffix: string, cwd: string): { pid?: number; error?: string } {
284
391
  if (!jitiCliPath) {
285
392
  return { error: "upstream jiti for TypeScript execution could not be found; ensure package dependencies are installed" };
@@ -299,6 +406,15 @@ function spawnRunner(cfg: object, suffix: string, cwd: string): { pid?: number;
299
406
  fs.writeFileSync(cfgPath, JSON.stringify(cfg));
300
407
  const runner = path.join(path.dirname(fileURLToPath(import.meta.url)), "subagent-runner.ts");
301
408
  const nodeCommand = resolveAsyncRunnerNodeCommand();
409
+ const startupPath = typeof (cfg as { revivalLease?: unknown; asyncDir?: unknown }).revivalLease === "object"
410
+ && typeof (cfg as { asyncDir?: unknown }).asyncDir === "string"
411
+ ? path.join((cfg as { asyncDir: string }).asyncDir, "runner-startup.json")
412
+ : undefined;
413
+ const startupAckPath = startupPath ? path.join(path.dirname(startupPath), "runner-startup-ack.json") : undefined;
414
+ const startupProceedPath = startupPath ? path.join(path.dirname(startupPath), "runner-startup-proceed.json") : undefined;
415
+ if (startupPath) fs.rmSync(startupPath, { force: true });
416
+ if (startupAckPath) fs.rmSync(startupAckPath, { force: true });
417
+ if (startupProceedPath) fs.rmSync(startupProceedPath, { force: true });
302
418
 
303
419
  const logPaths = resolveAsyncRunnerLogPaths(cfg);
304
420
  let stdoutFd: number | undefined;
@@ -328,6 +444,35 @@ function spawnRunner(cfg: object, suffix: string, cwd: string): { pid?: number;
328
444
  return { error: `async runner did not produce a pid for cwd: ${cwd}` };
329
445
  }
330
446
  proc.unref();
447
+ if (startupPath && startupAckPath && startupProceedPath) {
448
+ const ready = waitForRunnerStartup(startupPath, "ready", RUNNER_STARTUP_TIMEOUT_MS);
449
+ if (!ready.ok) {
450
+ terminateRunnerBeforeProceed(proc.pid);
451
+ return { error: ready.error };
452
+ }
453
+ try {
454
+ writeRunnerStartupControl(startupAckPath, { action: "ack", token: ready.token });
455
+ } catch (error) {
456
+ terminateRunnerBeforeProceed(proc.pid);
457
+ return { error: `Failed to acknowledge async runner startup: ${error instanceof Error ? error.message : String(error)}` };
458
+ }
459
+ const acknowledged = waitForRunnerStartup(startupPath, "acknowledged", RUNNER_STARTUP_TIMEOUT_MS, ready.token);
460
+ if (!acknowledged.ok) {
461
+ terminateRunnerBeforeProceed(proc.pid);
462
+ return { error: acknowledged.error };
463
+ }
464
+ try {
465
+ writeRunnerStartupControl(startupProceedPath, { action: "proceed", token: ready.token });
466
+ } catch (error) {
467
+ terminateRunnerBeforeProceed(proc.pid);
468
+ return { error: `Failed to authorize async runner startup: ${error instanceof Error ? error.message : String(error)}` };
469
+ }
470
+ try {
471
+ fs.rmSync(startupPath, { force: true });
472
+ } catch {
473
+ // Proceed is the commit point; handshake cleanup cannot turn a running revival into a start error.
474
+ }
475
+ }
331
476
  return { pid: proc.pid };
332
477
  } catch (error) {
333
478
  closeFd(stdoutFd);
@@ -415,19 +560,33 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
415
560
  ...(s.reads !== undefined ? { reads: s.reads } : {}),
416
561
  ...(s.progress !== undefined ? { progress: s.progress } : {}),
417
562
  ...(stepSkillInput !== undefined ? { skills: stepSkillInput } : {}),
418
- ...(s.model ? { model: s.model } : {}),
563
+ ...(s.model !== undefined ? { model: s.model } : {}),
419
564
  };
420
565
  };
421
- const buildSeqStep = (s: SequentialStep, sessionFile?: string, behaviorCwd?: string, progressPrecreated = false, resolvedBehavior?: ResolvedStepBehavior, flatIndex?: number) => {
566
+ const buildSeqStep = (s: SequentialStep, sessionFile?: string, behaviorCwd?: string, progressPrecreated = false, resolvedBehavior?: ResolvedStepBehavior, flatIndex?: number, parallelOutputNamespace?: { stepIndex: number; taskIndex?: number }) => {
422
567
  const a = agents.find((x) => x.name === s.agent)!;
423
568
  const toolBudgetInput = s.toolBudget ?? params.toolBudget ?? a.toolBudget ?? params.configToolBudget;
424
569
  const resolvedToolBudget = validateToolBudgetConfig(toolBudgetInput, s.toolBudget ? "toolBudget" : a.toolBudget ? "agent.toolBudget" : "config.toolBudget");
425
570
  if (resolvedToolBudget.error) throw new AsyncStartValidationError(resolvedToolBudget.error);
426
571
  const stepCwd = resolveChildCwd(runnerCwd, s.cwd);
427
572
  const instructionCwd = behaviorCwd ?? stepCwd;
428
- const behavior = suppressProgressForReadOnlyTask(resolvedBehavior ?? resolveStepBehavior(a, buildStepOverrides(s), chainSkills), s.task, originalTask);
573
+ let behavior = suppressProgressForReadOnlyTask(resolvedBehavior ?? resolveStepBehavior(a, buildStepOverrides(s), chainSkills), s.task, originalTask);
574
+ const inheritedRelativeParallelOutput = parallelOutputNamespace && s.output === undefined && typeof behavior.output === "string" && !path.isAbsolute(behavior.output);
575
+ if (inheritedRelativeParallelOutput && parallelOutputNamespace.taskIndex !== undefined) {
576
+ behavior = {
577
+ ...behavior,
578
+ output: path.join(`parallel-${parallelOutputNamespace.stepIndex}`, `${parallelOutputNamespace.taskIndex}-${s.agent}`, behavior.output),
579
+ };
580
+ }
581
+ const namespaceOutputPath = Boolean(inheritedRelativeParallelOutput && parallelOutputNamespace.taskIndex === undefined);
429
582
  const skillNames = behavior.skills === false ? [] : behavior.skills;
430
- const { resolved: resolvedSkills, missing: missingSkills } = resolveSkillsWithFallback(skillNames, stepCwd, ctx.cwd);
583
+ const { resolved: resolvedSkills, missing: missingSkills } = resolveSkillsWithFallback(
584
+ skillNames,
585
+ stepCwd,
586
+ ctx.cwd,
587
+ a.skillPath,
588
+ a.filePath ? path.dirname(a.filePath) : stepCwd,
589
+ );
431
590
  if (missingSkills.includes("pi-subagents")) throw new UnavailableSubagentSkillError(UNAVAILABLE_SUBAGENT_SKILL_ERROR);
432
591
 
433
592
  let systemPrompt = a.systemPrompt?.trim() ?? "";
@@ -445,16 +604,23 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
445
604
  if (behavior.progress) progressInstructionCreated = true;
446
605
  const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false }, progressDir, isFirstProgressAgent);
447
606
  const outputPath = resolveSingleOutputPath(behavior.output, ctx.cwd, instructionCwd, outputBaseDir);
448
- systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath);
607
+ if (!namespaceOutputPath) systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath, a);
449
608
  const validationError = validateFileOnlyOutputMode(behavior.outputMode, outputPath, `Async step (${s.agent})`);
450
609
  if (validationError) throw new AsyncStartValidationError(validationError);
451
610
  let taskTemplate = s.task ?? "{previous}";
452
611
  taskTemplate = taskTemplate.replace(/\{task\}/g, originalTask ?? "");
453
612
  taskTemplate = taskTemplate.replace(/\{chain_dir\}/g, runnerCwd);
454
- const task = injectSingleOutputInstruction(`${readInstructions.prefix}${taskTemplate}${progressInstructions.suffix}`, outputPath);
613
+ const taskText = `${readInstructions.prefix}${taskTemplate}${progressInstructions.suffix}`;
614
+ const task = namespaceOutputPath ? taskText : injectSingleOutputInstruction(taskText, outputPath, a);
455
615
 
456
- const requestedModel = behavior.model ?? a.model;
457
- const primaryModel = resolveSubagentModelOverride(requestedModel, ctx.currentModel, availableModels, ctx.currentModelProvider, { scope: ctx.modelScope, source: behavior.model ? "explicit" : "inherited" });
616
+ const primaryModel = resolveEffectiveSubagentModel(
617
+ s.model,
618
+ a.model,
619
+ ctx.currentModel,
620
+ availableModels,
621
+ ctx.currentModelProvider,
622
+ { scope: ctx.modelScope },
623
+ );
458
624
  const thinkingOverride = flatIndex === undefined ? undefined : thinkingOverridesByFlatIndex?.[flatIndex];
459
625
  const effectiveThinking = thinkingOverride ?? a.thinking;
460
626
  const model = applyThinkingSuffix(primaryModel, effectiveThinking, thinkingOverride !== undefined);
@@ -483,17 +649,22 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
483
649
  inheritSkills: a.inheritSkills,
484
650
  skills: resolvedSkills.map((r) => r.name),
485
651
  outputPath,
652
+ ...(namespaceOutputPath ? { namespaceOutputPath: true } : {}),
486
653
  outputMode: behavior.outputMode,
487
654
  sessionFile,
488
655
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, a.maxSubagentDepth),
656
+ waitToolEnabled: params.waitToolEnabled,
489
657
  effectiveAcceptance: resolveEffectiveAcceptance({
490
658
  explicit: s.acceptance,
491
659
  agentName: s.agent,
492
- task: s.task,
660
+ acceptanceRole: a.acceptanceRole,
661
+ task,
493
662
  mode: resultMode,
494
663
  async: true,
495
664
  dynamic: false,
496
665
  }),
666
+ acceptanceInput: s.acceptance,
667
+ acceptanceRole: a.acceptanceRole,
497
668
  ...(s.outputSchema ? { structuredOutputSchema: s.outputSchema } : {}),
498
669
  ...(s.outputSchema ? { structuredOutput: createStructuredOutputRuntime(s.outputSchema, path.join(asyncDir, "structured-output")) } : {}),
499
670
  ...(resolvedToolBudget.budget ? { toolBudget: resolvedToolBudget.budget } : {}),
@@ -536,7 +707,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
536
707
  }
537
708
  }
538
709
  const staticStep = nextFlatStep();
539
- return buildSeqStep(t, staticStep.sessionFile, behaviorCwd, progressPrecreated, parallelBehaviors[taskIndex], staticStep.index);
710
+ return buildSeqStep(t, staticStep.sessionFile, behaviorCwd, progressPrecreated, parallelBehaviors[taskIndex], staticStep.index, { stepIndex, taskIndex });
540
711
  }),
541
712
  concurrency: s.concurrency,
542
713
  failFast: s.failFast,
@@ -553,9 +724,10 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
553
724
  }
554
725
  const maxItems = s.expand.maxItems ?? params.dynamicFanoutMaxItems ?? 0;
555
726
  const dynamicFlatSteps = Array.from({ length: maxItems }, () => nextFlatStep());
727
+ const parallel = buildSeqStep(s.parallel as SequentialStep, undefined, undefined, progressPrecreated, behavior, undefined, { stepIndex });
556
728
  return {
557
729
  expand: s.expand,
558
- parallel: buildSeqStep(s.parallel as SequentialStep, undefined, undefined, progressPrecreated, behavior),
730
+ parallel,
559
731
  collect: s.collect,
560
732
  concurrency: s.concurrency,
561
733
  failFast: s.failFast,
@@ -566,11 +738,14 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
566
738
  effectiveAcceptance: resolveEffectiveAcceptance({
567
739
  explicit: s.acceptance,
568
740
  agentName: s.parallel.agent,
569
- task: s.parallel.task,
741
+ acceptanceRole: agent.acceptanceRole,
742
+ task: parallel.task,
570
743
  mode: resultMode,
571
744
  async: true,
572
745
  dynamicGroup: true,
573
746
  }),
747
+ acceptanceInput: s.acceptance,
748
+ acceptanceRole: agent.acceptanceRole,
574
749
  };
575
750
  }
576
751
  const staticStep = nextFlatStep();
@@ -592,6 +767,19 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
592
767
  inheritSkills: false,
593
768
  }, ...builtSteps]
594
769
  : builtSteps;
770
+ for (const step of steps) {
771
+ if (!("parallel" in step) || !Array.isArray(step.parallel)) continue;
772
+ const seen = new Map<string, { index: number; agent: string }>();
773
+ for (let index = 0; index < step.parallel.length; index++) {
774
+ const task = step.parallel[index]!;
775
+ if (!task.outputPath) continue;
776
+ const previous = seen.get(task.outputPath);
777
+ if (previous) {
778
+ throw new AsyncStartValidationError(`Parallel tasks ${previous.index + 1} (${previous.agent}) and ${index + 1} (${task.agent}) resolve output to the same path: ${task.outputPath}. Use distinct output paths.`);
779
+ }
780
+ seen.set(task.outputPath, { index, agent: task.agent });
781
+ }
782
+ }
595
783
  return { steps, runnerCwd, workflowGraph, eventChain: graphChain, ...(originalTask !== undefined ? { originalTask } : {}) };
596
784
  } catch (error) {
597
785
  if (error instanceof UnavailableSubagentSkillError || error instanceof AsyncStartValidationError) return { error: error.message };
@@ -660,6 +848,7 @@ export function executeAsyncChain(
660
848
  outputBaseDir: artifactsDir ? path.join(artifactsDir, "outputs", id) : undefined,
661
849
  dynamicFanoutMaxItems: params.dynamicFanoutMaxItems,
662
850
  maxSubagentDepth,
851
+ waitToolEnabled: params.waitToolEnabled,
663
852
  worktreeBaseDir,
664
853
  asyncDir,
665
854
  toolBudget: params.toolBudget,
@@ -751,6 +940,12 @@ export function executeAsyncChain(
751
940
  : isDynamicParallelStep(eventFirstStep)
752
941
  ? [eventFirstStep.parallel.agent]
753
942
  : [(eventFirstStep as SequentialStep).agent];
943
+ const firstTask = isParallelStep(eventFirstStep)
944
+ ? eventFirstStep.parallel[0]?.task
945
+ : isDynamicParallelStep(eventFirstStep)
946
+ ? eventFirstStep.parallel.task
947
+ : (eventFirstStep as SequentialStep).task;
948
+ const workflowGoal = params.goal ?? (params.task?.trim() || firstTask);
754
949
  const parallelGroups: Array<{ start: number; count: number; stepIndex: number }> = [];
755
950
  const flatAgents: string[] = [];
756
951
  let flatStepStart = 0;
@@ -813,11 +1008,8 @@ export function executeAsyncChain(
813
1008
  mode: resultMode,
814
1009
  agent: firstAgents[0],
815
1010
  agents: flatAgents,
816
- task: isParallelStep(eventFirstStep)
817
- ? eventFirstStep.parallel[0]?.task?.slice(0, 50)
818
- : isDynamicParallelStep(eventFirstStep)
819
- ? eventFirstStep.parallel.task?.slice(0, 50)
820
- : (eventFirstStep as SequentialStep).task?.slice(0, 50),
1011
+ task: firstTask?.slice(0, 50),
1012
+ goal: workflowGoal?.slice(0, 120),
821
1013
  chain: eventChain.map((s) =>
822
1014
  isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : isDynamicParallelStep(s) ? `expand:${s.parallel.agent}` : (s as SequentialStep).agent,
823
1015
  ),
@@ -839,7 +1031,7 @@ export function executeAsyncChain(
839
1031
  .join(" -> ");
840
1032
 
841
1033
  return {
842
- content: [{ type: "text", text: formatAsyncStartedMessage(`Async ${resultMode}: ${chainDesc} [${id}]`) }],
1034
+ content: [{ type: "text", text: formatAsyncStartedMessage(`Async ${resultMode}: ${chainDesc} [${id}]`, ctx.interactive === true) }],
843
1035
  details: { mode: resultMode, runId: id, results: [], asyncId: id, asyncDir, workflowGraph, ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}), ...(params.turnBudget ? { turnBudget: params.turnBudget } : {}), ...(params.toolBudget ? { toolBudget: params.toolBudget } : {}) },
844
1036
  };
845
1037
  }
@@ -875,7 +1067,13 @@ export function executeAsyncSingle(
875
1067
  const runnerCwd = resolveChildCwd(ctx.cwd, cwd);
876
1068
  const skillNames = params.skills ?? agentConfig.skills ?? [];
877
1069
  const availableModels = params.availableModels;
878
- const { resolved: resolvedSkills, missing: missingSkills } = resolveSkillsWithFallback(skillNames, runnerCwd, ctx.cwd);
1070
+ const { resolved: resolvedSkills, missing: missingSkills } = resolveSkillsWithFallback(
1071
+ skillNames,
1072
+ runnerCwd,
1073
+ ctx.cwd,
1074
+ agentConfig.skillPath,
1075
+ agentConfig.filePath ? path.dirname(agentConfig.filePath) : runnerCwd,
1076
+ );
879
1077
  if (missingSkills.includes("pi-subagents")) return formatAsyncStartError("single", UNAVAILABLE_SUBAGENT_SKILL_ERROR);
880
1078
  let systemPrompt = agentConfig.systemPrompt?.trim() ?? "";
881
1079
  if (resolvedSkills.length > 0) {
@@ -905,11 +1103,11 @@ export function executeAsyncSingle(
905
1103
 
906
1104
  const effectiveOutput = normalizeSingleOutputOverride(params.output, agentConfig.output);
907
1105
  const outputPath = resolveSingleOutputPath(effectiveOutput, ctx.cwd, runnerCwd, params.outputBaseDir ?? (artifactsDir ? path.join(artifactsDir, "outputs", id) : undefined));
908
- systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath);
1106
+ systemPrompt = injectOutputPathSystemPrompt(systemPrompt, outputPath, agentConfig);
909
1107
  const outputMode = params.outputMode ?? "inline";
910
1108
  const validationError = validateFileOnlyOutputMode(outputMode, outputPath, `Async single run (${agent})`);
911
1109
  if (validationError) return formatAsyncStartError("single", validationError);
912
- const taskWithOutputInstruction = injectSingleOutputInstruction(task, outputPath);
1110
+ const taskWithOutputInstruction = injectSingleOutputInstruction(task, outputPath, agentConfig);
913
1111
  const primaryModel = resolveSubagentModelOverride(
914
1112
  params.modelOverride ?? agentConfig.model,
915
1113
  ctx.currentModel,
@@ -921,8 +1119,62 @@ export function executeAsyncSingle(
921
1119
  const toolBudgetInput = params.toolBudget ?? agentConfig.toolBudget ?? params.configToolBudget;
922
1120
  const resolvedToolBudget = validateToolBudgetConfig(toolBudgetInput, params.toolBudget ? "toolBudget" : agentConfig.toolBudget ? "agent.toolBudget" : "config.toolBudget");
923
1121
  if (resolvedToolBudget.error) return formatAsyncStartError("single", resolvedToolBudget.error);
924
- const deadlineAt = params.timeoutMs !== undefined ? Date.now() + params.timeoutMs : undefined;
1122
+ const deadlineAt = params.absoluteDeadlineAt ?? (params.timeoutMs !== undefined ? Date.now() + params.timeoutMs : undefined);
1123
+ const timeoutMs = params.absoluteDeadlineAt !== undefined && deadlineAt !== undefined
1124
+ ? deadlineAt - Date.now()
1125
+ : params.timeoutMs;
1126
+ if (timeoutMs !== undefined && timeoutMs <= 0) return formatAsyncStartError("single", "The source run's absolute deadline expired before recovery could launch.");
925
1127
  const initialTurnBudget = params.turnBudget ? initialTurnBudgetState(params.turnBudget) : undefined;
1128
+ const resolvedSessionDir = params.sessionDir ?? (sessionRoot ? path.join(sessionRoot, `async-${id}`) : undefined);
1129
+ const resolvedAcceptance = resolveEffectiveAcceptance({
1130
+ explicit: params.acceptance,
1131
+ agentName: agent,
1132
+ acceptanceRole: agentConfig.acceptanceRole,
1133
+ task,
1134
+ mode: "single",
1135
+ async: true,
1136
+ });
1137
+ const recoveryDescriptor: SteeringRecoveryDescriptor = {
1138
+ version: 1,
1139
+ sourceRunId: id,
1140
+ agent,
1141
+ ...(sessionFile ? { sessionFile } : {}),
1142
+ cwd: runnerCwd,
1143
+ ...(model ? { model } : {}),
1144
+ ...(agentConfig.fallbackModels ? { fallbackModels: [...agentConfig.fallbackModels] } : {}),
1145
+ ...(effectiveThinking ? { thinking: resolveEffectiveThinking(model, effectiveThinking) } : {}),
1146
+ ...(agentConfig.tools ? { tools: [...agentConfig.tools] } : {}),
1147
+ ...(agentConfig.extensions ? { extensions: [...agentConfig.extensions] } : {}),
1148
+ ...(agentConfig.subagentOnlyExtensions ? { subagentOnlyExtensions: [...agentConfig.subagentOnlyExtensions] } : {}),
1149
+ ...(agentConfig.mcpDirectTools ? { mcpDirectTools: [...agentConfig.mcpDirectTools] } : {}),
1150
+ ...(agentConfig.systemPrompt ? { systemPrompt: agentConfig.systemPrompt } : {}),
1151
+ systemPromptMode: agentConfig.systemPromptMode,
1152
+ inheritProjectContext: agentConfig.inheritProjectContext,
1153
+ inheritSkills: agentConfig.inheritSkills,
1154
+ ...(resolvedSkills.length ? { skills: resolvedSkills.map((skill) => skill.name) } : {}),
1155
+ ...(agentConfig.skillPath ? { skillPath: [...agentConfig.skillPath] } : {}),
1156
+ ...(agentConfig.filePath ? { agentFilePath: agentConfig.filePath } : {}),
1157
+ ...(agentConfig.completionGuard !== undefined ? { completionGuard: agentConfig.completionGuard } : {}),
1158
+ ...(agentConfig.memory ? { memory: { ...agentConfig.memory } } : {}),
1159
+ ...(outputPath ? { outputPath } : {}),
1160
+ outputMode,
1161
+ ...(resolvedAcceptance ? { acceptance: resolvedAcceptance } : {}),
1162
+ ...(controlConfig ? { controlConfig } : {}),
1163
+ ...(deadlineAt !== undefined ? { absoluteDeadlineAt: deadlineAt } : {}),
1164
+ ...(params.turnBudget ? { initialTurnBudget: params.turnBudget } : {}),
1165
+ ...(resolvedToolBudget.budget ? { initialToolBudget: resolvedToolBudget.budget } : {}),
1166
+ maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, agentConfig.maxSubagentDepth),
1167
+ ...(maxOutput ? { maxOutput } : {}),
1168
+ share: shareEnabled,
1169
+ ...(resolvedSessionDir ? { sessionDir: resolvedSessionDir } : {}),
1170
+ ...(artifactsDir ? { artifactsDir } : {}),
1171
+ artifactConfig,
1172
+ };
1173
+ try {
1174
+ writePrivateAtomicJson(path.join(asyncDir, "recovery-descriptor.json"), recoveryDescriptor);
1175
+ } catch (error) {
1176
+ return formatAsyncStartError("single", `Failed to persist async recovery descriptor for '${id}': ${error instanceof Error ? error.message : String(error)}`);
1177
+ }
926
1178
  let spawnResult: { pid?: number; error?: string } = {};
927
1179
  try {
928
1180
  spawnResult = spawnRunner(
@@ -953,13 +1205,8 @@ export function executeAsyncSingle(
953
1205
  outputMode,
954
1206
  sessionFile,
955
1207
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, agentConfig.maxSubagentDepth),
956
- effectiveAcceptance: resolveEffectiveAcceptance({
957
- explicit: params.acceptance,
958
- agentName: agent,
959
- task,
960
- mode: "single",
961
- async: true,
962
- }),
1208
+ waitToolEnabled: params.waitToolEnabled,
1209
+ effectiveAcceptance: resolvedAcceptance,
963
1210
  ...(resolvedToolBudget.budget ? { toolBudget: resolvedToolBudget.budget } : {}),
964
1211
  },
965
1212
  ],
@@ -970,7 +1217,7 @@ export function executeAsyncSingle(
970
1217
  artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
971
1218
  artifactConfig,
972
1219
  share: shareEnabled,
973
- sessionDir: sessionRoot ? path.join(sessionRoot, `async-${id}`) : undefined,
1220
+ sessionDir: resolvedSessionDir,
974
1221
  asyncDir,
975
1222
  sessionId: ctx.currentSessionId,
976
1223
  piPackageRoot,
@@ -979,13 +1226,14 @@ export function executeAsyncSingle(
979
1226
  worktreeSetupHookTimeoutMs,
980
1227
  worktreeBaseDir,
981
1228
  controlConfig,
982
- timeoutMs: params.timeoutMs,
1229
+ timeoutMs,
983
1230
  deadlineAt,
984
1231
  turnBudget: params.turnBudget,
985
1232
  toolBudget: params.toolBudget,
986
1233
  controlIntercomTarget,
987
1234
  childIntercomTargets: childIntercomTarget ? [childIntercomTarget(agent, 0)] : undefined,
988
1235
  resultMode: "single",
1236
+ ...(params.revivalLease ? { revivalLease: params.revivalLease } : {}),
989
1237
  nestedRoute: nestedRoute ?? inheritedNestedRoute,
990
1238
  nestedSelf: inheritedNestedRoute && nestedAddress ? {
991
1239
  parentRunId: nestedAddress.parentRunId,
@@ -1032,7 +1280,7 @@ export function executeAsyncSingle(
1032
1280
  agent,
1033
1281
  agents: [agent],
1034
1282
  chainStepCount: 1,
1035
- ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}),
1283
+ ...(timeoutMs !== undefined ? { timeoutMs, deadlineAt } : {}),
1036
1284
  ...(initialTurnBudget ? { turnBudget: initialTurnBudget } : {}),
1037
1285
  startedAt: now,
1038
1286
  lastUpdate: now,
@@ -1050,16 +1298,17 @@ export function executeAsyncSingle(
1050
1298
  mode: "single",
1051
1299
  agent,
1052
1300
  task: task?.slice(0, 50),
1301
+ goal: (params.goal ?? task).slice(0, 120),
1053
1302
  cwd: runnerCwd,
1054
1303
  asyncDir,
1055
- ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}),
1304
+ ...(timeoutMs !== undefined ? { timeoutMs, deadlineAt } : {}),
1056
1305
  ...(initialTurnBudget ? { turnBudget: initialTurnBudget } : {}),
1057
1306
  nestedRoute,
1058
1307
  });
1059
1308
  }
1060
1309
 
1061
1310
  return {
1062
- content: [{ type: "text", text: formatAsyncStartedMessage(`Async: ${agent} [${id}]`) }],
1063
- details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir, ...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs, deadlineAt } : {}), ...(params.turnBudget ? { turnBudget: params.turnBudget } : {}), ...(params.toolBudget ? { toolBudget: params.toolBudget } : {}) },
1311
+ content: [{ type: "text", text: formatAsyncStartedMessage(`Async: ${agent} [${id}]`, ctx.interactive === true) }],
1312
+ details: { mode: "single", runId: id, results: [], asyncId: id, asyncDir, ...(timeoutMs !== undefined ? { timeoutMs, deadlineAt } : {}), ...(params.turnBudget ? { turnBudget: params.turnBudget } : {}), ...(params.toolBudget ? { toolBudget: params.toolBudget } : {}) },
1064
1313
  };
1065
1314
  }