pi-subagents 0.45.0 → 0.45.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.45.2] - 2026-08-10
6
+
7
+ ### Fixed
8
+ - Tell parents to revive resumable failed async runs before reporting failure or launching a replacement. Thanks to @Livan-pro for #938.
9
+ - Persist the actual agent and session file for workflow children when they start so their sessions can resume after a parent restart. Thanks to @Livan-pro for #932.
10
+ - Retry steering requests that remain pending after manual compaction and fail unresolved requests at shutdown. Thanks to @jtac for #933.
11
+ - Omit undefined object fields from `workflowScript` return values so completed `runs.all` results are not discarded when callers include unsupported fields such as `status` (#930).
12
+ - Keep child steering inbox `auto` requests queued between `agent_end` and `agent_settled` so settlement-time guidance is not sent as an idle prompt too early. Thanks to @jtac for #928.
13
+
14
+ ## [0.45.1] - 2026-08-09
15
+
16
+ ### Changed
17
+ - Simplified async workflow activity projection and its regression test to reuse canonical status types.
18
+
19
+ ### Fixed
20
+ - Add actionable guidance when Markdown fence backticks make a `workflowScript` invalid JavaScript.
21
+ - Prevent async interrupt requests from signaling unverified runner PIDs, including the shared host PID stored by workflows. Thanks to @kdasme for #925.
22
+
5
23
  ## [0.45.0] - 2026-08-09
6
24
 
7
25
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.45.0",
3
+ "version": "0.45.2",
4
4
  "description": "Pi extension for single-agent delegation and scripted multi-agent workflows",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -18,7 +18,7 @@ export const FULL_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { work
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. 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 {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, and resume keeps the stored agent/model/tool contract. 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, 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
+ • 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 {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, and resume keeps the stored agent/model/tool contract. 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, 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
22
  • 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
23
  • 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
24
  • 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.
@@ -37,7 +37,7 @@ export const COMPACT_SUBAGENT_TOOL_DESCRIPTION = `Run subagents only through { w
37
37
 
38
38
  EXECUTE:
39
39
  • Call { action:"list" } first and use only executable/non-disabled agents.
40
- • SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and runs.all for parallel work. 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. 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. 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.
40
+ • SCRIPT {workflowScript:"return runs.run('main', {agent:'worker', task:'...'})"}. Use stable-key runs.run for one child and runs.all for parallel work. 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. 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
41
  • 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
42
  • 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.
43
43
 
@@ -9,9 +9,8 @@
9
9
  * This module adds a portable, file-based control inbox inside the run directory.
10
10
  * The parent drops an interrupt request file; the runner watches the inbox and
11
11
  * routes the request into its existing graceful `interruptRunner()` (pause +
12
- * resumable), identically on every platform. The OS signal is kept only as an
13
- * opportunistic fast-path; its failure is non-fatal because the file inbox is
14
- * authoritative.
12
+ * resumable), identically on every platform. The file inbox is authoritative and
13
+ * avoids signaling a PID that the extension cannot prove belongs to the runner.
15
14
  */
16
15
 
17
16
  import { randomUUID } from "node:crypto";
@@ -21,13 +20,6 @@ import { writeAtomicJson } from "../../shared/atomic-json.ts";
21
20
  import { POLL_INTERVAL_MS } from "../../shared/types.ts";
22
21
  import { resolveWatchPath } from "../../shared/utils.ts";
23
22
 
24
- /**
25
- * Opportunistic fast-path interrupt signal. On Unix `SIGUSR2` is trapped by the
26
- * runner; on Windows `process.kill(pid, "SIGBREAK")` is not deliverable
27
- * cross-process and throws `ENOSYS`, so the file inbox below is the real channel.
28
- */
29
- export const INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
30
-
31
23
  export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch" | "readdirSync" | "readFileSync" | "realpathSync">;
32
24
  export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
33
25
  type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
@@ -545,38 +537,13 @@ export function consumeCheckpointDecisionRequest(
545
537
  return undefined;
546
538
  }
547
539
 
548
- /**
549
- * Parent side: portable interrupt = authoritative file request + best-effort OS
550
- * signal. The signal is only a latency optimization on Unix; ENOSYS on Windows
551
- * is swallowed because the file inbox is authoritative there. Other signal
552
- * failures are surfaced because they usually mean the runner is not alive to
553
- * consume the request.
554
- */
540
+ /** Parent side: write the authoritative portable interrupt request. */
555
541
  export function deliverInterruptRequest(input: {
556
542
  asyncDir: string;
557
- pid?: number;
558
- kill?: KillFn;
559
- signal?: NodeJS.Signals;
560
543
  now?: () => number;
561
544
  source?: string;
562
545
  }): void {
563
- const requestPath = requestAsyncInterrupt(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
564
- if (typeof input.pid === "number" && input.pid > 0) {
565
- try {
566
- (input.kill ?? process.kill)(input.pid, input.signal ?? INTERRUPT_SIGNAL);
567
- } catch (error) {
568
- if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOSYS") {
569
- // File inbox is authoritative when custom cross-process signals are unavailable.
570
- return;
571
- }
572
- try {
573
- fs.rmSync(requestPath, { force: true });
574
- } catch {
575
- // Best effort cleanup; the caller still gets the signal failure.
576
- }
577
- throw error;
578
- }
579
- }
546
+ requestAsyncInterrupt(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
580
547
  }
581
548
 
582
549
  export function deliverTimeoutRequest(input: {
@@ -0,0 +1,33 @@
1
+ import * as fs from "node:fs";
2
+ import type { AsyncRunSummary } from "./async-status.ts";
3
+
4
+ export function formatAsyncReviveCommand(run: AsyncRunSummary): string | undefined {
5
+ const step = run.steps.find((candidate) => candidate.status === "failed" && candidate.sessionFile && fs.existsSync(candidate.sessionFile));
6
+ if (!step) {
7
+ if (run.steps.length === 1 && run.sessionFile && fs.existsSync(run.sessionFile)) {
8
+ return `subagent({ action: "resume", id: "${run.id}", message: "Continue from the persisted child session and report the result." })`;
9
+ }
10
+ return undefined;
11
+ }
12
+ const index = run.steps.length === 1 ? "" : `, index: ${step.index}`;
13
+ return `subagent({ action: "resume", id: "${run.id}"${index}, message: "Continue from the persisted child session and report the result." })`;
14
+ }
15
+
16
+ export function formatResumeFirstFailedRunDetail(run: AsyncRunSummary): string | undefined {
17
+ if (run.state !== "failed") return undefined;
18
+ const command = formatAsyncReviveCommand(run);
19
+ if (!command) return undefined;
20
+ return `Resume-first: failed run "${run.id}" has a persisted child session. Revive the original run with ${command} before reporting failure or launching a replacement. Launch a replacement only if revive fails or the user explicitly asks for one.`;
21
+ }
22
+
23
+ export function formatResumeFirstFailedRunsNote(runs: AsyncRunSummary[]): string {
24
+ const resumable = runs
25
+ .filter((run) => run.state === "failed")
26
+ .map((run) => ({ run, command: formatAsyncReviveCommand(run) }))
27
+ .filter((entry): entry is { run: AsyncRunSummary; command: string } => Boolean(entry.command));
28
+ if (resumable.length === 0) return "";
29
+ const guidance = resumable.length === 1
30
+ ? `failed run "${resumable[0]!.run.id}" has a persisted child session. Revive the original run with ${resumable[0]!.command}`
31
+ : `${resumable.length} failed runs have persisted child sessions. Inspect status and revive each original run before retrying`;
32
+ return ` Resume-first: ${guidance} before reporting failure or launching a replacement. Launch a replacement only if revive fails or the user explicitly asks for one.`;
33
+ }
@@ -2287,7 +2287,7 @@ async function runSubagent(
2287
2287
  const nestedAsyncDir = run.asyncDir ?? resolveNestedAsyncDir(config.nestedRoute.rootRunId, run);
2288
2288
  if (!nestedAsyncDir) continue;
2289
2289
  try {
2290
- deliverInterruptRequest(omitUndefinedProperties({ asyncDir: nestedAsyncDir, pid: run.pid, source: "ancestor-interrupt" }));
2290
+ deliverInterruptRequest({ asyncDir: nestedAsyncDir, source: "ancestor-interrupt" });
2291
2291
  } catch (error) {
2292
2292
  appendJsonl(eventsPath, JSON.stringify({
2293
2293
  type: "subagent.nested.interrupt_failed",
@@ -62,6 +62,7 @@ import {
62
62
  } from "../../shared/types.ts";
63
63
  import { formatDuration, shortenPath } from "../../shared/formatters.ts";
64
64
  import { collectWaitCompletions } from "./wait-completions.ts";
65
+ import { formatResumeFirstFailedRunsNote } from "./resume-guidance.ts";
65
66
  export { WAIT_TOOL_ENABLED_ENV, resolveWaitToolConfig, type ResolvedWaitToolConfig } from "./wait-config.ts";
66
67
 
67
68
  /** States that mean a run is still in flight (not yet resolved). */
@@ -604,6 +605,7 @@ export async function waitForSubagents(
604
605
  let finishedAsyncCount: number;
605
606
  let failedAsyncCount: number;
606
607
  let completions: WaitCompletion[] | undefined;
608
+ let resumeGuidance = "";
607
609
  const activeProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
608
610
  const providerFinishedCount = [...initialProviderIds].filter((id) => !activeProviderIds.has(id)).length;
609
611
  try {
@@ -612,6 +614,7 @@ export async function waitForSubagents(
612
614
  finishedAsyncCount = terminal.length;
613
615
  failedAsyncCount = terminal.filter((run) => run.state === "failed").length;
614
616
  terminalSummary = summarizeTerminalRuns(terminal, providerFinishedCount);
617
+ resumeGuidance = formatResumeFirstFailedRunsNote(terminal);
615
618
  completions = collectWaitCompletions(terminal, deps.state, deps.resultsDir ?? DIRS.results);
616
619
  } catch (error) {
617
620
  return result(error instanceof Error ? error.message : String(error), true);
@@ -637,7 +640,7 @@ export async function waitForSubagents(
637
640
  : `${initialAsyncIds.size} async run(s) and ${initialProviderIds.size} provider item(s)`;
638
641
  const status = relevantAttention.length > 0 ? "attention required" : "done";
639
642
  return result(
640
- `Waited ${elapsed} for ${scope}; ${status}.${outcome}${attentionNote} Completion/control events have been observed; inspect status if a notification is not visible yet.`,
643
+ `Waited ${elapsed} for ${scope}; ${status}.${outcome}${resumeGuidance}${attentionNote} Completion/control events have been observed; inspect status if a notification is not visible yet.`,
641
644
  deps.failOnFailedRuns === true && failedAsyncCount > 0,
642
645
  completions,
643
646
  );
@@ -654,7 +657,7 @@ export async function waitForSubagents(
654
657
  ? `${relevantAttention.length} of ${initialCount} ${subject} need attention`
655
658
  : `${finishedCount} of ${initialCount} ${subject} finished`;
656
659
  return result(
657
- `Waited ${elapsed}; ${progress}.${outcome}${attentionNote}${remainder} Relevant completion/control events have been observed; inspect status if a notification is not visible yet.`,
660
+ `Waited ${elapsed}; ${progress}.${outcome}${resumeGuidance}${attentionNote}${remainder} Relevant completion/control events have been observed; inspect status if a notification is not visible yet.`,
658
661
  deps.failOnFailedRuns === true && failedAsyncCount > 0,
659
662
  completions,
660
663
  );
@@ -2,7 +2,8 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
- import { listAsyncRuns } from "./async-status.ts";
5
+ import { listAsyncRuns, type AsyncRunSummary } from "./async-status.ts";
6
+ import { formatResumeFirstFailedRunDetail } from "./resume-guidance.ts";
6
7
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
7
8
  import {
8
9
  DIRS,
@@ -61,7 +62,7 @@ function parseRecord(value: unknown): WaitSubscriptionRecord | undefined {
61
62
  return record as WaitSubscriptionRecord;
62
63
  }
63
64
 
64
- function needsAttention(run: ReturnType<typeof listAsyncRuns>[number]): boolean {
65
+ function needsAttention(run: AsyncRunSummary): boolean {
65
66
  return run.activityState === "needs_attention" || run.steps.some((step) => step.activityState === "needs_attention");
66
67
  }
67
68
 
@@ -166,7 +167,7 @@ export function createWaitSubscriptionManager(
166
167
  return;
167
168
  }
168
169
  if (run.state !== "queued" && run.state !== "running") {
169
- settle(record, run.state === "complete" ? "completed" : run.state, "Inspect the run status for its final output.");
170
+ settle(record, run.state === "complete" ? "completed" : run.state, formatResumeFirstFailedRunDetail(run) ?? "Inspect the run status for its final output.");
170
171
  }
171
172
  };
172
173
 
@@ -173,7 +173,7 @@ export async function steerAsyncRun(input: {
173
173
  return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: preCommitResult } };
174
174
  }
175
175
  try {
176
- deliverInterruptRequest({ asyncDir, pid: latest?.pid ?? status.pid, kill: input.kill, source: "steering-recovery" });
176
+ deliverInterruptRequest({ asyncDir, source: "steering-recovery" });
177
177
  } catch (error) {
178
178
  fs.rmSync(markerPath, { force: true });
179
179
  fs.rmSync(claimPath, { force: true });
@@ -868,8 +868,15 @@ function interruptAsyncRun(
868
868
  details: { mode: "management", results: [] },
869
869
  };
870
870
  }
871
+ if (status.mode === "workflow") {
872
+ return {
873
+ content: [{ type: "text", text: `Interrupt is unsupported for async workflow ${target.asyncId}; use stop instead.` }],
874
+ isError: true,
875
+ details: { mode: "management", results: [] },
876
+ };
877
+ }
871
878
  try {
872
- deliverInterruptRequest(omitUndefinedProperties({ asyncDir: target.asyncDir, pid: status.pid, kill, source: "interrupt-action" }));
879
+ deliverInterruptRequest({ asyncDir: target.asyncDir, source: "interrupt-action" });
873
880
  const tracked = state.asyncJobs.get(target.asyncId);
874
881
  if (tracked) {
875
882
  delete tracked.activityState;
@@ -1178,7 +1185,7 @@ function directNestedAsyncInterrupt(target: ResolvedSubagentRunId & { kind: "nes
1178
1185
  const pid = typeof status?.pid === "number" && status.pid > 0 ? status.pid : run.pid;
1179
1186
  if (!status || status.state !== "running" || typeof pid !== "number" || pid <= 0) return undefined;
1180
1187
  try {
1181
- deliverInterruptRequest({ asyncDir, pid, source: "nested-interrupt" });
1188
+ deliverInterruptRequest({ asyncDir, source: "nested-interrupt" });
1182
1189
  return { content: [{ type: "text", text: `Interrupt requested for nested async run ${run.id}.` }], details: { mode: "management", results: [] } };
1183
1190
  } catch (error) {
1184
1191
  const message = error instanceof Error ? error.message : String(error);
@@ -3935,6 +3942,8 @@ function duplicateSubagentCallResult(params: SubagentParamsLike): AgentToolResul
3935
3942
  };
3936
3943
  }
3937
3944
 
3945
+ const workflowLaunchObservers = new WeakMap<object, (launch: { agent: string; sessionFile?: string }) => void>();
3946
+
3938
3947
  function workflowChildResult(key: string, result: AgentToolResult<Details>): WorkflowScriptChildResult {
3939
3948
  const receiptOutput = result.content.map((part) => part.type === "text" ? part.text : "").filter(Boolean).join("\n");
3940
3949
  const output = result.details.results.length === 1 && result.details.results[0]?.finalOutput !== undefined
@@ -4152,6 +4161,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4152
4161
  ctx: ExtensionContext,
4153
4162
  preserveActiveSession = false,
4154
4163
  ): Promise<AgentToolResult<Details>> => {
4164
+ const workflowLaunchObserver = workflowLaunchObservers.get(params);
4155
4165
  const delegatedThinkingOverride = delegatedThinkingOverrides.get(params);
4156
4166
  const allowZeroToolBudget = delegatedZeroToolBudgets.has(params);
4157
4167
  if (!preserveActiveSession) deps.state.baseCwd = ctx.cwd;
@@ -4273,7 +4283,8 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4273
4283
  }
4274
4284
  };
4275
4285
  const projectWorkflowActivity = () => {
4276
- const runningSteps = (status.steps ?? []).filter((step) => step.status === "running");
4286
+ const steps = status.steps ?? [];
4287
+ const runningSteps = steps.filter((step) => step.status === "running");
4277
4288
  const lastActivityAt = runningSteps.reduce<number | undefined>((latest, step) => step.lastActivityAt === undefined ? latest : Math.max(latest ?? step.lastActivityAt, step.lastActivityAt), undefined);
4278
4289
  const activeToolStep = runningSteps
4279
4290
  .filter((step) => step.currentTool)
@@ -4286,11 +4297,11 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4286
4297
  status.currentTool = activeToolStep?.currentTool;
4287
4298
  status.currentToolStartedAt = activeToolStep?.currentToolStartedAt;
4288
4299
  status.currentPath = activeToolStep?.currentPath;
4289
- const turnCounts = (status.steps ?? []).flatMap((step) => step.turnCount === undefined ? [] : [step.turnCount]);
4290
- const toolCounts = (status.steps ?? []).flatMap((step) => step.toolCount === undefined ? [] : [step.toolCount]);
4300
+ const turnCounts = steps.flatMap((step) => step.turnCount === undefined ? [] : [step.turnCount]);
4301
+ const toolCounts = steps.flatMap((step) => step.toolCount === undefined ? [] : [step.toolCount]);
4291
4302
  status.turnCount = turnCounts.length > 0 ? turnCounts.reduce((total, count) => total + count, 0) : undefined;
4292
4303
  status.toolCount = toolCounts.length > 0 ? toolCounts.reduce((total, count) => total + count, 0) : undefined;
4293
- status.currentStep = runningSteps.length === 1 ? status.steps?.indexOf(runningSteps[0]!) : undefined;
4304
+ status.currentStep = runningSteps.length === 1 ? steps.indexOf(runningSteps[0]!) : undefined;
4294
4305
  };
4295
4306
  const workflowJob: AsyncJobState = { asyncId: workflowRunId, asyncDir, cwd: workflowCwd, status: "running", sessionId: currentSessionId ?? undefined, mode: "workflow", agents: [], steps: [], startedAt, updatedAt: startedAt, ...(timeout !== undefined ? { timeoutMs: timeout, deadlineAt: startedAt + timeout } : {}), workflow: status.workflow };
4296
4307
  deps.state.asyncJobs.set(workflowRunId, workflowJob);
@@ -4341,6 +4352,13 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4341
4352
  if (budgetState?.exhausted) return workflowChildResult(key, buildRequestedModeError(childParams as SubagentParamsLike, usageBudgetExceededMessage(budgetState)));
4342
4353
  patchMissionObjective(childParams.task);
4343
4354
  const childRequest = prepareWorkflowLaunchParams(workflowChildDefaults, childParams, workflowRunId, key, { missionDetached: detachWorkflowChildMissions });
4355
+ workflowLaunchObservers.set(childRequest, (launch) => {
4356
+ const step = status.steps?.find((candidate) => candidate.workflowKey === key);
4357
+ if (!step) return;
4358
+ step.agent = launch.agent;
4359
+ step.sessionFile = launch.sessionFile;
4360
+ persist();
4361
+ });
4344
4362
  const result = await execute(randomUUID(), childRequest, workflowSignal, (update) => {
4345
4363
  const progress = update.details.progress?.[0];
4346
4364
  const step = status.steps?.find((candidate) => candidate.workflowKey === key);
@@ -5413,6 +5431,18 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
5413
5431
 
5414
5432
  let nestedForegroundStarted = false;
5415
5433
  try {
5434
+ if (workflowLaunchObserver) {
5435
+ const singleTask = hasTasks && effectiveParams.tasks?.length === 1 ? effectiveParams.tasks[0] : undefined;
5436
+ const launch = hasSingle
5437
+ ? { agent: effectiveParams.agent!, sessionFile: childSessionFileForTask(effectiveParams.agent!, 0, effectiveParams.model) }
5438
+ : singleTask
5439
+ ? { agent: singleTask.agent, sessionFile: childSessionFileForTask(singleTask.agent, 0, singleTask.model) }
5440
+ : undefined;
5441
+ if (launch) {
5442
+ workflowLaunchObservers.delete(params);
5443
+ workflowLaunchObserver(launch);
5444
+ }
5445
+ }
5416
5446
  const asyncResult = runAsyncPath(execData, deps);
5417
5447
  if (asyncResult) return attachMission(withResolvedContext(withForkThinkingNotes(asyncResult, forkThinkingDowngrades), contextPolicy.contextSummary));
5418
5448
  if (foregroundControl) {
@@ -30,6 +30,7 @@ import { drainOutstandingWork } from "../background/auto-drain.ts";
30
30
  const SUBAGENT_INHERIT_PROJECT_CONTEXT_ENV = "PI_SUBAGENT_INHERIT_PROJECT_CONTEXT";
31
31
  const SUBAGENT_INHERIT_SKILLS_ENV = "PI_SUBAGENT_INHERIT_SKILLS";
32
32
  export const SUBAGENT_INTERCOM_SESSION_NAME_ENV = "PI_SUBAGENT_INTERCOM_SESSION_NAME";
33
+ const STEERING_LEGACY_SETTLE_FALLBACK_MS = 1000;
33
34
 
34
35
  const STRUCTURED_OUTPUT_INSTRUCTIONS = [
35
36
  "This subagent step has a strict structured output contract.",
@@ -330,7 +331,7 @@ function registerToolBudget(pi: ExtensionAPI, budget: ResolvedToolBudget | undef
330
331
 
331
332
  export function registerSteeringInbox(
332
333
  pi: ExtensionAPI,
333
- deps: { watch?: typeof fs.watch; nativeRealpath?: (filePath: string) => string } = {},
334
+ deps: { watch?: typeof fs.watch; nativeRealpath?: (filePath: string) => string; legacySettleFallbackMs?: number } = {},
334
335
  ): void {
335
336
  const steerInbox = process.env[SUBAGENT_STEER_INBOX_ENV]?.trim();
336
337
  if (!steerInbox) return;
@@ -343,11 +344,14 @@ export function registerSteeringInbox(
343
344
  let disposed = false;
344
345
  let agentRunning = false;
345
346
  let inTurn = false;
347
+ let awaitingSettlement = false;
346
348
  let flushing = false;
347
349
  let started = false;
348
350
  let canSteer = typeof sendUserMessage === "function";
349
351
  let watcher: fs.FSWatcher | undefined;
350
352
  let interval: NodeJS.Timeout | undefined;
353
+ let settleFallback: NodeJS.Timeout | undefined;
354
+ const legacySettleFallbackMs = deps.legacySettleFallbackMs ?? STEERING_LEGACY_SETTLE_FALLBACK_MS;
351
355
  const acknowledge = (request: SteerRequest, state: "delivered" | "queued" | "failed", message: string, deliveryStatus?: SteerDeliveryStatus): void => {
352
356
  if (!ackDir || !Number.isInteger(childIndex) || childIndex < 0) return;
353
357
  writeSteerAckAt(steerAckPathFromDir(ackDir, request.id), {
@@ -375,7 +379,8 @@ export function registerSteeringInbox(
375
379
  continue;
376
380
  }
377
381
  const requestedMode = request.mode ?? "steer";
378
- const delivery = requestedMode === "follow_up" || (requestedMode === "auto" && inTurn) ? "followUp" as const : "steer" as const;
382
+ const autoCanUseIdle = requestedMode === "auto" && !agentRunning && !awaitingSettlement;
383
+ const delivery = requestedMode === "follow_up" || (requestedMode === "auto" && (inTurn || awaitingSettlement)) ? "followUp" as const : "steer" as const;
379
384
  const pendingFollowUps = [...pending.values()].reduce((count, entries) => count + entries.filter((entry) => entry.deliveryStatus === "queued").length, 0);
380
385
  if (delivery === "followUp" && queued.length + pendingFollowUps >= MAX_STEER_QUEUE_SIZE) {
381
386
  acknowledge(request, "failed", `Follow-up queue is full (${MAX_STEER_QUEUE_SIZE} messages).`);
@@ -386,7 +391,7 @@ export function registerSteeringInbox(
386
391
  entries.push({ request, deliveryStatus: delivery === "followUp" ? "queued" : "delivered" });
387
392
  pending.set(formatted, entries);
388
393
  try {
389
- sendUserMessage(formatted, requestedMode === "auto" && !agentRunning ? undefined : { deliverAs: delivery });
394
+ sendUserMessage(formatted, autoCanUseIdle ? undefined : { deliverAs: delivery });
390
395
  } catch (error) {
391
396
  entries.pop();
392
397
  if (entries.length === 0) pending.delete(formatted);
@@ -440,14 +445,71 @@ export function registerSteeringInbox(
440
445
  flush();
441
446
  return undefined;
442
447
  };
448
+ const clearSettleFallback = (): void => {
449
+ if (!settleFallback) return;
450
+ clearTimeout(settleFallback);
451
+ settleFallback = undefined;
452
+ };
453
+ const markSettled = (): undefined => {
454
+ clearSettleFallback();
455
+ agentRunning = false;
456
+ inTurn = false;
457
+ awaitingSettlement = false;
458
+ return activate();
459
+ };
460
+ const armLegacySettleFallback = (): void => {
461
+ clearSettleFallback();
462
+ settleFallback = setTimeout(() => {
463
+ settleFallback = undefined;
464
+ if (disposed || !awaitingSettlement) return;
465
+ agentRunning = false;
466
+ inTurn = false;
467
+ awaitingSettlement = false;
468
+ activate();
469
+ }, legacySettleFallbackMs);
470
+ settleFallback.unref?.();
471
+ };
443
472
 
444
473
  const onRuntimeEvent = pi.on as unknown as (event: string, handler: (event: unknown, ctx?: unknown) => unknown) => void;
445
474
  // Register input before the watcher so an accepted extension input cannot race request dispatch.
446
475
  onRuntimeEvent("input", onInput);
447
476
  onRuntimeEvent("session_start", () => start());
448
- onRuntimeEvent("agent_start", () => { agentRunning = true; return activate(); });
449
- onRuntimeEvent("agent_end", () => { agentRunning = false; inTurn = false; return activate(); });
477
+ onRuntimeEvent("agent_start", () => {
478
+ clearSettleFallback();
479
+ agentRunning = true;
480
+ awaitingSettlement = false;
481
+ return activate();
482
+ });
483
+ onRuntimeEvent("agent_end", (event) => {
484
+ inTurn = false;
485
+ if ((event as { willRetry?: unknown } | undefined)?.willRetry === true) {
486
+ clearSettleFallback();
487
+ agentRunning = true;
488
+ awaitingSettlement = true;
489
+ return activate();
490
+ }
491
+ agentRunning = true;
492
+ awaitingSettlement = true;
493
+ armLegacySettleFallback();
494
+ return activate();
495
+ });
496
+ onRuntimeEvent("agent_settled", markSettled);
497
+ onRuntimeEvent("session_compact", () => {
498
+ const unresolved = [...pending.values()].flat();
499
+ pending.clear();
500
+ for (const entry of unresolved) {
501
+ try {
502
+ writeSteerRequestToDir(steerInbox, { ...entry.request, mode: "follow_up" });
503
+ } catch (error) {
504
+ acknowledge(entry.request, "failed", `Could not retry steering after compaction: ${error instanceof Error ? error.message : String(error)}`);
505
+ }
506
+ }
507
+ return activate();
508
+ });
450
509
  onRuntimeEvent("turn_start", () => {
510
+ clearSettleFallback();
511
+ agentRunning = true;
512
+ awaitingSettlement = false;
451
513
  inTurn = true;
452
514
  const next = queued.findIndex((entry) => entry.ready);
453
515
  if (next >= 0) {
@@ -466,7 +528,11 @@ export function registerSteeringInbox(
466
528
  }
467
529
  onRuntimeEvent("session_shutdown", () => {
468
530
  for (const entry of queued) acknowledge(entry.request, "failed", "Run ended before queued follow-up delivery.", "queued");
531
+ for (const entries of pending.values()) {
532
+ for (const entry of entries) acknowledge(entry.request, "failed", "Run ended before Pi confirmed steering input delivery.");
533
+ }
469
534
  disposed = true;
535
+ clearSettleFallback();
470
536
  try { watcher?.close(); } catch {}
471
537
  if (interval) clearInterval(interval);
472
538
  });
@@ -105,6 +105,17 @@ const capturedConsole = Object.freeze(Object.fromEntries(
105
105
  }]),
106
106
  ));
107
107
 
108
+ function formatWorkflowScriptSyntaxError(error) {
109
+ const details = error && error.stack ? error.stack : String(error);
110
+ return [
111
+ "workflowScript must be valid JavaScript.",
112
+ "If task text contains Markdown fences or backticks, use an array joined with \"\\n\" or escaped strings instead of a raw backtick template literal.",
113
+ "",
114
+ "Original SyntaxError:",
115
+ details,
116
+ ].join("\n");
117
+ }
118
+
108
119
  function assertJsonValue(value, path = "emit", seen = new Set()) {
109
120
  if (value === null || typeof value === "string" || typeof value === "boolean") return;
110
121
  if (typeof value === "number") {
@@ -128,6 +139,25 @@ function assertJsonValue(value, path = "emit", seen = new Set()) {
128
139
  seen.delete(value);
129
140
  }
130
141
 
142
+ function isPlainWorkflowObject(value) {
143
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
144
+ const prototype = Object.getPrototypeOf(value);
145
+ return prototype === null || prototype === Object.prototype || prototype === contextObjectPrototype;
146
+ }
147
+
148
+ function omitUndefinedWorkflowValues(value, seen = new Set()) {
149
+ if (value === null || typeof value !== "object") return value;
150
+ if (seen.has(value)) return value;
151
+ seen.add(value);
152
+ const normalized = Array.isArray(value)
153
+ ? value.map((entry) => entry === undefined ? null : omitUndefinedWorkflowValues(entry, seen))
154
+ : isPlainWorkflowObject(value) && Object.getOwnPropertySymbols(value).length === 0
155
+ ? Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => entry === undefined ? [] : [[key, omitUndefinedWorkflowValues(entry, seen)]]))
156
+ : value;
157
+ seen.delete(value);
158
+ return normalized;
159
+ }
160
+
131
161
  parentPort.on("message", async (message) => {
132
162
  if (message.type === "response") {
133
163
  const entry = pending.get(message.callId);
@@ -143,9 +173,16 @@ parentPort.on("message", async (message) => {
143
173
  if (message.stateEnabled) sandbox.state = state;
144
174
  const context = vm.createContext(sandbox, { codeGeneration: { strings: false, wasm: false } });
145
175
  contextObjectPrototype = vm.runInContext("Object.prototype", context);
146
- const compiled = new vm.Script("(async () => {\n" + message.script + "\n})()", { filename: "workflow-script.js" });
176
+ let compiled;
177
+ try {
178
+ compiled = new vm.Script("(async () => {\n" + message.script + "\n})()", { filename: "workflow-script.js" });
179
+ } catch (error) {
180
+ if (!(error instanceof SyntaxError)) throw error;
181
+ parentPort.postMessage({ type: "error", error: formatWorkflowScriptSyntaxError(error) });
182
+ return;
183
+ }
147
184
  const value = await compiled.runInContext(context);
148
- const persistedValue = value === undefined ? null : value;
185
+ const persistedValue = value === undefined ? null : omitUndefinedWorkflowValues(value);
149
186
  assertJsonValue(persistedValue, "return");
150
187
  parentPort.postMessage({ type: "complete", value: persistedValue });
151
188
  } catch (error) {