pi-subagents 0.31.0 → 0.32.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 (42) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +170 -8
  3. package/package.json +1 -1
  4. package/skills/pi-subagents/SKILL.md +6 -1
  5. package/src/agents/agent-management.ts +6 -1
  6. package/src/agents/agents.ts +55 -11
  7. package/src/extension/companion-suggestions.ts +359 -0
  8. package/src/extension/config.ts +27 -4
  9. package/src/extension/doctor.ts +2 -0
  10. package/src/extension/fanout-child.ts +1 -0
  11. package/src/extension/index.ts +69 -4
  12. package/src/extension/schemas.ts +2 -2
  13. package/src/intercom/intercom-bridge.ts +25 -1
  14. package/src/profiles/profiles.ts +637 -0
  15. package/src/runs/background/async-execution.ts +138 -33
  16. package/src/runs/background/async-job-tracker.ts +77 -1
  17. package/src/runs/background/async-resume.ts +11 -13
  18. package/src/runs/background/async-status.ts +41 -9
  19. package/src/runs/background/chain-root-attachment.ts +34 -4
  20. package/src/runs/background/control-channel.ts +227 -0
  21. package/src/runs/background/run-status.ts +1 -0
  22. package/src/runs/background/stale-run-reconciler.ts +28 -1
  23. package/src/runs/background/subagent-runner.ts +459 -113
  24. package/src/runs/foreground/chain-execution.ts +29 -7
  25. package/src/runs/foreground/execution.ts +24 -6
  26. package/src/runs/foreground/subagent-executor.ts +240 -44
  27. package/src/runs/shared/acceptance.ts +45 -22
  28. package/src/runs/shared/dynamic-fanout.ts +1 -1
  29. package/src/runs/shared/model-fallback.ts +4 -0
  30. package/src/runs/shared/nested-events.ts +58 -0
  31. package/src/runs/shared/parallel-utils.ts +49 -1
  32. package/src/runs/shared/pi-args.ts +5 -3
  33. package/src/runs/shared/pi-spawn.ts +52 -20
  34. package/src/runs/shared/single-output.ts +2 -0
  35. package/src/runs/shared/subagent-prompt-runtime.ts +3 -2
  36. package/src/runs/shared/worktree.ts +28 -5
  37. package/src/shared/artifacts.ts +15 -1
  38. package/src/shared/fork-context.ts +133 -22
  39. package/src/shared/types.ts +82 -3
  40. package/src/shared/utils.ts +99 -14
  41. package/src/slash/slash-commands.ts +726 -40
  42. package/src/tui/render.ts +16 -4
@@ -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 NestedRunSummary, type SubagentRunMode, type TokenUsage } from "../../shared/types.ts";
5
+ import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type CostSummary, type NestedRunSummary, type SubagentRunMode, type TokenUsage } from "../../shared/types.ts";
6
6
  import { readStatus } from "../../shared/utils.ts";
7
- import { attachRootChildrenToSteps, findNestedRouteForRootId, projectNestedRegistryForRoot } from "../shared/nested-events.ts";
7
+ import { attachRootChildrenToSteps, buildNestedRouteIndex, type NestedRoute, projectNestedEvents } from "../shared/nested-events.ts";
8
8
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
9
9
  import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
10
10
  import { reconcileAsyncRun, reconcileNestedAsyncDescendants } from "./stale-run-reconciler.ts";
@@ -29,11 +29,13 @@ interface AsyncRunStepSummary {
29
29
  toolCount?: number;
30
30
  durationMs?: number;
31
31
  tokens?: TokenUsage;
32
+ totalCost?: CostSummary;
32
33
  skills?: string[];
33
34
  model?: string;
34
35
  thinking?: string;
35
36
  attemptedModels?: string[];
36
37
  error?: string;
38
+ timedOut?: boolean;
37
39
  children?: NestedRunSummary[];
38
40
  }
39
41
 
@@ -42,6 +44,7 @@ export interface AsyncRunSummary {
42
44
  asyncDir: string;
43
45
  sessionId?: string;
44
46
  state: "queued" | "running" | "complete" | "failed" | "paused";
47
+ error?: string;
45
48
  activityState?: ActivityState;
46
49
  lastActivityAt?: number;
47
50
  currentTool?: string;
@@ -54,6 +57,9 @@ export interface AsyncRunSummary {
54
57
  startedAt: number;
55
58
  lastUpdate?: number;
56
59
  endedAt?: number;
60
+ timeoutMs?: number;
61
+ deadlineAt?: number;
62
+ timedOut?: boolean;
57
63
  currentStep?: number;
58
64
  chainStepCount?: number;
59
65
  pendingAppends?: number;
@@ -62,6 +68,7 @@ export interface AsyncRunSummary {
62
68
  sessionDir?: string;
63
69
  outputFile?: string;
64
70
  totalTokens?: TokenUsage;
71
+ totalCost?: CostSummary;
65
72
  sessionFile?: string;
66
73
  nestedChildren?: NestedRunSummary[];
67
74
  nestedWarnings?: string[];
@@ -122,7 +129,7 @@ function deriveAsyncActivityState(asyncDir: string, status: AsyncStatus): { acti
122
129
  };
123
130
  }
124
131
 
125
- function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = []): AsyncRunSummary {
132
+ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string }, nestedWarnings: string[] = [], nestedRoute?: NestedRoute): AsyncRunSummary {
126
133
  if (status.sessionId !== undefined && typeof status.sessionId !== "string") {
127
134
  throw new Error(`Invalid async status '${path.join(asyncDir, "status.json")}': sessionId must be a string.`);
128
135
  }
@@ -131,9 +138,11 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
131
138
  const chainStepCount = status.chainStepCount ?? steps.length;
132
139
  const parallelGroups = normalizeParallelGroups(status.parallelGroups, steps.length, chainStepCount);
133
140
  let nestedChildren: NestedRunSummary[] = [];
134
- if (nestedWarnings.length === 0) {
141
+ if (nestedWarnings.length === 0 && nestedRoute) {
135
142
  try {
136
- nestedChildren = projectNestedRegistryForRoot(status.runId || path.basename(asyncDir))?.children ?? [];
143
+ // The route is resolved by the caller via buildNestedRouteIndex, so this
144
+ // avoids a fresh scan of the nested-events directory per run.
145
+ nestedChildren = projectNestedEvents(nestedRoute)?.children ?? [];
137
146
  } catch (error) {
138
147
  nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
139
148
  }
@@ -161,11 +170,13 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
161
170
  ...(step.toolCount !== undefined ? { toolCount: step.toolCount } : {}),
162
171
  ...(step.durationMs !== undefined ? { durationMs: step.durationMs } : {}),
163
172
  ...(step.tokens ? { tokens: step.tokens } : {}),
173
+ ...(step.totalCost ? { totalCost: step.totalCost } : {}),
164
174
  ...(step.skills ? { skills: step.skills } : {}),
165
175
  ...(step.model ? { model: step.model } : {}),
166
176
  ...(step.thinking ? { thinking: step.thinking } : {}),
167
177
  ...(step.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
168
178
  ...(step.error ? { error: step.error } : {}),
179
+ ...(step.timedOut !== undefined ? { timedOut: step.timedOut } : {}),
169
180
  ...(step.children?.length ? { children: step.children } : {}),
170
181
  };
171
182
  });
@@ -175,6 +186,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
175
186
  asyncDir,
176
187
  ...(status.sessionId ? { sessionId: status.sessionId } : {}),
177
188
  state: status.state,
189
+ ...(status.error ? { error: status.error } : {}),
178
190
  activityState,
179
191
  lastActivityAt,
180
192
  currentTool: status.currentTool,
@@ -187,6 +199,9 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
187
199
  startedAt: status.startedAt,
188
200
  lastUpdate: status.lastUpdate,
189
201
  endedAt: status.endedAt,
202
+ ...(status.timeoutMs !== undefined ? { timeoutMs: status.timeoutMs } : {}),
203
+ ...(status.deadlineAt !== undefined ? { deadlineAt: status.deadlineAt } : {}),
204
+ ...(status.timedOut !== undefined ? { timedOut: status.timedOut } : {}),
190
205
  currentStep: status.currentStep,
191
206
  ...(status.chainStepCount !== undefined ? { chainStepCount: status.chainStepCount } : {}),
192
207
  ...(status.pendingAppends !== undefined ? { pendingAppends: status.pendingAppends } : {}),
@@ -197,6 +212,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
197
212
  ...(status.sessionDir ? { sessionDir: status.sessionDir } : {}),
198
213
  ...(status.outputFile ? { outputFile: status.outputFile } : {}),
199
214
  ...(status.totalTokens ? { totalTokens: status.totalTokens } : {}),
215
+ ...(status.totalCost ? { totalCost: status.totalCost } : {}),
200
216
  ...(status.sessionFile ? { sessionFile: status.sessionFile } : {}),
201
217
  };
202
218
  }
@@ -233,6 +249,16 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
233
249
 
234
250
  const allowedStates = options.states ? new Set(options.states) : undefined;
235
251
  const runs: AsyncRunSummary[] = [];
252
+ // Route resolution for every run shares a single index built from the
253
+ // nested-events directory, so the per-run lookup is O(1) instead of scanning
254
+ // the directory once per run. The index is built lazily on first use, so
255
+ // load-time restoration (which only wants queued/running runs) skips it
256
+ // entirely when no active runs match.
257
+ let nestedRouteIndex: Map<string, NestedRoute> | undefined;
258
+ const resolveNestedRoute = (rootRunId: string): NestedRoute | undefined => {
259
+ if (!nestedRouteIndex) nestedRouteIndex = buildNestedRouteIndex();
260
+ return nestedRouteIndex.get(rootRunId);
261
+ };
236
262
  for (const entry of entries) {
237
263
  const asyncDir = path.join(asyncDirRoot, entry);
238
264
  const reconciliation = options.reconcile === false
@@ -240,16 +266,21 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
240
266
  : reconcileAsyncRun(asyncDir, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
241
267
  const status = (reconciliation?.status ?? readStatus(asyncDir)) as (AsyncStatus & { cwd?: string }) | null;
242
268
  if (!status) continue;
269
+ // Filter before the nested-route lookup: the lookup builds an index over
270
+ // the nested-events directory, so deferring it for filtered-out runs keeps
271
+ // restoration at load from scanning that directory when no active runs
272
+ // match.
273
+ if (allowedStates && !allowedStates.has(status.state)) continue;
274
+ if (options.sessionId && status.sessionId !== options.sessionId) continue;
243
275
  const nestedWarnings: string[] = [];
276
+ let nestedRoute: NestedRoute | undefined;
244
277
  try {
245
- const nestedRoute = findNestedRouteForRootId(status.runId || path.basename(asyncDir));
278
+ nestedRoute = resolveNestedRoute(status.runId || path.basename(asyncDir));
246
279
  if (nestedRoute) reconcileNestedAsyncDescendants(nestedRoute, { resultsDir: options.resultsDir, kill: options.kill, now: options.now });
247
280
  } catch (error) {
248
281
  nestedWarnings.push(`Nested status unavailable: ${getErrorMessage(error)}`);
249
282
  }
250
- const summary = statusToSummary(asyncDir, status, nestedWarnings);
251
- if (allowedStates && !allowedStates.has(summary.state)) continue;
252
- if (options.sessionId && summary.sessionId !== options.sessionId) continue;
283
+ const summary = statusToSummary(asyncDir, status, nestedWarnings, nestedRoute);
253
284
  runs.push(summary);
254
285
  }
255
286
 
@@ -328,6 +359,7 @@ export function formatAsyncRunList(runs: AsyncRunSummary[], heading = "Active as
328
359
  const attached = new Set(run.steps.flatMap((step) => step.children?.map((child) => child.id) ?? []));
329
360
  const unattached = run.nestedChildren?.filter((child) => !attached.has(child.id)) ?? [];
330
361
  lines.push(...formatNestedRunStatusLines(unattached, { indent: " ", maxLines: 12 }));
362
+ if (run.error) lines.push(` Error: ${run.error}`);
331
363
  for (const warning of run.nestedWarnings ?? []) lines.push(` Warning: ${warning}`);
332
364
  const outputPath = formatAsyncRunOutputPath(run);
333
365
  if (outputPath) lines.push(` output: ${shortenPath(outputPath)}`);
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import type { AcceptanceLedger, AsyncStatus, ModelAttempt } from "../../shared/types.ts";
3
+ import type { AcceptanceLedger, AsyncStatus, CostSummary, ModelAttempt } from "../../shared/types.ts";
4
4
  import { readStatus } from "../../shared/utils.ts";
5
5
 
6
6
  export interface ImportedAsyncRoot {
@@ -21,26 +21,32 @@ export interface ImportedAsyncRootResult {
21
21
  model?: string;
22
22
  attemptedModels?: string[];
23
23
  modelAttempts?: ModelAttempt[];
24
+ totalCost?: CostSummary;
24
25
  structuredOutput?: unknown;
25
26
  structuredOutputPath?: string;
26
27
  structuredOutputSchemaPath?: string;
27
28
  acceptance?: AcceptanceLedger;
29
+ timedOut?: boolean;
28
30
  }
29
31
 
30
32
  interface AsyncResultFile {
31
33
  state?: string;
32
34
  success?: boolean;
33
35
  summary?: string;
36
+ error?: string;
37
+ timedOut?: boolean;
34
38
  results?: Array<{
35
39
  agent?: string;
36
40
  output?: string;
37
41
  error?: string;
38
42
  success?: boolean;
43
+ timedOut?: boolean;
39
44
  sessionFile?: string;
40
45
  intercomTarget?: string;
41
46
  model?: string;
42
47
  attemptedModels?: string[];
43
48
  modelAttempts?: ModelAttempt[];
49
+ totalCost?: CostSummary;
44
50
  structuredOutput?: unknown;
45
51
  structuredOutputPath?: string;
46
52
  structuredOutputSchemaPath?: string;
@@ -85,6 +91,7 @@ function resultState(result: AsyncResultFile | undefined, child: NonNullable<Asy
85
91
 
86
92
  function outputFromTerminalStatus(root: ImportedAsyncRoot, status: AsyncStatus, step: NonNullable<AsyncStatus["steps"]>[number] | undefined): ImportedAsyncRootResult {
87
93
  const agent = step?.agent ?? status.steps?.[root.index]?.agent ?? "subagent";
94
+ const timedOut = step?.timedOut === true || status.timedOut === true;
88
95
  const message = step?.error ?? status.error ?? `Attached async root ${root.runId} ended without a result file at ${root.resultPath}.`;
89
96
  return {
90
97
  agent,
@@ -92,10 +99,12 @@ function outputFromTerminalStatus(root: ImportedAsyncRoot, status: AsyncStatus,
92
99
  success: false,
93
100
  exitCode: 1,
94
101
  error: message,
102
+ ...(timedOut ? { timedOut: true } : {}),
95
103
  ...(step?.sessionFile ?? status.sessionFile ? { sessionFile: step?.sessionFile ?? status.sessionFile } : {}),
96
104
  ...(step?.model ? { model: step.model } : {}),
97
105
  ...(step?.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
98
106
  ...(step?.modelAttempts ? { modelAttempts: step.modelAttempts } : {}),
107
+ ...(step?.totalCost ? { totalCost: step.totalCost } : {}),
99
108
  ...(step?.structuredOutput !== undefined ? { structuredOutput: step.structuredOutput } : {}),
100
109
  ...(step?.structuredOutputPath ? { structuredOutputPath: step.structuredOutputPath } : {}),
101
110
  ...(step?.structuredOutputSchemaPath ? { structuredOutputSchemaPath: step.structuredOutputSchemaPath } : {}),
@@ -103,25 +112,45 @@ function outputFromTerminalStatus(root: ImportedAsyncRoot, status: AsyncStatus,
103
112
  };
104
113
  }
105
114
 
115
+ function outputFromTimeout(root: ImportedAsyncRoot, status: AsyncStatus | null, message: string): ImportedAsyncRootResult {
116
+ const step = selectedStatusStep(status, root.index);
117
+ return {
118
+ agent: step?.agent ?? status?.steps?.[root.index]?.agent ?? "subagent",
119
+ output: message,
120
+ success: false,
121
+ exitCode: 1,
122
+ error: message,
123
+ timedOut: true,
124
+ ...(step?.sessionFile ?? status?.sessionFile ? { sessionFile: step?.sessionFile ?? status?.sessionFile } : {}),
125
+ ...(step?.model ? { model: step.model } : {}),
126
+ ...(step?.attemptedModels ? { attemptedModels: step.attemptedModels } : {}),
127
+ ...(step?.modelAttempts ? { modelAttempts: step.modelAttempts } : {}),
128
+ ...(step?.totalCost ? { totalCost: step.totalCost } : {}),
129
+ };
130
+ }
131
+
106
132
  function buildImportedResult(root: ImportedAsyncRoot, status: AsyncStatus | null, result: AsyncResultFile): ImportedAsyncRootResult {
107
133
  const child = result.results?.[root.index];
108
134
  const step = selectedStatusStep(status, root.index);
109
135
  const state = resultState(result, child);
110
136
  const agent = child?.agent ?? step?.agent ?? status?.steps?.[root.index]?.agent ?? "subagent";
111
137
  const output = child?.output ?? result.summary ?? "";
112
- const success = state === "complete";
113
- const error = child?.error ?? (success ? undefined : result.summary ?? status?.error ?? `Attached async root ${root.runId} did not complete successfully.`);
138
+ const timedOut = child?.timedOut === true || step?.timedOut === true || result.timedOut === true || status?.timedOut === true;
139
+ const success = state === "complete" && !timedOut;
140
+ const error = child?.error ?? (success ? undefined : result.error ?? result.summary ?? status?.error ?? `Attached async root ${root.runId} did not complete successfully.`);
114
141
  return {
115
142
  agent,
116
143
  output: success ? output : (output || error || ""),
117
144
  success,
118
145
  exitCode: success ? 0 : 1,
119
146
  ...(error ? { error } : {}),
147
+ ...(timedOut ? { timedOut: true } : {}),
120
148
  ...(child?.sessionFile ?? step?.sessionFile ?? status?.sessionFile ? { sessionFile: child?.sessionFile ?? step?.sessionFile ?? status?.sessionFile } : {}),
121
149
  ...(child?.intercomTarget ? { intercomTarget: child.intercomTarget } : {}),
122
150
  ...(child?.model ?? step?.model ? { model: child?.model ?? step?.model } : {}),
123
151
  ...(child?.attemptedModels ?? step?.attemptedModels ? { attemptedModels: child?.attemptedModels ?? step?.attemptedModels } : {}),
124
152
  ...(child?.modelAttempts ?? step?.modelAttempts ? { modelAttempts: child?.modelAttempts ?? step?.modelAttempts } : {}),
153
+ ...(child?.totalCost ?? step?.totalCost ? { totalCost: child?.totalCost ?? step?.totalCost } : {}),
125
154
  ...(child?.structuredOutput !== undefined ? { structuredOutput: child.structuredOutput } : step?.structuredOutput !== undefined ? { structuredOutput: step.structuredOutput } : {}),
126
155
  ...(child?.structuredOutputPath ?? step?.structuredOutputPath ? { structuredOutputPath: child?.structuredOutputPath ?? step?.structuredOutputPath } : {}),
127
156
  ...(child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath ? { structuredOutputSchemaPath: child?.structuredOutputSchemaPath ?? step?.structuredOutputSchemaPath } : {}),
@@ -131,7 +160,7 @@ function buildImportedResult(root: ImportedAsyncRoot, status: AsyncStatus | null
131
160
 
132
161
  export async function waitForImportedAsyncRoot(
133
162
  root: ImportedAsyncRoot,
134
- options: { pollIntervalMs?: number; terminalResultGraceMs?: number; now?: () => number } = {},
163
+ options: { pollIntervalMs?: number; terminalResultGraceMs?: number; now?: () => number; shouldAbort?: () => boolean; timeoutMessage?: string } = {},
135
164
  ): Promise<ImportedAsyncRootResult> {
136
165
  const pollIntervalMs = options.pollIntervalMs ?? 500;
137
166
  const terminalResultGraceMs = options.terminalResultGraceMs ?? 1_000;
@@ -139,6 +168,7 @@ export async function waitForImportedAsyncRoot(
139
168
  let terminalSince: number | undefined;
140
169
  for (;;) {
141
170
  const status = readStatus(root.asyncDir);
171
+ if (options.shouldAbort?.()) return outputFromTimeout(root, status, options.timeoutMessage ?? "Subagent timed out.");
142
172
  const result = readResultFile(root.resultPath);
143
173
  if (result) return buildImportedResult(root, status, result);
144
174
  if (isTerminalStatus(status, root.index)) {
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Cross-OS control channel for async subagent runs.
3
+ *
4
+ * Background runs are detached OS processes. The original control path delivered
5
+ * an interrupt with `process.kill(pid, SIGUSR2|SIGBREAK)`, but Windows cannot
6
+ * deliver those signals cross-process via `process.kill` and throws `ENOSYS`,
7
+ * which left async runs uninterruptible (no stop, no live steer) on Windows.
8
+ *
9
+ * This module adds a portable, file-based control inbox inside the run directory.
10
+ * The parent drops an interrupt request file; the runner watches the inbox and
11
+ * routes the request into its existing graceful `interruptRunner()` (pause +
12
+ * resumable), identically on every platform. The OS signal is kept only as an
13
+ * opportunistic fast-path; its failure is non-fatal because the file inbox is
14
+ * authoritative.
15
+ */
16
+
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import { writeAtomicJson } from "../../shared/atomic-json.ts";
20
+ import { POLL_INTERVAL_MS } from "../../shared/types.ts";
21
+
22
+ /**
23
+ * Opportunistic fast-path interrupt signal. On Unix `SIGUSR2` is trapped by the
24
+ * runner; on Windows `process.kill(pid, "SIGBREAK")` is not deliverable
25
+ * cross-process and throws `ENOSYS`, so the file inbox below is the real channel.
26
+ */
27
+ export const INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
28
+
29
+ export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch">;
30
+ export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
31
+ type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
32
+
33
+ export interface InterruptRequest {
34
+ type: "interrupt";
35
+ ts?: number;
36
+ source?: string;
37
+ reason?: string;
38
+ }
39
+
40
+ export interface TimeoutRequest {
41
+ type: "timeout";
42
+ ts?: number;
43
+ source?: string;
44
+ reason?: string;
45
+ }
46
+
47
+ /** Control inbox directory inside an async run dir. */
48
+ export function controlInboxDir(asyncDir: string): string {
49
+ return path.join(asyncDir, "control");
50
+ }
51
+
52
+ /** Path of the portable interrupt request file. */
53
+ export function interruptRequestPath(asyncDir: string): string {
54
+ return path.join(controlInboxDir(asyncDir), "interrupt.json");
55
+ }
56
+
57
+ /** Path of the portable timeout request file. */
58
+ export function timeoutRequestPath(asyncDir: string): string {
59
+ return path.join(controlInboxDir(asyncDir), "timeout.json");
60
+ }
61
+
62
+ /**
63
+ * Parent side: drop a portable interrupt request the runner's inbox watcher will
64
+ * pick up regardless of OS. Written atomically (temp + rename), dir auto-created.
65
+ */
66
+ export function requestAsyncInterrupt(
67
+ asyncDir: string,
68
+ payload: Omit<InterruptRequest, "type"> = {},
69
+ deps: { now?: () => number } = {},
70
+ ): string {
71
+ const requestPath = interruptRequestPath(asyncDir);
72
+ const request: InterruptRequest = { ...payload, ts: payload.ts ?? deps.now?.() ?? Date.now(), type: "interrupt" };
73
+ writeAtomicJson(requestPath, request);
74
+ return requestPath;
75
+ }
76
+
77
+ export function requestAsyncTimeout(
78
+ asyncDir: string,
79
+ payload: Omit<TimeoutRequest, "type"> = {},
80
+ deps: { now?: () => number } = {},
81
+ ): string {
82
+ const requestPath = timeoutRequestPath(asyncDir);
83
+ const request: TimeoutRequest = { ...payload, ts: payload.ts ?? deps.now?.() ?? Date.now(), type: "timeout" };
84
+ writeAtomicJson(requestPath, request);
85
+ return requestPath;
86
+ }
87
+
88
+ /**
89
+ * Runner side: consume a pending interrupt request. Idempotent — removes the file
90
+ * so each distinct request fires exactly once. Returns whether one was pending.
91
+ */
92
+ export function consumeInterruptRequest(
93
+ asyncDir: string,
94
+ fsImpl: Pick<typeof fs, "existsSync" | "rmSync"> = fs,
95
+ ): boolean {
96
+ const requestPath = interruptRequestPath(asyncDir);
97
+ if (!fsImpl.existsSync(requestPath)) return false;
98
+ try {
99
+ fsImpl.rmSync(requestPath, { force: true, recursive: true });
100
+ } catch {
101
+ // Already removed by a concurrent check — still counts as consumed.
102
+ }
103
+ return true;
104
+ }
105
+
106
+ export function consumeTimeoutRequest(
107
+ asyncDir: string,
108
+ fsImpl: Pick<typeof fs, "existsSync" | "rmSync"> = fs,
109
+ ): boolean {
110
+ const requestPath = timeoutRequestPath(asyncDir);
111
+ if (!fsImpl.existsSync(requestPath)) return false;
112
+ try {
113
+ fsImpl.rmSync(requestPath, { force: true, recursive: true });
114
+ } catch {
115
+ // Already removed by a concurrent check — still counts as consumed.
116
+ }
117
+ return true;
118
+ }
119
+
120
+ /**
121
+ * Parent side: portable interrupt = authoritative file request + best-effort OS
122
+ * signal. The signal is only a latency optimization on Unix; ENOSYS on Windows
123
+ * is swallowed because the file inbox is authoritative there. Other signal
124
+ * failures are surfaced because they usually mean the runner is not alive to
125
+ * consume the request.
126
+ */
127
+ export function deliverInterruptRequest(input: {
128
+ asyncDir: string;
129
+ pid?: number;
130
+ kill?: KillFn;
131
+ signal?: NodeJS.Signals;
132
+ now?: () => number;
133
+ source?: string;
134
+ }): void {
135
+ const requestPath = requestAsyncInterrupt(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
136
+ if (typeof input.pid === "number" && input.pid > 0) {
137
+ try {
138
+ (input.kill ?? process.kill)(input.pid, input.signal ?? INTERRUPT_SIGNAL);
139
+ } catch (error) {
140
+ if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOSYS") {
141
+ // File inbox is authoritative when custom cross-process signals are unavailable.
142
+ return;
143
+ }
144
+ try {
145
+ fs.rmSync(requestPath, { force: true });
146
+ } catch {
147
+ // Best effort cleanup; the caller still gets the signal failure.
148
+ }
149
+ throw error;
150
+ }
151
+ }
152
+ }
153
+
154
+ export function deliverTimeoutRequest(input: {
155
+ asyncDir: string;
156
+ pid?: number;
157
+ kill?: KillFn;
158
+ signal?: NodeJS.Signals;
159
+ now?: () => number;
160
+ source?: string;
161
+ }): void {
162
+ requestAsyncTimeout(input.asyncDir, input.source ? { source: input.source } : {}, { now: input.now });
163
+ }
164
+
165
+ /**
166
+ * Runner side: watch the control inbox and route interrupt requests into
167
+ * `onInterrupt`. Uses `fs.watch` when available plus an interval poll as a
168
+ * portable safety net (covers filesystems/platforms where `fs.watch` is
169
+ * unreliable). Fires once per distinct request. Returns a disposer.
170
+ */
171
+ export function watchAsyncControlInbox(
172
+ asyncDir: string,
173
+ opts: {
174
+ onInterrupt: () => void;
175
+ onTimeout?: () => void;
176
+ pollIntervalMs?: number;
177
+ fs?: ControlChannelFs;
178
+ timers?: ControlChannelTimers;
179
+ },
180
+ ): () => void {
181
+ const fsImpl = opts.fs ?? fs;
182
+ const timers = opts.timers ?? { setInterval, clearInterval };
183
+ const dir = controlInboxDir(asyncDir);
184
+ try {
185
+ fsImpl.mkdirSync(dir, { recursive: true });
186
+ } catch {
187
+ // Best effort — the poll/watch below tolerates a missing dir.
188
+ }
189
+
190
+ let disposed = false;
191
+ const check = (): void => {
192
+ if (disposed) return;
193
+ try {
194
+ if (consumeTimeoutRequest(asyncDir, fsImpl)) opts.onTimeout?.();
195
+ if (consumeInterruptRequest(asyncDir, fsImpl)) opts.onInterrupt();
196
+ } catch {
197
+ // Never let inbox errors crash the runner.
198
+ }
199
+ };
200
+
201
+ // Handle a request that may have arrived before the watcher started.
202
+ check();
203
+
204
+ let watcher: fs.FSWatcher | undefined;
205
+ try {
206
+ watcher = fsImpl.watch(dir, () => check());
207
+ watcher.on?.("error", () => {
208
+ // fs.watch can emit on transient FS errors; the interval poll keeps us live.
209
+ });
210
+ } catch {
211
+ watcher = undefined;
212
+ }
213
+
214
+ const interval = timers.setInterval(check, opts.pollIntervalMs ?? POLL_INTERVAL_MS);
215
+ interval.unref?.();
216
+
217
+ return () => {
218
+ if (disposed) return;
219
+ disposed = true;
220
+ try {
221
+ watcher?.close();
222
+ } catch {
223
+ // ignore
224
+ }
225
+ timers.clearInterval(interval);
226
+ };
227
+ }
@@ -202,6 +202,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
202
202
  const lines = [
203
203
  `Run: ${status.runId}`,
204
204
  `State: ${status.state}`,
205
+ status.error ? `Error: ${status.error}` : undefined,
205
206
  statusActivityText ? `Activity: ${statusActivityText}` : undefined,
206
207
  `Mode: ${status.mode}`,
207
208
  `Progress: ${progressLabel}`,
@@ -41,6 +41,31 @@ function getErrorMessage(error: unknown): string {
41
41
  return error instanceof Error ? error.message : String(error);
42
42
  }
43
43
 
44
+ function readRunnerStartupDiagnostics(asyncDir: string): string | undefined {
45
+ const stderrPath = path.join(asyncDir, "runner.stderr.log");
46
+ const maxBytes = 64 * 1024;
47
+ let content: string;
48
+ try {
49
+ const stat = fs.statSync(stderrPath);
50
+ if (stat.size <= 0) return undefined;
51
+ const fd = fs.openSync(stderrPath, "r");
52
+ try {
53
+ const bytesToRead = Math.min(stat.size, maxBytes);
54
+ const start = Math.max(0, stat.size - bytesToRead);
55
+ const buffer = Buffer.alloc(bytesToRead);
56
+ fs.readSync(fd, buffer, 0, bytesToRead, start);
57
+ content = buffer.toString("utf-8").trim();
58
+ } finally {
59
+ fs.closeSync(fd);
60
+ }
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ if (!content) return undefined;
65
+ const lines = content.split(/\r?\n/).slice(-30).join("\n");
66
+ return lines.length > 4000 ? `${lines.slice(-4000)}\n[stderr tail truncated]` : lines;
67
+ }
68
+
44
69
  function isNotFoundError(error: unknown): boolean {
45
70
  return typeof error === "object"
46
71
  && error !== null
@@ -172,7 +197,9 @@ function buildStartedStatus(asyncDir: string, startedRun: StartedRunMetadata, no
172
197
  function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, reason?: string): { status: AsyncStatus; result: object; message: string } {
173
198
  const runId = status.runId || path.basename(asyncDir);
174
199
  const pid = typeof status.pid === "number" ? status.pid : "unknown";
175
- const message = reason ?? `Async runner process ${pid} exited or disappeared before writing a result. Marked run failed by stale-run reconciliation.`;
200
+ const baseMessage = reason ?? `Async runner process ${pid} exited or disappeared before writing a result. Marked run failed by stale-run reconciliation.`;
201
+ const diagnostics = readRunnerStartupDiagnostics(asyncDir);
202
+ const message = diagnostics ? `${baseMessage}\n\nRunner stderr tail:\n${diagnostics}` : baseMessage;
176
203
  const steps = status.steps?.length ? status.steps : [{ agent: "subagent", status: "running" as const }];
177
204
  const repairedSteps = steps.map((step) => step.status === "running" || step.status === "pending"
178
205
  ? {