pi-subagents 0.30.0 → 0.31.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 +26 -0
  2. package/README.md +116 -17
  3. package/agents/context-builder.md +3 -3
  4. package/agents/planner.md +1 -1
  5. package/agents/researcher.md +1 -1
  6. package/agents/scout.md +1 -1
  7. package/package.json +7 -7
  8. package/skills/pi-subagents/SKILL.md +5 -0
  9. package/src/agents/agent-management.ts +170 -6
  10. package/src/agents/agent-serializer.ts +31 -13
  11. package/src/agents/agents.ts +207 -23
  12. package/src/agents/frontmatter.ts +66 -2
  13. package/src/agents/skills.ts +117 -20
  14. package/src/extension/doctor.ts +20 -0
  15. package/src/extension/fanout-child.ts +1 -0
  16. package/src/extension/index.ts +47 -4
  17. package/src/extension/schemas.ts +10 -76
  18. package/src/intercom/intercom-bridge.ts +2 -3
  19. package/src/runs/background/async-execution.ts +14 -4
  20. package/src/runs/background/async-job-tracker.ts +56 -11
  21. package/src/runs/background/result-watcher.ts +11 -2
  22. package/src/runs/background/stale-run-reconciler.ts +9 -4
  23. package/src/runs/background/subagent-runner.ts +79 -3
  24. package/src/runs/foreground/chain-execution.ts +26 -2
  25. package/src/runs/foreground/execution.ts +113 -8
  26. package/src/runs/foreground/subagent-executor.ts +325 -77
  27. package/src/runs/shared/acceptance.ts +285 -34
  28. package/src/runs/shared/completion-guard.ts +1 -1
  29. package/src/runs/shared/dynamic-fanout.ts +4 -2
  30. package/src/runs/shared/mcp-direct-tool-allowlist.ts +2 -2
  31. package/src/runs/shared/parallel-utils.ts +6 -1
  32. package/src/runs/shared/pi-args.ts +9 -1
  33. package/src/runs/shared/single-output.ts +15 -1
  34. package/src/shared/settings.ts +1 -0
  35. package/src/shared/types.ts +8 -2
  36. package/src/shared/utils.ts +19 -1
  37. package/src/slash/prompt-template-bridge.ts +26 -3
  38. package/src/slash/slash-commands.ts +33 -3
  39. package/src/tui/render.ts +265 -13
@@ -26,6 +26,10 @@ interface AsyncJobTrackerOptions {
26
26
  now?: () => number;
27
27
  }
28
28
 
29
+ const CONTROL_EVENT_READ_CHUNK_BYTES = 64 * 1024;
30
+ const MAX_CONTROL_EVENT_LINE_BYTES = 1024 * 1024;
31
+ const CONTROL_EVENT_SCAN_WINDOW_BYTES = 2 * 1024 * 1024;
32
+
29
33
  export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: SubagentState, asyncDirRoot: string, options: AsyncJobTrackerOptions = {}): {
30
34
  ensurePoller: () => void;
31
35
  handleStarted: (data: unknown) => void;
@@ -68,25 +72,24 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
68
72
  }
69
73
  try {
70
74
  const stat = fs.fstatSync(fd);
71
- const cursor = stat.size < (job.controlEventCursor ?? 0) ? 0 : (job.controlEventCursor ?? 0);
75
+ const savedCursor = job.controlEventCursor;
76
+ let cursor = stat.size < (savedCursor ?? 0) ? 0 : (savedCursor ?? 0);
77
+ const startedFromTail = savedCursor === undefined && stat.size > CONTROL_EVENT_SCAN_WINDOW_BYTES;
78
+ if (startedFromTail) cursor = stat.size - CONTROL_EVENT_SCAN_WINDOW_BYTES;
72
79
  if (stat.size <= cursor) return;
73
- const buffer = Buffer.alloc(stat.size - cursor);
74
- fs.readSync(fd, buffer, 0, buffer.length, cursor);
75
- const lastNewline = buffer.lastIndexOf(0x0a);
76
- if (lastNewline === -1) return;
77
- job.controlEventCursor = cursor + lastNewline + 1;
78
- for (const line of buffer.subarray(0, lastNewline).toString("utf-8").split("\n")) {
79
- if (!line.trim()) continue;
80
+ const scanEnd = Math.min(stat.size, cursor + CONTROL_EVENT_SCAN_WINDOW_BYTES);
81
+ const handleLine = (line: string) => {
82
+ if (!line.trim()) return;
80
83
  let parsed: unknown;
81
84
  try {
82
85
  parsed = JSON.parse(line);
83
86
  } catch (error) {
84
87
  console.error(`Ignoring malformed async control event in '${eventsPath}':`, error);
85
- continue;
88
+ return;
86
89
  }
87
- if (!parsed || typeof parsed !== "object" || (parsed as { type?: unknown }).type !== "subagent.control") continue;
90
+ if (!parsed || typeof parsed !== "object" || (parsed as { type?: unknown }).type !== "subagent.control") return;
88
91
  const record = parsed as { event?: ControlEvent; channels?: string[]; childIntercomTarget?: string; noticeText?: string; intercom?: { to?: string; message?: string } };
89
- if (!record.event || !Array.isArray(record.channels)) continue;
92
+ if (!record.event || !Array.isArray(record.channels)) return;
90
93
  const payload = {
91
94
  event: record.event,
92
95
  source: "async" as const,
@@ -104,7 +107,48 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
104
107
  message: record.intercom.message,
105
108
  });
106
109
  }
110
+ };
111
+ let readCursor = cursor;
112
+ let lastCompleteCursor = cursor;
113
+ let lineParts: Buffer[] = [];
114
+ let lineBytes = 0;
115
+ let skippingOversizedLine = startedFromTail;
116
+ const appendLineSegment = (segment: Buffer) => {
117
+ if (segment.length === 0 || skippingOversizedLine) return;
118
+ if (lineBytes + segment.length > MAX_CONTROL_EVENT_LINE_BYTES) {
119
+ lineParts = [];
120
+ lineBytes = 0;
121
+ skippingOversizedLine = true;
122
+ return;
123
+ }
124
+ lineParts.push(segment);
125
+ lineBytes += segment.length;
126
+ };
127
+ while (readCursor < scanEnd) {
128
+ const toRead = Math.min(CONTROL_EVENT_READ_CHUNK_BYTES, scanEnd - readCursor);
129
+ const buffer = Buffer.alloc(toRead);
130
+ const bytesRead = fs.readSync(fd, buffer, 0, toRead, readCursor);
131
+ if (bytesRead <= 0) break;
132
+ const chunk = bytesRead === buffer.length ? buffer : buffer.subarray(0, bytesRead);
133
+ let lineStart = 0;
134
+ for (let index = 0; index < chunk.length; index++) {
135
+ if (chunk[index] !== 0x0a) continue;
136
+ appendLineSegment(chunk.subarray(lineStart, index));
137
+ if (!skippingOversizedLine && lineBytes > 0) {
138
+ handleLine(Buffer.concat(lineParts, lineBytes).toString("utf-8"));
139
+ }
140
+ lineParts = [];
141
+ lineBytes = 0;
142
+ skippingOversizedLine = false;
143
+ lastCompleteCursor = readCursor + index + 1;
144
+ lineStart = index + 1;
145
+ }
146
+ appendLineSegment(chunk.subarray(lineStart));
147
+ readCursor += bytesRead;
148
+ if (skippingOversizedLine) job.controlEventCursor = readCursor;
107
149
  }
150
+ if (lastCompleteCursor > cursor) job.controlEventCursor = lastCompleteCursor;
151
+ else if (scanEnd < stat.size || startedFromTail) job.controlEventCursor = scanEnd;
108
152
  } catch (error) {
109
153
  console.error(`Failed to read async control events for '${job.asyncDir}':`, error);
110
154
  } finally {
@@ -261,6 +305,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
261
305
  activeParallelGroup: Boolean(firstGroupCount && firstGroupCount > 0),
262
306
  startedAt: now,
263
307
  updatedAt: now,
308
+ controlEventCursor: 0,
264
309
  });
265
310
  ensurePoller();
266
311
  if (state.lastUiContext) {
@@ -21,7 +21,7 @@ import { projectNestedRegistryForRoot, sanitizeSummary } from "../shared/nested-
21
21
  const WATCHER_RESTART_DELAY_MS = 3000;
22
22
  const POLL_INTERVAL_MS = 3000;
23
23
 
24
- type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "watch">;
24
+ type ResultWatcherFs = Pick<typeof fs, "existsSync" | "readFileSync" | "unlinkSync" | "readdirSync" | "mkdirSync" | "realpathSync" | "watch">;
25
25
 
26
26
  type ResultWatcherTimers = {
27
27
  setTimeout: typeof setTimeout;
@@ -91,6 +91,14 @@ function shouldFallBackToPolling(error: unknown): boolean {
91
91
  return code === "EMFILE" || code === "ENOSPC";
92
92
  }
93
93
 
94
+ function resolveNativeWatchDir(fsApi: ResultWatcherFs, resultsDir: string): string {
95
+ try {
96
+ return fsApi.realpathSync.native(resultsDir);
97
+ } catch {
98
+ return resultsDir;
99
+ }
100
+ }
101
+
94
102
  export function createResultWatcher(
95
103
  pi: { events: IntercomEventBus },
96
104
  state: SubagentState,
@@ -264,7 +272,8 @@ export function createResultWatcher(
264
272
  state.watcherRestartTimer = null;
265
273
  }
266
274
  try {
267
- state.watcher = fsApi.watch(resultsDir, (ev, file) => {
275
+ const watchDir = resolveNativeWatchDir(fsApi, resultsDir);
276
+ state.watcher = fsApi.watch(watchDir, (ev, file) => {
268
277
  if (ev !== "rename" || !file) return;
269
278
  const fileName = file.toString();
270
279
  if (!fileName.endsWith(".json")) return;
@@ -48,9 +48,14 @@ function isNotFoundError(error: unknown): boolean {
48
48
  && (error as NodeJS.ErrnoException).code === "ENOENT";
49
49
  }
50
50
 
51
- function appendJsonl(filePath: string, payload: object): void {
52
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
53
- fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf-8");
51
+ function appendJsonlBestEffort(filePath: string, payload: object): void {
52
+ try {
53
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
54
+ fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf-8");
55
+ } catch {
56
+ // Repair status/result writes are the important path. A broken or full
57
+ // diagnostic event log must not make stale-run reconciliation fail.
58
+ }
54
59
  }
55
60
 
56
61
  function readStatusFile(asyncDir: string): AsyncStatus | null {
@@ -223,7 +228,7 @@ function writeFailedRepair(asyncDir: string, status: AsyncStatus, resultPath: st
223
228
  const repair = buildFailedRepair(status, asyncDir, now, reason);
224
229
  writeAtomicJson(resultPath, repair.result);
225
230
  writeAtomicJson(path.join(asyncDir, "status.json"), repair.status);
226
- appendJsonl(path.join(asyncDir, "events.jsonl"), {
231
+ appendJsonlBestEffort(path.join(asyncDir, "events.jsonl"), {
227
232
  type: "subagent.run.repaired_stale",
228
233
  ts: now,
229
234
  runId: repair.status.runId,
@@ -4,7 +4,7 @@ import * as path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import type { Message } from "@earendil-works/pi-ai";
6
6
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
7
- import { appendJsonl, getArtifactPaths } from "../../shared/artifacts.ts";
7
+ import { appendJsonl as appendRawJsonl, getArtifactPaths } from "../../shared/artifacts.ts";
8
8
  import { PI_CODING_AGENT_PACKAGE, getPiSpawnCommand, resolveInstalledPiPackageRoot } from "../shared/pi-spawn.ts";
9
9
  import { captureSingleOutputSnapshot, finalizeSingleOutput, formatSavedOutputReference, resolveSingleOutput, type SingleOutputSnapshot } from "../shared/single-output.ts";
10
10
  import {
@@ -131,6 +131,79 @@ interface StepResult {
131
131
  }
132
132
 
133
133
  const ASYNC_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
134
+ const DEFAULT_MAX_ASYNC_EVENTS_BYTES = 50 * 1024 * 1024;
135
+ const ASYNC_EVENTS_MAX_BYTES_ENV = "PI_SUBAGENT_ASYNC_EVENTS_MAX_BYTES";
136
+ const TRUNCATED_EVENT_TYPE = "subagent.events.truncated";
137
+ const TRUNCATION_MARKER_RESERVE_BYTES = 512;
138
+
139
+ interface AsyncEventLogState {
140
+ bytes: number;
141
+ diagnosticsTruncated: boolean;
142
+ }
143
+
144
+ const asyncEventLogStates = new Map<string, AsyncEventLogState>();
145
+
146
+ function maxAsyncEventsBytes(): number {
147
+ const raw = process.env[ASYNC_EVENTS_MAX_BYTES_ENV];
148
+ if (!raw) return DEFAULT_MAX_ASYNC_EVENTS_BYTES;
149
+ const parsed = Number(raw);
150
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_MAX_ASYNC_EVENTS_BYTES;
151
+ return Math.floor(parsed);
152
+ }
153
+
154
+ function eventLogState(filePath: string): AsyncEventLogState {
155
+ let state = asyncEventLogStates.get(filePath);
156
+ if (state) return state;
157
+ let bytes = 0;
158
+ try {
159
+ bytes = fs.statSync(filePath).size;
160
+ } catch (error) {
161
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
162
+ // Diagnostic event accounting is best-effort; writes below are also safe.
163
+ }
164
+ }
165
+ state = { bytes, diagnosticsTruncated: false };
166
+ asyncEventLogStates.set(filePath, state);
167
+ return state;
168
+ }
169
+
170
+ function appendJsonl(filePath: string, line: string): void {
171
+ try {
172
+ appendRawJsonl(filePath, line);
173
+ const state = asyncEventLogStates.get(filePath);
174
+ if (state) state.bytes += Buffer.byteLength(`${line}\n`, "utf-8");
175
+ } catch {
176
+ // Async event logging is diagnostic and must not fail the run.
177
+ }
178
+ }
179
+
180
+ function appendDiagnosticJsonl(filePath: string, line: string, droppedEventType?: string): void {
181
+ if (!line.trim()) return;
182
+ const state = eventLogState(filePath);
183
+ if (state.diagnosticsTruncated) return;
184
+ const maxBytes = maxAsyncEventsBytes();
185
+ const chunkBytes = Buffer.byteLength(`${line}\n`, "utf-8");
186
+ const diagnosticBudget = Math.max(0, maxBytes - TRUNCATION_MARKER_RESERVE_BYTES);
187
+ if (state.bytes + chunkBytes <= diagnosticBudget) {
188
+ appendJsonl(filePath, line);
189
+ return;
190
+ }
191
+
192
+ const marker = JSON.stringify({
193
+ type: TRUNCATED_EVENT_TYPE,
194
+ ts: Date.now(),
195
+ maxBytes,
196
+ droppedEventType,
197
+ });
198
+ if (state.bytes + Buffer.byteLength(`${marker}\n`, "utf-8") <= maxBytes) {
199
+ appendJsonl(filePath, marker);
200
+ }
201
+ state.diagnosticsTruncated = true;
202
+ }
203
+
204
+ function shouldPersistChildEvent(event: Record<string, unknown>): boolean {
205
+ return event.type !== "message_update";
206
+ }
134
207
 
135
208
  function findLatestSessionFile(sessionDir: string): string | null {
136
209
  try {
@@ -274,14 +347,15 @@ function runPiStreaming(
274
347
 
275
348
  const appendChildEvent = (event: Record<string, unknown>) => {
276
349
  if (!childEventContext) return;
277
- appendJsonl(childEventContext.eventsPath, JSON.stringify({
350
+ if (!shouldPersistChildEvent(event)) return;
351
+ appendDiagnosticJsonl(childEventContext.eventsPath, JSON.stringify({
278
352
  ...event,
279
353
  subagentSource: "child",
280
354
  subagentRunId: childEventContext.runId,
281
355
  subagentStepIndex: childEventContext.stepIndex,
282
356
  subagentAgent: childEventContext.agent,
283
357
  observedAt: Date.now(),
284
- }));
358
+ }), typeof event.type === "string" ? event.type : undefined);
285
359
  };
286
360
 
287
361
  const appendChildLine = (type: "subagent.child.stdout" | "subagent.child.stderr", line: string) => {
@@ -676,6 +750,7 @@ async function runSingleStep(
676
750
  }
677
751
  }
678
752
  const { args, env, tempDir } = buildPiArgs({
753
+ parentSessionId: step.parentSessionId,
679
754
  baseArgs: ["--mode", "json", "-p"],
680
755
  task,
681
756
  sessionEnabled,
@@ -684,6 +759,7 @@ async function runSingleStep(
684
759
  model: candidate,
685
760
  inheritProjectContext: step.inheritProjectContext,
686
761
  inheritSkills: step.inheritSkills,
762
+ requireReadTool: Boolean(step.skills?.length),
687
763
  tools: step.tools,
688
764
  extensions: step.extensions,
689
765
  subagentOnlyExtensions: step.subagentOnlyExtensions,
@@ -102,6 +102,7 @@ interface ParallelChainRunInput {
102
102
  globalTaskIndex: number;
103
103
  sessionDirForIndex: (idx?: number) => string | undefined;
104
104
  sessionFileForIndex?: (idx?: number) => string | undefined;
105
+ sessionFileForTask?: (agentName: string, idx?: number) => string | undefined;
105
106
  shareEnabled: boolean;
106
107
  artifactConfig: ArtifactConfig;
107
108
  artifactsDir: string;
@@ -136,6 +137,8 @@ interface ParallelChainRunInput {
136
137
  worktreeSetup?: WorktreeSetup;
137
138
  maxSubagentDepth: number;
138
139
  nestedRoute?: NestedRouteInfo;
140
+ timeoutMs?: number;
141
+ deadlineAt?: number;
139
142
  }
140
143
 
141
144
  function buildChainExecutionDetails(input: ChainExecutionDetailsInput): Details {
@@ -266,6 +269,7 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
266
269
  ? createStructuredOutputRuntime(task.outputSchema, path.join(input.chainDir, "structured-output"))
267
270
  : undefined;
268
271
  const result = await runSync(input.ctx.cwd, input.agents, task.agent, taskStr, {
272
+ parentSessionId: input.ctx.sessionManager.getSessionId() ?? undefined,
269
273
  cwd: taskCwd,
270
274
  signal: input.signal,
271
275
  interruptSignal: interruptController.signal,
@@ -274,7 +278,8 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
274
278
  runId: input.runId,
275
279
  index: input.globalTaskIndex + taskIndex,
276
280
  sessionDir: input.sessionDirForIndex(input.globalTaskIndex + taskIndex),
277
- sessionFile: input.sessionFileForIndex?.(input.globalTaskIndex + taskIndex),
281
+ sessionFile: input.sessionFileForTask?.(task.agent, input.globalTaskIndex + taskIndex)
282
+ ?? input.sessionFileForIndex?.(input.globalTaskIndex + taskIndex),
278
283
  share: input.shareEnabled,
279
284
  artifactsDir: input.artifactConfig.enabled ? input.artifactsDir : undefined,
280
285
  artifactConfig: input.artifactConfig,
@@ -293,6 +298,8 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
293
298
  structuredOutput: structuredRuntime,
294
299
  acceptance: task.acceptance,
295
300
  acceptanceContext: { mode: "chain" },
301
+ timeoutMs: input.timeoutMs,
302
+ deadlineAt: input.deadlineAt,
296
303
  onUpdate: input.onUpdate
297
304
  ? (progressUpdate) => {
298
305
  const stepResults = progressUpdate.details?.results || [];
@@ -365,6 +372,7 @@ interface ChainExecutionParams {
365
372
  shareEnabled: boolean;
366
373
  sessionDirForIndex: (idx?: number) => string | undefined;
367
374
  sessionFileForIndex?: (idx?: number) => string | undefined;
375
+ sessionFileForTask?: (agentName: string, idx?: number) => string | undefined;
368
376
  artifactsDir: string;
369
377
  artifactConfig: ArtifactConfig;
370
378
  includeProgress?: boolean;
@@ -395,6 +403,8 @@ interface ChainExecutionParams {
395
403
  nestedRoute?: NestedRouteInfo;
396
404
  worktreeSetupHook?: string;
397
405
  worktreeSetupHookTimeoutMs?: number;
406
+ timeoutMs?: number;
407
+ deadlineAt?: number;
398
408
  }
399
409
 
400
410
  interface ChainExecutionResult {
@@ -422,6 +432,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
422
432
  shareEnabled,
423
433
  sessionDirForIndex,
424
434
  sessionFileForIndex,
435
+ sessionFileForTask,
425
436
  artifactsDir,
426
437
  artifactConfig,
427
438
  includeProgress,
@@ -584,6 +595,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
584
595
  tuiBehaviorOverrides = result.behaviorOverrides;
585
596
  }
586
597
 
598
+ const deadlineAt = params.deadlineAt ?? (params.timeoutMs !== undefined ? Date.now() + params.timeoutMs : undefined);
587
599
  let prev = "";
588
600
  let globalTaskIndex = 0;
589
601
  let progressCreated = false;
@@ -649,6 +661,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
649
661
  globalTaskIndex,
650
662
  sessionDirForIndex,
651
663
  sessionFileForIndex,
664
+ sessionFileForTask,
652
665
  shareEnabled,
653
666
  artifactConfig,
654
667
  artifactsDir,
@@ -670,6 +683,8 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
670
683
  nestedRoute: params.nestedRoute,
671
684
  worktreeSetup,
672
685
  maxSubagentDepth: params.maxSubagentDepth,
686
+ timeoutMs: params.timeoutMs,
687
+ deadlineAt,
673
688
  });
674
689
  globalTaskIndex += step.parallel.length;
675
690
 
@@ -739,6 +754,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
739
754
  output: getSingleResultOutput(result),
740
755
  exitCode: result.exitCode,
741
756
  error: result.error,
757
+ timedOut: result.timedOut,
742
758
  outputTargetPath,
743
759
  outputTargetExists: outputTargetPath ? fs.existsSync(outputTargetPath) : undefined,
744
760
  };
@@ -855,6 +871,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
855
871
  globalTaskIndex,
856
872
  sessionDirForIndex,
857
873
  sessionFileForIndex,
874
+ sessionFileForTask,
858
875
  shareEnabled,
859
876
  artifactConfig,
860
877
  artifactsDir,
@@ -875,6 +892,8 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
875
892
  foregroundControl,
876
893
  nestedRoute: params.nestedRoute,
877
894
  maxSubagentDepth: params.maxSubagentDepth,
895
+ timeoutMs: params.timeoutMs,
896
+ deadlineAt,
878
897
  });
879
898
  globalTaskIndex += dynamicParallelStep.parallel.length;
880
899
 
@@ -970,6 +989,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
970
989
  output: getSingleResultOutput(result),
971
990
  exitCode: result.exitCode,
972
991
  error: result.error,
992
+ timedOut: result.timedOut,
973
993
  }));
974
994
  prev = aggregateParallelOutputs(taskResults, (i, agent) => `=== Dynamic Item ${i + 1} (${agent}, key ${materialized.items[i]?.key ?? i}) ===`);
975
995
  } else {
@@ -1054,6 +1074,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1054
1074
  ? createStructuredOutputRuntime(seqStep.outputSchema, path.join(chainDir, "structured-output"))
1055
1075
  : undefined;
1056
1076
  const r = await runSync(ctx.cwd, agents, seqStep.agent, stepTask, {
1077
+ parentSessionId: ctx.sessionManager.getSessionId() ?? undefined,
1057
1078
  cwd: resolveChildCwd(cwd ?? ctx.cwd, seqStep.cwd),
1058
1079
  signal,
1059
1080
  interruptSignal: interruptController.signal,
@@ -1062,7 +1083,8 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1062
1083
  runId,
1063
1084
  index: globalTaskIndex,
1064
1085
  sessionDir: sessionDirForIndex(globalTaskIndex),
1065
- sessionFile: sessionFileForIndex?.(globalTaskIndex),
1086
+ sessionFile: sessionFileForTask?.(seqStep.agent, globalTaskIndex)
1087
+ ?? sessionFileForIndex?.(globalTaskIndex),
1066
1088
  share: shareEnabled,
1067
1089
  artifactsDir: artifactConfig.enabled ? artifactsDir : undefined,
1068
1090
  artifactConfig,
@@ -1081,6 +1103,8 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1081
1103
  structuredOutput: structuredRuntime,
1082
1104
  acceptance: seqStep.acceptance,
1083
1105
  acceptanceContext: { mode: "chain" },
1106
+ timeoutMs: params.timeoutMs,
1107
+ deadlineAt,
1084
1108
  onUpdate: onUpdate
1085
1109
  ? (p) => {
1086
1110
  const stepResults = p.details?.results || [];
@@ -23,6 +23,8 @@ import {
23
23
  DEFAULT_MAX_OUTPUT,
24
24
  INTERCOM_DETACH_REQUEST_EVENT,
25
25
  INTERCOM_DETACH_RESPONSE_EVENT,
26
+ type AcceptanceLedger,
27
+ type ResolvedAcceptanceConfig,
26
28
  truncateOutput,
27
29
  getSubagentDepthEnv,
28
30
  } from "../../shared/types.ts";
@@ -47,7 +49,7 @@ import { createJsonlWriter } from "../../shared/jsonl-writer.ts";
47
49
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
48
50
  import { applyThinkingSuffix, buildPiArgs, cleanupTempDir } from "../shared/pi-args.ts";
49
51
  import { readStructuredOutput } from "../shared/structured-output.ts";
50
- import { captureSingleOutputSnapshot, formatSavedOutputReference, resolveSingleOutput, validateFileOnlyOutputMode, type SingleOutputSnapshot } from "../shared/single-output.ts";
52
+ import { captureSingleOutputSnapshot, formatSavedOutputReference, injectOutputPathSystemPrompt, resolveSingleOutput, validateFileOnlyOutputMode, type SingleOutputSnapshot } from "../shared/single-output.ts";
51
53
  import {
52
54
  buildModelCandidates,
53
55
  formatModelAttemptNote,
@@ -82,6 +84,34 @@ function sumUsage(target: Usage, source: Usage): void {
82
84
  target.turns += source.turns;
83
85
  }
84
86
 
87
+ function formatTimeoutMessage(timeoutMs: number): string {
88
+ return `Subagent timed out after ${timeoutMs}ms.`;
89
+ }
90
+
91
+ function resolveAttemptTimeout(options: RunSyncOptions): { timeoutMs: number; remainingMs: number; message: string } | undefined {
92
+ if (options.timeoutMs === undefined) return undefined;
93
+ const deadlineAt = options.deadlineAt ?? Date.now() + options.timeoutMs;
94
+ return {
95
+ timeoutMs: options.timeoutMs,
96
+ remainingMs: Math.max(0, deadlineAt - Date.now()),
97
+ message: formatTimeoutMessage(options.timeoutMs),
98
+ };
99
+ }
100
+
101
+ function buildTimedOutAcceptanceLedger(acceptance: ResolvedAcceptanceConfig): AcceptanceLedger {
102
+ return {
103
+ status: acceptance.level === "none" ? "not-required" : "rejected",
104
+ explicit: acceptance.explicit,
105
+ effectiveAcceptance: acceptance,
106
+ inferredReason: acceptance.inferredReason,
107
+ criteria: acceptance.criteria,
108
+ runtimeChecks: acceptance.level === "none"
109
+ ? []
110
+ : [{ id: "timeout", status: "failed", message: "Acceptance was not evaluated because the subagent timed out." }],
111
+ verifyRuns: [],
112
+ };
113
+ }
114
+
85
115
  function appendRecentOutput(progress: AgentProgress, lines: string[]): void {
86
116
  if (lines.length === 0) return;
87
117
  progress.recentOutput.push(...lines.filter((line) => line.trim()));
@@ -162,6 +192,7 @@ async function runSingleAttempt(
162
192
  systemPromptMode: agent.systemPromptMode,
163
193
  inheritProjectContext: agent.inheritProjectContext,
164
194
  inheritSkills: agent.inheritSkills,
195
+ requireReadTool: Boolean(shared.resolvedSkillNames?.length),
165
196
  tools: agent.tools,
166
197
  extensions: agent.extensions,
167
198
  subagentOnlyExtensions: agent.subagentOnlyExtensions,
@@ -178,6 +209,7 @@ async function runSingleAttempt(
178
209
  parentControlInbox: options.nestedRoute?.controlInbox,
179
210
  parentRootRunId: options.nestedRoute?.rootRunId,
180
211
  parentCapabilityToken: options.nestedRoute?.capabilityToken,
212
+ parentSessionId: options.parentSessionId,
181
213
  structuredOutput: options.structuredOutput,
182
214
  });
183
215
 
@@ -227,6 +259,21 @@ async function runSingleAttempt(
227
259
  lastActivityAt: startTime,
228
260
  };
229
261
  result.progress = progress;
262
+ const attemptTimeout = resolveAttemptTimeout(options);
263
+ if (attemptTimeout?.remainingMs === 0) {
264
+ result.exitCode = 1;
265
+ result.timedOut = true;
266
+ result.error = attemptTimeout.message;
267
+ result.finalOutput = attemptTimeout.message;
268
+ progress.status = "failed";
269
+ progress.error = attemptTimeout.message;
270
+ result.progressSummary = {
271
+ toolCount: progress.toolCount,
272
+ tokens: progress.tokens,
273
+ durationMs: progress.durationMs,
274
+ };
275
+ return result;
276
+ }
230
277
  const spawnEnv = { ...process.env, ...sharedEnv, ...getSubagentDepthEnv(options.maxSubagentDepth) };
231
278
  let observedMutationAttempt = false;
232
279
 
@@ -248,6 +295,23 @@ async function runSingleAttempt(
248
295
  let removeAbortListener: (() => void) | undefined;
249
296
  let removeInterruptListener: (() => void) | undefined;
250
297
  let activityTimer: NodeJS.Timeout | undefined;
298
+ let timeoutTimer: NodeJS.Timeout | undefined;
299
+ let timeoutTerminationTimer: NodeJS.Timeout | undefined;
300
+ let timeoutHardKillTimer: NodeJS.Timeout | undefined;
301
+ const clearTimeoutTimers = () => {
302
+ if (timeoutTimer) {
303
+ clearTimeout(timeoutTimer);
304
+ timeoutTimer = undefined;
305
+ }
306
+ if (timeoutTerminationTimer) {
307
+ clearTimeout(timeoutTerminationTimer);
308
+ timeoutTerminationTimer = undefined;
309
+ }
310
+ if (timeoutHardKillTimer) {
311
+ clearTimeout(timeoutHardKillTimer);
312
+ timeoutHardKillTimer = undefined;
313
+ }
314
+ };
251
315
 
252
316
  const detachForIntercom = () => {
253
317
  detached = true;
@@ -316,6 +380,7 @@ async function runSingleAttempt(
316
380
  settled = true;
317
381
  clearFinalDrainTimers();
318
382
  clearStdioGuard();
383
+ clearTimeoutTimers();
319
384
  if (activityTimer) {
320
385
  clearInterval(activityTimer);
321
386
  activityTimer = undefined;
@@ -429,7 +494,8 @@ async function runSingleAttempt(
429
494
  const fireUpdate = () => {
430
495
  if (!options.onUpdate || processClosed) return;
431
496
  progress.durationMs = Date.now() - startTime;
432
- emitUpdateSnapshot(getFinalOutput(result.messages) || "(running...)");
497
+ const output = result.timedOut && result.finalOutput ? result.finalOutput : getFinalOutput(result.messages);
498
+ emitUpdateSnapshot(output || "(running...)");
433
499
  };
434
500
 
435
501
  const processLine = (line: string) => {
@@ -555,6 +621,31 @@ async function runSingleAttempt(
555
621
  activityTimer.unref?.();
556
622
  }
557
623
 
624
+ if (attemptTimeout) {
625
+ timeoutTimer = setTimeout(() => {
626
+ if (processClosed || settled || detached || interruptedByControl) return;
627
+ result.timedOut = true;
628
+ result.error = attemptTimeout.message;
629
+ result.finalOutput = attemptTimeout.message;
630
+ progress.status = "failed";
631
+ progress.error = attemptTimeout.message;
632
+ progress.durationMs = Date.now() - startTime;
633
+ fireUpdate();
634
+ trySignalChild(proc, "SIGINT");
635
+ timeoutTerminationTimer = setTimeout(() => {
636
+ if (processClosed || settled || detached) return;
637
+ trySignalChild(proc, "SIGTERM");
638
+ }, 1000);
639
+ timeoutTerminationTimer.unref?.();
640
+ timeoutHardKillTimer = setTimeout(() => {
641
+ if (processClosed || settled || detached) return;
642
+ trySignalChild(proc, "SIGKILL");
643
+ }, 4000);
644
+ timeoutHardKillTimer.unref?.();
645
+ }, attemptTimeout.remainingMs);
646
+ timeoutTimer.unref?.();
647
+ }
648
+
558
649
  let stderrBuf = "";
559
650
 
560
651
  const clearStdioGuard = attachPostExitStdioGuard(proc, { idleMs: 2000, hardMs: 8000 });
@@ -625,7 +716,9 @@ async function runSingleAttempt(
625
716
  if (options.interruptSignal) {
626
717
  const interrupt = () => {
627
718
  if (processClosed || detached || settled) return;
719
+ if (result.timedOut) return;
628
720
  interruptedByControl = true;
721
+ clearTimeoutTimers();
629
722
  progress.status = "running";
630
723
  progress.durationMs = Date.now() - startTime;
631
724
  result.interrupted = true;
@@ -710,8 +803,14 @@ async function runSingleAttempt(
710
803
  durationMs: progress.durationMs,
711
804
  };
712
805
 
713
- const acceptanceOutput = getFinalOutput(result.messages);
714
- let fullOutput = stripAcceptanceReport(acceptanceOutput);
806
+ const acceptanceOutput = getFinalOutput(result.messages);
807
+ let fullOutput = stripAcceptanceReport(acceptanceOutput);
808
+ if (result.timedOut) {
809
+ const timeoutMessage = formatTimeoutMessage(options.timeoutMs ?? 0);
810
+ fullOutput = fullOutput.trim()
811
+ ? `${timeoutMessage}\n\nPartial output before timeout:\n${fullOutput}`
812
+ : timeoutMessage;
813
+ }
715
814
  const completionGuard = result.exitCode === 0 && !result.error && agent.completionGuard !== false
716
815
  ? evaluateCompletionMutationGuard({
717
816
  agent: agent.name,
@@ -835,6 +934,7 @@ export async function runSync(
835
934
  const skillInjection = buildSkillInjection(resolvedSkills);
836
935
  systemPrompt = systemPrompt ? `${systemPrompt}\n\n${skillInjection}` : skillInjection;
837
936
  }
937
+ systemPrompt = injectOutputPathSystemPrompt(systemPrompt, options.outputPath);
838
938
 
839
939
  const candidates = buildModelCandidates(
840
940
  options.modelOverride ?? agent.model,
@@ -892,6 +992,9 @@ export async function runSync(
892
992
  usage: { ...result.usage },
893
993
  };
894
994
  modelAttempts.push(attempt);
995
+ if (result.timedOut) {
996
+ break;
997
+ }
895
998
  if (attemptSucceeded) {
896
999
  break;
897
1000
  }
@@ -967,14 +1070,16 @@ export async function runSync(
967
1070
  if (sessionFile) result.sessionFile = sessionFile;
968
1071
  }
969
1072
 
970
- result.acceptance = await evaluateAcceptance({
1073
+ result.acceptance = result.timedOut
1074
+ ? buildTimedOutAcceptanceLedger(effectiveAcceptance)
1075
+ : await evaluateAcceptance({
971
1076
  acceptance: effectiveAcceptance,
972
1077
  output: acceptanceOutputByResult.get(result) ?? result.finalOutput ?? "",
973
1078
  cwd: options.cwd ?? runtimeCwd,
974
1079
  });
975
- const acceptanceFailure = acceptanceFailureMessage(result.acceptance);
976
- stripAcceptanceReportsFromMessages(result.messages);
977
- if (acceptanceFailure && result.acceptance.explicit && result.exitCode === 0 && !result.detached && !result.interrupted) {
1080
+ const acceptanceFailure = acceptanceFailureMessage(result.acceptance);
1081
+ stripAcceptanceReportsFromMessages(result.messages);
1082
+ if (acceptanceFailure && result.acceptance.explicit && result.exitCode === 0 && !result.detached && !result.interrupted && !result.timedOut) {
978
1083
  result.exitCode = 1;
979
1084
  result.error = result.error ? `${result.error}\n${acceptanceFailure}` : acceptanceFailure;
980
1085
  if (result.progress) {