pi-subagents 0.65.1 → 0.66.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 (85) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/agents/researcher.md +23 -13
  4. package/docs/agents.md +16 -2
  5. package/docs/configuration.md +18 -0
  6. package/docs/extension-api.md +91 -0
  7. package/docs/models.md +58 -1
  8. package/docs/observability.md +42 -2
  9. package/docs/tool-reference.md +3 -3
  10. package/docs/workflows.md +14 -7
  11. package/package.json +2 -1
  12. package/skills/pi-subagents/references/execution-controls.md +14 -1
  13. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
  14. package/src/agents/advertised-agent-prompt.ts +63 -0
  15. package/src/agents/agent-management.ts +14 -1
  16. package/src/agents/agent-serializer.ts +2 -0
  17. package/src/agents/agents.ts +8 -0
  18. package/src/api/shared-types.ts +1 -1
  19. package/src/api/workflow-resources.ts +6 -0
  20. package/src/extension/index.ts +40 -2
  21. package/src/extension/public-execution.ts +0 -1
  22. package/src/extension/rpc.ts +4 -21
  23. package/src/extension/schemas.ts +8 -6
  24. package/src/extension/tool-description.ts +6 -5
  25. package/src/intercom/native-supervisor-channel.ts +82 -54
  26. package/src/runs/background/active-async-capacity.ts +18 -18
  27. package/src/runs/background/async-job-tracker.ts +35 -3
  28. package/src/runs/background/async-status-snapshot.ts +10 -12
  29. package/src/runs/background/async-status.ts +17 -9
  30. package/src/runs/background/auto-drain.ts +40 -29
  31. package/src/runs/background/chain-root-attachment.ts +8 -0
  32. package/src/runs/background/control-channel.ts +78 -44
  33. package/src/runs/background/notify.ts +86 -12
  34. package/src/runs/background/owned-process-tree.ts +6 -6
  35. package/src/runs/background/process-terminal.ts +23 -23
  36. package/src/runs/background/run-child-session.ts +60 -32
  37. package/src/runs/background/run-status.ts +75 -5
  38. package/src/runs/background/runner-aliases.ts +18 -7
  39. package/src/runs/background/runner-child-launch.ts +86 -0
  40. package/src/runs/background/stale-run-reconciler.ts +3 -1
  41. package/src/runs/background/subagent-runner.ts +412 -209
  42. package/src/runs/background/subagent-wait.ts +3 -0
  43. package/src/runs/background/wait-completions.ts +4 -0
  44. package/src/runs/foreground/async-steering-action.ts +19 -0
  45. package/src/runs/foreground/execution.ts +101 -25
  46. package/src/runs/foreground/subagent-executor.ts +474 -197
  47. package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
  48. package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
  49. package/src/runs/shared/acceptance.ts +2 -2
  50. package/src/runs/shared/agent-contract.ts +1 -1
  51. package/src/runs/shared/async-status-projection.ts +47 -47
  52. package/src/runs/shared/child-hooks.ts +151 -2
  53. package/src/runs/shared/child-launch.ts +18 -13
  54. package/src/runs/shared/child-session.ts +42 -6
  55. package/src/runs/shared/child-tool-plan.ts +2 -2
  56. package/src/runs/shared/completion-evidence.ts +2 -2
  57. package/src/runs/shared/completion-guard.ts +1 -0
  58. package/src/runs/shared/host-step-status.ts +11 -11
  59. package/src/runs/shared/llm-intent-arbiter.ts +10 -9
  60. package/src/runs/shared/model-fallback.ts +10 -6
  61. package/src/runs/shared/nested-events.ts +5 -5
  62. package/src/runs/shared/orca-progress-tabs.ts +6 -0
  63. package/src/runs/shared/parallel-handoff.ts +57 -12
  64. package/src/runs/shared/parallel-utils.ts +2 -2
  65. package/src/runs/shared/readonly-drain-observation.ts +42 -0
  66. package/src/runs/shared/readonly-model-continuation.ts +69 -0
  67. package/src/runs/shared/readonly-session-evidence.ts +307 -0
  68. package/src/runs/shared/run-fanout-budget.ts +8 -8
  69. package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
  70. package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
  71. package/src/runs/shared/worktree-setup-command.ts +190 -0
  72. package/src/runs/shared/worktree.ts +332 -204
  73. package/src/shared/types.ts +81 -60
  74. package/src/shared/utils.ts +7 -2
  75. package/src/shared/workflow-child-permit.ts +18 -13
  76. package/src/tui/fleet.ts +11 -5
  77. package/src/tui/render.ts +23 -5
  78. package/src/workflows/chat-progress.ts +3 -3
  79. package/src/workflows/scripted-workflow.ts +38 -10
  80. package/src/workflows/workflow-checklist.ts +11 -15
  81. package/src/workflows/workflow-child-summary.ts +57 -8
  82. package/src/workflows/workflow-preflight.ts +19 -19
  83. package/src/workflows/workflow-receipt.ts +3 -3
  84. package/src/workflows/workflow-resources.ts +96 -21
  85. package/src/workflows/workflow-settlement.ts +3 -0
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "../../shared/formatters.ts";
4
4
  import { previewDisplayText } from "../../shared/display-text.ts";
5
5
  import { formatActivityLabel, formatParallelOutcome } from "../../shared/status-format.ts";
6
- import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type CostSummary, type Details, type HostStepNodeV1, type HostStepState, type LaunchResolvedChildExtensionsV1, type RuntimeAcknowledgedChildExtensionsV1, type NestedRunSummary, type SteeringStatus, type SubagentRunMode, type TimeoutRecoveryProjection, type TokenUsage, type TurnBudgetState, type UsageBudgetState, type WorktreeNaming, type WorkflowPreflightV1, type WorkflowGraphSnapshot } from "../../shared/types.ts";
6
+ import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type CostSummary, type Details, type HostStepNode, type HostStepState, type LaunchResolvedChildExtensions, type RuntimeAcknowledgedChildExtensions, type NestedRunSummary, type SteeringStatus, type SubagentRunMode, type TimeoutRecoveryProjection, type TokenUsage, type TurnBudgetState, type UsageBudgetState, type WorktreeNaming, type WorkflowPreflight, type WorkflowGraphSnapshot } from "../../shared/types.ts";
7
7
  import type { ResolvedSubagentCapabilityCeiling, SubagentCapabilityAudit } from "../shared/capability-ceiling.ts";
8
8
  import { readStatus } from "../../shared/utils.ts";
9
9
  import { attachRootChildrenToSteps, buildNestedRouteIndex, findNestedRouteForRootId, type NestedRoute, projectNestedEvents } from "../shared/nested-events.ts";
@@ -25,6 +25,7 @@ import { formatWorkflowPreflightPlanSummary, formatWorkflowPreflightWarningSumma
25
25
  import { workflowGraphStageNodes } from "../shared/workflow-graph.ts";
26
26
  import { formatTimeoutRecoveryLines, projectTimeoutRecovery } from "../shared/mutation-evidence.ts";
27
27
  import { formatWorkflowChecklistText, projectWorkflowChecklist } from "../../workflows/workflow-checklist.ts";
28
+ import type { RawDrainStatusObserver } from "../shared/readonly-drain-observation.ts";
28
29
 
29
30
  interface AsyncRunStepSummary {
30
31
  index: number;
@@ -82,8 +83,8 @@ interface AsyncRunStepSummary {
82
83
  effects?: AsyncJobStep["effects"];
83
84
  processTerminal?: AsyncJobStep["processTerminal"];
84
85
  timeoutRecovery?: TimeoutRecoveryProjection;
85
- launchResolvedExtensions?: LaunchResolvedChildExtensionsV1;
86
- runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensionsV1;
86
+ launchResolvedExtensions?: LaunchResolvedChildExtensions;
87
+ runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensions;
87
88
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
88
89
  capabilityAudit?: SubagentCapabilityAudit;
89
90
  children?: NestedRunSummary[];
@@ -122,7 +123,7 @@ export interface AsyncRunSummary {
122
123
  chainStepCount?: number;
123
124
  pendingAppends?: number;
124
125
  parallelGroups?: AsyncParallelGroupStatus[];
125
- hostSteps?: HostStepNodeV1[];
126
+ hostSteps?: HostStepNode[];
126
127
  workflowGraph?: AsyncStatus["workflowGraph"];
127
128
  steps: AsyncRunStepSummary[];
128
129
  sessionDir?: string;
@@ -135,8 +136,8 @@ export interface AsyncRunSummary {
135
136
  nestedWarnings?: string[];
136
137
  processTerminal?: AsyncStatus["processTerminal"];
137
138
  runFanoutBudget?: AsyncStatus["runFanoutBudget"];
138
- launchResolvedExtensions?: LaunchResolvedChildExtensionsV1;
139
- runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensionsV1;
139
+ launchResolvedExtensions?: LaunchResolvedChildExtensions;
140
+ runtimeAcknowledgedExtensions?: RuntimeAcknowledgedChildExtensions;
140
141
  capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
141
142
  capabilityAudit?: SubagentCapabilityAudit;
142
143
  parentWorkflowRunId?: string;
@@ -144,7 +145,7 @@ export interface AsyncRunSummary {
144
145
  lane?: AsyncStatus["lane"];
145
146
  workflow?: Details["workflow"];
146
147
  workflowChildren?: Details["workflowChildren"];
147
- preflight?: WorkflowPreflightV1;
148
+ preflight?: WorkflowPreflight;
148
149
  }
149
150
 
150
151
  interface AsyncRunListOptions {
@@ -481,7 +482,7 @@ function sortRuns(runs: AsyncRunSummary[]): AsyncRunSummary[] {
481
482
  });
482
483
  }
483
484
 
484
- export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions = {}): AsyncRunSummary[] {
485
+ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions = {}, observeStatus?: RawDrainStatusObserver): AsyncRunSummary[] {
485
486
  let entries: string[];
486
487
  const activeEntries = new Set<string>();
487
488
  const wantsActive = options.states === undefined || options.states.some(isActiveAsyncState);
@@ -508,6 +509,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
508
509
  indexed.add(entry);
509
510
  activeEntries.add(entry);
510
511
  } else {
512
+ observeStatus?.(null);
511
513
  updateActiveRunIndex(path.join(asyncDirRoot, entry), "failed");
512
514
  }
513
515
  }
@@ -519,6 +521,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
519
521
  }
520
522
  } catch (error) {
521
523
  if (isNotFoundError(error)) return [];
524
+ observeStatus?.(null);
522
525
  throw new Error(`Failed to list async runs in '${asyncDirRoot}': ${getErrorMessage(error)}`, {
523
526
  cause: error instanceof Error ? error : undefined,
524
527
  });
@@ -544,14 +547,17 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
544
547
  try {
545
548
  const reconciliation = options.reconcile === false
546
549
  ? undefined
547
- : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
550
+ : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now }, observeStatus);
548
551
  status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
552
+ if (options.reconcile === false) observeStatus?.(status);
549
553
  } catch (error) {
554
+ observeStatus?.(null);
550
555
  if (!activeEntries.has(entry) || !isAsyncStatusIsolationError(asyncDir, error)) throw error;
551
556
  isolateCorruptActiveRun(asyncDir, entry, error, options.now);
552
557
  continue;
553
558
  }
554
559
  if (!status) {
560
+ observeStatus?.(null);
555
561
  if (activeEntries.has(entry)) updateActiveRunIndex(asyncDir, "failed");
556
562
  continue;
557
563
  }
@@ -573,6 +579,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
573
579
  nestedRoute = resolveNestedRoute(status.runId || path.basename(asyncDir));
574
580
  if (nestedRoute) reconcileNestedAsyncDescendants(nestedRoute, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
575
581
  } catch (error) {
582
+ observeStatus?.(null);
576
583
  nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
577
584
  }
578
585
  }
@@ -580,6 +587,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
580
587
  try {
581
588
  summary = statusToSummary(asyncDir, status, nestedWarnings, nestedRoute);
582
589
  } catch (error) {
590
+ observeStatus?.(null);
583
591
  if (!activeEntries.has(entry) || !isAsyncStatusIsolationError(asyncDir, error)) throw error;
584
592
  isolateCorruptActiveRun(asyncDir, entry, error, options.now);
585
593
  continue;
@@ -2,6 +2,7 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
2
  import { snapshotBackgroundWork } from "../../api/background-work.ts";
3
3
  import { DIRS, type Details, type SubagentState } from "../../shared/types.ts";
4
4
  import { listAsyncRuns } from "./async-status.ts";
5
+ import type { ReadonlyDrainObservation } from "../shared/readonly-drain-observation.ts";
5
6
  import { waitForSubagents, type SubagentWaitDeps, type SubagentWaitParams, type WaitEventBus } from "./subagent-wait.ts";
6
7
 
7
8
  export const DEFAULT_AUTO_DRAIN_TIMEOUT_MS = 30 * 60 * 1000;
@@ -23,46 +24,56 @@ function resultText(value: AgentToolResult<Details>): string {
23
24
  return value.content.map((part) => part.type === "text" ? part.text : "").join(" ").trim();
24
25
  }
25
26
 
26
- function hasOutstandingWork(sessionId: string, nowMs: number): boolean {
27
+ function hasOutstandingWork(sessionId: string, nowMs: number, observation?: ReadonlyDrainObservation): boolean {
27
28
  const asyncRuns = listAsyncRuns(DIRS.async, {
28
29
  states: ["queued", "running"],
29
30
  sessionId,
30
31
  resultsDir: DIRS.results,
31
32
  now: () => nowMs,
32
- });
33
+ }, observation?.status);
33
34
  return asyncRuns.length > 0 || snapshotBackgroundWork(sessionId, nowMs).items.length > 0;
34
35
  }
35
36
 
36
37
  /** Drain all work owned by the current headless session, including work added while draining. */
37
- export async function drainOutstandingWork(deps: AutoDrainDeps): Promise<void> {
38
+ export async function drainOutstandingWork(deps: AutoDrainDeps, observation?: ReadonlyDrainObservation): Promise<void> {
38
39
  const sessionId = deps.state.currentSessionId;
39
- if (!sessionId) throw new Error("Cannot auto-drain background work without an active session identity.");
40
- const now = deps.now ?? Date.now;
41
- const timeoutMs = deps.timeoutMs ?? DEFAULT_AUTO_DRAIN_TIMEOUT_MS;
42
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Auto-drain timeoutMs must be a positive finite number.");
43
- const deadlineAt = now() + timeoutMs;
44
- const hasWork = deps.hasWork ?? hasOutstandingWork;
45
- const wait = deps.wait ?? waitForSubagents;
40
+ observation?.begin(sessionId, !deps.hasWork && !deps.wait && !deps.now);
41
+ try {
42
+ if (!sessionId) throw new Error("Cannot auto-drain background work without an active session identity.");
43
+ const now = deps.now ?? Date.now;
44
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_AUTO_DRAIN_TIMEOUT_MS;
45
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Auto-drain timeoutMs must be a positive finite number.");
46
+ const deadlineAt = now() + timeoutMs;
47
+ const hasWork = deps.hasWork ?? (observation ? (id: string, time: number) => hasOutstandingWork(id, time, observation) : hasOutstandingWork);
48
+ const wait = deps.wait ?? waitForSubagents;
46
49
 
47
- while (hasWork(sessionId, now())) {
48
- const remainingMs = deadlineAt - now();
49
- if (remainingMs <= 0) {
50
- throw new Error(`Auto-drain timed out after ${timeoutMs}ms with background work still active in session '${sessionId}'.`);
51
- }
52
- const waitResult = await wait(
53
- { all: true, timeoutMs: remainingMs },
54
- undefined,
55
- {
56
- state: deps.state,
57
- events: deps.events,
58
- now,
59
- stopOnAttention: false,
60
- failOnFailedRuns: true,
61
- failOnAttention: true,
62
- },
63
- );
64
- if (waitResult.isError) {
65
- throw new Error(`Auto-drain failed for session '${sessionId}': ${resultText(waitResult) || "bg_wait returned an error without details"}.`);
50
+ while (true) {
51
+ const work = hasWork(sessionId, now());
52
+ observation?.predicate(work);
53
+ if (!work) break;
54
+ const remainingMs = deadlineAt - now();
55
+ if (remainingMs <= 0) {
56
+ throw new Error(`Auto-drain timed out after ${timeoutMs}ms with background work still active in session '${sessionId}'.`);
57
+ }
58
+ const waitResult = await wait(
59
+ { all: true, timeoutMs: remainingMs },
60
+ undefined,
61
+ {
62
+ state: deps.state,
63
+ events: deps.events,
64
+ now,
65
+ stopOnAttention: false,
66
+ failOnFailedRuns: true,
67
+ failOnAttention: true,
68
+ },
69
+ );
70
+ if (waitResult.isError) {
71
+ throw new Error(`Auto-drain failed for session '${sessionId}': ${resultText(waitResult) || "bg_wait returned an error without details"}.`);
72
+ }
66
73
  }
74
+ observation?.complete();
75
+ } catch (error) {
76
+ observation?.deny();
77
+ throw error;
67
78
  }
68
79
  }
@@ -32,6 +32,7 @@ export interface ImportedAsyncRootResult {
32
32
  structuredOutputSchemaPath?: string;
33
33
  acceptance?: AcceptanceLedger;
34
34
  artifactPaths?: ArtifactPaths;
35
+ savedOutputPath?: string;
35
36
  outputSaveError?: string;
36
37
  transcriptPath?: string;
37
38
  transcriptError?: string;
@@ -71,6 +72,7 @@ interface AsyncResultFile {
71
72
  structuredOutputSchemaPath?: string;
72
73
  acceptance?: AcceptanceLedger;
73
74
  artifactPaths?: ArtifactPaths;
75
+ savedOutputPath?: string;
74
76
  outputSaveError?: string;
75
77
  transcriptPath?: string;
76
78
  transcriptError?: string;
@@ -121,6 +123,11 @@ function selectedStatusStep(status: AsyncStatus | null, index: number): NonNulla
121
123
 
122
124
  function isTerminalStatus(status: AsyncStatus | null, index: number): boolean {
123
125
  if (!status) return false;
126
+ // A workflow-owned single runner publishes its result after completing its
127
+ // step. Keep that window open while root process proof is absent or pending;
128
+ // explicit terminal/unavailable proof retains the existing step fallback.
129
+ if (status.mode === "single" && status.parentWorkflowRunId
130
+ && (!status.processTerminal || status.processTerminal.state === "pending")) return TERMINAL_STATES.has(status.state);
124
131
  const step = selectedStatusStep(status, index);
125
132
  if (step && TERMINAL_STEP_STATUSES.has(step.status)) return true;
126
133
  return TERMINAL_STATES.has(status.state);
@@ -229,6 +236,7 @@ function buildImportedResult(root: ImportedAsyncRoot, status: AsyncStatus | null
229
236
  ...(child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath ? { structuredOutputSchemaPath: child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath } : {}),
230
237
  ...(child?.acceptance ?? step?.acceptance ? { acceptance: child?.acceptance ?? step?.acceptance } : {}),
231
238
  ...(child?.artifactPaths ? { artifactPaths: child.artifactPaths } : {}),
239
+ ...(child?.savedOutputPath ? { savedOutputPath: child.savedOutputPath } : {}),
232
240
  ...(child?.outputSaveError ? { outputSaveError: child.outputSaveError } : {}),
233
241
  ...(child?.transcriptPath ?? step?.transcriptPath ? { transcriptPath: child?.transcriptPath ?? step?.transcriptPath } : {}),
234
242
  ...(child?.transcriptError ? { transcriptError: child.transcriptError } : {}),
@@ -275,28 +275,36 @@ function parseSteerRequest(raw: unknown): SteerRequest | undefined {
275
275
  };
276
276
  }
277
277
 
278
- export function consumeSteerRequestsFromDir(dir: string, fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs): SteerRequest[] {
279
- if (!fsImpl.existsSync(dir)) return [];
278
+ export function consumeSteerRequestsFromDir(dir: string, fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs, onError: (error: unknown) => void = () => {}): SteerRequest[] {
280
279
  let entries: string[];
281
280
  try {
282
281
  entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
283
- } catch {
282
+ } catch (error) {
284
283
  // Leave requests in place so the periodic poll can retry the scan.
284
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") onError(error);
285
285
  return [];
286
286
  }
287
287
  const requests: SteerRequest[] = [];
288
288
  for (const entry of entries) {
289
289
  const requestPath = path.join(dir, entry);
290
290
  let parsed: SteerRequest | undefined;
291
+ let text: string;
292
+ try {
293
+ text = fsImpl.readFileSync(requestPath, "utf-8");
294
+ } catch (error) {
295
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") onError(error);
296
+ continue;
297
+ }
291
298
  try {
292
- parsed = parseSteerRequest(JSON.parse(fsImpl.readFileSync(requestPath, "utf-8")));
299
+ parsed = parseSteerRequest(JSON.parse(text));
293
300
  } catch {
294
301
  parsed = undefined;
295
302
  }
296
303
  try {
297
304
  fsImpl.rmSync(requestPath, { recursive: true });
298
- } catch {
305
+ } catch (error) {
299
306
  // Already removed by a concurrent check — do not execute it twice.
307
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") onError(error);
300
308
  continue;
301
309
  }
302
310
  if (parsed) requests.push(parsed);
@@ -304,8 +312,8 @@ export function consumeSteerRequestsFromDir(dir: string, fsImpl: Pick<typeof fs,
304
312
  return requests.sort((left, right) => left.ts - right.ts || left.id.localeCompare(right.id));
305
313
  }
306
314
 
307
- export function consumeSteerRequests(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs): SteerRequest[] {
308
- return consumeSteerRequestsFromDir(steerRequestsDir(asyncDir), fsImpl);
315
+ export function consumeSteerRequests(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs, onError?: (error: unknown) => void): SteerRequest[] {
316
+ return consumeSteerRequestsFromDir(steerRequestsDir(asyncDir), fsImpl, onError);
309
317
  }
310
318
 
311
319
  export function queueRevivalBrief(asyncDir: string, request: SteerRequest): string {
@@ -387,17 +395,26 @@ function parseStopRequest(raw: unknown): StopRequest | undefined {
387
395
  function consumeStopRequestFile(
388
396
  requestPath: string,
389
397
  fsImpl: Pick<typeof fs, "rmSync" | "readFileSync">,
398
+ onError?: (error: unknown) => void,
390
399
  ): StopRequest | undefined {
400
+ let text: string;
401
+ try {
402
+ text = fsImpl.readFileSync(requestPath, "utf-8");
403
+ } catch (error) {
404
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) onError?.(error);
405
+ return undefined;
406
+ }
391
407
  let request: StopRequest | undefined;
392
408
  try {
393
- request = parseStopRequest(JSON.parse(fsImpl.readFileSync(requestPath, "utf-8")));
409
+ request = parseStopRequest(JSON.parse(text));
394
410
  } catch {
395
411
  request = undefined;
396
412
  }
397
413
  try {
398
414
  fsImpl.rmSync(requestPath, { force: true, recursive: true });
399
- } catch {
400
- // Already removed by a concurrent check — do not execute it twice.
415
+ } catch (error) {
416
+ // Execute only after successful removal.
417
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) onError?.(error);
401
418
  return undefined;
402
419
  }
403
420
  return request;
@@ -406,6 +423,7 @@ function consumeStopRequestFile(
406
423
  export function consumeStopRequestPayloads(
407
424
  asyncDir: string,
408
425
  fsImpl: Pick<typeof fs, "existsSync" | "rmSync" | "readdirSync" | "readFileSync"> = fs,
426
+ onError?: (error: unknown) => void,
409
427
  ): StopRequest[] {
410
428
  const dir = stopRequestsDir(asyncDir);
411
429
  const requests: StopRequest[] = [];
@@ -413,18 +431,19 @@ export function consumeStopRequestPayloads(
413
431
  let entries: string[];
414
432
  try {
415
433
  entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
416
- } catch {
434
+ } catch (error) {
435
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) onError?.(error);
417
436
  entries = [];
418
437
  }
419
438
  for (const entry of entries) {
420
- const request = consumeStopRequestFile(path.join(dir, entry), fsImpl);
439
+ const request = consumeStopRequestFile(path.join(dir, entry), fsImpl, onError);
421
440
  if (request) requests.push(request);
422
441
  }
423
442
  }
424
443
 
425
444
  const legacyPath = stopRequestPath(asyncDir);
426
445
  if (fsImpl.existsSync(legacyPath)) {
427
- const request = consumeStopRequestFile(legacyPath, fsImpl);
446
+ const request = consumeStopRequestFile(legacyPath, fsImpl, onError);
428
447
  if (request) requests.push(request);
429
448
  }
430
449
  return requests.sort((left, right) => (left.ts ?? 0) - (right.ts ?? 0));
@@ -470,19 +489,21 @@ export function deliverStopRequest(input: {
470
489
  requestAsyncStop(input.asyncDir, { ...(input.source ? { source: input.source } : {}), ...(input.targetIndex !== undefined ? { targetIndex: input.targetIndex } : {}), ...(input.childId ? { childId: input.childId } : {}) }, { now: input.now });
471
490
  }
472
491
 
492
+
473
493
  /**
474
- * Runner side: watch the control inbox and route interrupt requests into
475
- * `onInterrupt`. Uses `fs.watch` when available and starts interval polling
494
+ * Active owner: watch and consume only kinds with installed handlers.
495
+ * Uses `fs.watch` when available and starts interval polling
476
496
  * only when native watching is unavailable or fails. Fires once per distinct
477
497
  * request. Returns a disposer.
478
498
  */
479
499
  export function watchAsyncControlInbox(
480
500
  asyncDir: string,
481
501
  opts: {
482
- onInterrupt: () => void;
502
+ onInterrupt?: () => void;
483
503
  onTimeout?: () => void;
484
504
  onStop?: (request: StopRequest) => void;
485
505
  onSteer?: (request: SteerRequest) => void;
506
+ onError?: (error: unknown, phase: "install" | "scan" | "callback", request?: SteerRequest) => void;
486
507
  pollIntervalMs?: number;
487
508
  safetyPollIntervalMs?: number;
488
509
  platform?: NodeJS.Platform;
@@ -493,22 +514,44 @@ export function watchAsyncControlInbox(
493
514
  const fsImpl = opts.fs ?? fs;
494
515
  const timers = opts.timers ?? { setInterval, clearInterval };
495
516
  const dir = controlInboxDir(asyncDir);
517
+ const report = (error: unknown, phase: "install" | "scan" | "callback", request?: SteerRequest): void => {
518
+ try {
519
+ if (opts.onError) opts.onError(error, phase, request);
520
+ else console.error(`Control inbox ${phase} failed:`, error);
521
+ } catch (reportError) {
522
+ console.error("Control inbox error reporter failed:", reportError);
523
+ }
524
+ };
525
+ const dirs = [
526
+ ...(opts.onInterrupt || opts.onTimeout || opts.onStop ? [dir] : []),
527
+ ...(opts.onStop ? [stopRequestsDir(asyncDir)] : []),
528
+ ...(opts.onSteer ? [steerRequestsDir(asyncDir)] : []),
529
+ ];
530
+ if (dirs.length === 0) return () => {};
496
531
  try {
497
- fsImpl.mkdirSync(dir, { recursive: true });
498
- } catch {
499
- // Best effort — the poll/watch below tolerates a missing dir.
532
+ for (const target of dirs) fsImpl.mkdirSync(target, { recursive: true });
533
+ } catch (error) {
534
+ report(error, "install");
500
535
  }
501
536
 
502
537
  let disposed = false;
503
538
  const check = (): void => {
504
539
  if (disposed) return;
505
540
  try {
506
- for (const stopRequest of consumeStopRequestPayloads(asyncDir, fsImpl)) opts.onStop?.(stopRequest);
507
- if (consumeTimeoutRequest(asyncDir, fsImpl)) opts.onTimeout?.();
508
- if (consumeInterruptRequest(asyncDir, fsImpl)) opts.onInterrupt();
509
- for (const request of consumeSteerRequests(asyncDir, fsImpl)) opts.onSteer?.(request);
510
- } catch {
511
- // Never let inbox errors crash the runner.
541
+ if (opts.onStop) for (const request of consumeStopRequestPayloads(asyncDir, fsImpl, (error) => report(error, "scan"))) {
542
+ try { opts.onStop(request); } catch (error) { report(error, "callback"); }
543
+ }
544
+ if (opts.onTimeout && consumeTimeoutRequest(asyncDir, fsImpl)) {
545
+ try { opts.onTimeout(); } catch (error) { report(error, "callback"); }
546
+ }
547
+ if (opts.onInterrupt && consumeInterruptRequest(asyncDir, fsImpl)) {
548
+ try { opts.onInterrupt(); } catch (error) { report(error, "callback"); }
549
+ }
550
+ if (opts.onSteer) for (const request of consumeSteerRequestsFromDir(steerRequestsDir(asyncDir), fsImpl, (error) => report(error, "scan"))) {
551
+ try { opts.onSteer(request); } catch (error) { report(error, "callback", request); }
552
+ }
553
+ } catch (error) {
554
+ report(error, "scan");
512
555
  }
513
556
  };
514
557
 
@@ -516,40 +559,31 @@ export function watchAsyncControlInbox(
516
559
  check();
517
560
 
518
561
  const watchers: fs.FSWatcher[] = [];
519
- const watchedDirs = new Set<string>();
520
562
  let interval: ReturnType<typeof setInterval> | undefined;
521
563
  let safetyInterval: ReturnType<typeof setInterval> | undefined;
522
564
  const startPolling = (): void => {
523
565
  if (interval || disposed) return;
566
+ if (safetyInterval) { timers.clearInterval(safetyInterval); safetyInterval = undefined; }
524
567
  interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
525
568
  interval.unref?.();
526
569
  };
527
- const startSafetyPolling = (): void => {
528
- if (safetyInterval || disposed) return;
529
- safetyInterval = timers.setInterval(check, opts.safetyPollIntervalMs ?? CONTROL_SAFETY_POLL_INTERVAL_MS);
530
- safetyInterval.unref?.();
531
- };
532
- const watchDir = (target: string, create = false): void => {
533
- if (disposed || watchedDirs.has(target)) return;
534
- if (create) fsImpl.mkdirSync(target, { recursive: true });
535
- const watcher = fsImpl.watch(resolveWatchPath(target, fsImpl.realpathSync.native), () => check());
536
- watcher.on?.("error", startPolling);
537
- watchers.push(watcher);
538
- watchedDirs.add(target);
539
- };
540
570
  try {
541
571
  if (shouldUseNativeFsWatch("runner-control-inbox", opts.platform)) {
542
- watchDir(dir);
543
- watchDir(stopRequestsDir(asyncDir), true);
544
- watchDir(steerRequestsDir(asyncDir), true);
545
- startSafetyPolling();
572
+ for (const target of dirs) {
573
+ const watcher = fsImpl.watch(resolveWatchPath(target, fsImpl.realpathSync.native), check);
574
+ watcher.on?.("error", startPolling);
575
+ watchers.push(watcher);
576
+ }
577
+ if (!interval) {
578
+ safetyInterval = timers.setInterval(check, opts.safetyPollIntervalMs ?? CONTROL_SAFETY_POLL_INTERVAL_MS);
579
+ safetyInterval.unref?.();
580
+ }
546
581
  } else {
547
582
  startPolling();
548
583
  }
549
584
  } catch {
550
585
  startPolling();
551
586
  }
552
-
553
587
  return () => {
554
588
  if (disposed) return;
555
589
  disposed = true;