pi-subagents 0.48.0 → 0.50.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 (88) hide show
  1. package/CHANGELOG.md +82 -19
  2. package/agents/oracle.md +7 -5
  3. package/agents/researcher.md +1 -1
  4. package/agents/reviewer.md +2 -2
  5. package/agents/scout.md +1 -1
  6. package/agents/worker.md +1 -1
  7. package/docs/agents.md +3 -0
  8. package/docs/configuration.md +75 -8
  9. package/docs/extension-api.md +36 -0
  10. package/docs/missions.md +5 -3
  11. package/docs/observability.md +42 -2
  12. package/docs/tool-reference.md +19 -2
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/skills/pi-subagents/references/constraints-and-recipes.md +3 -2
  16. package/skills/pi-subagents/references/execution-controls.md +5 -4
  17. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  18. package/skills/pi-subagents/references/prompting-and-roles.md +31 -15
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +37 -12
  21. package/src/api/external-runs.ts +174 -84
  22. package/src/api/preflight.ts +13 -7
  23. package/src/extension/config.ts +53 -0
  24. package/src/extension/index.ts +62 -9
  25. package/src/extension/public-execution.ts +32 -4
  26. package/src/extension/rpc.ts +5 -1
  27. package/src/extension/schemas.ts +10 -8
  28. package/src/extension/tool-description.ts +13 -9
  29. package/src/inspectors/herdr/actions.ts +13 -8
  30. package/src/inspectors/herdr/inspector-runner.ts +16 -3
  31. package/src/inspectors/herdr/project-panes.ts +2 -6
  32. package/src/inspectors/herdr/shell-command.ts +16 -0
  33. package/src/intercom/intercom-bridge.ts +5 -4
  34. package/src/intercom/native-supervisor-channel.ts +19 -42
  35. package/src/missions/goal-driver.ts +3 -1
  36. package/src/missions/store.ts +8 -3
  37. package/src/runs/background/active-async-capacity.ts +82 -25
  38. package/src/runs/background/active-run-index.ts +71 -1
  39. package/src/runs/background/async-execution.ts +73 -43
  40. package/src/runs/background/async-job-tracker.ts +5 -0
  41. package/src/runs/background/async-resume.ts +14 -6
  42. package/src/runs/background/async-status-snapshot.ts +277 -0
  43. package/src/runs/background/async-status.ts +8 -3
  44. package/src/runs/background/chain-root-attachment.ts +2 -2
  45. package/src/runs/background/completion-replay.ts +11 -1
  46. package/src/runs/background/fleet-view.ts +21 -6
  47. package/src/runs/background/result-files.ts +437 -0
  48. package/src/runs/background/result-watcher.ts +188 -41
  49. package/src/runs/background/resume-guidance.ts +27 -7
  50. package/src/runs/background/retained-children.ts +75 -18
  51. package/src/runs/background/run-id-resolver.ts +30 -24
  52. package/src/runs/background/run-status.ts +101 -4
  53. package/src/runs/background/scheduled-runs.ts +54 -28
  54. package/src/runs/background/stale-run-reconciler.ts +27 -13
  55. package/src/runs/background/subagent-runner.ts +298 -33
  56. package/src/runs/background/subagent-wait.ts +2 -0
  57. package/src/runs/background/wait-completions.ts +5 -2
  58. package/src/runs/foreground/async-dismiss-action.ts +2 -1
  59. package/src/runs/foreground/chain-execution.ts +16 -0
  60. package/src/runs/foreground/execution.ts +219 -15
  61. package/src/runs/foreground/prompt-audit.ts +4 -3
  62. package/src/runs/foreground/subagent-executor.ts +328 -43
  63. package/src/runs/shared/completion-guard.ts +107 -1
  64. package/src/runs/shared/external-cli-runner.ts +4 -0
  65. package/src/runs/shared/llm-intent-arbiter.ts +39 -23
  66. package/src/runs/shared/model-fallback.ts +16 -2
  67. package/src/runs/shared/nested-events.ts +66 -62
  68. package/src/runs/shared/orca-progress-tabs.ts +375 -0
  69. package/src/runs/shared/parallel-utils.ts +2 -0
  70. package/src/runs/shared/subagent-control.ts +15 -0
  71. package/src/runs/shared/subagent-prompt-runtime.ts +1 -9
  72. package/src/runs/shared/subagent-startup-retry.ts +12 -0
  73. package/src/runs/shared/tool-timeout.ts +93 -0
  74. package/src/shared/agent-stream-options.ts +5 -0
  75. package/src/shared/artifacts.ts +2 -6
  76. package/src/shared/display-text.ts +50 -0
  77. package/src/shared/node-executable.ts +21 -0
  78. package/src/shared/types.ts +51 -5
  79. package/src/slash/slash-commands.ts +34 -25
  80. package/src/slash/slash-live-state.ts +3 -0
  81. package/src/tui/fleet-status.ts +160 -45
  82. package/src/tui/fleet-transcript.ts +1 -48
  83. package/src/tui/fleet.ts +128 -16
  84. package/src/tui/render.ts +122 -44
  85. package/src/watchdog/permission-arbiter.ts +2 -1
  86. package/src/watchdog/review.ts +4 -3
  87. package/src/workflows/chat-progress.ts +10 -2
  88. package/src/workflows/scripted-workflow.ts +272 -76
@@ -3,6 +3,8 @@ import * as path from "node:path";
3
3
  import { DIRS, type SubagentState } from "../../shared/types.ts";
4
4
  import { readStatus } from "../../shared/utils.ts";
5
5
  import { findAsyncRunPrefixMatches, type AsyncRunLocation } from "./async-resume.ts";
6
+ import { resultFilePath, resultFilesForToolCall } from "./result-files.ts";
7
+ import { readActiveRunToolCallIndex } from "./active-run-index.ts";
6
8
  import { assertSafeNestedId, findNestedRunMatchesById, type NestedRoute, type NestedRunMatch, type NestedRunResolutionScope } from "../shared/nested-events.ts";
7
9
 
8
10
  export type ResolvedSubagentRunId =
@@ -19,7 +21,7 @@ export interface ResolveSubagentRunIdDeps {
19
21
 
20
22
  function exactAsyncLocation(id: string, asyncDirRoot: string, resultsDir: string): AsyncRunLocation | undefined {
21
23
  const asyncDir = path.join(asyncDirRoot, id);
22
- const resultPath = path.join(resultsDir, `${id}.json`);
24
+ const resultPath = resultFilePath(resultsDir, id);
23
25
  if (!fs.existsSync(asyncDir) && !fs.existsSync(resultPath)) return undefined;
24
26
  return {
25
27
  asyncDir: fs.existsSync(asyncDir) ? asyncDir : null,
@@ -52,39 +54,28 @@ function readWorkflowResultIdentity(resultPath: string): WorkflowResultIdentity
52
54
  };
53
55
  }
54
56
 
55
- function directoryEntries(root: string): string[] {
56
- try {
57
- return fs.readdirSync(root);
58
- } catch (error) {
59
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
60
- throw error;
61
- }
62
- }
63
-
64
57
  function resultPathFor(resultsDir: string, runId: string): string | null {
65
- const resultPath = path.join(resultsDir, `${runId}.json`);
58
+ const resultPath = resultFilePath(resultsDir, runId);
66
59
  return fs.existsSync(resultPath) ? resultPath : null;
67
60
  }
68
61
 
69
- function toolCallIdMatches(value: string | undefined, query: string, options: { prefix?: boolean }): boolean {
70
- if (value === undefined) return false;
71
- return options.prefix === true ? value.startsWith(query) : value === query;
62
+ function toolCallIdMatches(value: string | undefined, query: string): boolean {
63
+ return value === query;
72
64
  }
73
65
 
74
- function toolCallIdAsyncLocations(toolCallId: string, asyncDirRoot: string, resultsDir: string, options: { prefix?: boolean } = {}): AsyncRunMatch[] {
66
+ function indexedToolCallIdAsyncLocations(toolCallId: string, asyncDirRoot: string, resultsDir: string): AsyncRunMatch[] {
75
67
  const byId = new Map<string, AsyncRunLocation>();
76
- for (const entry of directoryEntries(asyncDirRoot)) {
68
+ for (const entry of readActiveRunToolCallIndex(asyncDirRoot, toolCallId)) {
77
69
  const asyncDir = path.join(asyncDirRoot, entry);
78
70
  const status = readStatus(asyncDir);
79
- if (!status || !toolCallIdMatches(status.toolCallId, toolCallId, options)) continue;
71
+ if (!status || !toolCallIdMatches(status.toolCallId, toolCallId)) continue;
80
72
  const runId = status.runId || entry;
81
73
  byId.set(runId, { asyncDir, resultPath: resultPathFor(resultsDir, runId), resolvedId: runId });
82
74
  }
83
- for (const entry of directoryEntries(resultsDir)) {
84
- if (!entry.endsWith(".json")) continue;
75
+ for (const entry of resultFilesForToolCall(resultsDir, toolCallId)) {
85
76
  const resultPath = path.join(resultsDir, entry);
86
77
  const identity = readWorkflowResultIdentity(resultPath);
87
- if (!identity || !toolCallIdMatches(identity.toolCallId, toolCallId, options)) continue;
78
+ if (!identity || !toolCallIdMatches(identity.toolCallId, toolCallId)) continue;
88
79
  const runId = identity.runId ?? identity.id ?? entry.slice(0, -".json".length);
89
80
  const asyncDir = path.join(asyncDirRoot, runId);
90
81
  byId.set(runId, { asyncDir: fs.existsSync(asyncDir) ? asyncDir : null, resultPath, resolvedId: runId });
@@ -109,6 +100,22 @@ function hasExactForegroundId(state: SubagentState | undefined, id: string): boo
109
100
  return Boolean(remembered && state.currentSessionId && remembered.sessionId === state.currentSessionId);
110
101
  }
111
102
 
103
+ function exactLiveAsyncToolCallMatch(state: SubagentState | undefined, toolCallId: string, asyncDirRoot: string, resultsDir: string): AsyncRunMatch | undefined {
104
+ if (!state?.asyncJobs) return undefined;
105
+ const matches = [...state.asyncJobs.values()].filter((job) => job.toolCallId === toolCallId);
106
+ if (matches.length > 1) throw new Error(`Subagent tool-call id '${toolCallId}' is ambiguous across async runs. Use the returned asyncId instead.`);
107
+ const match = matches[0];
108
+ if (!match) return undefined;
109
+ return {
110
+ id: match.asyncId,
111
+ location: {
112
+ asyncDir: fs.existsSync(match.asyncDir) ? match.asyncDir : path.join(asyncDirRoot, match.asyncId),
113
+ resultPath: resultPathFor(resultsDir, match.asyncId),
114
+ resolvedId: match.asyncId,
115
+ },
116
+ };
117
+ }
118
+
112
119
  function nestedScopeFromState(state: SubagentState | undefined): NestedRunResolutionScope | undefined {
113
120
  if (!state) return undefined;
114
121
  const routes: NestedRoute[] = [];
@@ -138,7 +145,9 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
138
145
  if (hasExactForegroundId(deps.state, id)) return { kind: "foreground", id };
139
146
  const exactAsync = exactAsyncLocation(id, asyncDirRoot, resultsDir);
140
147
  if (exactAsync) return { kind: "async", id, location: exactAsync };
141
- const exactToolCallIdMatches = toolCallIdAsyncLocations(id, asyncDirRoot, resultsDir);
148
+ const exactLiveToolCallMatch = exactLiveAsyncToolCallMatch(deps.state, id, asyncDirRoot, resultsDir);
149
+ if (exactLiveToolCallMatch) return { kind: "async", id: exactLiveToolCallMatch.id, location: exactLiveToolCallMatch.location };
150
+ const exactToolCallIdMatches = indexedToolCallIdAsyncLocations(id, asyncDirRoot, resultsDir);
142
151
  if (exactToolCallIdMatches.length > 1) throw new Error(`Subagent tool-call id '${id}' is ambiguous across async runs. Use the returned asyncId instead.`);
143
152
  if (exactToolCallIdMatches[0]) return { kind: "async", id: exactToolCallIdMatches[0].id, location: exactToolCallIdMatches[0].location };
144
153
  const exactNested = findNestedRunMatchesById(id, nestedScope ? { scope: nestedScope } : {});
@@ -152,9 +161,6 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
152
161
  for (const match of asyncPrefixMatches(id, asyncDirRoot, resultsDir)) {
153
162
  matches.push({ kind: "async", id: match.id, location: match.location });
154
163
  }
155
- for (const match of toolCallIdAsyncLocations(id, asyncDirRoot, resultsDir, { prefix: true })) {
156
- matches.push({ kind: "async", id: match.id, location: match.location });
157
- }
158
164
  for (const match of findNestedRunMatchesById(id, nestedScope ? { prefix: true, scope: nestedScope } : { prefix: true })) {
159
165
  matches.push({ kind: "nested", id: match.run.id, match });
160
166
  }
@@ -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";
@@ -31,6 +33,66 @@ interface RunStatusParams {
31
33
  lines?: number;
32
34
  }
33
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
+
34
96
  interface RunStatusDeps {
35
97
  asyncDirRoot?: string;
36
98
  resultsDir?: string;
@@ -39,6 +101,7 @@ interface RunStatusDeps {
39
101
  state?: SubagentState;
40
102
  nested?: NestedRunResolutionScope;
41
103
  sessionRoots?: string[];
104
+ activeCapacityRoot?: string;
42
105
  }
43
106
 
44
107
  function hasExistingSessionFile(value: unknown): value is string {
@@ -50,12 +113,21 @@ function formatCheckpointGuidance(runId: string | undefined, checkpoint: AsyncSt
50
113
  return `Checkpoint: ${checkpoint.name}${checkpoint.message ? ` — ${checkpoint.message}` : ""}\nApprove: subagent({ action: "approve-checkpoint", id: "${runId}" })\nReject: subagent({ action: "reject-checkpoint", id: "${runId}" })`;
51
114
  }
52
115
 
53
- 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; status?: unknown; activityState?: unknown }>, fallbackSessionFile?: unknown, options: { stopped?: boolean } = {}): string {
54
117
  if (options.stopped) return "Resume: unavailable; stopped runs are not resumable. Start a new run instead.";
55
118
  const knownChildren = children
56
119
  .map((child, index) => ({ child, index }))
57
120
  .filter(({ child }) => typeof child.agent === "string");
58
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
+ const supervisorDetachedWorkflowChildren = workflowChildren.filter(({ child }) => child.status === "paused" && child.activityState === "needs_attention");
124
+ const resumableWorkflowChildren = workflowChildren.filter(({ child }) => !(child.status === "paused" && child.activityState === "needs_attention"));
125
+ if (workflowChildren.length > 0) {
126
+ return [
127
+ ...supervisorDetachedWorkflowChildren.map(({ child }) => `Recovery workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: reply to the supervisor request first, then wait with subagent_wait({ id: "${child.runId}" }). Use subagent({ action: "status", id: "${child.runId}" }) to recover the result; do not resume or launch a replacement while it remains detached.`),
128
+ ...resumableWorkflowChildren.map(({ child }) => `Revive workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: subagent({ action: "resume", id: "${child.runId}", message: "..." })`),
129
+ ].join("\n");
130
+ }
59
131
  const singleSessionFile = knownChildren[0]?.child.sessionFile ?? fallbackSessionFile;
60
132
  if (children.length === 1 && knownChildren.length === 1 && hasExistingSessionFile(singleSessionFile)) {
61
133
  return `Revive: subagent({ action: "resume", id: "${runId}", message: "..." })`;
@@ -178,7 +250,7 @@ function formatRememberedForegroundTranscript(run: ForegroundResumeRun, options:
178
250
  lines.push("Result transcript tail:");
179
251
  if (outputLines.length === 0) lines.push(" (no recovered final output available yet)");
180
252
  else for (const line of outputLines) lines.push(` ${line}`);
181
- return lines.join("\n");
253
+ return lines.map((line) => safeTerminalText(line)).join("\n");
182
254
  }
183
255
 
184
256
  function formatNestedExactStatus(rootRunId: string, run: NestedRunSummary): string {
@@ -263,7 +335,9 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
263
335
  let location;
264
336
  try {
265
337
  const requestedId = params.id ?? params.runId;
266
- if (!params.dir && requestedId) {
338
+ if (params.action === "debug.run") {
339
+ location = resolveAsyncRunLocation(params, asyncDirRoot, resultsDir);
340
+ } else if (!params.dir && requestedId) {
267
341
  const resolved = resolveSubagentRunId(requestedId, { asyncDirRoot, resultsDir, state: deps.state, nested: deps.nested });
268
342
  if (resolved?.kind === "foreground") {
269
343
  const run = deps.state?.foregroundRuns?.get(resolved.id);
@@ -331,6 +405,14 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
331
405
  }
332
406
  const status = reconciliation.status;
333
407
  if (!status && diskStatus?.displayDismissedAt !== undefined) {
408
+ if (params.action === "debug.run") {
409
+ const { sidecar, overlay } = debugProcessTerminal(asyncDir, diskStatus);
410
+ const capacity = inspectActiveAsyncCapacityOwner({ runId: diskStatus.runId, sessionId: diskStatus.sessionId, asyncDir }, { rootDir: deps.activeCapacityRoot, liveWorkflowRunIds: new Set(deps.state?.workflowControllers?.keys() ?? []) });
411
+ return {
412
+ content: [{ type: "text", text: formatRunLifecycleDebug({ status: diskStatus, asyncDir, sidecarProcessTerminal: sidecar, overlayProcessTerminal: overlay, capacity }) }],
413
+ details: { mode: "single", results: [], ...((sidecar ?? overlay) ? { lifecycleStatus: { processTerminal: sidecar ?? overlay } } : {}) },
414
+ };
415
+ }
334
416
  if (params.view === "transcript") {
335
417
  if (currentSessionId && diskStatus.sessionId !== currentSessionId) {
336
418
  return {
@@ -350,6 +432,14 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
350
432
  const logPath = path.join(asyncDir, `subagent-log-${effectiveRunId}.md`);
351
433
  const eventsPath = path.join(asyncDir, "events.jsonl");
352
434
  if (status) {
435
+ if (params.action === "debug.run") {
436
+ const { sidecar, overlay } = debugProcessTerminal(asyncDir, status);
437
+ const capacity = inspectActiveAsyncCapacityOwner({ runId: status.runId, sessionId: status.sessionId, asyncDir }, { rootDir: deps.activeCapacityRoot, liveWorkflowRunIds: new Set(deps.state?.workflowControllers?.keys() ?? []) });
438
+ return {
439
+ content: [{ type: "text", text: formatRunLifecycleDebug({ status, asyncDir, sidecarProcessTerminal: sidecar, overlayProcessTerminal: overlay, capacity }) }],
440
+ details: { mode: "single", results: [], ...((sidecar ?? overlay) ? { lifecycleStatus: { processTerminal: sidecar ?? overlay } } : {}) },
441
+ };
442
+ }
353
443
  if (params.view === "transcript") {
354
444
  if (currentSessionId && status.sessionId !== currentSessionId) {
355
445
  return {
@@ -475,9 +565,16 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
475
565
  }
476
566
 
477
567
  if (resultPath) {
568
+ if (params.action === "debug.run") {
569
+ return {
570
+ content: [{ type: "text", text: "Run lifecycle debug needs an async run directory with status.json." }],
571
+ isError: true,
572
+ details: { mode: "single", results: [] },
573
+ };
574
+ }
478
575
  try {
479
576
  const raw = fs.readFileSync(resultPath, "utf-8");
480
- 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 }> };
577
+ 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 }> };
481
578
  if (params.view === "transcript") {
482
579
  try {
483
580
  return { content: [{ type: "text", text: formatAsyncResultTranscript(data, resultPath, { index: params.index, lines: params.lines }) }], details: { mode: "single", results: [] } };
@@ -239,8 +239,15 @@ class ScheduleStore {
239
239
  }
240
240
 
241
241
  get(id: string): ScheduleRecord {
242
+ const record = this.find(id);
243
+ if (!record) throw new Error(`Schedule '${id}' not found.`);
244
+ return record;
245
+ }
246
+
247
+ /** Like {@link get}, but returns undefined when the schedule no longer exists. */
248
+ find(id: string): ScheduleRecord | undefined {
242
249
  const file = path.join(scheduleDir(this.root, id, false, this.projectCwd), "schedule.json");
243
- if (!fs.existsSync(file)) throw new Error(`Schedule '${id}' not found.`);
250
+ if (!fs.existsSync(file)) return undefined;
244
251
  return parseSchedule(readJson(file, "schedule record"), file);
245
252
  }
246
253
 
@@ -361,6 +368,7 @@ export class ScheduledRunManager {
361
368
  private readonly stores = new Map<string, ScheduleStore>();
362
369
  private readonly contexts = new Map<string, ExtensionContext>();
363
370
  private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
371
+ private readonly observedAsyncIds = new Set<string>();
364
372
  private readonly now: () => number;
365
373
  private readonly randomId: () => string;
366
374
  private readonly timersApi: ScheduledRunTimers;
@@ -383,6 +391,7 @@ export class ScheduledRunManager {
383
391
  this.store = undefined;
384
392
  this.stores.clear();
385
393
  this.contexts.clear();
394
+ this.observedAsyncIds.clear();
386
395
  }
387
396
 
388
397
  async handleToolCall(params: SubagentParamsLike, ctx: ExtensionContext): Promise<AgentToolResult<Details>> {
@@ -407,26 +416,7 @@ export class ScheduledRunManager {
407
416
  }
408
417
 
409
418
  observedCompletionRunIds(): Set<string> {
410
- const runIds = new Set<string>();
411
- for (const store of this.stores.values()) {
412
- let ids: string[];
413
- try {
414
- ids = store.ids();
415
- } catch (error) {
416
- console.error(`Failed to inspect schedule store '${store.root}' during async completion discovery:`, error);
417
- continue;
418
- }
419
- for (const id of ids) {
420
- try {
421
- for (const run of store.history(id)) {
422
- if (run.state === "running" && run.asyncId) runIds.add(run.asyncId);
423
- }
424
- } catch (error) {
425
- console.error(`Failed to inspect schedule '${id}' in '${store.root}' during async completion discovery:`, error);
426
- }
427
- }
428
- }
429
- return runIds;
419
+ return new Set(this.observedAsyncIds);
430
420
  }
431
421
 
432
422
  handleAsyncCompletion(payload: unknown): void {
@@ -563,9 +553,10 @@ export class ScheduledRunManager {
563
553
  for (const schedule of store.list()) this.restoreOne(store, schedule);
564
554
  }
565
555
 
566
- private restoreOne(store: ScheduleStore, schedule: ScheduleRecord): void {
556
+ private restoreOne(store: ScheduleStore, schedule: ScheduleRecord, notBefore?: number, rearm = true): void {
567
557
  if (schedule.activeRunId) {
568
558
  const run = store.history(schedule.id).find((item) => item.id === schedule.activeRunId);
559
+ if (run?.state === "running" && run.asyncId) this.observedAsyncIds.add(run.asyncId);
569
560
  const startedAt = run?.startedAt ? Date.parse(run.startedAt) : Number.NaN;
570
561
  if (run?.state === "running" && run.asyncDir) {
571
562
  try {
@@ -588,26 +579,59 @@ export class ScheduledRunManager {
588
579
  fs.rmSync(path.join(store.directory(schedule.id), "active.lock"), { force: true });
589
580
  }
590
581
  }
591
- if (schedule.paused) return;
582
+ if (!rearm || schedule.paused) return;
592
583
  const next = nextRunAt(schedule);
593
584
  if (next === undefined) return;
594
- if (!schedule.activeRunId && next < this.now() && schedule.catchUp === "none") this.recordMissed(store, schedule, next, "timer");
595
- this.arm(schedule, store);
585
+ if (!schedule.activeRunId && next < this.now() && schedule.catchUp === "none") {
586
+ try {
587
+ this.recordMissed(store, schedule, next, "timer");
588
+ } catch (error) {
589
+ if (notBefore !== undefined) this.arm(schedule, store, notBefore);
590
+ throw error;
591
+ }
592
+ }
593
+ this.arm(schedule, store, notBefore);
596
594
  }
597
595
 
598
- private arm(schedule: ScheduleRecord, store: ScheduleStore): void {
596
+ private arm(schedule: ScheduleRecord, store: ScheduleStore, notBefore?: number): void {
599
597
  this.clearTimer(store, schedule.id);
600
598
  if (schedule.paused) return;
601
599
  const next = nextRunAt(schedule);
602
600
  if (next === undefined) return;
603
- const timer = this.timersApi.setTimeout(() => void this.fire(store, schedule.id), Math.min(Math.max(0, next - this.now()), MAX_TIMER_DELAY_MS));
601
+ const timer = this.timersApi.setTimeout(() => {
602
+ // A timer callback is outside every caller's try/catch, so an escaping
603
+ // rejection here reaches the process as an uncaught exception and exits
604
+ // Pi. Contain every failure to this one schedule.
605
+ void this.fire(store, schedule.id).catch((error) => {
606
+ console.warn(`[pi-subagents] Scheduled run '${schedule.id}' failed to fire: ${error instanceof Error ? error.message : String(error)}`);
607
+ this.restoreAfterFireError(store, schedule.id);
608
+ });
609
+ }, Math.min(Math.max(0, next - this.now(), (notBefore ?? 0) - this.now()), MAX_TIMER_DELAY_MS));
604
610
  timer.unref?.();
605
611
  this.timers.set(this.timerKey(store, schedule.id), timer);
606
612
  }
607
613
 
614
+ private restoreAfterFireError(store: ScheduleStore, id: string): void {
615
+ try {
616
+ const schedule = store.find(id);
617
+ if (!schedule) return;
618
+ const now = this.now();
619
+ const planned = schedule.trigger.kind === "interval" ? duePlannedAt(schedule, now) : undefined;
620
+ const notBefore = planned !== undefined && planned <= now ? Date.parse(nextAfter(schedule.trigger, planned, now)!) : undefined;
621
+ this.restoreOne(store, schedule, notBefore, schedule.trigger.kind === "interval");
622
+ } catch (error) {
623
+ console.warn(`[pi-subagents] Scheduled run '${id}' could not be restored after fire failure: ${error instanceof Error ? error.message : String(error)}`);
624
+ }
625
+ }
626
+
608
627
  private async fire(store: ScheduleStore, id: string): Promise<void> {
609
628
  this.clearTimer(store, id);
610
- const schedule = store.get(id);
629
+ // Schedules are project-scoped and shared, and `delete` only clears the
630
+ // timer inside the deleting process. Another session can therefore remove
631
+ // a schedule while this process still holds an armed timer for it; there
632
+ // is then nothing to run and nothing to re-arm.
633
+ const schedule = store.find(id);
634
+ if (!schedule) return;
611
635
  const planned = duePlannedAt(schedule, this.now());
612
636
  if (planned === undefined || schedule.paused) return;
613
637
  if (planned > this.now()) return this.arm(schedule, store);
@@ -661,6 +685,7 @@ export class ScheduledRunManager {
661
685
  if (result.isError || !asyncId) throw new Error(result.content.find((item) => item.type === "text")?.text ?? "Scheduled launch failed.");
662
686
  run.asyncId = asyncId;
663
687
  run.asyncDir = result.details?.asyncDir;
688
+ this.observedAsyncIds.add(asyncId);
664
689
  store.writeRun(schedule, run, "schedule.run.attached_async");
665
690
  this.arm(schedule, store);
666
691
  return run;
@@ -695,6 +720,7 @@ export class ScheduledRunManager {
695
720
  schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
696
721
  store.writeRun(schedule, skipped, "schedule.skipped_overlap");
697
722
  }
723
+ if (run.asyncId) this.observedAsyncIds.delete(run.asyncId);
698
724
  run.state = success ? "completed" : "failed_run";
699
725
  run.completedAt = timestamp(now);
700
726
  if (!success && error) run.error = error;
@@ -1,6 +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 { resultFilePath, resultPayloadPathForSessionRun, writeAsyncResultFile } from "./result-files.ts";
4
5
  import { releaseActiveRunIndex, updateActiveRunIndex } from "./active-run-index.ts";
5
6
  import { readStatus } from "../../shared/utils.ts";
6
7
  import { DIRS, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode } from "../../shared/types.ts";
@@ -98,14 +99,23 @@ interface ResultChildOutcome {
98
99
  }
99
100
 
100
101
  interface ResultRepairData {
101
- state: "complete" | "failed" | "paused" | "stopped";
102
+ state: "complete" | "failed" | "paused" | "stopped" | "rejected";
102
103
  results?: ResultChildOutcome[];
103
104
  }
104
105
 
106
+ function regularFileExists(filePath: string): boolean {
107
+ try {
108
+ return fs.statSync(filePath).isFile();
109
+ } catch (error) {
110
+ if (isNotFoundError(error)) return false;
111
+ throw error;
112
+ }
113
+ }
114
+
105
115
  function readResultRepairData(resultPath: string): ResultRepairData | undefined {
106
116
  try {
107
117
  const data = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as { success?: boolean; state?: string; exitCode?: number; results?: unknown };
108
- const state = data.success ? "complete" : data.state === "stopped" ? "stopped" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
118
+ const state = data.success ? "complete" : data.state === "stopped" ? "stopped" : data.state === "rejected" ? "rejected" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
109
119
  const results = Array.isArray(data.results)
110
120
  ? data.results.map((entry, index) => {
111
121
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) return {};
@@ -124,7 +134,7 @@ function readResultRepairData(resultPath: string): ResultRepairData | undefined
124
134
  }
125
135
  }
126
136
 
127
- function childState(overallState: ResultRepairData["state"], child: ResultChildOutcome | undefined): "complete" | "failed" | "paused" | "stopped" {
137
+ function childState(overallState: ResultRepairData["state"], child: ResultChildOutcome | undefined): ResultRepairData["state"] {
128
138
  if (child?.success === true) return "complete";
129
139
  if (child?.success === false) return "failed";
130
140
  return overallState;
@@ -197,7 +207,7 @@ function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, no
197
207
  };
198
208
  }
199
209
 
200
- function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, reason?: string): { status: AsyncStatus; result: object; message: string } {
210
+ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, reason?: string): { status: AsyncStatus; result: Record<string, unknown>; message: string } {
201
211
  const runId = status.runId || path.basename(asyncDir);
202
212
  const pid = typeof status.pid === "number" ? status.pid : "unknown";
203
213
  const baseMessage = reason ?? `Async runner process ${pid} exited or disappeared before writing a result. Marked run failed by stale-run reconciliation.`;
@@ -259,7 +269,7 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
259
269
 
260
270
  function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: string, now: number, reason?: string): ReconcileAsyncRunResult {
261
271
  const repair = buildFailedRepair(status, asyncDir, now, reason);
262
- writeAtomicJson(resultPath, repair.result);
272
+ if (repair.result.sessionId) writeAsyncResultFile(resultPath, repair.result);
263
273
  writeAtomicJson(path.join(asyncDir, "status.json"), repair.status);
264
274
  if (repair.status.processTerminal?.state === "observed") releaseActiveRunIndex(asyncDir);
265
275
  appendJsonlBestEffort(path.join(asyncDir, "events.jsonl"), {
@@ -267,14 +277,14 @@ function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: st
267
277
  ts: now,
268
278
  runId: repair.status.runId,
269
279
  pid: status.pid,
270
- resultPath,
280
+ ...(repair.result.sessionId ? { resultPath } : {}),
271
281
  message: repair.message,
272
282
  });
273
- return { status: repair.status, repaired: true, resultPath, message: repair.message };
283
+ return { status: repair.status, repaired: true, ...(repair.result.sessionId ? { resultPath } : {}), message: repair.message };
274
284
  }
275
285
 
276
286
  function terminal(state: AsyncStatus["state"]): boolean {
277
- return state === "complete" || state === "failed" || state === "paused" || state === "stopped";
287
+ return state === "complete" || state === "failed" || state === "paused" || state === "stopped" || state === "rejected";
278
288
  }
279
289
 
280
290
  function* nestedRuns(children: NestedRunSummary[] | undefined): Generator<NestedRunSummary> {
@@ -344,17 +354,21 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
344
354
  if (stepRecord.thinking !== undefined && typeof stepRecord.thinking !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].thinking must be a string.`);
345
355
  }
346
356
  const runId = effectiveStatus.runId || path.basename(asyncDir);
347
- const resultPath = path.join(options.resultsDir ?? DIRS.results, `${runId}.json`);
348
- if (fs.existsSync(resultPath)) {
357
+ const resultsDir = options.resultsDir ?? DIRS.results;
358
+ const resultPath = resultFilePath(resultsDir, runId);
359
+ const existingResultPath = effectiveStatus.sessionId
360
+ ? resultPayloadPathForSessionRun(resultsDir, effectiveStatus.sessionId, runId) ?? (regularFileExists(resultPath) ? resultPath : undefined)
361
+ : regularFileExists(resultPath) ? resultPath : undefined;
362
+ if (existingResultPath) {
349
363
  const terminalStatus = effectiveStatus.state === "running" || effectiveStatus.state === "queued"
350
- ? terminalStatusFromResult(effectiveStatus, resultPath, now)
364
+ ? terminalStatusFromResult(effectiveStatus, existingResultPath, now)
351
365
  : undefined;
352
366
  if (terminalStatus) {
353
367
  writeAtomicJson(path.join(asyncDir, "status.json"), terminalStatus);
354
368
  if (terminalStatus.processTerminal?.state === "observed") releaseActiveRunIndex(asyncDir);
355
- return { status: terminalStatus, repaired: true, resultPath, message: "Existing async result file was used to repair stale running status." };
369
+ return { status: terminalStatus, repaired: true, resultPath: existingResultPath, message: "Existing async result file was used to repair stale running status." };
356
370
  }
357
- if (effectiveStatus.displayDismissedAt === undefined) return { status: effectiveStatus, repaired: false, resultPath };
371
+ if (effectiveStatus.displayDismissedAt === undefined) return { status: effectiveStatus, repaired: false, resultPath: existingResultPath };
358
372
  }
359
373
  if (effectiveStatus.displayDismissedAt !== undefined) {
360
374
  return { status: null, repaired: false, resultPath };