pi-subagents 0.47.1 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +2 -0
  3. package/docs/agents.md +1 -0
  4. package/docs/configuration.md +74 -7
  5. package/docs/missions.md +4 -2
  6. package/docs/observability.md +27 -3
  7. package/docs/tool-reference.md +1 -1
  8. package/package.json +1 -1
  9. package/src/agents/agents.ts +36 -15
  10. package/src/api/preflight.ts +1 -1
  11. package/src/extension/config.ts +26 -0
  12. package/src/extension/doctor.ts +40 -0
  13. package/src/extension/index.ts +39 -8
  14. package/src/extension/public-execution.ts +35 -4
  15. package/src/extension/rpc.ts +3 -9
  16. package/src/extension/schemas.ts +10 -9
  17. package/src/extension/tool-description.ts +10 -8
  18. package/src/inspectors/herdr/actions.ts +11 -2
  19. package/src/inspectors/herdr/inspector-runner.ts +16 -3
  20. package/src/intercom/intercom-bridge.ts +7 -2
  21. package/src/missions/lifecycle.ts +4 -7
  22. package/src/missions/store.ts +12 -7
  23. package/src/missions/workflow-state.ts +6 -2
  24. package/src/runs/background/active-async-capacity.ts +431 -0
  25. package/src/runs/background/active-run-index.ts +9 -5
  26. package/src/runs/background/async-execution.ts +141 -65
  27. package/src/runs/background/async-job-tracker.ts +4 -0
  28. package/src/runs/background/async-resume.ts +18 -2
  29. package/src/runs/background/async-status.ts +11 -5
  30. package/src/runs/background/chain-append.ts +33 -15
  31. package/src/runs/background/fleet-view.ts +18 -5
  32. package/src/runs/background/owned-process-tree.ts +104 -0
  33. package/src/runs/background/process-terminal.ts +17 -3
  34. package/src/runs/background/resume-guidance.ts +27 -7
  35. package/src/runs/background/retained-children.ts +14 -6
  36. package/src/runs/background/run-status.ts +101 -5
  37. package/src/runs/background/stale-run-reconciler.ts +3 -3
  38. package/src/runs/background/subagent-runner.ts +60 -32
  39. package/src/runs/foreground/chain-execution.ts +37 -2
  40. package/src/runs/foreground/execution.ts +90 -19
  41. package/src/runs/foreground/foreground-control.ts +12 -0
  42. package/src/runs/foreground/prompt-audit.ts +172 -0
  43. package/src/runs/foreground/subagent-executor.ts +829 -203
  44. package/src/runs/shared/acceptance.ts +13 -4
  45. package/src/runs/shared/completion-guard.ts +17 -1
  46. package/src/runs/shared/llm-intent-arbiter.ts +302 -0
  47. package/src/runs/shared/parallel-utils.ts +2 -0
  48. package/src/runs/shared/pi-args.ts +44 -1
  49. package/src/runs/shared/run-fanout-budget.ts +280 -0
  50. package/src/runs/shared/single-output.ts +4 -2
  51. package/src/runs/shared/task-intent.ts +19 -3
  52. package/src/runs/shared/worktree.ts +17 -5
  53. package/src/shared/agent-stream-options.ts +5 -0
  54. package/src/shared/artifacts.ts +2 -6
  55. package/src/shared/display-text.ts +50 -0
  56. package/src/shared/node-executable.ts +21 -0
  57. package/src/shared/types.ts +115 -4
  58. package/src/shared/utils.ts +3 -1
  59. package/src/tui/fleet-status.ts +7 -5
  60. package/src/tui/fleet-transcript.ts +1 -48
  61. package/src/tui/fleet.ts +228 -13
  62. package/src/tui/render.ts +86 -39
  63. package/src/watchdog/permission-arbiter.ts +2 -1
  64. package/src/watchdog/review.ts +4 -3
  65. package/src/workflows/chat-progress.ts +8 -2
  66. package/src/workflows/scripted-workflow.ts +66 -10
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
5
5
  import { appendJsonl } from "../../shared/artifacts.ts";
6
6
  import type { AsyncParallelGroupStatus, AsyncStatus, WorkflowGraphNode, WorkflowGraphSnapshot } from "../../shared/types.ts";
7
- import { readStatus } from "../../shared/utils.ts";
7
+ import { PROMPT_REDACTED, readStatus } from "../../shared/utils.ts";
8
8
  import type { DynamicRunnerGroup, ParallelStepGroup, RunnerStep, RunnerSubagentStep, RunnerCheckpointStep } from "../shared/parallel-utils.ts";
9
9
  import { isCheckpointRunnerStep, isDynamicRunnerGroup, isParallelGroup } from "../shared/parallel-utils.ts";
10
10
 
@@ -19,6 +19,7 @@ export interface ChainAppendRequest {
19
19
  export interface ChainAppendResult {
20
20
  request: ChainAppendRequest;
21
21
  pendingCount: number;
22
+ bookkeepingError?: string;
22
23
  }
23
24
 
24
25
  type StatusStep = NonNullable<AsyncStatus["steps"]>[number];
@@ -69,6 +70,7 @@ export function enqueueChainAppendRequest(input: {
69
70
  runId: string;
70
71
  steps: RunnerStep[];
71
72
  now?: number;
73
+ admit?: (persist: () => void) => void;
72
74
  }): ChainAppendResult {
73
75
  const status = readStatus(input.asyncDir);
74
76
  if (!status) throw new Error(`No async run status found for '${input.runId}'.`);
@@ -85,20 +87,36 @@ export function enqueueChainAppendRequest(input: {
85
87
  steps: input.steps,
86
88
  };
87
89
  fs.mkdirSync(appendDir(input.asyncDir), { recursive: true });
88
- writeAtomicJson(appendRequestPath(input.asyncDir, request), request);
89
- const pendingCount = countPendingChainAppendRequests(input.asyncDir);
90
+ const persist = () => writeAtomicJson(appendRequestPath(input.asyncDir, request), request);
91
+ if (input.admit) input.admit(persist);
92
+ else persist();
93
+ let pendingCount = 1;
94
+ const bookkeepingErrors: string[] = [];
95
+ try {
96
+ pendingCount = countPendingChainAppendRequests(input.asyncDir);
97
+ } catch (error) {
98
+ bookkeepingErrors.push(`pending append count failed: ${error instanceof Error ? error.message : String(error)}`);
99
+ }
90
100
  const statusPath = path.join(input.asyncDir, "status.json");
91
101
  const updatedStatus = { ...status, pendingAppends: pendingCount, lastUpdate: request.createdAt };
92
- writeAtomicJson(statusPath, updatedStatus);
93
- appendJsonl(path.join(input.asyncDir, "events.jsonl"), JSON.stringify({
94
- type: "subagent.chain.append.requested",
95
- ts: request.createdAt,
96
- runId: input.runId,
97
- requestId: request.id,
98
- stepCount: input.steps.length,
99
- pendingAppends: pendingCount,
100
- }));
101
- return { request, pendingCount };
102
+ try {
103
+ writeAtomicJson(statusPath, updatedStatus);
104
+ } catch (error) {
105
+ bookkeepingErrors.push(`status update failed: ${error instanceof Error ? error.message : String(error)}`);
106
+ }
107
+ try {
108
+ appendJsonl(path.join(input.asyncDir, "events.jsonl"), JSON.stringify({
109
+ type: "subagent.chain.append.requested",
110
+ ts: request.createdAt,
111
+ runId: input.runId,
112
+ requestId: request.id,
113
+ stepCount: input.steps.length,
114
+ pendingAppends: pendingCount,
115
+ }));
116
+ } catch (error) {
117
+ bookkeepingErrors.push(`event append failed: ${error instanceof Error ? error.message : String(error)}`);
118
+ }
119
+ return { request, pendingCount, ...(bookkeepingErrors.length > 0 ? { bookkeepingError: bookkeepingErrors.join("; ") } : {}) };
102
120
  }
103
121
 
104
122
  function readAppendRequest(filePath: string): ChainAppendRequest | undefined {
@@ -135,8 +153,8 @@ const MAX_STATUS_STEP_DESCRIPTION_CHARS = 160;
135
153
 
136
154
  /** Bounded one-line per-step task description persisted into status.json for fleet display. */
137
155
  export function statusStepDescription(task: string | undefined): string | undefined {
138
- const description = task?.replace(/\s+/g, " ").trim();
139
- if (!description) return undefined;
156
+ if (!task?.trim()) return undefined;
157
+ const description = PROMPT_REDACTED;
140
158
  return description.length > MAX_STATUS_STEP_DESCRIPTION_CHARS
141
159
  ? `${description.slice(0, MAX_STATUS_STEP_DESCRIPTION_CHARS - 1)}…`
142
160
  : description;
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
+ import { safeTerminalText } from "../../shared/display-text.ts";
4
5
  import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
5
6
  import { formatActivityLabel } from "../../shared/status-format.ts";
6
7
  import {
@@ -196,7 +197,7 @@ function readSessionTranscriptTail(sessionFile: string, maxLines: number, truste
196
197
  try {
197
198
  const parsed = JSON.parse(line) as unknown;
198
199
  const messageLine = sessionMessageLine(parsed);
199
- if (messageLine) lines.push(messageLine);
200
+ if (messageLine) lines.push(...messageLine.split(/\r?\n/));
200
201
  } catch {
201
202
  malformed++;
202
203
  }
@@ -410,6 +411,18 @@ function appendKnownArtifacts(lines: string[], input: { outputPaths: string[]; s
410
411
  for (const artifact of artifacts) lines.push(` ${artifact}`);
411
412
  }
412
413
 
414
+ /**
415
+ * Sanitizes each assembled line independently.
416
+ *
417
+ * Sanitizing the joined response would let a single binary fragment in child
418
+ * output collapse the whole transcript, including run state, artifact paths, and
419
+ * warnings, into the binary placeholder. Per-line sanitization keeps that damage
420
+ * to the offending line.
421
+ */
422
+ function safeTranscriptLines(lines: string[]): string {
423
+ return lines.map((line) => safeTerminalText(line)).join("\n");
424
+ }
425
+
413
426
  function appendTranscriptBody(lines: string[], sourceLabel: string, sourceLines: string[], truncated: boolean): void {
414
427
  lines.push(`${sourceLabel}${truncated ? " (tail truncated)" : ""}:`);
415
428
  if (sourceLines.length === 0) {
@@ -470,7 +483,7 @@ export function formatAsyncRunTranscript(status: AsyncStatus, asyncDir: string,
470
483
  for (const warning of warnings) lines.push(` ${warning}`);
471
484
  }
472
485
  appendTranscriptBody(lines, transcriptSource, transcriptLines, truncated);
473
- return lines.join("\n");
486
+ return safeTranscriptLines(lines);
474
487
  }
475
488
 
476
489
  export function formatNestedRunTranscript(run: NestedRunSummary, options: TranscriptOptions = {}): string {
@@ -488,7 +501,7 @@ export function formatNestedRunTranscript(run: NestedRunSummary, options: Transc
488
501
  appendKnownArtifacts(lines, { outputPaths: [], sessionFile: run.sessionFile });
489
502
  if (!run.sessionFile) {
490
503
  appendTranscriptBody(lines, "Transcript tail", [], false);
491
- return lines.join("\n");
504
+ return safeTranscriptLines(lines);
492
505
  }
493
506
  const sessionTail = readSessionTranscriptTail(run.sessionFile, lineLimit, options.sessionRoots ?? []);
494
507
  if (sessionTail.warnings.length) {
@@ -496,7 +509,7 @@ export function formatNestedRunTranscript(run: NestedRunSummary, options: Transc
496
509
  for (const warning of sessionTail.warnings) lines.push(` ${warning}`);
497
510
  }
498
511
  appendTranscriptBody(lines, `Session transcript tail from ${run.sessionFile}`, sessionTail.lines, false);
499
- return lines.join("\n");
512
+ return safeTranscriptLines(lines);
500
513
  }
501
514
 
502
515
  export function formatAsyncResultTranscript(data: {
@@ -536,5 +549,5 @@ export function formatAsyncResultTranscript(data: {
536
549
  ].filter((line): line is string => Boolean(line));
537
550
  appendKnownArtifacts(lines, { outputPaths: [], sessionFile, resultPath });
538
551
  appendTranscriptBody(lines, "Result transcript tail", transcriptLines.filter((line) => line.trim()), output.split(/\r?\n/).length > lineLimit);
539
- return lines.join("\n");
552
+ return safeTranscriptLines(lines);
540
553
  }
@@ -0,0 +1,104 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import type { ProcessTreeTerminalV1 } from "../../shared/types.ts";
3
+
4
+ const DEFAULT_TERM_GRACE_MS = 3000;
5
+ const DEFAULT_KILL_VERIFY_MS = 1000;
6
+ const VERIFY_INTERVAL_MS = 25;
7
+
8
+ type SignalResult = "sent" | "absent" | { diagnostic: string };
9
+
10
+ function diagnostic(error: unknown): string {
11
+ return error instanceof Error ? error.message : String(error);
12
+ }
13
+
14
+ function signalProcess(id: number, signal: NodeJS.Signals): SignalResult {
15
+ try {
16
+ process.kill(id, signal);
17
+ return "sent";
18
+ } catch (error) {
19
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") return "absent";
20
+ return { diagnostic: diagnostic(error) };
21
+ }
22
+ }
23
+
24
+ function activeProcessGroupMembers(processGroupId: number): number[] | { diagnostic: string } {
25
+ const result = spawnSync("ps", ["-axo", "pid=,pgid=,stat="], { encoding: "utf-8" });
26
+ if (result.error || result.status !== 0) {
27
+ return { diagnostic: result.error ? diagnostic(result.error) : (result.stderr.trim() || `ps exited with ${result.status}`) };
28
+ }
29
+ const members: number[] = [];
30
+ for (const line of result.stdout.split("\n")) {
31
+ const match = /^\s*(\d+)\s+(\d+)\s+(\S+)/.exec(line);
32
+ if (!match || Number(match[2]) !== processGroupId || match[3]!.startsWith("Z")) continue;
33
+ members.push(Number(match[1]));
34
+ }
35
+ return members;
36
+ }
37
+
38
+ async function waitUntilGroupTerminal(
39
+ processGroupId: number,
40
+ timeoutMs: number,
41
+ ): Promise<false | { state: "enumeration-failed" | "still-active"; diagnostic: string }> {
42
+ const deadline = Date.now() + timeoutMs;
43
+ while (true) {
44
+ const members = activeProcessGroupMembers(processGroupId);
45
+ if (Array.isArray(members) && members.length === 0) return false;
46
+ const remaining = deadline - Date.now();
47
+ if (remaining <= 0) {
48
+ if (!Array.isArray(members)) return { state: "enumeration-failed", diagnostic: members.diagnostic };
49
+ return { state: "still-active", diagnostic: `Process group ${processGroupId} still has active members: ${members.join(", ")}.` };
50
+ }
51
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.min(VERIFY_INTERVAL_MS, remaining)));
52
+ }
53
+ }
54
+
55
+ function observed(processGroupId: number): ProcessTreeTerminalV1 {
56
+ return { state: "observed", mechanism: "posix-process-group", processGroupId, verifiedAt: Date.now() };
57
+ }
58
+
59
+ /** Owns one writer process group and arbitrates its cleanup exactly once. */
60
+ export interface OwnedProcessTreeController {
61
+ terminate(): Promise<ProcessTreeTerminalV1>;
62
+ finishAfterWriterClose(): Promise<ProcessTreeTerminalV1>;
63
+ }
64
+
65
+ export function createOwnedProcessTreeController(
66
+ pid: number,
67
+ options: { termGraceMs?: number; killVerifyMs?: number } = {},
68
+ ): OwnedProcessTreeController {
69
+ let termination: Promise<ProcessTreeTerminalV1> | undefined;
70
+ const posixGroupOwned = process.platform !== "win32";
71
+ const target = posixGroupOwned ? -pid : pid;
72
+
73
+ const terminate = (): Promise<ProcessTreeTerminalV1> => {
74
+ if (termination) return termination;
75
+ termination = (async () => {
76
+ if (!posixGroupOwned) {
77
+ signalProcess(target, "SIGTERM");
78
+ return { state: "unknown", reason: "unsupported-platform" };
79
+ }
80
+ const term = signalProcess(target, "SIGTERM");
81
+ if (term !== "sent" && term !== "absent") {
82
+ return { state: "unknown", reason: "signal-failed", diagnostic: term.diagnostic };
83
+ }
84
+ const termExit = await waitUntilGroupTerminal(pid, options.termGraceMs ?? DEFAULT_TERM_GRACE_MS);
85
+ if (termExit === false) return observed(pid);
86
+
87
+ const kill = signalProcess(target, "SIGKILL");
88
+ if (kill !== "sent" && kill !== "absent") {
89
+ const members = activeProcessGroupMembers(pid);
90
+ if (!Array.isArray(members) || members.length > 0) {
91
+ return { state: "unknown", reason: "signal-failed", diagnostic: kill.diagnostic };
92
+ }
93
+ }
94
+ const killExit = await waitUntilGroupTerminal(pid, options.killVerifyMs ?? DEFAULT_KILL_VERIFY_MS);
95
+ if (killExit !== false) {
96
+ return { state: "unknown", reason: "verification-failed", diagnostic: killExit.diagnostic };
97
+ }
98
+ return observed(pid);
99
+ })();
100
+ return termination;
101
+ };
102
+
103
+ return { terminate, finishAfterWriterClose: terminate };
104
+ }
@@ -10,6 +10,7 @@ import {
10
10
  type ProcessTerminalV1,
11
11
  } from "../../shared/types.ts";
12
12
  import { canonicalSessionId, inspectSessionLease } from "../shared/session-lease.ts";
13
+ import { releaseActiveRunIndex } from "./active-run-index.ts";
13
14
 
14
15
  export interface ProcessTerminalCandidate {
15
16
  version: 1;
@@ -40,9 +41,19 @@ function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): va
40
41
  if (typeof value.closeObservedAt !== "number" || !Number.isFinite(value.closeObservedAt)) return false;
41
42
  if (typeof value.exitCode !== "number" && value.exitCode !== null) return false;
42
43
  if (typeof value.signal !== "string" && value.signal !== null) return false;
43
- return value.kind === "runner"
44
- ? value.attempt === undefined
45
- : typeof value.attempt === "number" && Number.isInteger(value.attempt) && value.attempt >= 0;
44
+ if (value.kind === "runner") return value.attempt === undefined;
45
+ if (typeof value.attempt !== "number" || !Number.isInteger(value.attempt) || value.attempt < 0 || !isRecord(value.processTree)) return false;
46
+ if (value.processTree.state === "observed") {
47
+ return value.processTree.mechanism === "posix-process-group"
48
+ && typeof value.processTree.processGroupId === "number"
49
+ && Number.isInteger(value.processTree.processGroupId)
50
+ && value.processTree.processGroupId > 0
51
+ && typeof value.processTree.verifiedAt === "number"
52
+ && Number.isFinite(value.processTree.verifiedAt);
53
+ }
54
+ return value.processTree.state === "unknown"
55
+ && ["unsupported-platform", "signal-failed", "verification-failed"].includes(String(value.processTree.reason))
56
+ && (value.processTree.diagnostic === undefined || typeof value.processTree.diagnostic === "string");
46
57
  }
47
58
 
48
59
  function validInstance(value: unknown): value is ProcessInstanceExitV1 {
@@ -249,6 +260,8 @@ export function finalizeProcessTerminal(
249
260
  proof = unknownProof(runId, runnerClose.processInstanceId, "canonical-session-release-unverified");
250
261
  } else if (inconsistentWriters || (allWriters.length === 0 && expectedEntries.length === 0)) {
251
262
  proof = unknownProof(runId, runnerClose.processInstanceId, "writer-close-unverified");
263
+ } else if (allWriters.some((writer) => writer.kind === "pi-writer" && writer.processTree.state !== "observed")) {
264
+ proof = unknownProof(runId, runnerClose.processInstanceId, "process-tree-unverified");
252
265
  } else {
253
266
  const runner: ProcessInstanceExitV1 = { kind: "runner", ...runnerClose };
254
267
  const canonicalSession = session && sessionProjection(candidate, session);
@@ -271,6 +284,7 @@ export function finalizeProcessTerminal(
271
284
  try {
272
285
  writeAtomicJson(processTerminalPath(asyncDir), proof);
273
286
  durable = true;
287
+ if (proof.state === "observed") releaseActiveRunIndex(asyncDir);
274
288
  overlayStatus(asyncDir, proof, candidateForOverlay);
275
289
  fs.appendFileSync(path.join(asyncDir, "events.jsonl"), `${JSON.stringify({ type: "subagent.run.process_terminal", lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION, ts: Date.now(), runId, processTerminal: proof })}\n`, "utf-8");
276
290
  } catch {
@@ -1,6 +1,19 @@
1
1
  import * as fs from "node:fs";
2
2
  import type { AsyncRunSummary } from "./async-status.ts";
3
3
 
4
+ const INTERCOM_DETACH_ERROR = "detached for intercom coordination";
5
+
6
+ function isIntercomDetached(run: AsyncRunSummary): boolean {
7
+ return run.steps.some((step) => step.execution?.status === "detached"
8
+ || step.error?.toLowerCase().includes(INTERCOM_DETACH_ERROR) === true)
9
+ || run.error?.toLowerCase().includes(INTERCOM_DETACH_ERROR) === true;
10
+ }
11
+
12
+ function formatIntercomDetachGuidance(run: AsyncRunSummary): string | undefined {
13
+ if (!isIntercomDetached(run)) return undefined;
14
+ return `Run "${run.id}" detached for intercom coordination. Reply to the supervisor request first, then wait with subagent_wait({ id: "${run.id}" }). Use subagent({ action: "status", id: "${run.id}" }) to recover the result; do not resume or launch a replacement while it remains detached.`;
15
+ }
16
+
4
17
  export function formatAsyncReviveCommand(run: AsyncRunSummary): string | undefined {
5
18
  const step = run.steps.find((candidate) => candidate.status === "failed" && candidate.sessionFile && fs.existsSync(candidate.sessionFile));
6
19
  if (!step) {
@@ -15,19 +28,26 @@ export function formatAsyncReviveCommand(run: AsyncRunSummary): string | undefin
15
28
 
16
29
  export function formatResumeFirstFailedRunDetail(run: AsyncRunSummary): string | undefined {
17
30
  if (run.state !== "failed") return undefined;
31
+ const detachGuidance = formatIntercomDetachGuidance(run);
32
+ if (detachGuidance) return detachGuidance;
18
33
  const command = formatAsyncReviveCommand(run);
19
34
  if (!command) return undefined;
20
35
  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
36
  }
22
37
 
23
38
  export function formatResumeFirstFailedRunsNote(runs: AsyncRunSummary[]): string {
24
- const resumable = runs
25
- .filter((run) => run.state === "failed")
39
+ const failedRuns = runs.filter((run) => run.state === "failed");
40
+ const detachGuidance = failedRuns
41
+ .map(formatIntercomDetachGuidance)
42
+ .filter((guidance): guidance is string => Boolean(guidance));
43
+ const resumable = failedRuns
44
+ .filter((run) => !isIntercomDetached(run))
26
45
  .map((run) => ({ run, command: formatAsyncReviveCommand(run) }))
27
46
  .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.`;
47
+ const resumeGuidance = resumable.length === 0
48
+ ? ""
49
+ : resumable.length === 1
50
+ ? ` Resume-first: failed run "${resumable[0]!.run.id}" has a persisted child session. Revive the original run with ${resumable[0]!.command} before reporting failure or launching a replacement. Launch a replacement only if revive fails or the user explicitly asks for one.`
51
+ : ` Resume-first: ${resumable.length} failed runs have persisted child sessions. Inspect status and revive each original run before reporting failure or launching a replacement. Launch a replacement only if revive fails or the user explicitly asks for one.`;
52
+ return `${detachGuidance.length > 0 ? ` ${detachGuidance.join(" ")}` : ""}${resumeGuidance}`;
33
53
  }
@@ -8,6 +8,8 @@ const MAX_TASK_SUMMARY_LENGTH = 120;
8
8
  export interface RetainedChild {
9
9
  runId: string;
10
10
  parentRunId?: string;
11
+ workflowKey?: string;
12
+ state: "complete" | "failed" | "paused";
11
13
  agent: string;
12
14
  taskSummary: string;
13
15
  completedAt: number;
@@ -33,16 +35,20 @@ function boundedTaskSummary(value: string | undefined): string {
33
35
  }
34
36
 
35
37
  export function listRetainedChildren(asyncDirRoot: string, sessionId: string): RetainedChild[] {
36
- return listAsyncRuns(asyncDirRoot, { sessionId, states: ["complete"], reconcile: false })
38
+ return listAsyncRuns(asyncDirRoot, { sessionId, states: ["complete", "failed", "paused"], reconcile: false })
37
39
  .flatMap((run) => {
38
40
  if (!run.parentWorkflowRunId || run.steps.length !== 1) return [];
41
+ if (run.state !== "complete" && run.state !== "failed" && run.state !== "paused") return [];
39
42
  const step = run.steps[0]!;
40
- if ((step.status !== "complete" && step.status !== "completed") || !retainedSessionFile(step.sessionFile ?? run.sessionFile)) return [];
41
- const completedAt = run.endedAt;
43
+ const terminalStep = step.status === "complete" || step.status === "completed" || step.status === "failed" || step.status === "paused";
44
+ if (!terminalStep || !retainedSessionFile(step.sessionFile ?? run.sessionFile)) return [];
45
+ const completedAt = run.endedAt ?? run.lastUpdate;
42
46
  if (completedAt === undefined) return [];
43
47
  return [{
44
48
  runId: run.id,
45
49
  ...(run.parentWorkflowRunId ? { parentRunId: run.parentWorkflowRunId } : {}),
50
+ ...(run.workflowKey ? { workflowKey: run.workflowKey } : {}),
51
+ state: run.state,
46
52
  agent: step.agent,
47
53
  taskSummary: boundedTaskSummary(step.description),
48
54
  completedAt,
@@ -55,13 +61,15 @@ export function listRetainedChildren(asyncDirRoot: string, sessionId: string): R
55
61
  }
56
62
 
57
63
  export function formatRetainedChildren(children: RetainedChild[]): string {
58
- if (children.length === 0) return "No completed retained children in the active parent session.";
64
+ if (children.length === 0) return "No recoverable retained children in the active parent session.";
59
65
  return [
60
- `Completed retained children (newest first, last ${MAX_RETAINED_CHILDREN}):`,
66
+ `Recoverable retained children (newest first, last ${MAX_RETAINED_CHILDREN}):`,
61
67
  ...children.flatMap((child) => [
62
- `- ${child.runId} | ${child.agent} | ${new Date(child.completedAt).toISOString()}`,
68
+ `- ${child.runId} | ${child.agent} | ${child.state} | ${new Date(child.completedAt).toISOString()}`,
69
+ ...(child.parentRunId ? [` workflow: ${child.parentRunId}${child.workflowKey ? ` (${child.workflowKey})` : ""}`] : []),
63
70
  ` task: ${child.taskSummary || "(no task summary)"}`,
64
71
  ` session: ${child.sessionPath}`,
72
+ ` resume: subagent({ action: "resume", id: "${child.runId}", message: "..." })`,
65
73
  ...(child.tokenTotals ? [` tokens: input ${child.tokenTotals.input}, output ${child.tokenTotals.output}, total ${child.tokenTotals.total}`] : []),
66
74
  ]),
67
75
  ].join("\n");
@@ -1,12 +1,14 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
+ import { safeTerminalText } from "../../shared/display-text.ts";
4
5
  import { formatAsyncRunList, formatAsyncRunOutputPath, formatAsyncRunProgressLabel, listAsyncRuns } from "./async-status.ts";
5
6
  import { formatAsyncResultTranscript, formatAsyncRunTranscript, formatNestedRunTranscript, inspectSubagentFleet } from "./fleet-view.ts";
6
7
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
7
8
  import { formatModelThinking } from "../../shared/formatters.ts";
8
9
  import { formatActivityLabel } from "../../shared/status-format.ts";
9
10
  import { DIRS, type AsyncStatus, type Details, type ForegroundResumeRun, type NestedRunSummary, type SteeringStatus, type SubagentState } from "../../shared/types.ts";
11
+ import { inspectActiveAsyncCapacityOwner, type ActiveAsyncCapacityInspection } from "./active-async-capacity.ts";
10
12
  import { readStatus } from "../../shared/utils.ts";
11
13
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
12
14
  import { resolveSubagentResultStatus } from "../../intercom/result-intercom.ts";
@@ -19,6 +21,7 @@ import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-
19
21
  import { attachRootChildrenToSteps, findNestedRouteForRootId, projectNestedRegistryForRoot, type NestedRunResolutionScope } from "../shared/nested-events.ts";
20
22
  import { readMissionBinding } from "../../missions/lifecycle.ts";
21
23
  import { formatWorkflowJsonPreview } from "../../workflows/scripted-workflow.ts";
24
+ import { formatRunFanoutBudget, getRunFanoutBudgetSnapshot, readRunFanoutBudgetDescriptor } from "../shared/run-fanout-budget.ts";
22
25
 
23
26
  interface RunStatusParams {
24
27
  action?: string;
@@ -30,6 +33,66 @@ interface RunStatusParams {
30
33
  lines?: number;
31
34
  }
32
35
 
36
+ function formatProcessTerminal(value: AsyncStatus["processTerminal"] | undefined): string {
37
+ if (!value) return "missing";
38
+ return `${value.state}${value.reason ? ` (${value.reason})` : ""}${value.runnerProcessInstanceId ? ` · runner ${value.runnerProcessInstanceId}` : ""}`;
39
+ }
40
+
41
+ function debugProcessTerminal(asyncDir: string, status: AsyncStatus): { sidecar?: AsyncStatus["processTerminal"]; overlay?: AsyncStatus["processTerminal"] } {
42
+ const expected = { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId };
43
+ return {
44
+ sidecar: readProcessTerminal(asyncDir, expected),
45
+ overlay: sanitizeProcessTerminal(status.processTerminal, expected, path.join(asyncDir, "status.json")),
46
+ };
47
+ }
48
+
49
+ function formatCapacityOwner(inspect: ActiveAsyncCapacityInspection): string[] {
50
+ if (!inspect.owner) return [`Active capacity: ${inspect.release.state} — ${inspect.release.reason}`];
51
+ const owner = inspect.owner;
52
+ return [
53
+ `Active capacity: ${inspect.release.state} — ${inspect.release.reason}`,
54
+ `Capacity owner: ${inspect.relation} slot ${owner.slot}, ${owner.kind}, generation ${owner.generation}`,
55
+ `Capacity session: ${owner.ownerSessionId}`,
56
+ owner.sourceRunId ? `Capacity source run: ${owner.sourceRunId}` : undefined,
57
+ `Capacity async dir: ${owner.asyncDir}`,
58
+ owner.runnerProcessInstanceId ? `Capacity runner: ${owner.runnerProcessInstanceId}` : undefined,
59
+ owner.runnerStartedAt !== undefined ? `Capacity runner started: ${new Date(owner.runnerStartedAt).toISOString()}` : undefined,
60
+ ].filter((line): line is string => line !== undefined);
61
+ }
62
+
63
+ function formatWorkflowDebug(status: AsyncStatus): string[] {
64
+ if (status.mode !== "workflow" && !status.parentWorkflowRunId && !status.workflowKey) return [];
65
+ const lines = [
66
+ status.parentWorkflowRunId ? `Workflow parent: ${status.parentWorkflowRunId}${status.workflowKey ? ` (${status.workflowKey})` : ""}` : undefined,
67
+ status.mode === "workflow" ? `Workflow children: ${(status.steps ?? []).length}` : undefined,
68
+ ].filter((line): line is string => line !== undefined);
69
+ for (const [index, step] of (status.steps ?? []).entries()) {
70
+ lines.push(` ${index + 1}. key ${step.workflowKey ?? "n/a"} · ${step.agent} · ${step.status} · async ${step.async === undefined ? "unknown" : step.async ? "yes" : "no"}${step.runId ? ` · run ${step.runId}` : ""}`);
71
+ }
72
+ return lines;
73
+ }
74
+
75
+ function formatRunLifecycleDebug(input: { status: AsyncStatus; asyncDir: string; sidecarProcessTerminal: AsyncStatus["processTerminal"] | undefined; overlayProcessTerminal: AsyncStatus["processTerminal"] | undefined; capacity: ActiveAsyncCapacityInspection }): string {
76
+ const { status, asyncDir, sidecarProcessTerminal, overlayProcessTerminal, capacity } = input;
77
+ const lines = [
78
+ "Run lifecycle debug",
79
+ `Run: ${status.runId}`,
80
+ `Dir: ${asyncDir}`,
81
+ `Status file: ${path.join(asyncDir, "status.json")}`,
82
+ `Process terminal file: ${path.join(asyncDir, "process-terminal.json")}`,
83
+ `Session: ${status.sessionId ?? "unknown"}`,
84
+ `State: ${status.state}`,
85
+ `Mode: ${status.mode}`,
86
+ status.parentWorkflowRunId ? `Workflow parent: ${status.parentWorkflowRunId}` : undefined,
87
+ status.workflowKey ? `Workflow key: ${status.workflowKey}` : undefined,
88
+ `Status process terminal: ${formatProcessTerminal(overlayProcessTerminal)}`,
89
+ `Sidecar process terminal: ${formatProcessTerminal(sidecarProcessTerminal)}`,
90
+ ...formatCapacityOwner(capacity),
91
+ ...formatWorkflowDebug(status),
92
+ ].filter((line): line is string => line !== undefined);
93
+ return lines.join("\n");
94
+ }
95
+
33
96
  interface RunStatusDeps {
34
97
  asyncDirRoot?: string;
35
98
  resultsDir?: string;
@@ -38,6 +101,7 @@ interface RunStatusDeps {
38
101
  state?: SubagentState;
39
102
  nested?: NestedRunResolutionScope;
40
103
  sessionRoots?: string[];
104
+ activeCapacityRoot?: string;
41
105
  }
42
106
 
43
107
  function hasExistingSessionFile(value: unknown): value is string {
@@ -49,12 +113,16 @@ function formatCheckpointGuidance(runId: string | undefined, checkpoint: AsyncSt
49
113
  return `Checkpoint: ${checkpoint.name}${checkpoint.message ? ` — ${checkpoint.message}` : ""}\nApprove: subagent({ action: "approve-checkpoint", id: "${runId}" })\nReject: subagent({ action: "reject-checkpoint", id: "${runId}" })`;
50
114
  }
51
115
 
52
- function formatResumeGuidance(runId: string | undefined, children: Array<{ agent?: unknown; sessionFile?: unknown }>, fallbackSessionFile?: unknown, options: { stopped?: boolean } = {}): string {
116
+ function formatResumeGuidance(runId: string | undefined, children: Array<{ agent?: unknown; sessionFile?: unknown; runId?: unknown; workflowKey?: unknown }>, fallbackSessionFile?: unknown, options: { stopped?: boolean } = {}): string {
53
117
  if (options.stopped) return "Resume: unavailable; stopped runs are not resumable. Start a new run instead.";
54
118
  const knownChildren = children
55
119
  .map((child, index) => ({ child, index }))
56
120
  .filter(({ child }) => typeof child.agent === "string");
57
121
  if (!runId || knownChildren.length === 0) return "Resume: unavailable; no child session file was persisted.";
122
+ const workflowChildren = knownChildren.filter(({ child }) => typeof child.runId === "string" && child.runId.trim() && hasExistingSessionFile(child.sessionFile));
123
+ if (workflowChildren.length > 0) {
124
+ return workflowChildren.map(({ child }) => `Revive workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: subagent({ action: "resume", id: "${child.runId}", message: "..." })`).join("\n");
125
+ }
58
126
  const singleSessionFile = knownChildren[0]?.child.sessionFile ?? fallbackSessionFile;
59
127
  if (children.length === 1 && knownChildren.length === 1 && hasExistingSessionFile(singleSessionFile)) {
60
128
  return `Revive: subagent({ action: "resume", id: "${runId}", message: "..." })`;
@@ -177,7 +245,7 @@ function formatRememberedForegroundTranscript(run: ForegroundResumeRun, options:
177
245
  lines.push("Result transcript tail:");
178
246
  if (outputLines.length === 0) lines.push(" (no recovered final output available yet)");
179
247
  else for (const line of outputLines) lines.push(` ${line}`);
180
- return lines.join("\n");
248
+ return lines.map((line) => safeTerminalText(line)).join("\n");
181
249
  }
182
250
 
183
251
  function formatNestedExactStatus(rootRunId: string, run: NestedRunSummary): string {
@@ -262,7 +330,9 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
262
330
  let location;
263
331
  try {
264
332
  const requestedId = params.id ?? params.runId;
265
- if (!params.dir && requestedId) {
333
+ if (params.action === "debug.run") {
334
+ location = resolveAsyncRunLocation(params, asyncDirRoot, resultsDir);
335
+ } else if (!params.dir && requestedId) {
266
336
  const resolved = resolveSubagentRunId(requestedId, { asyncDirRoot, resultsDir, state: deps.state, nested: deps.nested });
267
337
  if (resolved?.kind === "foreground") {
268
338
  const run = deps.state?.foregroundRuns?.get(resolved.id);
@@ -330,6 +400,14 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
330
400
  }
331
401
  const status = reconciliation.status;
332
402
  if (!status && diskStatus?.displayDismissedAt !== undefined) {
403
+ if (params.action === "debug.run") {
404
+ const { sidecar, overlay } = debugProcessTerminal(asyncDir, diskStatus);
405
+ const capacity = inspectActiveAsyncCapacityOwner({ runId: diskStatus.runId, sessionId: diskStatus.sessionId, asyncDir }, { rootDir: deps.activeCapacityRoot, liveWorkflowRunIds: new Set(deps.state?.workflowControllers?.keys() ?? []) });
406
+ return {
407
+ content: [{ type: "text", text: formatRunLifecycleDebug({ status: diskStatus, asyncDir, sidecarProcessTerminal: sidecar, overlayProcessTerminal: overlay, capacity }) }],
408
+ details: { mode: "single", results: [], ...((sidecar ?? overlay) ? { lifecycleStatus: { processTerminal: sidecar ?? overlay } } : {}) },
409
+ };
410
+ }
333
411
  if (params.view === "transcript") {
334
412
  if (currentSessionId && diskStatus.sessionId !== currentSessionId) {
335
413
  return {
@@ -349,6 +427,14 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
349
427
  const logPath = path.join(asyncDir, `subagent-log-${effectiveRunId}.md`);
350
428
  const eventsPath = path.join(asyncDir, "events.jsonl");
351
429
  if (status) {
430
+ if (params.action === "debug.run") {
431
+ const { sidecar, overlay } = debugProcessTerminal(asyncDir, status);
432
+ const capacity = inspectActiveAsyncCapacityOwner({ runId: status.runId, sessionId: status.sessionId, asyncDir }, { rootDir: deps.activeCapacityRoot, liveWorkflowRunIds: new Set(deps.state?.workflowControllers?.keys() ?? []) });
433
+ return {
434
+ content: [{ type: "text", text: formatRunLifecycleDebug({ status, asyncDir, sidecarProcessTerminal: sidecar, overlayProcessTerminal: overlay, capacity }) }],
435
+ details: { mode: "single", results: [], ...((sidecar ?? overlay) ? { lifecycleStatus: { processTerminal: sidecar ?? overlay } } : {}) },
436
+ };
437
+ }
352
438
  if (params.view === "transcript") {
353
439
  if (currentSessionId && status.sessionId !== currentSessionId) {
354
440
  return {
@@ -389,6 +475,8 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
389
475
  const steeringText = formatSteeringSummary(status);
390
476
  const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId })
391
477
  ?? sanitizeProcessTerminal(status.processTerminal, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId }, path.join(asyncDir, "status.json"));
478
+ const runFanoutBudgetDescriptor = readRunFanoutBudgetDescriptor(asyncDir);
479
+ const runFanoutBudget = runFanoutBudgetDescriptor ? getRunFanoutBudgetSnapshot(runFanoutBudgetDescriptor) : status.runFanoutBudget;
392
480
  let missionId: string | undefined;
393
481
  try {
394
482
  missionId = readMissionBinding(asyncDir)?.missionId;
@@ -410,6 +498,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
410
498
  statusActivityText ? `Activity: ${statusActivityText}` : undefined,
411
499
  steeringText ? `Steering: ${steeringText}` : undefined,
412
500
  `Mode: ${status.mode}`,
501
+ runFanoutBudget ? formatRunFanoutBudget(runFanoutBudget) : undefined,
413
502
  status.parentWorkflowRunId ? `Workflow parent: ${status.parentWorkflowRunId}${status.workflowKey ? ` (${status.workflowKey})` : ""}` : undefined,
414
503
  status.mode === "workflow" && workflowReturnPreview !== undefined ? `Return: ${workflowReturnPreview}` : undefined,
415
504
  status.mode === "workflow" && workflowEmitPreview !== undefined ? `Latest emit: ${workflowEmitPreview}` : undefined,
@@ -466,14 +555,21 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
466
555
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
467
556
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
468
557
 
469
- return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
558
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(runFanoutBudget ? { runFanoutBudget } : {}), ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
470
559
  }
471
560
  }
472
561
 
473
562
  if (resultPath) {
563
+ if (params.action === "debug.run") {
564
+ return {
565
+ content: [{ type: "text", text: "Run lifecycle debug needs an async run directory with status.json." }],
566
+ isError: true,
567
+ details: { mode: "single", results: [] },
568
+ };
569
+ }
474
570
  try {
475
571
  const raw = fs.readFileSync(resultPath, "utf-8");
476
- const data = JSON.parse(raw) as { id?: string; runId?: string; toolCallId?: string; agent?: string; success?: boolean; summary?: string; output?: string; exitCode?: number; state?: string; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; processSignal?: string | null; sessionFile?: string; parallelHandoff?: { path?: string }; results?: Array<{ agent?: string; output?: string; summary?: string; sessionFile?: string; state?: string; success?: boolean; exitCode?: number | null; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; interrupted?: boolean; processSignal?: string | null }> };
572
+ const data = JSON.parse(raw) as { id?: string; runId?: string; toolCallId?: string; agent?: string; success?: boolean; summary?: string; output?: string; exitCode?: number; state?: string; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; processSignal?: string | null; sessionFile?: string; parallelHandoff?: { path?: string }; results?: Array<{ agent?: string; runId?: string; workflowKey?: string; output?: string; summary?: string; sessionFile?: string; state?: string; success?: boolean; exitCode?: number | null; stopped?: boolean; timedOut?: boolean; turnBudgetExceeded?: boolean; interrupted?: boolean; processSignal?: string | null }> };
477
573
  if (params.view === "transcript") {
478
574
  try {
479
575
  return { content: [{ type: "text", text: formatAsyncResultTranscript(data, resultPath, { index: params.index, lines: params.lines }) }], details: { mode: "single", results: [] } };
@@ -1,7 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
- import { updateActiveRunIndex } from "./active-run-index.ts";
4
+ import { releaseActiveRunIndex, updateActiveRunIndex } from "./active-run-index.ts";
5
5
  import { readStatus } from "../../shared/utils.ts";
6
6
  import { DIRS, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode } from "../../shared/types.ts";
7
7
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
@@ -261,7 +261,7 @@ function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: st
261
261
  const repair = buildFailedRepair(status, asyncDir, now, reason);
262
262
  writeAtomicJson(resultPath, repair.result);
263
263
  writeAtomicJson(path.join(asyncDir, "status.json"), repair.status);
264
- updateActiveRunIndex(asyncDir, repair.status.state);
264
+ if (repair.status.processTerminal?.state === "observed") releaseActiveRunIndex(asyncDir);
265
265
  appendJsonlBestEffort(path.join(asyncDir, "events.jsonl"), {
266
266
  type: "subagent.run.repaired_stale",
267
267
  ts: now,
@@ -351,7 +351,7 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
351
351
  : undefined;
352
352
  if (terminalStatus) {
353
353
  writeAtomicJson(path.join(asyncDir, "status.json"), terminalStatus);
354
- updateActiveRunIndex(asyncDir, terminalStatus.state);
354
+ if (terminalStatus.processTerminal?.state === "observed") releaseActiveRunIndex(asyncDir);
355
355
  return { status: terminalStatus, repaired: true, resultPath, message: "Existing async result file was used to repair stale running status." };
356
356
  }
357
357
  if (effectiveStatus.displayDismissedAt === undefined) return { status: effectiveStatus, repaired: false, resultPath };