pi-subagents 0.49.0 → 0.51.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 (117) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/agents/gpt-pro.md +17 -0
  3. package/agents/oracle.md +7 -5
  4. package/agents/researcher.md +1 -1
  5. package/agents/reviewer.md +2 -2
  6. package/agents/scout.md +1 -1
  7. package/agents/worker.md +1 -1
  8. package/async-retention-discovery-worker.mjs +180 -0
  9. package/docs/agents.md +37 -2
  10. package/docs/configuration.md +76 -14
  11. package/docs/extension-api.md +78 -1
  12. package/docs/missions.md +1 -1
  13. package/docs/observability.md +20 -4
  14. package/docs/tool-reference.md +55 -39
  15. package/docs/workflows.md +171 -5
  16. package/package.json +4 -2
  17. package/skills/pi-subagents/SKILL.md +5 -4
  18. package/skills/pi-subagents/references/constraints-and-recipes.md +9 -6
  19. package/skills/pi-subagents/references/execution-controls.md +22 -18
  20. package/skills/pi-subagents/references/management-authoring-rpc.md +5 -5
  21. package/skills/pi-subagents/references/prompting-and-roles.md +33 -17
  22. package/src/agents/agent-management.ts +100 -345
  23. package/src/agents/agent-serializer.ts +2 -0
  24. package/src/agents/agents.ts +135 -25
  25. package/src/api/external-job-provider.ts +185 -0
  26. package/src/api/external-runs.ts +174 -84
  27. package/src/api/preflight.ts +42 -11
  28. package/src/api/shared-types.ts +2 -0
  29. package/src/extension/config.ts +36 -3
  30. package/src/extension/doctor.ts +3 -6
  31. package/src/extension/fanout-child.ts +2 -2
  32. package/src/extension/index.ts +210 -90
  33. package/src/extension/public-execution.ts +31 -2
  34. package/src/extension/rpc.ts +5 -1
  35. package/src/extension/schemas.ts +14 -36
  36. package/src/extension/tool-description.ts +37 -24
  37. package/src/inspectors/herdr/actions.ts +5 -9
  38. package/src/inspectors/herdr/inspector-runner.ts +2 -1
  39. package/src/inspectors/herdr/project-panes.ts +4 -8
  40. package/src/inspectors/herdr/shell-command.ts +16 -0
  41. package/src/intercom/intercom-bridge.ts +2 -3
  42. package/src/intercom/native-supervisor-channel.ts +49 -51
  43. package/src/missions/goal-driver.ts +3 -1
  44. package/src/missions/lifecycle.ts +6 -1
  45. package/src/missions/store.ts +4 -9
  46. package/src/profiles/profiles.ts +3 -1
  47. package/src/runs/background/active-run-index.ts +94 -1
  48. package/src/runs/background/async-execution.ts +98 -38
  49. package/src/runs/background/async-job-tracker.ts +21 -4
  50. package/src/runs/background/async-resume.ts +30 -17
  51. package/src/runs/background/async-retention.ts +888 -0
  52. package/src/runs/background/async-status-snapshot.ts +277 -0
  53. package/src/runs/background/async-status.ts +47 -56
  54. package/src/runs/background/chain-append.ts +3 -33
  55. package/src/runs/background/chain-root-attachment.ts +2 -2
  56. package/src/runs/background/completion-replay.ts +11 -1
  57. package/src/runs/background/control-channel.ts +14 -68
  58. package/src/runs/background/fleet-view.ts +3 -1
  59. package/src/runs/background/index-segment.ts +59 -0
  60. package/src/runs/background/notify.ts +3 -1
  61. package/src/runs/background/result-files.ts +505 -0
  62. package/src/runs/background/result-watcher.ts +250 -51
  63. package/src/runs/background/retained-children.ts +79 -20
  64. package/src/runs/background/run-id-query.ts +7 -0
  65. package/src/runs/background/run-id-resolver.ts +37 -29
  66. package/src/runs/background/run-status.ts +34 -20
  67. package/src/runs/background/scheduled-runs.ts +71 -27
  68. package/src/runs/background/stale-run-reconciler.ts +33 -16
  69. package/src/runs/background/steering.ts +11 -1
  70. package/src/runs/background/subagent-runner.ts +535 -161
  71. package/src/runs/background/subagent-wait.ts +9 -7
  72. package/src/runs/background/terminal-run-index.ts +129 -0
  73. package/src/runs/background/wait-completions.ts +22 -2
  74. package/src/runs/background/wait-subscriptions.ts +80 -1
  75. package/src/runs/foreground/async-dismiss-action.ts +2 -1
  76. package/src/runs/foreground/async-steering-action.ts +21 -14
  77. package/src/runs/foreground/execution.ts +221 -15
  78. package/src/runs/foreground/subagent-executor.ts +529 -1519
  79. package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
  80. package/src/runs/shared/chain-outputs.ts +1 -3
  81. package/src/runs/shared/completion-guard.ts +96 -6
  82. package/src/runs/shared/external-cli-runner.ts +4 -0
  83. package/src/runs/shared/external-job-bridge.ts +450 -0
  84. package/src/runs/shared/external-job-runner.ts +286 -0
  85. package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
  86. package/src/runs/shared/model-fallback.ts +34 -3
  87. package/src/runs/shared/nested-events.ts +66 -62
  88. package/src/runs/shared/orca-progress-tabs.ts +437 -0
  89. package/src/runs/shared/parallel-handoff.ts +46 -4
  90. package/src/runs/shared/parallel-utils.ts +6 -15
  91. package/src/runs/shared/permissions.ts +5 -1
  92. package/src/runs/shared/pi-args.ts +8 -1
  93. package/src/runs/shared/subagent-control.ts +41 -4
  94. package/src/runs/shared/subagent-prompt-runtime.ts +13 -15
  95. package/src/runs/shared/subagent-startup-retry.ts +12 -0
  96. package/src/runs/shared/tool-timeout.ts +93 -0
  97. package/src/runs/shared/workflow-graph.ts +1 -23
  98. package/src/runs/shared/worktree.ts +12 -1
  99. package/src/shared/atomic-json.ts +22 -2
  100. package/src/shared/capacity-resilient-json.ts +102 -0
  101. package/src/shared/completion-owner.ts +14 -0
  102. package/src/shared/file-system-retry.ts +49 -1
  103. package/src/shared/fork-context.ts +42 -0
  104. package/src/shared/prompt-resources.ts +0 -40
  105. package/src/shared/settings.ts +3 -27
  106. package/src/shared/types.ts +88 -27
  107. package/src/shared/utils.ts +8 -0
  108. package/src/shared/watch-strategy.ts +10 -0
  109. package/src/slash/slash-commands.ts +45 -28
  110. package/src/slash/slash-live-state.ts +3 -0
  111. package/src/tui/fleet-status.ts +160 -45
  112. package/src/tui/fleet.ts +186 -28
  113. package/src/tui/render.ts +41 -10
  114. package/src/workflows/chat-progress.ts +7 -5
  115. package/src/workflows/scripted-workflow.ts +424 -125
  116. package/src/runs/foreground/chain-clarify.ts +0 -1354
  117. package/src/runs/foreground/chain-execution.ts +0 -1565
@@ -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 { resultCandidateFilesForToolCall, resultFilePath, resultPayloadPathForIndexedRun } 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,11 +21,12 @@ 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`);
23
- if (!fs.existsSync(asyncDir) && !fs.existsSync(resultPath)) return undefined;
24
+ const asyncDirExists = fs.existsSync(asyncDir);
25
+ const resultPath = resultPathFor(resultsDir, id);
26
+ if (!asyncDirExists && !resultPath) return undefined;
24
27
  return {
25
- asyncDir: fs.existsSync(asyncDir) ? asyncDir : null,
26
- resultPath: fs.existsSync(resultPath) ? resultPath : null,
28
+ asyncDir: asyncDirExists ? asyncDir : null,
29
+ resultPath,
27
30
  resolvedId: id,
28
31
  };
29
32
  }
@@ -52,40 +55,30 @@ function readWorkflowResultIdentity(resultPath: string): WorkflowResultIdentity
52
55
  };
53
56
  }
54
57
 
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
58
  function resultPathFor(resultsDir: string, runId: string): string | null {
65
- const resultPath = path.join(resultsDir, `${runId}.json`);
59
+ const resultPath = resultPayloadPathForIndexedRun(resultsDir, runId) ?? resultFilePath(resultsDir, runId);
66
60
  return fs.existsSync(resultPath) ? resultPath : null;
67
61
  }
68
62
 
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;
63
+ function toolCallIdMatches(value: string | undefined, query: string): boolean {
64
+ return value === query;
72
65
  }
73
66
 
74
- function toolCallIdAsyncLocations(toolCallId: string, asyncDirRoot: string, resultsDir: string, options: { prefix?: boolean } = {}): AsyncRunMatch[] {
67
+ function indexedToolCallIdAsyncLocations(toolCallId: string, asyncDirRoot: string, resultsDir: string): AsyncRunMatch[] {
75
68
  const byId = new Map<string, AsyncRunLocation>();
76
- for (const entry of directoryEntries(asyncDirRoot)) {
69
+ for (const entry of readActiveRunToolCallIndex(asyncDirRoot, toolCallId)) {
77
70
  const asyncDir = path.join(asyncDirRoot, entry);
78
71
  const status = readStatus(asyncDir);
79
- if (!status || !toolCallIdMatches(status.toolCallId, toolCallId, options)) continue;
72
+ if (!status || !toolCallIdMatches(status.toolCallId, toolCallId)) continue;
80
73
  const runId = status.runId || entry;
81
74
  byId.set(runId, { asyncDir, resultPath: resultPathFor(resultsDir, runId), resolvedId: runId });
82
75
  }
83
- for (const entry of directoryEntries(resultsDir)) {
84
- if (!entry.endsWith(".json")) continue;
85
- const resultPath = path.join(resultsDir, entry);
76
+ for (const entry of resultCandidateFilesForToolCall(resultsDir, toolCallId)) {
77
+ const runIdFromFile = entry.slice(0, -".json".length);
78
+ const resultPath = resultPayloadPathForIndexedRun(resultsDir, runIdFromFile) ?? path.join(resultsDir, entry);
86
79
  const identity = readWorkflowResultIdentity(resultPath);
87
- if (!identity || !toolCallIdMatches(identity.toolCallId, toolCallId, options)) continue;
88
- const runId = identity.runId ?? identity.id ?? entry.slice(0, -".json".length);
80
+ if (!identity || !toolCallIdMatches(identity.toolCallId, toolCallId)) continue;
81
+ const runId = identity.runId ?? identity.id ?? runIdFromFile;
89
82
  const asyncDir = path.join(asyncDirRoot, runId);
90
83
  byId.set(runId, { asyncDir: fs.existsSync(asyncDir) ? asyncDir : null, resultPath, resolvedId: runId });
91
84
  }
@@ -109,6 +102,22 @@ function hasExactForegroundId(state: SubagentState | undefined, id: string): boo
109
102
  return Boolean(remembered && state.currentSessionId && remembered.sessionId === state.currentSessionId);
110
103
  }
111
104
 
105
+ function exactLiveAsyncToolCallMatch(state: SubagentState | undefined, toolCallId: string, asyncDirRoot: string, resultsDir: string): AsyncRunMatch | undefined {
106
+ if (!state?.asyncJobs) return undefined;
107
+ const matches = [...state.asyncJobs.values()].filter((job) => job.toolCallId === toolCallId);
108
+ if (matches.length > 1) throw new Error(`Subagent tool-call id '${toolCallId}' is ambiguous across async runs. Use the returned asyncId instead.`);
109
+ const match = matches[0];
110
+ if (!match) return undefined;
111
+ return {
112
+ id: match.asyncId,
113
+ location: {
114
+ asyncDir: fs.existsSync(match.asyncDir) ? match.asyncDir : path.join(asyncDirRoot, match.asyncId),
115
+ resultPath: resultPathFor(resultsDir, match.asyncId),
116
+ resolvedId: match.asyncId,
117
+ },
118
+ };
119
+ }
120
+
112
121
  function nestedScopeFromState(state: SubagentState | undefined): NestedRunResolutionScope | undefined {
113
122
  if (!state) return undefined;
114
123
  const routes: NestedRoute[] = [];
@@ -138,7 +147,9 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
138
147
  if (hasExactForegroundId(deps.state, id)) return { kind: "foreground", id };
139
148
  const exactAsync = exactAsyncLocation(id, asyncDirRoot, resultsDir);
140
149
  if (exactAsync) return { kind: "async", id, location: exactAsync };
141
- const exactToolCallIdMatches = toolCallIdAsyncLocations(id, asyncDirRoot, resultsDir);
150
+ const exactLiveToolCallMatch = exactLiveAsyncToolCallMatch(deps.state, id, asyncDirRoot, resultsDir);
151
+ if (exactLiveToolCallMatch) return { kind: "async", id: exactLiveToolCallMatch.id, location: exactLiveToolCallMatch.location };
152
+ const exactToolCallIdMatches = indexedToolCallIdAsyncLocations(id, asyncDirRoot, resultsDir);
142
153
  if (exactToolCallIdMatches.length > 1) throw new Error(`Subagent tool-call id '${id}' is ambiguous across async runs. Use the returned asyncId instead.`);
143
154
  if (exactToolCallIdMatches[0]) return { kind: "async", id: exactToolCallIdMatches[0].id, location: exactToolCallIdMatches[0].location };
144
155
  const exactNested = findNestedRunMatchesById(id, nestedScope ? { scope: nestedScope } : {});
@@ -152,9 +163,6 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
152
163
  for (const match of asyncPrefixMatches(id, asyncDirRoot, resultsDir)) {
153
164
  matches.push({ kind: "async", id: match.id, location: match.location });
154
165
  }
155
- for (const match of toolCallIdAsyncLocations(id, asyncDirRoot, resultsDir, { prefix: true })) {
156
- matches.push({ kind: "async", id: match.id, location: match.location });
157
- }
158
166
  for (const match of findNestedRunMatchesById(id, nestedScope ? { prefix: true, scope: nestedScope } : { prefix: true })) {
159
167
  matches.push({ kind: "nested", id: match.run.id, match });
160
168
  }
@@ -108,20 +108,20 @@ function hasExistingSessionFile(value: unknown): value is string {
108
108
  return typeof value === "string" && fs.existsSync(value);
109
109
  }
110
110
 
111
- function formatCheckpointGuidance(runId: string | undefined, checkpoint: AsyncStatus["checkpoint"] | undefined): string | undefined {
112
- if (!runId || !checkpoint || checkpoint.status !== "pending") return undefined;
113
- return `Checkpoint: ${checkpoint.name}${checkpoint.message ? ` — ${checkpoint.message}` : ""}\nApprove: subagent({ action: "approve-checkpoint", id: "${runId}" })\nReject: subagent({ action: "reject-checkpoint", id: "${runId}" })`;
114
- }
115
-
116
- function formatResumeGuidance(runId: string | undefined, children: Array<{ agent?: unknown; sessionFile?: unknown; runId?: unknown; workflowKey?: unknown }>, fallbackSessionFile?: unknown, options: { stopped?: boolean } = {}): string {
111
+ 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 {
117
112
  if (options.stopped) return "Resume: unavailable; stopped runs are not resumable. Start a new run instead.";
118
113
  const knownChildren = children
119
114
  .map((child, index) => ({ child, index }))
120
115
  .filter(({ child }) => typeof child.agent === "string");
121
116
  if (!runId || knownChildren.length === 0) return "Resume: unavailable; no child session file was persisted.";
122
117
  const workflowChildren = knownChildren.filter(({ child }) => typeof child.runId === "string" && child.runId.trim() && hasExistingSessionFile(child.sessionFile));
118
+ const supervisorDetachedWorkflowChildren = workflowChildren.filter(({ child }) => child.status === "paused" && child.activityState === "needs_attention");
119
+ const resumableWorkflowChildren = workflowChildren.filter(({ child }) => !(child.status === "paused" && child.activityState === "needs_attention"));
123
120
  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");
121
+ return [
122
+ ...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.`),
123
+ ...resumableWorkflowChildren.map(({ child }) => `Revive workflow child${typeof child.workflowKey === "string" && child.workflowKey.trim() ? ` '${child.workflowKey}'` : ""}: subagent({ action: "resume", id: "${child.runId}", message: "..." })`),
124
+ ].join("\n");
125
125
  }
126
126
  const singleSessionFile = knownChildren[0]?.child.sessionFile ?? fallbackSessionFile;
127
127
  if (children.length === 1 && knownChildren.length === 1 && hasExistingSessionFile(singleSessionFile)) {
@@ -205,12 +205,6 @@ function formatRememberedForegroundStatus(run: ForegroundResumeRun): string {
205
205
  else lines.push(`Transcript: subagent({ action: "status", id: "${run.runId}", index: 0, view: "transcript" })`);
206
206
  const detached = run.children.some((child) => child.status === "detached");
207
207
  const resumable = run.children.find((child) => hasExistingSessionFile(child.sessionFile));
208
- if (run.checkpoint?.status === "pending") {
209
- lines.push(`Checkpoint: ${run.checkpoint.name}${run.checkpoint.message ? ` — ${run.checkpoint.message}` : ""}`);
210
- lines.push(`Approve: subagent({ action: "approve-checkpoint", id: "${run.runId}" })`);
211
- lines.push(`Reject: subagent({ action: "reject-checkpoint", id: "${run.runId}" })`);
212
- return lines.join("\n");
213
- }
214
208
  if (detached) {
215
209
  lines.push(`Recovery: reply to the supervisor request first, then wait with subagent_wait({ id: "${run.runId}" }); do not resume or launch a replacement while any child remains detached.`);
216
210
  } else if (resumable) {
@@ -513,6 +507,12 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
513
507
  reconciliation.message ? `Diagnosis: ${reconciliation.message}` : undefined,
514
508
  reconciliation.resultPath && fs.existsSync(reconciliation.resultPath) ? `Result: ${reconciliation.resultPath}` : undefined,
515
509
  ].filter((line): line is string => Boolean(line));
510
+ const liveWorkflowControls = status.mode === "workflow" && deps.state?.currentSessionId === status.sessionId && deps.state?.workflowControllers?.has(status.runId)
511
+ ? [...(deps.state?.foregroundControls.values() ?? [])].filter((control) => control.parentWorkflowRunId === status.runId
512
+ && control.sessionId === status.sessionId
513
+ && Boolean(control.workflowSteeringDir && fs.existsSync(control.workflowSteeringDir))
514
+ && (control.activeChildren?.size ?? 0) > 0)
515
+ : [];
516
516
  for (const [index, step] of (status.steps ?? []).entries()) {
517
517
  const stepActivityText = step.status === "running" ? formatActivityLabel(step.lastActivityAt, step.activityState) : undefined;
518
518
  const modelThinking = formatModelThinking(step.model, step.thinking);
@@ -529,28 +529,42 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
529
529
  lines.push(` Runner: external-cli (${step.runner.command}${step.runner.args.length ? ` ${step.runner.args.join(" ")}` : ""})`);
530
530
  if (step.externalProcess?.pid !== undefined) lines.push(` Process: ${step.externalProcess.pid}`);
531
531
  if (step.externalProcess) lines.push(` Stdout: ${step.externalProcess.stdoutPath}`, ` Stderr: ${step.externalProcess.stderrPath}`);
532
+ } else if (step.runner?.type === "external-job") {
533
+ lines.push(` Runner: external-job (${step.runner.provider})`);
534
+ if (step.externalJob?.providerJobId) lines.push(` Provider job: ${step.externalJob.providerJobId}`);
535
+ if (step.externalJob?.state) lines.push(` Provider state: ${step.externalJob.state}`);
536
+ if (step.externalJob?.conversationUrl) lines.push(` Conversation: ${step.externalJob.conversationUrl}`);
537
+ if (step.externalJob?.resultArtifactPath) lines.push(` Result artifact: ${step.externalJob.resultArtifactPath}`);
532
538
  }
533
539
  lines.push(...formatNestedRunStatusLines(step.children, { indent: " ", commandHints: true, maxLines: 20 }));
534
540
  const stepOutputPath = path.join(asyncDir, `output-${index}.log`);
535
541
  if (stepOutputPath !== outputPath && fs.existsSync(stepOutputPath)) lines.push(` Output: ${stepOutputPath}`);
536
- if (step.status === "running" && step.runner?.type !== "external-cli") {
542
+ if (step.status === "running" && step.runner?.type !== "external-cli" && step.runner?.type !== "external-job" && status.mode !== "workflow") {
537
543
  lines.push(` Intercom target: ${resolveSubagentIntercomTarget(status.runId, step.agent, index)} (if registered)`);
538
544
  lines.push(` Steer: subagent({ action: "steer", id: "${status.runId}", index: ${index}, message: "..." })`);
539
- } else if (step.status === "running" && step.runner?.type === "external-cli") {
540
- lines.push(" Steer: unavailable; one-shot external CLI runners do not accept live messages.");
545
+ } else if (step.status === "running" && (step.runner?.type === "external-cli" || step.runner?.type === "external-job")) {
546
+ lines.push(" Steer: unavailable; external runners do not accept live messages.");
541
547
  }
542
548
  }
543
549
  const attached = new Set((status.steps ?? []).flatMap((step) => step.children?.map((child) => child.id) ?? []));
544
550
  const unattached = nestedChildren.filter((child) => !attached.has(child.id));
545
551
  lines.push(...formatNestedRunStatusLines(unattached, { indent: "", commandHints: true, maxLines: 20 }));
552
+ if (status.mode === "workflow" && status.state === "running") {
553
+ if (liveWorkflowControls.length === 0) lines.push("Steer: unavailable; no live foreground route is registered in the active session.");
554
+ else for (const control of liveWorkflowControls) {
555
+ for (const index of [...control.activeChildren!.keys()].sort((left, right) => left - right)) {
556
+ lines.push(`Steer live foreground child: subagent({ action: "steer", id: "${control.runId}", index: ${index}, message: "..." })`);
557
+ }
558
+ }
559
+ }
546
560
  if (nestedWarning) lines.push(`Warning: ${nestedWarning}`);
547
561
  if (status.sessionFile) lines.push(`Session: ${status.sessionFile}`);
548
- const allExternal = (status.steps?.length ?? 0) > 0 && status.steps!.every((step) => step.runner?.type === "external-cli");
549
- if (status.state === "running" && !allExternal) lines.push(`Steer running child: subagent({ action: "steer", id: "${status.runId}", message: "..." })`);
562
+ const allExternal = (status.steps?.length ?? 0) > 0 && status.steps!.every((step) => step.runner?.type === "external-cli" || step.runner?.type === "external-job");
563
+ if (status.state === "running" && !allExternal && status.mode !== "workflow") lines.push(`Steer running child: subagent({ action: "steer", id: "${status.runId}", message: "..." })`);
550
564
  if (status.state !== "running") {
551
565
  lines.push(allExternal
552
- ? "Resume: unavailable; one-shot external CLI runners do not persist sessions."
553
- : formatCheckpointGuidance(status.runId, status.checkpoint) ?? formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile, { stopped: status.state === "stopped" || status.stopped === true }));
566
+ ? "Resume: unavailable; external runners do not persist Pi sessions."
567
+ : formatResumeGuidance(status.runId, status.steps ?? [], status.sessionFile, { stopped: status.state === "stopped" || status.stopped === true }));
554
568
  }
555
569
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
556
570
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
@@ -152,7 +152,13 @@ function assertScheduleRoot(root: string, projectCwd: string | undefined, create
152
152
  if (create) fs.mkdirSync(root, { recursive: true, mode: 0o700 });
153
153
  return;
154
154
  }
155
- const projectPath = fs.realpathSync(projectCwd);
155
+ let projectPath: string;
156
+ try {
157
+ projectPath = fs.realpathSync(projectCwd);
158
+ } catch (error) {
159
+ if (!create && (error as NodeJS.ErrnoException).code === "ENOENT") return;
160
+ throw error;
161
+ }
156
162
  let existing = root;
157
163
  while (!fs.existsSync(existing)) {
158
164
  const parent = path.dirname(existing);
@@ -235,12 +241,19 @@ class ScheduleStore {
235
241
  }
236
242
 
237
243
  list(): ScheduleRecord[] {
238
- return this.ids().map((id) => this.get(id));
244
+ return this.ids().map((id) => this.find(id)).filter((record): record is ScheduleRecord => record !== undefined);
239
245
  }
240
246
 
241
247
  get(id: string): ScheduleRecord {
248
+ const record = this.find(id);
249
+ if (!record) throw new Error(`Schedule '${id}' not found.`);
250
+ return record;
251
+ }
252
+
253
+ /** Like {@link get}, but returns undefined when the schedule no longer exists. */
254
+ find(id: string): ScheduleRecord | undefined {
242
255
  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.`);
256
+ if (!fs.existsSync(file)) return undefined;
244
257
  return parseSchedule(readJson(file, "schedule record"), file);
245
258
  }
246
259
 
@@ -361,6 +374,7 @@ export class ScheduledRunManager {
361
374
  private readonly stores = new Map<string, ScheduleStore>();
362
375
  private readonly contexts = new Map<string, ExtensionContext>();
363
376
  private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
377
+ private readonly observedAsyncIds = new Set<string>();
364
378
  private readonly now: () => number;
365
379
  private readonly randomId: () => string;
366
380
  private readonly timersApi: ScheduledRunTimers;
@@ -383,6 +397,7 @@ export class ScheduledRunManager {
383
397
  this.store = undefined;
384
398
  this.stores.clear();
385
399
  this.contexts.clear();
400
+ this.observedAsyncIds.clear();
386
401
  }
387
402
 
388
403
  async handleToolCall(params: SubagentParamsLike, ctx: ExtensionContext): Promise<AgentToolResult<Details>> {
@@ -407,22 +422,15 @@ export class ScheduledRunManager {
407
422
  }
408
423
 
409
424
  observedCompletionRunIds(): Set<string> {
410
- const runIds = new Set<string>();
425
+ return new Set(this.observedAsyncIds);
426
+ }
427
+
428
+ referencedAsyncRunIds(): Set<string> {
429
+ const runIds = new Set(this.observedAsyncIds);
411
430
  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);
431
+ for (const scheduleId of store.ids()) {
432
+ for (const run of store.history(scheduleId)) {
433
+ if (run.asyncId) runIds.add(run.asyncId);
426
434
  }
427
435
  }
428
436
  }
@@ -471,7 +479,7 @@ export class ScheduledRunManager {
471
479
  if (this.deps.resolveCapabilityCeiling?.(sessionId)) return textResult("Cannot persist a schedule while a capability ceiling is active.", undefined, undefined, true);
472
480
  if (store.list().length >= resolveMaxPending(this.deps.config)) return textResult(`Schedule limit reached (${resolveMaxPending(this.deps.config)}).`, undefined, undefined, true);
473
481
  const id = validateScheduleId((params.id?.trim() || this.randomId()));
474
- if (store.list().some((item) => item.id === id)) return textResult(`Schedule '${id}' already exists.`, undefined, undefined, true);
482
+ if (store.ids().includes(id)) return textResult(`Schedule '${id}' already exists.`, undefined, undefined, true);
475
483
  const now = this.now();
476
484
  let trigger: ScheduleTrigger;
477
485
  if (at) {
@@ -484,7 +492,7 @@ export class ScheduledRunManager {
484
492
  const schedule: ScheduleRecord = {
485
493
  schemaVersion: 1,
486
494
  id,
487
- name: params.name?.trim() || params.scheduleName?.trim() || targetLabel(target.target!),
495
+ name: params.name?.trim() || targetLabel(target.target!),
488
496
  cwd: path.resolve(params.cwd ?? ctx.cwd),
489
497
  trigger,
490
498
  target: target.target!,
@@ -563,9 +571,10 @@ export class ScheduledRunManager {
563
571
  for (const schedule of store.list()) this.restoreOne(store, schedule);
564
572
  }
565
573
 
566
- private restoreOne(store: ScheduleStore, schedule: ScheduleRecord): void {
574
+ private restoreOne(store: ScheduleStore, schedule: ScheduleRecord, notBefore?: number, rearm = true): void {
567
575
  if (schedule.activeRunId) {
568
576
  const run = store.history(schedule.id).find((item) => item.id === schedule.activeRunId);
577
+ if (run?.state === "running" && run.asyncId) this.observedAsyncIds.add(run.asyncId);
569
578
  const startedAt = run?.startedAt ? Date.parse(run.startedAt) : Number.NaN;
570
579
  if (run?.state === "running" && run.asyncDir) {
571
580
  try {
@@ -588,26 +597,59 @@ export class ScheduledRunManager {
588
597
  fs.rmSync(path.join(store.directory(schedule.id), "active.lock"), { force: true });
589
598
  }
590
599
  }
591
- if (schedule.paused) return;
600
+ if (!rearm || schedule.paused) return;
592
601
  const next = nextRunAt(schedule);
593
602
  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);
603
+ if (!schedule.activeRunId && next < this.now() && schedule.catchUp === "none") {
604
+ try {
605
+ this.recordMissed(store, schedule, next, "timer");
606
+ } catch (error) {
607
+ if (notBefore !== undefined) this.arm(schedule, store, notBefore);
608
+ throw error;
609
+ }
610
+ }
611
+ this.arm(schedule, store, notBefore);
596
612
  }
597
613
 
598
- private arm(schedule: ScheduleRecord, store: ScheduleStore): void {
614
+ private arm(schedule: ScheduleRecord, store: ScheduleStore, notBefore?: number): void {
599
615
  this.clearTimer(store, schedule.id);
600
616
  if (schedule.paused) return;
601
617
  const next = nextRunAt(schedule);
602
618
  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));
619
+ const timer = this.timersApi.setTimeout(() => {
620
+ // A timer callback is outside every caller's try/catch, so an escaping
621
+ // rejection here reaches the process as an uncaught exception and exits
622
+ // Pi. Contain every failure to this one schedule.
623
+ void this.fire(store, schedule.id).catch((error) => {
624
+ console.warn(`[pi-subagents] Scheduled run '${schedule.id}' failed to fire: ${error instanceof Error ? error.message : String(error)}`);
625
+ this.restoreAfterFireError(store, schedule.id);
626
+ });
627
+ }, Math.min(Math.max(0, next - this.now(), (notBefore ?? 0) - this.now()), MAX_TIMER_DELAY_MS));
604
628
  timer.unref?.();
605
629
  this.timers.set(this.timerKey(store, schedule.id), timer);
606
630
  }
607
631
 
632
+ private restoreAfterFireError(store: ScheduleStore, id: string): void {
633
+ try {
634
+ const schedule = store.find(id);
635
+ if (!schedule) return;
636
+ const now = this.now();
637
+ const planned = schedule.trigger.kind === "interval" ? duePlannedAt(schedule, now) : undefined;
638
+ const notBefore = planned !== undefined && planned <= now ? Date.parse(nextAfter(schedule.trigger, planned, now)!) : undefined;
639
+ this.restoreOne(store, schedule, notBefore, schedule.trigger.kind === "interval");
640
+ } catch (error) {
641
+ console.warn(`[pi-subagents] Scheduled run '${id}' could not be restored after fire failure: ${error instanceof Error ? error.message : String(error)}`);
642
+ }
643
+ }
644
+
608
645
  private async fire(store: ScheduleStore, id: string): Promise<void> {
609
646
  this.clearTimer(store, id);
610
- const schedule = store.get(id);
647
+ // Schedules are project-scoped and shared, and `delete` only clears the
648
+ // timer inside the deleting process. Another session can therefore remove
649
+ // a schedule while this process still holds an armed timer for it; there
650
+ // is then nothing to run and nothing to re-arm.
651
+ const schedule = store.find(id);
652
+ if (!schedule) return;
611
653
  const planned = duePlannedAt(schedule, this.now());
612
654
  if (planned === undefined || schedule.paused) return;
613
655
  if (planned > this.now()) return this.arm(schedule, store);
@@ -661,6 +703,7 @@ export class ScheduledRunManager {
661
703
  if (result.isError || !asyncId) throw new Error(result.content.find((item) => item.type === "text")?.text ?? "Scheduled launch failed.");
662
704
  run.asyncId = asyncId;
663
705
  run.asyncDir = result.details?.asyncDir;
706
+ this.observedAsyncIds.add(asyncId);
664
707
  store.writeRun(schedule, run, "schedule.run.attached_async");
665
708
  this.arm(schedule, store);
666
709
  return run;
@@ -695,6 +738,7 @@ export class ScheduledRunManager {
695
738
  schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
696
739
  store.writeRun(schedule, skipped, "schedule.skipped_overlap");
697
740
  }
741
+ if (run.asyncId) this.observedAsyncIds.delete(run.asyncId);
698
742
  run.state = success ? "completed" : "failed_run";
699
743
  run.completedAt = timestamp(now);
700
744
  if (!success && error) run.error = error;
@@ -1,7 +1,8 @@
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 { releaseActiveRunIndex, updateActiveRunIndex } from "./active-run-index.ts";
4
+ import { resultFilePath, resultPayloadPathForSessionRun, writeAsyncResultFile } from "./result-files.ts";
5
+ import { 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";
7
8
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
@@ -16,6 +17,7 @@ interface StartedRunMetadata {
16
17
  runId: string;
17
18
  pid?: number;
18
19
  sessionId?: string;
20
+ completionOwnerId?: string;
19
21
  mode?: SubagentRunMode;
20
22
  agents?: string[];
21
23
  chainStepCount?: number;
@@ -98,14 +100,23 @@ interface ResultChildOutcome {
98
100
  }
99
101
 
100
102
  interface ResultRepairData {
101
- state: "complete" | "failed" | "paused" | "stopped";
103
+ state: "complete" | "failed" | "paused" | "stopped" | "rejected";
102
104
  results?: ResultChildOutcome[];
103
105
  }
104
106
 
107
+ function regularFileExists(filePath: string): boolean {
108
+ try {
109
+ return fs.statSync(filePath).isFile();
110
+ } catch (error) {
111
+ if (isNotFoundError(error)) return false;
112
+ throw error;
113
+ }
114
+ }
115
+
105
116
  function readResultRepairData(resultPath: string): ResultRepairData | undefined {
106
117
  try {
107
118
  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";
119
+ const state = data.success ? "complete" : data.state === "stopped" ? "stopped" : data.state === "rejected" ? "rejected" : data.state === "paused" || data.exitCode === 0 ? "paused" : "failed";
109
120
  const results = Array.isArray(data.results)
110
121
  ? data.results.map((entry, index) => {
111
122
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) return {};
@@ -124,7 +135,7 @@ function readResultRepairData(resultPath: string): ResultRepairData | undefined
124
135
  }
125
136
  }
126
137
 
127
- function childState(overallState: ResultRepairData["state"], child: ResultChildOutcome | undefined): "complete" | "failed" | "paused" | "stopped" {
138
+ function childState(overallState: ResultRepairData["state"], child: ResultChildOutcome | undefined): ResultRepairData["state"] {
128
139
  if (child?.success === true) return "complete";
129
140
  if (child?.success === false) return "failed";
130
141
  return overallState;
@@ -180,6 +191,7 @@ function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, no
180
191
  return {
181
192
  runId: startedRun.runId || path.basename(asyncDir),
182
193
  ...(startedRun.sessionId ? { sessionId: startedRun.sessionId } : {}),
194
+ ...(startedRun.completionOwnerId ? { completionOwnerId: startedRun.completionOwnerId } : {}),
183
195
  mode: startedRun.mode ?? "single",
184
196
  state: "running",
185
197
  pid: startedRun.pid,
@@ -197,7 +209,7 @@ function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, no
197
209
  };
198
210
  }
199
211
 
200
- function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, reason?: string): { status: AsyncStatus; result: object; message: string } {
212
+ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, reason?: string): { status: AsyncStatus; result: Record<string, unknown>; message: string } {
201
213
  const runId = status.runId || path.basename(asyncDir);
202
214
  const pid = typeof status.pid === "number" ? status.pid : "unknown";
203
215
  const baseMessage = reason ?? `Async runner process ${pid} exited or disappeared before writing a result. Marked run failed by stale-run reconciliation.`;
@@ -234,6 +246,7 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
234
246
  id: runId,
235
247
  agent: resultAgent,
236
248
  mode: status.mode,
249
+ ...(status.completionOwnerId ? { completionOwnerId: status.completionOwnerId } : {}),
237
250
  success: false,
238
251
  state: "failed",
239
252
  summary: message,
@@ -259,22 +272,22 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
259
272
 
260
273
  function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: string, now: number, reason?: string): ReconcileAsyncRunResult {
261
274
  const repair = buildFailedRepair(status, asyncDir, now, reason);
262
- writeAtomicJson(resultPath, repair.result);
275
+ if (repair.result.sessionId) writeAsyncResultFile(resultPath, repair.result);
263
276
  writeAtomicJson(path.join(asyncDir, "status.json"), repair.status);
264
- if (repair.status.processTerminal?.state === "observed") releaseActiveRunIndex(asyncDir);
277
+ updateActiveRunIndex(asyncDir, repair.status.state, repair.status.toolCallId);
265
278
  appendJsonlBestEffort(path.join(asyncDir, "events.jsonl"), {
266
279
  type: "subagent.run.repaired_stale",
267
280
  ts: now,
268
281
  runId: repair.status.runId,
269
282
  pid: status.pid,
270
- resultPath,
283
+ ...(repair.result.sessionId ? { resultPath } : {}),
271
284
  message: repair.message,
272
285
  });
273
- return { status: repair.status, repaired: true, resultPath, message: repair.message };
286
+ return { status: repair.status, repaired: true, ...(repair.result.sessionId ? { resultPath } : {}), message: repair.message };
274
287
  }
275
288
 
276
289
  function terminal(state: AsyncStatus["state"]): boolean {
277
- return state === "complete" || state === "failed" || state === "paused" || state === "stopped";
290
+ return state === "complete" || state === "failed" || state === "paused" || state === "stopped" || state === "rejected";
278
291
  }
279
292
 
280
293
  function* nestedRuns(children: NestedRunSummary[] | undefined): Generator<NestedRunSummary> {
@@ -344,17 +357,21 @@ export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOp
344
357
  if (stepRecord.thinking !== undefined && typeof stepRecord.thinking !== "string") throw new Error(`Invalid async status file '${statusPath}': steps[${index}].thinking must be a string.`);
345
358
  }
346
359
  const runId = effectiveStatus.runId || path.basename(asyncDir);
347
- const resultPath = path.join(options.resultsDir ?? DIRS.results, `${runId}.json`);
348
- if (fs.existsSync(resultPath)) {
360
+ const resultsDir = options.resultsDir ?? DIRS.results;
361
+ const resultPath = resultFilePath(resultsDir, runId);
362
+ const existingResultPath = effectiveStatus.sessionId
363
+ ? resultPayloadPathForSessionRun(resultsDir, effectiveStatus.sessionId, runId) ?? (regularFileExists(resultPath) ? resultPath : undefined)
364
+ : regularFileExists(resultPath) ? resultPath : undefined;
365
+ if (existingResultPath) {
349
366
  const terminalStatus = effectiveStatus.state === "running" || effectiveStatus.state === "queued"
350
- ? terminalStatusFromResult(effectiveStatus, resultPath, now)
367
+ ? terminalStatusFromResult(effectiveStatus, existingResultPath, now)
351
368
  : undefined;
352
369
  if (terminalStatus) {
353
370
  writeAtomicJson(path.join(asyncDir, "status.json"), terminalStatus);
354
- 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." };
371
+ updateActiveRunIndex(asyncDir, terminalStatus.state, terminalStatus.toolCallId);
372
+ return { status: terminalStatus, repaired: true, resultPath: existingResultPath, message: "Existing async result file was used to repair stale running status." };
356
373
  }
357
- if (effectiveStatus.displayDismissedAt === undefined) return { status: effectiveStatus, repaired: false, resultPath };
374
+ if (effectiveStatus.displayDismissedAt === undefined) return { status: effectiveStatus, repaired: false, resultPath: existingResultPath };
358
375
  }
359
376
  if (effectiveStatus.displayDismissedAt !== undefined) {
360
377
  return { status: null, repaired: false, resultPath };
@@ -13,10 +13,20 @@ import type {
13
13
  SteeringTargetStatus,
14
14
  } from "../../shared/types.ts";
15
15
  import { readStatus } from "../../shared/utils.ts";
16
+ import { previewDisplayText } from "../../shared/display-text.ts";
17
+ import { redactSecretValues } from "../shared/permissions.ts";
16
18
 
17
19
  export const MAX_STEERING_REQUESTS = 20;
18
20
  export const STEERING_MESSAGE_PREVIEW_LIMIT = 160;
19
21
 
22
+ export function steeringMessagePreview(message: string): string {
23
+ return previewDisplayText(redactSecretValues(message), STEERING_MESSAGE_PREVIEW_LIMIT);
24
+ }
25
+
26
+ export function steeringReceipt(message: string, receipt: string): string {
27
+ return `${receipt} Message: ${JSON.stringify(steeringMessagePreview(message))}`;
28
+ }
29
+
20
30
  export function createSteeringStatus(): SteeringStatus {
21
31
  return { requested: 0, scheduled: 0, pending: 0, delivered: 0, failed: 0, recovered: 0, recent: [] };
22
32
  }
@@ -35,7 +45,7 @@ export function recordSteeringRequest(
35
45
  id: input.id,
36
46
  requestedAt: input.requestedAt,
37
47
  ...(input.source ? { source: input.source } : {}),
38
- messagePreview: input.message.slice(0, STEERING_MESSAGE_PREVIEW_LIMIT),
48
+ messagePreview: steeringMessagePreview(input.message),
39
49
  targets: input.targets.map((target) => ({ index: target.index, state: target.state, ...(target.reason ? { reason: target.reason } : {}) })),
40
50
  };
41
51
  status.requested++;