pi-subagents 0.31.1 → 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 (39) hide show
  1. package/CHANGELOG.md +40 -4
  2. package/README.md +97 -7
  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 +58 -4
  12. package/src/extension/schemas.ts +2 -2
  13. package/src/runs/background/async-execution.ts +138 -33
  14. package/src/runs/background/async-job-tracker.ts +77 -1
  15. package/src/runs/background/async-status.ts +41 -9
  16. package/src/runs/background/chain-root-attachment.ts +34 -4
  17. package/src/runs/background/control-channel.ts +50 -0
  18. package/src/runs/background/run-status.ts +1 -0
  19. package/src/runs/background/stale-run-reconciler.ts +28 -1
  20. package/src/runs/background/subagent-runner.ts +454 -115
  21. package/src/runs/foreground/chain-execution.ts +29 -7
  22. package/src/runs/foreground/execution.ts +24 -6
  23. package/src/runs/foreground/subagent-executor.ts +209 -35
  24. package/src/runs/shared/acceptance.ts +45 -22
  25. package/src/runs/shared/dynamic-fanout.ts +1 -1
  26. package/src/runs/shared/model-fallback.ts +4 -0
  27. package/src/runs/shared/nested-events.ts +58 -0
  28. package/src/runs/shared/parallel-utils.ts +49 -1
  29. package/src/runs/shared/pi-args.ts +5 -3
  30. package/src/runs/shared/pi-spawn.ts +52 -20
  31. package/src/runs/shared/single-output.ts +2 -0
  32. package/src/runs/shared/subagent-prompt-runtime.ts +2 -2
  33. package/src/runs/shared/worktree.ts +28 -5
  34. package/src/shared/artifacts.ts +15 -1
  35. package/src/shared/fork-context.ts +133 -22
  36. package/src/shared/types.ts +81 -3
  37. package/src/shared/utils.ts +99 -14
  38. package/src/slash/slash-commands.ts +117 -0
  39. 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)) {
@@ -37,6 +37,13 @@ export interface InterruptRequest {
37
37
  reason?: string;
38
38
  }
39
39
 
40
+ export interface TimeoutRequest {
41
+ type: "timeout";
42
+ ts?: number;
43
+ source?: string;
44
+ reason?: string;
45
+ }
46
+
40
47
  /** Control inbox directory inside an async run dir. */
41
48
  export function controlInboxDir(asyncDir: string): string {
42
49
  return path.join(asyncDir, "control");
@@ -47,6 +54,11 @@ export function interruptRequestPath(asyncDir: string): string {
47
54
  return path.join(controlInboxDir(asyncDir), "interrupt.json");
48
55
  }
49
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
+
50
62
  /**
51
63
  * Parent side: drop a portable interrupt request the runner's inbox watcher will
52
64
  * pick up regardless of OS. Written atomically (temp + rename), dir auto-created.
@@ -62,6 +74,17 @@ export function requestAsyncInterrupt(
62
74
  return requestPath;
63
75
  }
64
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
+
65
88
  /**
66
89
  * Runner side: consume a pending interrupt request. Idempotent — removes the file
67
90
  * so each distinct request fires exactly once. Returns whether one was pending.
@@ -80,6 +103,20 @@ export function consumeInterruptRequest(
80
103
  return true;
81
104
  }
82
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
+
83
120
  /**
84
121
  * Parent side: portable interrupt = authoritative file request + best-effort OS
85
122
  * signal. The signal is only a latency optimization on Unix; ENOSYS on Windows
@@ -114,6 +151,17 @@ export function deliverInterruptRequest(input: {
114
151
  }
115
152
  }
116
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
+
117
165
  /**
118
166
  * Runner side: watch the control inbox and route interrupt requests into
119
167
  * `onInterrupt`. Uses `fs.watch` when available plus an interval poll as a
@@ -124,6 +172,7 @@ export function watchAsyncControlInbox(
124
172
  asyncDir: string,
125
173
  opts: {
126
174
  onInterrupt: () => void;
175
+ onTimeout?: () => void;
127
176
  pollIntervalMs?: number;
128
177
  fs?: ControlChannelFs;
129
178
  timers?: ControlChannelTimers;
@@ -142,6 +191,7 @@ export function watchAsyncControlInbox(
142
191
  const check = (): void => {
143
192
  if (disposed) return;
144
193
  try {
194
+ if (consumeTimeoutRequest(asyncDir, fsImpl)) opts.onTimeout?.();
145
195
  if (consumeInterruptRequest(asyncDir, fsImpl)) opts.onInterrupt();
146
196
  } catch {
147
197
  // Never let inbox errors crash the runner.
@@ -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
  ? {