pi-agent-flow 1.5.0 → 1.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-agent-flow",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Flow-state delegation extension for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/flow.ts CHANGED
@@ -25,10 +25,11 @@ import { DEFAULT_AGENT_SESSION_MODE, getAgentSessionTimeoutMs, type AgentSession
25
25
 
26
26
  const isWindows = process.platform === "win32";
27
27
  const SIGKILL_TIMEOUT_MS = 5000;
28
+ const FINISH_KILL_GRACE_MS = 5_000; // wait 5s after finish() before force-killing the child process
28
29
  const AGENT_END_GRACE_MS = 2000;
29
30
  const FLOW_TIME_BUDGET_WARNING_MS = 2 * 60 * 1000; // warn 2 min before kill
30
- const FLOW_FINAL_URGE_MS = 30 * 1000; // final urge 30 s before kill
31
- const REPORTING_GRACE_MS = 10_000; // grace period after timeout for agent to report findings
31
+ const FLOW_FINAL_URGE_MS = 135 * 1000; // final urge 135 s (2m15s) before kill (increased from 30s for wider summary window)
32
+ const REPORTING_GRACE_MS = 90_000; // grace period after timeout for agent to report findings (increased from 10s to 90s)
32
33
  const FLOW_TOOL_SUMMARY_GRACE_MS = FLOW_FINAL_URGE_MS; // bash/tool abort lead time so the agent can summarize
33
34
  const FLOW_DEPTH_ENV = "PI_FLOW_DEPTH";
34
35
  const FLOW_MAX_DEPTH_ENV = "PI_FLOW_MAX_DEPTH";
@@ -38,6 +39,54 @@ const FLOW_DEADLINE_ENV = "PI_FLOW_DEADLINE_MS";
38
39
  const FLOW_TOOL_SUMMARY_GRACE_ENV = "PI_FLOW_TOOL_SUMMARY_GRACE_MS";
39
40
  export const FLOW_TOOL_OPTIMIZE_ENV = "PI_FLOW_TOOL_OPTIMIZE";
40
41
  const PI_OFFLINE_ENV = "PI_OFFLINE";
42
+ const FLOW_REMINDER_FILE_ENV = "PI_FLOW_REMINDER_FILE";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Global child process group tracking for signal propagation
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /** Track child process groups so we can kill them on parent exit / signal. */
49
+ const runningChildGroups = new Map<number, { groupPid: number; name: string }>();
50
+
51
+ /** Register a child process group for cleanup on shutdown. */
52
+ export function registerChildGroup(pid: number, name: string): void {
53
+ if (pid > 0 && !isWindows) {
54
+ runningChildGroups.set(pid, { groupPid: pid, name });
55
+ }
56
+ }
57
+
58
+ /** Unregister a completed/stopped child process group. */
59
+ export function unregisterChildGroup(pid: number): void {
60
+ runningChildGroups.delete(pid);
61
+ }
62
+
63
+ /**
64
+ * Terminate all registered child process groups via SIGTERM (then SIGKILL after timeout).
65
+ * Called on parent exit, SIGINT, SIGTERM, or pi-agent-flow:shutdown.
66
+ */
67
+ export function terminateAllChildGroups(): void {
68
+ if (runningChildGroups.size === 0) return;
69
+ const pids = Array.from(runningChildGroups.keys());
70
+ for (const pid of pids) {
71
+ try {
72
+ process.kill(-pid, "SIGTERM");
73
+ } catch {
74
+ try { process.kill(pid, "SIGTERM"); } catch { /* gone */ }
75
+ }
76
+ }
77
+ // Hard kill after timeout
78
+ const sigkillTimer = setTimeout(() => {
79
+ for (const pid of pids) {
80
+ try {
81
+ process.kill(-pid, "SIGKILL");
82
+ } catch {
83
+ try { process.kill(pid, "SIGKILL"); } catch { /* gone */ }
84
+ }
85
+ }
86
+ runningChildGroups.clear();
87
+ }, 5000);
88
+ sigkillTimer.unref();
89
+ }
41
90
 
42
91
  type FlowUpdateCallback = (partial: AgentToolResult<FlowDetails>) => void;
43
92
 
@@ -109,6 +158,24 @@ function cleanupFlowTempDir(dir: string | null): void {
109
158
  }
110
159
  }
111
160
 
161
+ // ---------------------------------------------------------------------------
162
+ // Reminder file helpers
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Write a reminder message to the reminder file so the child agent can see it
167
+ * via the timed-bash wrapper before its next tool call.
168
+ * Creates the file if it doesn't exist; appends the message.
169
+ */
170
+ function writeReminderFile(reminderFilePath: string | null, message: string): void {
171
+ if (!reminderFilePath) return;
172
+ try {
173
+ fs.writeFileSync(reminderFilePath, message + "\n", { encoding: "utf-8", flag: "a" });
174
+ } catch {
175
+ /* best-effort */
176
+ }
177
+ }
178
+
112
179
  // ---------------------------------------------------------------------------
113
180
  // Build pi CLI arguments (fork-only)
114
181
  // ---------------------------------------------------------------------------
@@ -428,6 +495,15 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
428
495
  forkSessionTmpPath = forkTmp.filePath;
429
496
  }
430
497
 
498
+ // Create a temp dir for the reminder file so the child agent can read timeout warnings
499
+ // via the timed-bash wrapper before its next tool call.
500
+ let reminderTmpDir: string | null = null;
501
+ let reminderFilePath: string | null = null;
502
+ if (effectiveTimeout > 0) {
503
+ reminderTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-flow-reminder-"));
504
+ reminderFilePath = path.join(reminderTmpDir, "reminder.txt");
505
+ }
506
+
431
507
  try {
432
508
  const piArgs = buildFlowArgs(
433
509
  flow,
@@ -472,13 +548,20 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
472
548
  ...(effectiveTimeout > 0 ? {
473
549
  [FLOW_DEADLINE_ENV]: String(deadlineAtMs),
474
550
  [FLOW_TOOL_SUMMARY_GRACE_ENV]: String(toolSummaryGraceMs),
551
+ [FLOW_REMINDER_FILE_ENV]: reminderFilePath ?? "",
475
552
  } : {
476
553
  [FLOW_DEADLINE_ENV]: "",
477
554
  [FLOW_TOOL_SUMMARY_GRACE_ENV]: "0",
555
+ [FLOW_REMINDER_FILE_ENV]: "",
478
556
  }),
479
557
  },
480
558
  });
481
559
 
560
+ // Register the child process group for global cleanup on signal/exit
561
+ if (proc.pid !== undefined && !isWindows) {
562
+ registerChildGroup(proc.pid, normalizedFlowName);
563
+ }
564
+
482
565
  let stdinEnded = false;
483
566
  const endStdin = () => {
484
567
  if (stdinEnded) return;
@@ -497,6 +580,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
497
580
  let timeoutFired = false;
498
581
  let semanticCompletionTimer: NodeJS.Timeout | undefined;
499
582
  let countdownTimer: NodeJS.Timeout | undefined;
583
+ let finishKillTimer: NodeJS.Timeout | undefined;
500
584
 
501
585
  const clearSemanticCompletionTimer = () => {
502
586
  if (semanticCompletionTimer) {
@@ -534,6 +618,13 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
534
618
  sigkillTimer.unref();
535
619
  };
536
620
 
621
+ const clearFinishKillTimer = () => {
622
+ if (finishKillTimer) {
623
+ clearTimeout(finishKillTimer);
624
+ finishKillTimer = undefined;
625
+ }
626
+ };
627
+
537
628
  const finish = (code: number) => {
538
629
  if (settled) return;
539
630
  settled = true;
@@ -543,6 +634,15 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
543
634
  if (signal && abortHandler) {
544
635
  signal.removeEventListener("abort", abortHandler);
545
636
  }
637
+ // Soft-kill: give the child a short grace to exit naturally after stdin close.
638
+ // If it hasn't closed by then, force-kill to prevent orphaned processes.
639
+ clearFinishKillTimer();
640
+ finishKillTimer = setTimeout(() => {
641
+ if (!didClose) {
642
+ terminateChild();
643
+ }
644
+ }, FINISH_KILL_GRACE_MS);
645
+ finishKillTimer.unref();
546
646
  resolve(code);
547
647
  };
548
648
 
@@ -596,6 +696,10 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
596
696
 
597
697
  proc.on("close", (code) => {
598
698
  didClose = true;
699
+ clearFinishKillTimer();
700
+ if (proc.pid !== undefined) {
701
+ unregisterChildGroup(proc.pid);
702
+ }
599
703
  if (buffer.trim()) flushBufferedLines(buffer);
600
704
  finish(code ?? 0);
601
705
  });
@@ -630,13 +734,15 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
630
734
  const remainingSec = Math.round(FLOW_TIME_BUDGET_WARNING_MS / 1000);
631
735
  const warnMsg = `\n[Flow warning] ${remainingSec}s remaining before hard timeout. The agent should wrap up now.`;
632
736
  result.stderr += warnMsg;
737
+ // Write to reminder file so the child agent sees it on its next bash call.
738
+ writeReminderFile(reminderFilePath, `[Flow warning] ${remainingSec}s remaining before hard timeout. Wrap up your work and output structured findings.`);
633
739
  // Force an update so the parent UI shows the warning immediately.
634
740
  emitUpdate();
635
741
  }, warningMs);
636
742
  warnTimer.unref();
637
743
  }
638
744
 
639
- // Final urge timer: stronger warning 30 s before hard timeout
745
+ // Final urge timer: stronger warning 45 s before hard timeout
640
746
  const urgeMs = effectiveTimeout - FLOW_FINAL_URGE_MS;
641
747
  if (urgeMs > 0) {
642
748
  const urgeTimer = setTimeout(() => {
@@ -644,6 +750,8 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
644
750
  const remainingSec = Math.round(FLOW_FINAL_URGE_MS / 1000);
645
751
  const urgeMsg = `\n[Flow warning] ${remainingSec}s remaining before hard timeout. Stop all work and output your structured findings.`;
646
752
  result.stderr += urgeMsg;
753
+ // Write to reminder file so the child agent sees it on its next bash call.
754
+ writeReminderFile(reminderFilePath, `[Flow urge] ${remainingSec}s remaining before hard timeout. STOP all tool use and output your structured findings NOW.`);
647
755
  emitUpdate();
648
756
  }, urgeMs);
649
757
  urgeTimer.unref();
@@ -692,6 +800,7 @@ export async function runFlow(opts: RunFlowOptions): Promise<SingleResult> {
692
800
  return normalized;
693
801
  } finally {
694
802
  cleanupFlowTempDir(forkSessionTmpDir);
803
+ cleanupFlowTempDir(reminderTmpDir);
695
804
  }
696
805
  }
697
806
 
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ import { getInheritedCliArgs } from "./cli-args.js";
24
24
  import { renderFlowCall, renderFlowResult } from "./render.js";
25
25
  import { getFlowSummaryText } from "./runner-events.js";
26
26
  import { runHooks, runHooksDetailed, getRegisteredHooks, registerHook, unregisterHook } from "./hooks.js";
27
- import { mapFlowConcurrent, runFlow } from "./flow.js";
27
+ import { mapFlowConcurrent, runFlow, terminateAllChildGroups } from "./flow.js";
28
28
  import { executeFlows } from "./executor.js";
29
29
  import {
30
30
  type SingleResult,
@@ -1095,14 +1095,20 @@ flow [type] accomplished
1095
1095
  }
1096
1096
 
1097
1097
  // Register cleanup on process exit (once).
1098
- // We intentionally do NOT hook SIGINT/SIGTERM here. A plugin must not
1099
- // override the host process signal handling; doing so can cut off the
1100
- // host's terminal-cleanup logic and leave escape sequences or status
1101
- // lines visible on Ctrl+C. The 'exit' event fires for normal exits,
1102
- // including when the host handles the signal and calls process.exit().
1098
+ // We use prependListener on SIGINT/SIGTERM to propagate to child processes
1099
+ // before the host's own signal handler runs. This avoids orphaned sub-agents.
1100
+ // The host handler still runs afterward and handles terminal cleanup.
1103
1101
  if (!(globalThis as any).__pi_agent_flow_shutdown_registered) {
1104
1102
  (globalThis as any).__pi_agent_flow_shutdown_registered = true;
1103
+
1104
+ // Propagate signals to child process groups so sub-agents don't become orphans.
1105
+ // We use prependListener so our handler runs first, before the host's cleanup.
1106
+ process.prependListener("SIGINT", terminateAllChildGroups);
1107
+ process.prependListener("SIGTERM", terminateAllChildGroups);
1108
+
1109
+ // Also handle the 'exit' event, which fires when the host calls process.exit().
1105
1110
  const emitShutdown = () => {
1111
+ terminateAllChildGroups();
1106
1112
  if (typeof pi.emit === "function") {
1107
1113
  pi.emit("pi-agent-flow:shutdown", { reason: "process-exit" });
1108
1114
  }
@@ -108,12 +108,15 @@ export function getTruncationBudget(prefixLength: number): number {
108
108
  /** Fixed content budget for collapsed-line text (dir/act/log). */
109
109
  export const CONTENT_MAX = 60;
110
110
 
111
+ /** Longer budget for detail lines (act/msg) to show more content. */
112
+ export const DETAIL_CONTENT_MAX = 80;
113
+
111
114
  /**
112
115
  * Compute how many visible chars of content fit after a prefix,
113
- * using the fixed CONTENT_MAX budget. Floor of 8 to keep things readable.
116
+ * using the given budget (defaults to CONTENT_MAX). Floor of 8 to keep things readable.
114
117
  */
115
- export function contentBudget(prefixVisibleLen: number): number {
116
- return Math.max(CONTENT_MAX - prefixVisibleLen, 8);
118
+ export function contentBudget(prefixVisibleLen: number, max = CONTENT_MAX): number {
119
+ return Math.max(max - prefixVisibleLen, 8);
117
120
  }
118
121
 
119
122
  /**
package/src/render.ts CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  isFlowError,
23
23
  isFlowSuccess,
24
24
  } from "./types.js";
25
- import { formatCompactStats, formatCompactTokenPair, formatCountdown, formatFlowTypeName, truncateChars, tailText, contentBudget, visibleLength } from "./render-utils.js";
25
+ import { formatCompactStats, formatCompactTokenPair, formatCountdown, formatFlowTypeName, truncateChars, tailText, contentBudget, visibleLength, DETAIL_CONTENT_MAX } from "./render-utils.js";
26
26
 
27
27
  function shortenPath(p: string): string {
28
28
  const home = os.homedir();
@@ -33,6 +33,12 @@ type ThemeFg = (color: string, text: string) => string;
33
33
  type ThemeBg = (color: string, text: string) => string;
34
34
  type FlowTheme = { fg: ThemeFg; bold: (s: string) => string; bg: ThemeBg };
35
35
 
36
+ const COLLAPSED_FLOW_HEADER_TYPE_WIDTH = 8;
37
+
38
+ function formatCollapsedFlowHeaderTypeName(type: string): string {
39
+ return formatFlowTypeName(type).padEnd(COLLAPSED_FLOW_HEADER_TYPE_WIDTH, " ");
40
+ }
41
+
36
42
  function formatFlowToolCall(toolName: string, args: Record<string, unknown>, fg: ThemeFg): string {
37
43
  const pathArg = (args.file_path || args.path || "...") as string;
38
44
 
@@ -320,8 +326,8 @@ function renderFlowCollapsed(
320
326
  const container = new Container();
321
327
  const maxWidth = process.stdout.columns ?? 80;
322
328
  const stats = formatCompactStats(r.usage, r.model, maxWidth, { skipTokens: true });
323
- const typeName = formatFlowTypeName(r.type);
324
- let header = `${theme.fg("accent", theme.bold(typeName))} ${theme.fg("dim", stats)}`;
329
+ const typeName = formatCollapsedFlowHeaderTypeName(r.type);
330
+ let header = `${theme.fg("accent", theme.bold(typeName))}${theme.fg("dim", `· ${stats}`)}`;
325
331
  if (error && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
326
332
  container.addChild(new TruncatedText(header, 0, 0));
327
333
 
@@ -337,13 +343,13 @@ function renderFlowCollapsed(
337
343
  if (lastTool) {
338
344
  const actStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
339
345
  const actPrefix = `├─ act: [${r.usage.toolCalls}] - `;
340
- const actContent = truncateChars(actStr, contentBudget(visibleLength(actPrefix)));
346
+ const actContent = truncateChars(actStr, contentBudget(visibleLength(actPrefix), DETAIL_CONTENT_MAX));
341
347
  container.addChild(new TruncatedText(`${theme.fg("dim", actPrefix)}${actContent}`, 0, 0));
342
348
  }
343
349
 
344
350
  // msg: line (last assistant text or streaming)
345
351
  const msgPrefix = formatMsgLinePrefix("└─", r);
346
- const msgBudget = contentBudget(visibleLength(msgPrefix));
352
+ const msgBudget = contentBudget(visibleLength(msgPrefix), DETAIL_CONTENT_MAX);
347
353
  if (r.exitCode === -1 && streamingText) {
348
354
  const logContent = tailText(streamingText, msgBudget);
349
355
  container.addChild(new TruncatedText(`${theme.fg("dim", msgPrefix)}${theme.fg("dim", logContent)}`, 0, 0));
@@ -453,11 +459,11 @@ function renderActivityPanel(
453
459
  const isLast = i === results.length - 1;
454
460
  const stats = formatCompactStats(r.usage, r.model, maxWidth, { skipTokens: true });
455
461
  const error = isFlowError(r);
456
- const typeName = formatFlowTypeName(r.type);
462
+ const typeName = formatCollapsedFlowHeaderTypeName(r.type);
457
463
 
458
464
  // Header line
459
465
  const headerPrefix = isLast ? "└─" : "├─";
460
- let headerLine = `${theme.fg("dim", headerPrefix)} ${theme.fg("accent", theme.bold(typeName))} ${theme.fg("dim", stats)}`;
466
+ let headerLine = `${theme.fg("dim", headerPrefix)} ${theme.fg("accent", theme.bold(typeName))}${theme.fg("dim", `· ${stats}`)}`;
461
467
  if (error && r.stopReason) {
462
468
  headerLine += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
463
469
  }
@@ -478,13 +484,13 @@ function renderActivityPanel(
478
484
  if (lastTool) {
479
485
  const actStr = formatFlowToolCall(lastTool.name, lastTool.args, theme.fg.bind(theme));
480
486
  const actPrefix = `${indent}├─ act: [${r.usage.toolCalls}] - `;
481
- const actContent = truncateChars(actStr, contentBudget(visibleLength(actPrefix)));
487
+ const actContent = truncateChars(actStr, contentBudget(visibleLength(actPrefix), DETAIL_CONTENT_MAX));
482
488
  container.addChild(new TruncatedText(`${theme.fg("dim", actPrefix)}${actContent}`, 0, 0));
483
489
  }
484
490
 
485
491
  // msg: line (live streaming text or last assistant text)
486
492
  const msgPrefix = formatMsgLinePrefix(indent + "└─", r);
487
- const msgBudget = contentBudget(visibleLength(msgPrefix));
493
+ const msgBudget = contentBudget(visibleLength(msgPrefix), DETAIL_CONTENT_MAX);
488
494
  const liveText = r.exitCode === -1 ? r.streamingText : undefined;
489
495
  const lastText = liveText || getLastAssistantText(r.messages);
490
496
  if (lastText) {
@@ -1,15 +1,16 @@
1
- export const AGENT_SESSION_MODES = ["fast", "default", "long"] as const;
1
+ export const AGENT_SESSION_MODES = ["fast", "default", "long", "extreme_long"] as const;
2
2
 
3
3
  export type AgentSessionMode = typeof AGENT_SESSION_MODES[number];
4
4
 
5
5
  export const DEFAULT_AGENT_SESSION_MODE: AgentSessionMode = "default";
6
- export const MAX_AGENT_SESSION_TIMEOUT_MS = 900_000;
6
+ export const MAX_AGENT_SESSION_TIMEOUT_MS = 1_200_000;
7
7
  export const PI_FLOW_SESSION_MODE_ENV = "PI_FLOW_SESSION_MODE";
8
8
 
9
9
  export const AGENT_SESSION_TIMEOUTS_MS: Record<AgentSessionMode, number> = {
10
10
  fast: 300_000,
11
11
  default: 600_000,
12
- long: MAX_AGENT_SESSION_TIMEOUT_MS,
12
+ long: 900_000,
13
+ extreme_long: MAX_AGENT_SESSION_TIMEOUT_MS,
13
14
  };
14
15
 
15
16
  export function parseAgentSessionMode(value: unknown): AgentSessionMode | undefined {
package/src/timed-bash.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  * still active.
14
14
  */
15
15
 
16
+ import * as fs from "node:fs";
16
17
  import { createBashToolDefinition } from "@mariozechner/pi-coding-agent";
17
18
 
18
19
  export type TimingTier =
@@ -30,6 +31,7 @@ export interface TimingReport {
30
31
 
31
32
  const FLOW_DEADLINE_ENV = "PI_FLOW_DEADLINE_MS";
32
33
  const FLOW_TOOL_SUMMARY_GRACE_ENV = "PI_FLOW_TOOL_SUMMARY_GRACE_MS";
34
+ const FLOW_REMINDER_FILE_ENV = "PI_FLOW_REMINDER_FILE";
33
35
  const DEFAULT_FLOW_TOOL_SUMMARY_GRACE_MS = 30_000;
34
36
 
35
37
  /** Classify duration into user-defined tiers with actionable feedback. */
@@ -87,6 +89,30 @@ function getFlowToolSummaryGraceMs(): number {
87
89
  return parseNonNegativeSafeInteger(process.env[FLOW_TOOL_SUMMARY_GRACE_ENV]) ?? DEFAULT_FLOW_TOOL_SUMMARY_GRACE_MS;
88
90
  }
89
91
 
92
+ function getFlowReminderFilePath(): string | null {
93
+ const raw = process.env[FLOW_REMINDER_FILE_ENV];
94
+ return typeof raw === "string" && raw.trim() ? raw.trim() : null;
95
+ }
96
+
97
+ /**
98
+ * Read any pending reminder from the reminder file set by the parent runner.
99
+ * Returns the reminder text (without trailing newline), or null if no reminder exists.
100
+ * Clears the file after reading so the agent only sees each reminder once.
101
+ */
102
+ export function readAndClearReminderFile(): string | null {
103
+ const filePath = getFlowReminderFilePath();
104
+ if (!filePath) return null;
105
+ try {
106
+ if (!fs.existsSync(filePath)) return null;
107
+ const content = fs.readFileSync(filePath, { encoding: "utf-8" }).trim();
108
+ // Clear the file after reading so each reminder is only injected once.
109
+ try { fs.writeFileSync(filePath, "", { encoding: "utf-8" }); } catch { /* best-effort */ }
110
+ return content || null;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
90
116
  function formatDeadlineAppendix(): string {
91
117
  return "\n\n[Flow timeout] Bash command was interrupted to preserve time for the final flow summary. Stop running tools and return structured findings now.";
92
118
  }
@@ -189,6 +215,12 @@ export function createTimedBashToolDefinition(
189
215
  ) {
190
216
  const start = Date.now();
191
217
  const deadlineSignal = createDeadlineSignal(signal);
218
+
219
+ // Check for pending reminder from parent runner before executing bash.
220
+ // This allows the parent to inject timeout warnings into the agent's context
221
+ // via the tool result, since stdin is closed after spawn.
222
+ const reminderPreamble = readAndClearReminderFile();
223
+
192
224
  try {
193
225
  const result = await original.execute(
194
226
  toolCallId,
@@ -201,6 +233,10 @@ export function createTimedBashToolDefinition(
201
233
  const report = classifyDuration(duration);
202
234
  const appendix = formatTimingAppendix(report);
203
235
 
236
+ // Inject reminder preamble before timing info so it's the first thing the agent sees.
237
+ if (reminderPreamble) {
238
+ appendTextToToolResult(result, `\n\n[REMINDER FROM PARENT] ${reminderPreamble}`);
239
+ }
204
240
  appendTextToToolResult(result, appendix);
205
241
  if (deadlineSignal.wasDeadlineAbort()) {
206
242
  appendTextToToolResult(result, formatDeadlineAppendix());