pi-subagents 0.50.0 → 0.52.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 (109) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/agents/oracle.md +3 -1
  3. package/agents/reviewer.md +1 -0
  4. package/agents/scout.md +2 -2
  5. package/agents/worker.md +2 -1
  6. package/async-retention-discovery-worker.mjs +180 -0
  7. package/docs/agents.md +40 -2
  8. package/docs/configuration.md +30 -12
  9. package/docs/extension-api.md +42 -1
  10. package/docs/models.md +2 -0
  11. package/docs/observability.md +41 -5
  12. package/docs/tool-reference.md +38 -39
  13. package/docs/workflows.md +171 -3
  14. package/package.json +4 -2
  15. package/skills/pi-subagents/SKILL.md +6 -4
  16. package/skills/pi-subagents/references/constraints-and-recipes.md +12 -6
  17. package/skills/pi-subagents/references/execution-controls.md +29 -15
  18. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -3
  19. package/skills/pi-subagents/references/prompting-and-roles.md +4 -2
  20. package/src/agents/agent-management.ts +124 -351
  21. package/src/agents/agents.ts +157 -31
  22. package/src/agents/skills.ts +1 -1
  23. package/src/api/external-job-provider.ts +185 -0
  24. package/src/api/preflight.ts +36 -8
  25. package/src/api/shared-types.ts +2 -0
  26. package/src/extension/config.ts +3 -3
  27. package/src/extension/doctor.ts +3 -6
  28. package/src/extension/fanout-child.ts +2 -2
  29. package/src/extension/index.ts +166 -88
  30. package/src/extension/public-execution.ts +31 -2
  31. package/src/extension/schemas.ts +12 -35
  32. package/src/extension/tool-description.ts +35 -24
  33. package/src/inspectors/herdr/actions.ts +2 -2
  34. package/src/inspectors/herdr/inspector-runner.ts +2 -1
  35. package/src/inspectors/herdr/project-panes.ts +2 -2
  36. package/src/intercom/native-supervisor-channel.ts +32 -10
  37. package/src/missions/lifecycle.ts +6 -1
  38. package/src/missions/store.ts +4 -9
  39. package/src/missions/workflow-state.ts +2 -2
  40. package/src/profiles/profiles.ts +3 -1
  41. package/src/runs/background/active-run-index.ts +31 -8
  42. package/src/runs/background/async-execution.ts +68 -40
  43. package/src/runs/background/async-job-tracker.ts +20 -4
  44. package/src/runs/background/async-resume.ts +47 -15
  45. package/src/runs/background/async-retention.ts +886 -0
  46. package/src/runs/background/async-status.ts +39 -53
  47. package/src/runs/background/chain-append.ts +3 -33
  48. package/src/runs/background/completion-dedupe.ts +5 -1
  49. package/src/runs/background/completion-replay.ts +22 -12
  50. package/src/runs/background/control-channel.ts +14 -68
  51. package/src/runs/background/fleet-view.ts +68 -19
  52. package/src/runs/background/index-segment.ts +59 -0
  53. package/src/runs/background/inspect-rpc.ts +443 -0
  54. package/src/runs/background/notify.ts +31 -5
  55. package/src/runs/background/result-files.ts +158 -90
  56. package/src/runs/background/result-watcher.ts +116 -20
  57. package/src/runs/background/resume-guidance.ts +8 -5
  58. package/src/runs/background/retained-children.ts +13 -3
  59. package/src/runs/background/run-id-query.ts +7 -0
  60. package/src/runs/background/run-id-resolver.ts +11 -9
  61. package/src/runs/background/run-status.ts +27 -18
  62. package/src/runs/background/scheduled-runs.ts +24 -4
  63. package/src/runs/background/stale-run-reconciler.ts +6 -3
  64. package/src/runs/background/steering.ts +11 -1
  65. package/src/runs/background/subagent-runner.ts +253 -140
  66. package/src/runs/background/subagent-wait.ts +8 -8
  67. package/src/runs/background/terminal-run-index.ts +129 -0
  68. package/src/runs/background/wait-completions.ts +21 -4
  69. package/src/runs/background/wait-subscriptions.ts +81 -2
  70. package/src/runs/foreground/async-steering-action.ts +21 -14
  71. package/src/runs/foreground/execution.ts +3 -1
  72. package/src/runs/foreground/subagent-executor.ts +666 -1579
  73. package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
  74. package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
  75. package/src/runs/shared/acceptance.ts +4 -4
  76. package/src/runs/shared/chain-outputs.ts +1 -3
  77. package/src/runs/shared/external-job-bridge.ts +444 -0
  78. package/src/runs/shared/external-job-runner.ts +286 -0
  79. package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
  80. package/src/runs/shared/model-fallback.ts +98 -38
  81. package/src/runs/shared/orca-progress-tabs.ts +84 -22
  82. package/src/runs/shared/parallel-handoff.ts +46 -4
  83. package/src/runs/shared/parallel-utils.ts +4 -15
  84. package/src/runs/shared/permissions.ts +5 -1
  85. package/src/runs/shared/pi-args.ts +8 -1
  86. package/src/runs/shared/session-lease.ts +0 -6
  87. package/src/runs/shared/subagent-control.ts +26 -4
  88. package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
  89. package/src/runs/shared/workflow-graph.ts +1 -23
  90. package/src/runs/shared/worktree.ts +14 -2
  91. package/src/shared/atomic-json.ts +22 -2
  92. package/src/shared/capacity-resilient-json.ts +102 -0
  93. package/src/shared/completion-owner.ts +14 -0
  94. package/src/shared/file-system-retry.ts +49 -1
  95. package/src/shared/fork-context.ts +42 -0
  96. package/src/shared/prompt-resources.ts +0 -40
  97. package/src/shared/settings.ts +3 -27
  98. package/src/shared/types.ts +71 -26
  99. package/src/shared/utils.ts +8 -0
  100. package/src/shared/watch-strategy.ts +10 -0
  101. package/src/slash/slash-bridge.ts +2 -1
  102. package/src/slash/slash-commands.ts +29 -3
  103. package/src/tui/fleet.ts +63 -15
  104. package/src/watchdog/change-signature.ts +1 -1
  105. package/src/watchdog/lsp-diagnostics.ts +1 -0
  106. package/src/workflows/chat-progress.ts +2 -2
  107. package/src/workflows/scripted-workflow.ts +220 -67
  108. package/src/runs/foreground/chain-clarify.ts +0 -1354
  109. package/src/runs/foreground/chain-execution.ts +0 -1581
@@ -2,9 +2,9 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
4
4
  import { formatActivityLabel, formatParallelOutcome } from "../../shared/status-format.ts";
5
- import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type CostSummary, type Details, type LaunchResolvedChildExtensionsV1, type RuntimeAcknowledgedChildExtensionsV1, type NestedRunSummary, type SteeringStatus, type SubagentRunMode, type TokenUsage, type TurnBudgetState, type UsageBudgetState, type ChainCheckpointState } from "../../shared/types.ts";
5
+ import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type CostSummary, type Details, type LaunchResolvedChildExtensionsV1, type RuntimeAcknowledgedChildExtensionsV1, type NestedRunSummary, type SteeringStatus, type SubagentRunMode, type TokenUsage, type TurnBudgetState, type UsageBudgetState } from "../../shared/types.ts";
6
6
  import type { ResolvedSubagentCapabilityCeiling, SubagentCapabilityAudit } from "../shared/capability-ceiling.ts";
7
- import { pruneStatusCacheForAsyncRoot, readStatus } from "../../shared/utils.ts";
7
+ import { readStatus } from "../../shared/utils.ts";
8
8
  import { attachRootChildrenToSteps, buildNestedRouteIndex, findNestedRouteForRootId, type NestedRoute, projectNestedEvents } from "../shared/nested-events.ts";
9
9
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
10
10
  import { formatRunFanoutBudget, getRunFanoutBudgetSnapshot, readRunFanoutBudgetDescriptor } from "../shared/run-fanout-budget.ts";
@@ -13,6 +13,8 @@ import { contextModeLabel, summarizeContextModes, type ContextMode, type Context
13
13
  import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
14
14
  import { readProcessTerminal, sanitizeProcessTerminal } from "./process-terminal.ts";
15
15
  import { ACTIVE_RUN_INDEX_DIR, DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS, activeRunMarkerAgeMs, isActiveAsyncState, readActiveRunIndex, releaseActiveRunIndex, updateActiveRunIndex } from "./active-run-index.ts";
16
+ import { readRecentTerminalRunIndex, TERMINAL_RUN_INDEX_DIR } from "./terminal-run-index.ts";
17
+ import { canScanAsyncRunPrefix } from "./run-id-query.ts";
16
18
 
17
19
  interface AsyncRunStepSummary {
18
20
  index: number;
@@ -23,7 +25,6 @@ interface AsyncRunStepSummary {
23
25
  phase?: string;
24
26
  outputName?: string;
25
27
  structured?: boolean;
26
- checkpoint?: ChainCheckpointState;
27
28
  status: AsyncJobStep["status"];
28
29
  runner?: AsyncJobStep["runner"];
29
30
  activityState?: ActivityState;
@@ -83,6 +84,7 @@ export interface AsyncRunSummary {
83
84
  mode: SubagentRunMode;
84
85
  context?: ContextSummary;
85
86
  cwd?: string;
87
+ sessionRoot?: string;
86
88
  startedAt: number;
87
89
  lastUpdate?: number;
88
90
  endedAt?: number;
@@ -98,7 +100,6 @@ export interface AsyncRunSummary {
98
100
  pendingAppends?: number;
99
101
  parallelGroups?: AsyncParallelGroupStatus[];
100
102
  steps: AsyncRunStepSummary[];
101
- checkpoint?: ChainCheckpointState;
102
103
  sessionDir?: string;
103
104
  outputFile?: string;
104
105
  totalTokens?: TokenUsage;
@@ -122,14 +123,18 @@ interface AsyncRunListOptions {
122
123
  states?: Array<AsyncRunSummary["state"]>;
123
124
  sessionId?: string;
124
125
  limit?: number;
125
- /** Limits status-file reads after candidates are ordered by status mtime. */
126
+ /** Limits terminal candidates using the timestamp embedded in index marker names. */
126
127
  entryLimit?: number;
127
128
  resultsDir?: string;
128
129
  kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
129
130
  now?: () => number;
130
131
  reconcile?: boolean;
131
132
  runId?: string;
133
+ /** The caller already holds a canonical run id; never interpret a miss as a prefix. */
134
+ exactRunId?: boolean;
132
135
  includeNested?: boolean;
136
+ /** Explicit repair/debug escape hatch. Normal runtime paths must not set this. */
137
+ repairScan?: boolean;
133
138
  }
134
139
 
135
140
  function getErrorMessage(error: unknown): string {
@@ -157,7 +162,7 @@ function isAsyncRunDir(root: string, entry: string): boolean {
157
162
 
158
163
  type TargetedAsyncRunResolution =
159
164
  | { kind: "exact"; id: string }
160
- | { kind: "scan" }
165
+ | { kind: "prefix" }
161
166
  | { kind: "reject" };
162
167
 
163
168
  /**
@@ -165,13 +170,13 @@ type TargetedAsyncRunResolution =
165
170
  * accepting a path whose canonical location escaped the async root.
166
171
  */
167
172
  export function resolveTargetedAsyncRun(asyncDirRoot: string, id: string, sessionId?: string): TargetedAsyncRunResolution {
168
- if (!id || id === "." || id === ".." || id === ACTIVE_RUN_INDEX_DIR || path.basename(id) !== id) return { kind: "reject" };
173
+ if (!id || id === "." || id === ".." || id === ACTIVE_RUN_INDEX_DIR || id === TERMINAL_RUN_INDEX_DIR || path.basename(id) !== id) return { kind: "reject" };
169
174
  const asyncDir = path.join(asyncDirRoot, id);
170
175
  let entryStat: fs.Stats;
171
176
  try {
172
177
  entryStat = fs.lstatSync(asyncDir);
173
178
  } catch (error) {
174
- if (isNotFoundError(error)) return { kind: "scan" };
179
+ if (isNotFoundError(error)) return canScanAsyncRunPrefix(id) ? { kind: "prefix" } : { kind: "reject" };
175
180
  throw new Error(`Failed to inspect async run path '${asyncDir}': ${getErrorMessage(error)}`, {
176
181
  cause: error instanceof Error ? error : undefined,
177
182
  });
@@ -189,7 +194,7 @@ export function resolveTargetedAsyncRun(asyncDirRoot: string, id: string, sessio
189
194
  }
190
195
  if (sessionId !== undefined) {
191
196
  const status = readStatus(asyncDir);
192
- if (status?.sessionId !== sessionId) return { kind: "scan" };
197
+ if (status?.sessionId !== sessionId) return canScanAsyncRunPrefix(id) ? { kind: "prefix" } : { kind: "reject" };
193
198
  }
194
199
  return { kind: "exact", id };
195
200
  }
@@ -253,7 +258,6 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
253
258
  ...(step.phase ? { phase: step.phase } : {}),
254
259
  ...(step.outputName ? { outputName: step.outputName } : {}),
255
260
  ...(step.structured ? { structured: step.structured } : {}),
256
- ...(step.checkpoint ? { checkpoint: step.checkpoint } : {}),
257
261
  status: step.status,
258
262
  ...(step.runner ? { runner: step.runner } : {}),
259
263
  ...(stepActivityState ? { activityState: stepActivityState } : {}),
@@ -315,6 +319,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
315
319
  mode: status.mode,
316
320
  ...(summarizeContextModes(summarizedSteps.map((step) => step.context)) ? { context: summarizeContextModes(summarizedSteps.map((step) => step.context)) } : {}),
317
321
  cwd: status.cwd,
322
+ ...(status.sessionRoot ? { sessionRoot: status.sessionRoot } : {}),
318
323
  startedAt: status.startedAt,
319
324
  lastUpdate: status.lastUpdate,
320
325
  endedAt: status.endedAt,
@@ -330,7 +335,6 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
330
335
  ...(status.pendingAppends !== undefined ? { pendingAppends: status.pendingAppends } : {}),
331
336
  ...(parallelGroups.length ? { parallelGroups } : {}),
332
337
  steps: summarizedSteps,
333
- ...(status.checkpoint ? { checkpoint: status.checkpoint } : {}),
334
338
  ...(nestedChildren.length ? { nestedChildren } : {}),
335
339
  ...(nestedWarnings.length ? { nestedWarnings } : {}),
336
340
  ...(processTerminal ? { processTerminal } : {}),
@@ -379,35 +383,39 @@ function sortRuns(runs: AsyncRunSummary[]): AsyncRunSummary[] {
379
383
 
380
384
  export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions = {}): AsyncRunSummary[] {
381
385
  let entries: string[];
382
- let scannedCompleteRoot = false;
383
- let usedActiveIndex = false;
384
- const activeOnly = options.runId === undefined
385
- && options.entryLimit === undefined
386
- && options.states !== undefined
387
- && options.states.length > 0
388
- && options.states.every(isActiveAsyncState);
386
+ const activeEntries = new Set<string>();
387
+ const wantsActive = options.states === undefined || options.states.some(isActiveAsyncState);
388
+ const wantsTerminal = options.states === undefined || options.states.some((state) => !isActiveAsyncState(state));
389
389
  const includeNested = options.includeNested !== false;
390
390
  try {
391
391
  if (options.runId !== undefined) {
392
392
  const resolution = resolveTargetedAsyncRun(asyncDirRoot, options.runId, options.sessionId);
393
393
  entries = resolution.kind === "exact"
394
394
  ? [resolution.id]
395
- : resolution.kind === "scan"
395
+ : resolution.kind === "prefix" && options.exactRunId !== true
396
396
  ? fs.readdirSync(asyncDirRoot).filter((entry) =>
397
397
  (entry === options.runId || entry.startsWith(options.runId!))
398
398
  && resolveTargetedAsyncRun(asyncDirRoot, entry, options.sessionId).kind === "exact"
399
399
  )
400
400
  : [];
401
- } else if (activeOnly) {
402
- entries = (readActiveRunIndex(asyncDirRoot) ?? []).filter((entry) => {
403
- if (resolveTargetedAsyncRun(asyncDirRoot, entry).kind === "exact") return true;
404
- updateActiveRunIndex(path.join(asyncDirRoot, entry), "failed");
405
- return false;
406
- });
407
- usedActiveIndex = true;
401
+ } else if (options.repairScan === true) {
402
+ entries = fs.readdirSync(asyncDirRoot).filter((entry) => entry !== ACTIVE_RUN_INDEX_DIR && entry !== TERMINAL_RUN_INDEX_DIR && isAsyncRunDir(asyncDirRoot, entry));
408
403
  } else {
409
- entries = fs.readdirSync(asyncDirRoot).filter((entry) => entry !== ACTIVE_RUN_INDEX_DIR && isAsyncRunDir(asyncDirRoot, entry));
410
- scannedCompleteRoot = true;
404
+ const indexed = new Set<string>();
405
+ if (wantsActive) {
406
+ for (const entry of readActiveRunIndex(asyncDirRoot) ?? []) {
407
+ if (resolveTargetedAsyncRun(asyncDirRoot, entry).kind === "exact") {
408
+ indexed.add(entry);
409
+ activeEntries.add(entry);
410
+ } else {
411
+ updateActiveRunIndex(path.join(asyncDirRoot, entry), "failed");
412
+ }
413
+ }
414
+ }
415
+ if (wantsTerminal) {
416
+ for (const entry of readRecentTerminalRunIndex(asyncDirRoot, { sessionId: options.sessionId, ...(options.entryLimit !== undefined ? { limit: options.entryLimit } : {}) })) indexed.add(entry);
417
+ }
418
+ entries = [...indexed];
411
419
  }
412
420
  } catch (error) {
413
421
  if (isNotFoundError(error)) return [];
@@ -416,28 +424,6 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
416
424
  });
417
425
  }
418
426
 
419
- if (options.entryLimit !== undefined) {
420
- scannedCompleteRoot = false;
421
- const limit = Math.max(0, Math.floor(options.entryLimit));
422
- entries = entries
423
- .map((entry) => {
424
- try {
425
- return { entry, mtimeMs: fs.statSync(path.join(asyncDirRoot, entry, "status.json")).mtimeMs };
426
- } catch (error) {
427
- if (isNotFoundError(error)) return undefined;
428
- throw new Error(`Failed to inspect async status file for '${entry}': ${getErrorMessage(error)}`, {
429
- cause: error instanceof Error ? error : undefined,
430
- });
431
- }
432
- })
433
- .filter((candidate): candidate is { entry: string; mtimeMs: number } => candidate !== undefined)
434
- .sort((left, right) => right.mtimeMs - left.mtimeMs)
435
- .slice(0, limit)
436
- .map((candidate) => candidate.entry);
437
- }
438
-
439
- if (scannedCompleteRoot) pruneStatusCacheForAsyncRoot(asyncDirRoot, entries);
440
-
441
427
  const allowedStates = options.states ? new Set(options.states) : undefined;
442
428
  const runs: AsyncRunSummary[] = [];
443
429
  // Route resolution for every run shares a single index built from the
@@ -448,7 +434,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
448
434
  let nestedRouteIndex: Map<string, NestedRoute> | undefined;
449
435
  const resolveNestedRoute = (rootRunId: string): NestedRoute | undefined => {
450
436
  if (!includeNested) return undefined;
451
- if (usedActiveIndex) return findNestedRouteForRootId(rootRunId);
437
+ if (activeEntries.has(rootRunId)) return findNestedRouteForRootId(rootRunId);
452
438
  if (!nestedRouteIndex) nestedRouteIndex = buildNestedRouteIndex();
453
439
  return nestedRouteIndex.get(rootRunId);
454
440
  };
@@ -459,10 +445,10 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
459
445
  : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
460
446
  const status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
461
447
  if (!status) {
462
- if (usedActiveIndex) updateActiveRunIndex(asyncDir, "failed");
448
+ if (activeEntries.has(entry)) updateActiveRunIndex(asyncDir, "failed");
463
449
  continue;
464
450
  }
465
- if (usedActiveIndex && !isActiveAsyncState(status.state)) {
451
+ if (activeEntries.has(entry) && !isActiveAsyncState(status.state)) {
466
452
  const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId });
467
453
  if (processTerminal?.state === "observed" || (activeRunMarkerAgeMs(asyncDir, options.now?.()) ?? 0) > DEFAULT_STALE_TERMINAL_ACTIVE_MARKER_MS) releaseActiveRunIndex(asyncDir);
468
454
  }
@@ -5,8 +5,8 @@ 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
7
  import { PROMPT_REDACTED, readStatus } from "../../shared/utils.ts";
8
- import type { DynamicRunnerGroup, ParallelStepGroup, RunnerStep, RunnerSubagentStep, RunnerCheckpointStep } from "../shared/parallel-utils.ts";
9
- import { isCheckpointRunnerStep, isDynamicRunnerGroup, isParallelGroup } from "../shared/parallel-utils.ts";
8
+ import type { DynamicRunnerGroup, ParallelStepGroup, RunnerStep, RunnerSubagentStep } from "../shared/parallel-utils.ts";
9
+ import { isDynamicRunnerGroup, isParallelGroup } from "../shared/parallel-utils.ts";
10
10
 
11
11
  const APPEND_REQUESTS_DIR = "append-requests";
12
12
 
@@ -52,9 +52,7 @@ export function countPendingChainAppendRequests(asyncDir: string): number {
52
52
  export function runnerStepOutputNames(steps: RunnerStep[]): string[] {
53
53
  const names: string[] = [];
54
54
  for (const step of steps) {
55
- if (isCheckpointRunnerStep(step)) {
56
- continue;
57
- } else if (isParallelGroup(step)) {
55
+ if (isParallelGroup(step)) {
58
56
  names.push(...step.parallel.map((task) => task.outputName).filter((name): name is string => Boolean(name)));
59
57
  } else if (isDynamicRunnerGroup(step)) {
60
58
  if (step.collect.as) names.push(step.collect.as);
@@ -181,20 +179,7 @@ function statusStepForTask(task: RunnerSubagentStep): StatusStep {
181
179
  };
182
180
  }
183
181
 
184
- function statusStepForCheckpoint(step: RunnerCheckpointStep, stepIndex: number): StatusStep {
185
- return {
186
- agent: `checkpoint:${step.checkpoint}`,
187
- phase: step.phase,
188
- label: step.label ?? step.checkpoint,
189
- status: "pending",
190
- checkpoint: { name: step.checkpoint, ...(step.message ? { message: step.message } : {}), status: "pending", stepIndex },
191
- recentTools: [],
192
- recentOutput: [],
193
- };
194
- }
195
-
196
182
  function statusStepsForRunnerStep(step: RunnerStep, stepIndex: number): StatusStep[] {
197
- if (isCheckpointRunnerStep(step)) return [statusStepForCheckpoint(step, stepIndex)];
198
183
  if (isParallelGroup(step)) return step.parallel.map(statusStepForTask);
199
184
  if (isDynamicRunnerGroup(step)) {
200
185
  return [{
@@ -286,21 +271,6 @@ function graphNodeForDynamic(step: DynamicRunnerGroup, stepIndex: number): Workf
286
271
 
287
272
  function appendWorkflowNode(graph: WorkflowGraphSnapshot | undefined, step: RunnerStep, stepIndex: number, flatIndex: number): void {
288
273
  if (!graph) return;
289
- if (isCheckpointRunnerStep(step)) {
290
- const node: WorkflowGraphNode = {
291
- id: `step-${stepIndex}`,
292
- kind: "checkpoint",
293
- phase: step.phase,
294
- label: step.label?.trim() || step.checkpoint,
295
- status: "pending",
296
- flatIndex,
297
- stepIndex,
298
- checkpoint: { name: step.checkpoint, ...(step.message ? { message: step.message } : {}), status: "pending", stepIndex },
299
- };
300
- graph.nodes.push(node);
301
- pushPhase(graph, step.phase, node.id);
302
- return;
303
- }
304
274
  if (isParallelGroup(step)) {
305
275
  graph.nodes.push(graphNodeForParallel(step, stepIndex, flatIndex, graph));
306
276
  return;
@@ -3,6 +3,7 @@ interface CompletionDataLike {
3
3
  agent?: unknown;
4
4
  timestamp?: unknown;
5
5
  sessionId?: unknown;
6
+ state?: unknown;
6
7
  taskIndex?: unknown;
7
8
  totalTasks?: unknown;
8
9
  success?: unknown;
@@ -22,7 +23,10 @@ function asFiniteNumber(value: unknown): number | undefined {
22
23
  export function buildCompletionKey(data: CompletionDataLike, fallback: string): string {
23
24
  const sessionId = asNonEmptyString(data.sessionId) ?? "no-session";
24
25
  const id = asNonEmptyString(data.id);
25
- if (id) return `session:${sessionId}:id:${id}`;
26
+ const state = asNonEmptyString(data.state);
27
+ if (id) return state
28
+ ? `session:${sessionId}:id:${id}:state:${state}`
29
+ : `session:${sessionId}:id:${id}`;
26
30
  const agent = asNonEmptyString(data.agent) ?? "unknown";
27
31
  const timestamp = asFiniteNumber(data.timestamp);
28
32
  const taskIndex = asFiniteNumber(data.taskIndex);
@@ -14,6 +14,7 @@ const lastCleanupByResultsDir = new Map<string, number>();
14
14
 
15
15
  export interface CompletionArchiveEntry {
16
16
  agent?: string;
17
+ resultIndex?: number;
17
18
  source: "output-artifact" | "session" | "result-tail";
18
19
  path?: string;
19
20
  text?: string;
@@ -71,39 +72,45 @@ function outputArtifactPath(child: Record<string, unknown>): string | undefined
71
72
  /** Create a small archive that references saved child artifacts and retains only bounded fallback output text. */
72
73
  export function writeCompletionArchive(resultsDir: string, runId: string, data: Record<string, unknown>, createdAt: number): string {
73
74
  const entries: CompletionArchiveEntry[] = [];
74
- const fallback: string[] = [];
75
75
  const results = Array.isArray(data.results) ? data.results : [];
76
- for (const value of results) {
76
+ for (let resultIndex = 0; resultIndex < results.length; resultIndex++) {
77
+ const value = results[resultIndex];
77
78
  if (!value || typeof value !== "object" || Array.isArray(value)) continue;
78
79
  const child = value as Record<string, unknown>;
79
80
  const agent = nonEmptyString(child.agent);
80
81
  const artifactPath = outputArtifactPath(child);
81
82
  if (artifactPath) {
82
- entries.push({ ...(agent ? { agent } : {}), source: "output-artifact", path: artifactPath });
83
+ entries.push({ ...(agent ? { agent } : {}), resultIndex, source: "output-artifact", path: artifactPath });
83
84
  continue;
84
85
  }
85
86
  const sessionPath = existingFile(child.sessionFile);
86
87
  if (sessionPath) {
87
- entries.push({ ...(agent ? { agent } : {}), source: "session", path: sessionPath });
88
+ entries.push({ ...(agent ? { agent } : {}), resultIndex, source: "session", path: sessionPath });
88
89
  continue;
89
90
  }
90
91
  const output = nonEmptyString(child.output);
91
92
  const error = nonEmptyString(child.error);
92
93
  if (output || error) {
93
- fallback.push([agent ? `[${agent}]` : undefined, error ? `Error: ${error}` : undefined, output].filter(Boolean).join("\n"));
94
+ const bounded = utf8Tail([error ? `Error: ${error}` : undefined, output].filter(Boolean).join("\n"), ARCHIVE_TEXT_LIMIT_BYTES);
95
+ entries.push({
96
+ ...(agent ? { agent } : {}),
97
+ resultIndex,
98
+ source: "result-tail",
99
+ text: bounded.text,
100
+ ...(bounded.truncated ? { truncated: true } : {}),
101
+ });
94
102
  }
95
103
  }
96
104
  if (results.length === 0) {
97
105
  const sessionPath = existingFile(data.sessionFile);
98
106
  if (sessionPath) entries.push({ source: "session", path: sessionPath });
99
107
  }
100
- if (entries.length === 0 && fallback.length === 0) {
108
+ if (entries.length === 0) {
101
109
  const summary = nonEmptyString(data.summary);
102
- if (summary) fallback.push(summary);
103
- }
104
- if (fallback.length > 0) {
105
- const bounded = utf8Tail(fallback.join("\n\n"), ARCHIVE_TEXT_LIMIT_BYTES);
106
- entries.push({ source: "result-tail", text: bounded.text, ...(bounded.truncated ? { truncated: true } : {}) });
110
+ if (summary) {
111
+ const bounded = utf8Tail(summary, ARCHIVE_TEXT_LIMIT_BYTES);
112
+ entries.push({ source: "result-tail", text: bounded.text, ...(bounded.truncated ? { truncated: true } : {}) });
113
+ }
107
114
  }
108
115
  const archive: CompletionArchive = { version: ARCHIVE_VERSION, runId, createdAt, entries };
109
116
  const archivePath = completionArchivePath(resultsDir, runId);
@@ -165,6 +172,7 @@ function parseArchive(value: unknown): CompletionArchive | undefined {
165
172
  if (entry.source !== "output-artifact" && entry.source !== "session" && entry.source !== "result-tail") return [];
166
173
  return [{
167
174
  ...(typeof entry.agent === "string" ? { agent: entry.agent } : {}),
175
+ ...(typeof entry.resultIndex === "number" && Number.isSafeInteger(entry.resultIndex) && entry.resultIndex >= 0 ? { resultIndex: entry.resultIndex } : {}),
168
176
  source: entry.source,
169
177
  ...(typeof entry.path === "string" ? { path: entry.path } : {}),
170
178
  ...(typeof entry.text === "string" ? { text: entry.text } : {}),
@@ -228,7 +236,9 @@ export function readCompletionReplay(resultsDir: string, runId: string, options:
228
236
 
229
237
  export function readCompletionArchive(archivePath: string): CompletionArchive | undefined {
230
238
  try {
231
- return parseArchive(JSON.parse(fs.readFileSync(archivePath, "utf-8")));
239
+ const archive = parseArchive(JSON.parse(fs.readFileSync(archivePath, "utf-8")));
240
+ if (!archive) throw new Error("Completion archive is malformed.");
241
+ return archive;
232
242
  } catch (error) {
233
243
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
234
244
  throw error;
@@ -18,6 +18,7 @@ import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
19
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
20
20
  import { POLL_INTERVAL_MS } from "../../shared/types.ts";
21
+ import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
21
22
  import { resolveWatchPath } from "../../shared/utils.ts";
22
23
 
23
24
  export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch" | "readdirSync" | "readFileSync" | "realpathSync">;
@@ -56,13 +57,6 @@ export interface StopRequest {
56
57
  reason?: string;
57
58
  }
58
59
 
59
- export interface CheckpointDecisionRequest {
60
- type: "approve-checkpoint" | "reject-checkpoint";
61
- ts?: number;
62
- source?: string;
63
- reason?: string;
64
- }
65
-
66
60
  export type SteerDeliveryMode = "steer" | "follow_up" | "auto";
67
61
  export type SteerDeliveryStatus = "delivered" | "queued";
68
62
 
@@ -127,14 +121,6 @@ export function stopRequestPath(asyncDir: string): string {
127
121
  return path.join(controlInboxDir(asyncDir), "stop.json");
128
122
  }
129
123
 
130
- export function approveCheckpointRequestPath(asyncDir: string): string {
131
- return path.join(controlInboxDir(asyncDir), "approve-checkpoint.json");
132
- }
133
-
134
- export function rejectCheckpointRequestPath(asyncDir: string): string {
135
- return path.join(controlInboxDir(asyncDir), "reject-checkpoint.json");
136
- }
137
-
138
124
  /** Directory of parent-to-runner steering requests. */
139
125
  export function steerRequestsDir(asyncDir: string): string {
140
126
  return path.join(controlInboxDir(asyncDir), STEER_REQUESTS_DIR);
@@ -144,8 +130,8 @@ export function steerInboxClosedPath(asyncDir: string): string {
144
130
  return path.join(controlInboxDir(asyncDir), STEER_INBOX_CLOSED_FILE);
145
131
  }
146
132
 
147
- export function closeSteerInbox(asyncDir: string, state: string): void {
148
- writeAtomicJson(steerInboxClosedPath(asyncDir), { version: 1, closedAt: Date.now(), state });
133
+ export function closeSteerInbox(asyncDir: string, state: string, write: (filePath: string, payload: object) => void = writeAtomicJson): void {
134
+ write(steerInboxClosedPath(asyncDir), { version: 1, closedAt: Date.now(), state });
149
135
  }
150
136
 
151
137
  /** Per-child inbox consumed by the child prompt runtime inside the Pi process. */
@@ -299,18 +285,6 @@ export function requestAsyncStop(
299
285
  return requestPath;
300
286
  }
301
287
 
302
- export function requestAsyncCheckpointDecision(
303
- asyncDir: string,
304
- type: CheckpointDecisionRequest["type"],
305
- payload: Omit<CheckpointDecisionRequest, "type"> = {},
306
- deps: { now?: () => number } = {},
307
- ): string {
308
- const requestPath = type === "approve-checkpoint" ? approveCheckpointRequestPath(asyncDir) : rejectCheckpointRequestPath(asyncDir);
309
- const request: CheckpointDecisionRequest = { ...payload, ts: payload.ts ?? deps.now?.() ?? Date.now(), type };
310
- writeAtomicJson(requestPath, request);
311
- return requestPath;
312
- }
313
-
314
288
  export function requestAsyncSteer(
315
289
  asyncDir: string,
316
290
  payload: { message: string; mode?: SteerDeliveryMode; targetIndex?: number; targetIndexes?: number[]; source?: string; id?: string; ts?: number },
@@ -559,21 +533,6 @@ export function consumeStopRequest(
559
533
  return true;
560
534
  }
561
535
 
562
- export function consumeCheckpointDecisionRequest(
563
- asyncDir: string,
564
- fsImpl: Pick<typeof fs, "existsSync" | "rmSync"> = fs,
565
- ): "approved" | "rejected" | undefined {
566
- if (fsImpl.existsSync(rejectCheckpointRequestPath(asyncDir))) {
567
- try { fsImpl.rmSync(rejectCheckpointRequestPath(asyncDir), { force: true, recursive: true }); } catch {}
568
- return "rejected";
569
- }
570
- if (fsImpl.existsSync(approveCheckpointRequestPath(asyncDir))) {
571
- try { fsImpl.rmSync(approveCheckpointRequestPath(asyncDir), { force: true, recursive: true }); } catch {}
572
- return "approved";
573
- }
574
- return undefined;
575
- }
576
-
577
536
  /** Parent side: write the authoritative portable interrupt request. */
578
537
  export function deliverInterruptRequest(input: {
579
538
  asyncDir: string;
@@ -605,21 +564,6 @@ export function deliverStopRequest(input: {
605
564
  requestAsyncStop(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
606
565
  }
607
566
 
608
- export function deliverCheckpointDecisionRequest(input: {
609
- asyncDir: string;
610
- decision: "approved" | "rejected";
611
- now?: () => number;
612
- source?: string;
613
- reason?: string;
614
- }): void {
615
- requestAsyncCheckpointDecision(
616
- input.asyncDir,
617
- input.decision === "approved" ? "approve-checkpoint" : "reject-checkpoint",
618
- { ...(input.source ? { source: input.source } : {}), ...(input.reason ? { reason: input.reason } : {}) },
619
- { now: input.now },
620
- );
621
- }
622
-
623
567
  /**
624
568
  * Runner side: watch the control inbox and route interrupt requests into
625
569
  * `onInterrupt`. Uses `fs.watch` when available and starts interval polling
@@ -633,11 +577,11 @@ export function watchAsyncControlInbox(
633
577
  onTimeout?: () => void;
634
578
  onStop?: () => void;
635
579
  onSteer?: (request: SteerRequest) => void;
636
- onCheckpointDecision?: (decision: "approved" | "rejected") => void;
637
580
  onSteerCapability?: (capability: SteerCapability) => void;
638
581
  onSteerAck?: (ack: SteerAck) => void;
639
582
  pollIntervalMs?: number;
640
583
  safetyPollIntervalMs?: number;
584
+ platform?: NodeJS.Platform;
641
585
  fs?: ControlChannelFs;
642
586
  timers?: ControlChannelTimers;
643
587
  },
@@ -658,8 +602,6 @@ export function watchAsyncControlInbox(
658
602
  if (consumeStopRequest(asyncDir, fsImpl)) opts.onStop?.();
659
603
  if (consumeTimeoutRequest(asyncDir, fsImpl)) opts.onTimeout?.();
660
604
  if (consumeInterruptRequest(asyncDir, fsImpl)) opts.onInterrupt();
661
- const checkpointDecision = consumeCheckpointDecisionRequest(asyncDir, fsImpl);
662
- if (checkpointDecision) opts.onCheckpointDecision?.(checkpointDecision);
663
605
  for (const request of consumeSteerRequests(asyncDir, fsImpl)) opts.onSteer?.(request);
664
606
  for (const capability of consumeSteerCapabilities(asyncDir, fsImpl)) opts.onSteerCapability?.(capability);
665
607
  for (const ack of consumeSteerAcks(asyncDir, fsImpl)) opts.onSteerAck?.(ack);
@@ -707,12 +649,16 @@ export function watchAsyncControlInbox(
707
649
  for (const entry of entries) watchDir(path.join(ackRoot, entry));
708
650
  };
709
651
  try {
710
- watchDir(dir);
711
- watchDir(steerRequestsDir(asyncDir), true);
712
- watchDir(steerCapabilitiesDir(asyncDir), true);
713
- watchDir(path.join(dir, STEER_ACKS_DIR), true);
714
- watchExistingSteerAckDirs();
715
- startSafetyPolling();
652
+ if (shouldUseNativeFsWatch("runner-control-inbox", opts.platform)) {
653
+ watchDir(dir);
654
+ watchDir(steerRequestsDir(asyncDir), true);
655
+ watchDir(steerCapabilitiesDir(asyncDir), true);
656
+ watchDir(path.join(dir, STEER_ACKS_DIR), true);
657
+ watchExistingSteerAckDirs();
658
+ startSafetyPolling();
659
+ } else {
660
+ startPolling();
661
+ }
716
662
  } catch {
717
663
  startPolling();
718
664
  }
@@ -156,34 +156,83 @@ function stringifyJsonPreview(value: unknown, maxLength = 240): string {
156
156
  return raw.length > maxLength ? `${raw.slice(0, maxLength)}…` : raw;
157
157
  }
158
158
 
159
- function contentText(content: unknown): string {
160
- if (typeof content === "string") return content;
161
- if (!Array.isArray(content)) return "";
162
- return content.map((part) => {
163
- if (!part || typeof part !== "object") return "";
164
- const entry = part as { type?: unknown; text?: unknown; name?: unknown; toolName?: unknown; args?: unknown; result?: unknown; content?: unknown };
165
- if (typeof entry.text === "string") return entry.text;
159
+ /** One structured content part of a session JSONL record. Shared by the prose transcript formatter and inspect RPC. */
160
+ export interface SessionTranscriptMessage {
161
+ role: string;
162
+ kind: "text" | "toolCall" | "toolResult";
163
+ text: string;
164
+ name?: string;
165
+ isError?: boolean;
166
+ /** Ordinal of the session record this part came from, so consumers can
167
+ * regroup multi-part messages (one session record can yield several parts). */
168
+ recordIndex?: number;
169
+ }
170
+
171
+ function sessionMessageParts(record: unknown): SessionTranscriptMessage[] {
172
+ if (!record || typeof record !== "object") return [];
173
+ const outer = record as { message?: unknown; role?: unknown; content?: unknown; type?: unknown };
174
+ const message = outer.message && typeof outer.message === "object" ? outer.message as { role?: unknown; content?: unknown } : outer;
175
+ const role = typeof message.role === "string" ? message.role : undefined;
176
+ if (!role) return [];
177
+ const content = message.content;
178
+ if (typeof content === "string") return content.trim() ? [{ role, kind: "text", text: content }] : [];
179
+ if (!Array.isArray(content)) return [];
180
+ const parts: SessionTranscriptMessage[] = [];
181
+ for (const part of content) {
182
+ if (!part || typeof part !== "object") continue;
183
+ const entry = part as { type?: unknown; text?: unknown; name?: unknown; toolName?: unknown; args?: unknown; result?: unknown; content?: unknown; isError?: unknown };
184
+ if (typeof entry.text === "string") {
185
+ if (entry.text.trim()) parts.push({ role, kind: "text", text: entry.text });
186
+ continue;
187
+ }
166
188
  if (entry.type === "toolCall" || entry.type === "tool_call") {
167
189
  const name = typeof entry.name === "string" ? entry.name : typeof entry.toolName === "string" ? entry.toolName : "tool";
168
- return `[tool: ${name}${entry.args === undefined ? "" : ` ${stringifyJsonPreview(entry.args)}`}]`;
190
+ parts.push({ role, kind: "toolCall", name, text: `[tool: ${name}${entry.args === undefined ? "" : ` ${stringifyJsonPreview(entry.args)}`}]` });
191
+ continue;
169
192
  }
170
193
  if (entry.type === "toolResult" || entry.type === "tool_result") {
171
- return `[tool result${entry.result === undefined ? "" : `: ${stringifyJsonPreview(entry.result)}`}]`;
194
+ const name = typeof entry.name === "string" ? entry.name : typeof entry.toolName === "string" ? entry.toolName : undefined;
195
+ parts.push({ role, kind: "toolResult", ...(name ? { name } : {}), ...(entry.isError === true ? { isError: true } : {}), text: `[tool result${entry.result === undefined ? "" : `: ${stringifyJsonPreview(entry.result)}`}]` });
196
+ continue;
197
+ }
198
+ if (entry.content !== undefined) {
199
+ const preview = stringifyJsonPreview(entry.content);
200
+ if (preview.trim()) parts.push({ role, kind: "text", text: preview });
172
201
  }
173
- if (entry.content !== undefined) return stringifyJsonPreview(entry.content);
174
- return "";
175
- }).filter(Boolean).join("\n");
202
+ }
203
+ return parts;
176
204
  }
177
205
 
178
206
  function sessionMessageLine(record: unknown): string | undefined {
179
- if (!record || typeof record !== "object") return undefined;
180
- const outer = record as { message?: unknown; role?: unknown; content?: unknown; type?: unknown };
181
- const message = outer.message && typeof outer.message === "object" ? outer.message as { role?: unknown; content?: unknown } : outer;
182
- const role = typeof message.role === "string" ? message.role : undefined;
183
- if (!role) return undefined;
184
- const text = contentText(message.content).trim();
207
+ const parts = sessionMessageParts(record);
208
+ if (parts.length === 0) return undefined;
209
+ const text = parts.map((part) => part.text).join("\n").trim();
185
210
  if (!text) return undefined;
186
- return `${role}: ${text}`;
211
+ return `${parts[0]!.role}: ${text}`;
212
+ }
213
+
214
+ /** Structured session tail: parsed content parts, newest last, bounded by
215
+ * maxMessages. Same trusted-root containment as the prose transcript tail. */
216
+ export function readSessionMessagesTail(sessionFile: string, maxMessages: number, trustedRoots: string[]): { messages: SessionTranscriptMessage[]; warnings: string[]; truncated: boolean } {
217
+ const tail = readContainedTextTail(sessionFile, Math.max(maxMessages * 4, maxMessages), trustedRoots, "session");
218
+ const warnings: string[] = [];
219
+ if (tail.error) warnings.push(`Session read failed for ${sessionFile}: ${tail.error}`);
220
+ const parsedMessages: SessionTranscriptMessage[] = [];
221
+ let malformed = 0;
222
+ let recordIndex = 0;
223
+ for (const line of tail.lines) {
224
+ if (!line.trim()) continue;
225
+ try {
226
+ const parsed = JSON.parse(line) as unknown;
227
+ for (const part of sessionMessageParts(parsed)) parsedMessages.push({ ...part, recordIndex });
228
+ } catch {
229
+ malformed++;
230
+ }
231
+ recordIndex++;
232
+ }
233
+ if (malformed > 0) warnings.push(`Skipped ${malformed} malformed session tail line${malformed === 1 ? "" : "s"}.`);
234
+ const messages = parsedMessages.slice(-maxMessages);
235
+ return { messages, warnings, truncated: tail.truncated || parsedMessages.length > messages.length };
187
236
  }
188
237
 
189
238
  function readSessionTranscriptTail(sessionFile: string, maxLines: number, trustedRoots: string[]): { lines: string[]; warnings: string[] } {