pi-subagents 0.45.1 → 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,15 @@
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
+
5
14
  ## [0.45.1] - 2026-08-09
6
15
 
7
16
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents",
3
- "version": "0.45.1",
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",
@@ -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
+ }
@@ -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
 
@@ -3942,6 +3942,8 @@ function duplicateSubagentCallResult(params: SubagentParamsLike): AgentToolResul
3942
3942
  };
3943
3943
  }
3944
3944
 
3945
+ const workflowLaunchObservers = new WeakMap<object, (launch: { agent: string; sessionFile?: string }) => void>();
3946
+
3945
3947
  function workflowChildResult(key: string, result: AgentToolResult<Details>): WorkflowScriptChildResult {
3946
3948
  const receiptOutput = result.content.map((part) => part.type === "text" ? part.text : "").filter(Boolean).join("\n");
3947
3949
  const output = result.details.results.length === 1 && result.details.results[0]?.finalOutput !== undefined
@@ -4159,6 +4161,7 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4159
4161
  ctx: ExtensionContext,
4160
4162
  preserveActiveSession = false,
4161
4163
  ): Promise<AgentToolResult<Details>> => {
4164
+ const workflowLaunchObserver = workflowLaunchObservers.get(params);
4162
4165
  const delegatedThinkingOverride = delegatedThinkingOverrides.get(params);
4163
4166
  const allowZeroToolBudget = delegatedZeroToolBudgets.has(params);
4164
4167
  if (!preserveActiveSession) deps.state.baseCwd = ctx.cwd;
@@ -4349,6 +4352,13 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
4349
4352
  if (budgetState?.exhausted) return workflowChildResult(key, buildRequestedModeError(childParams as SubagentParamsLike, usageBudgetExceededMessage(budgetState)));
4350
4353
  patchMissionObjective(childParams.task);
4351
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
+ });
4352
4362
  const result = await execute(randomUUID(), childRequest, workflowSignal, (update) => {
4353
4363
  const progress = update.details.progress?.[0];
4354
4364
  const step = status.steps?.find((candidate) => candidate.workflowKey === key);
@@ -5421,6 +5431,18 @@ export function createSubagentExecutor(deps: ExecutorDeps): {
5421
5431
 
5422
5432
  let nestedForegroundStarted = false;
5423
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
+ }
5424
5446
  const asyncResult = runAsyncPath(execData, deps);
5425
5447
  if (asyncResult) return attachMission(withResolvedContext(withForkThinkingNotes(asyncResult, forkThinkingDowngrades), contextPolicy.contextSummary));
5426
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
  });
@@ -139,6 +139,25 @@ function assertJsonValue(value, path = "emit", seen = new Set()) {
139
139
  seen.delete(value);
140
140
  }
141
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
+
142
161
  parentPort.on("message", async (message) => {
143
162
  if (message.type === "response") {
144
163
  const entry = pending.get(message.callId);
@@ -163,7 +182,7 @@ parentPort.on("message", async (message) => {
163
182
  return;
164
183
  }
165
184
  const value = await compiled.runInContext(context);
166
- const persistedValue = value === undefined ? null : value;
185
+ const persistedValue = value === undefined ? null : omitUndefinedWorkflowValues(value);
167
186
  assertJsonValue(persistedValue, "return");
168
187
  parentPort.postMessage({ type: "complete", value: persistedValue });
169
188
  } catch (error) {