@ferris1225/pi-subagents 0.14.0 → 0.16.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/README.md CHANGED
@@ -23,10 +23,13 @@ agent, and keep the workflow moving without manual polling.
23
23
  elapsed time; completion also produces a concise notification.
24
24
  - **Per-agent configuration** — enable agents, pick model and thinking strength per agent,
25
25
  tune concurrency limits, and choose discovery scope from `/subagents-setup`.
26
+ - **Idle watchdog** — a sub-agent whose stdout goes silent for a configurable
27
+ duration is terminated and retried with the fallback model, so a stalled SSE
28
+ stream never hangs the workflow.
26
29
  - **Automatic model fallback** — if an agent's model fails at the provider level before
27
- producing any output, the run is retried once with the main window's current model.
28
- Per-run only, never persisted: a transient provider hiccup does not silently downgrade
29
- the configured model.
30
+ producing any output (or the idle watchdog fires), the run is retried once with the
31
+ main window's current model. Per-run only, never persisted: a transient provider
32
+ hiccup does not silently downgrade the configured model.
30
33
  - **Leaf processes** — child agents cannot access the `subagent` tool, so delegation cannot
31
34
  recurse.
32
35
 
@@ -331,7 +334,8 @@ agent's default — its frontmatter `thinking`, else the global default). The gl
331
334
  "proactiveInjection": true,
332
335
  "agentScope": "user",
333
336
  "maxConcurrency": 4,
334
- "maxFixRounds": 2
337
+ "maxFixRounds": 2,
338
+ "idleTimeoutSec": 90
335
339
  }
336
340
  ```
337
341
 
@@ -347,6 +351,7 @@ agent's default — its frontmatter `thinking`, else the global default). The gl
347
351
  | `agentScope` | `user`, `project`, or `both`; controls which user/project agent directories are discovered. |
348
352
  | `maxConcurrency` | Max sub-agent processes running at once (1–16, default 4), and the max tasks one parallel `subagent` call accepts. Extra work waits in the queue. |
349
353
  | `maxFixRounds` | Auto-fix rounds when a reviewer returns `REVIEW_FAIL`: the extension dispatches a `worker` (briefed with the review's concrete findings) then a `reviewer` re-review, repeating up to this many times before waking the main agent with the full chain. `0` disables it (the main agent handles fixes itself). Default 2. The reviewer stays read-only and in its own context; the loop is orchestrated by the extension, not by the reviewer. |
354
+ | `idleTimeoutSec` | Idle timeout in seconds: a sub-agent whose stdout (JSON event stream) goes silent for this long is terminated and retried with the fallback model (if one is available). `0` disables the idle watchdog. Default 90. Unlike the total timeout, this only fires when the child produces no output at all — a long but active run is never interrupted. |
350
355
 
351
356
  ### Configuration migration
352
357
 
@@ -361,6 +366,8 @@ The config file migrates itself on load — no manual steps after an upgrade:
361
366
  - **Removed keys** — `maxSubagentDepth` (0.14) is dropped on load: sub-agent children are
362
367
  always leaf processes (the `subagent` tool is excluded from their toolset, with a depth
363
368
  marker as defense in depth). To disable delegation entirely, use `"enabledAgents": []`.
369
+ - **New fields** — `idleTimeoutSec` (0.16) is filled in on load with its default (90)
370
+ when missing from an older config.
364
371
 
365
372
  Model selection uses this precedence:
366
373
 
@@ -375,8 +382,10 @@ At runtime, if an agent's model fails at the provider level before producing any
375
382
  model id, auth, thinking level, quota, ...), the run is retried **once** with the main window's
376
383
  current model. This per-run degradation is never persisted — a transient provider hiccup must
377
384
  not silently downgrade the configured model — and it does not apply to task-level failures
378
- (the model worked, the task failed), aborts, or timeouts. Results carry a `model fell back
379
- from …` note when it happened.
385
+ (the model worked, the task failed), aborts, or total timeouts. Idle timeouts (the child's
386
+ stdout goes silent for `idleTimeoutSec` seconds) are treated as model-level failures and do
387
+ trigger the fallback, since a stalled SSE stream is usually a provider-side issue. Results
388
+ carry a `model fell back from …` note when it happened.
380
389
 
381
390
  Thinking strength uses this precedence: `agentThinkingLevels` entry → agent frontmatter `thinking` → `thinkingLevel` default.
382
391
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Focused sub-agent delegation for pi: explore / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -58,6 +58,15 @@ export const DEFAULT_MAX_FIX_ROUNDS = 2;
58
58
  /** Upper bound accepted for maxFixRounds (defensive clamp). 0 disables the loop. */
59
59
  export const MAX_FIX_ROUNDS_LIMIT = 5;
60
60
 
61
+ /**
62
+ * Default idle timeout in seconds: a sub-agent whose stdout (JSON event stream)
63
+ * goes silent for this long is terminated and may be retried with the fallback
64
+ * model. 0 disables the idle watchdog. Default: 90.
65
+ */
66
+ export const DEFAULT_IDLE_TIMEOUT_SEC = 90;
67
+ /** Upper bound accepted for idleTimeoutSec (defensive clamp). 0 disables. */
68
+ export const IDLE_TIMEOUT_SEC_LIMIT = 600;
69
+
61
70
  export interface SubagentsConfig {
62
71
  /** Agent names that are discoverable and injected. Default: explore, worker, reviewer. */
63
72
  enabledAgents: string[];
@@ -93,6 +102,12 @@ export interface SubagentsConfig {
93
102
  * Default: 2.
94
103
  */
95
104
  maxFixRounds: number;
105
+ /**
106
+ * Idle timeout in seconds: a sub-agent whose stdout (JSON event stream) goes
107
+ * silent for this long is terminated and may be retried with the fallback
108
+ * model. 0 disables the idle watchdog. Default: 90.
109
+ */
110
+ idleTimeoutSec: number;
96
111
  }
97
112
 
98
113
  export const DEFAULT_CONFIG: SubagentsConfig = {
@@ -106,6 +121,7 @@ export const DEFAULT_CONFIG: SubagentsConfig = {
106
121
  agentScope: "user",
107
122
  maxConcurrency: DEFAULT_MAX_CONCURRENCY,
108
123
  maxFixRounds: DEFAULT_MAX_FIX_ROUNDS,
124
+ idleTimeoutSec: DEFAULT_IDLE_TIMEOUT_SEC,
109
125
  };
110
126
 
111
127
  export function getConfigPath(agentDir: string = getAgentDir()): string {
@@ -151,6 +167,7 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
151
167
  agentScope: DEFAULT_CONFIG.agentScope,
152
168
  maxConcurrency: DEFAULT_CONFIG.maxConcurrency,
153
169
  maxFixRounds: DEFAULT_CONFIG.maxFixRounds,
170
+ idleTimeoutSec: DEFAULT_CONFIG.idleTimeoutSec,
154
171
  };
155
172
 
156
173
  if (Array.isArray(raw.enabledAgents)) {
@@ -218,6 +235,11 @@ export function normalizeConfig(raw: unknown): SubagentsConfig {
218
235
  config.maxFixRounds = Math.max(0, Math.min(MAX_FIX_ROUNDS_LIMIT, Math.round(raw.maxFixRounds)));
219
236
  }
220
237
 
238
+ // 0 disables the idle watchdog; otherwise clamp to [0, upper].
239
+ if (typeof raw.idleTimeoutSec === "number" && Number.isFinite(raw.idleTimeoutSec)) {
240
+ config.idleTimeoutSec = Math.max(0, Math.min(IDLE_TIMEOUT_SEC_LIMIT, Math.round(raw.idleTimeoutSec)));
241
+ }
242
+
221
243
  return config;
222
244
  }
223
245
 
package/src/index.ts CHANGED
@@ -134,7 +134,7 @@ function formatCompletionBlock(result: SingleResult, maxResultLines: number): st
134
134
  const fallbackNote = result.modelFallbackFrom
135
135
  ? ` (model fell back from ${result.modelFallbackFrom} to ${result.model ?? "main-window model"})`
136
136
  : "";
137
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}`, "", `Task: ${formatTaskSummary(result.task)}`, "", text];
137
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
138
138
  if (truncated) {
139
139
  // The full text lives on disk so the main agent can read it on demand.
140
140
  lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent)})`);
@@ -375,6 +375,7 @@ export default function (pi: ExtensionAPI): void {
375
375
  signal,
376
376
  onLive,
377
377
  makeDetails: makeDetails("single", true),
378
+ idleTimeoutMs: config.idleTimeoutSec * 1000,
378
379
  },
379
380
  sessionRef,
380
381
  );
@@ -477,6 +478,7 @@ export default function (pi: ExtensionAPI): void {
477
478
  signal: backgroundSignal,
478
479
  onLive,
479
480
  makeDetails: makeDetails("single", true),
481
+ idleTimeoutMs: config.idleTimeoutSec * 1000,
480
482
  },
481
483
  sessionRef,
482
484
  );
@@ -587,14 +589,14 @@ export default function (pi: ExtensionAPI): void {
587
589
  if (args.tasks && args.tasks.length > 0) {
588
590
  let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
589
591
  for (const t of args.tasks.slice(0, 4)) {
590
- const preview = t.task.length > 48 ? `${t.task.slice(0, 48)}…` : t.task;
592
+ const preview = formatTaskSummary(t.task, 48);
591
593
  text += `\n ${theme.fg("accent", t.agent)} ${theme.fg("dim", preview)}`;
592
594
  }
593
595
  if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
594
596
  return new Text(text, 0, 0);
595
597
  }
596
598
  const task: string = args.task ?? "";
597
- const preview = task.length > 60 ? `${task.slice(0, 60)}…` : task;
599
+ const preview = formatTaskSummary(task, 60);
598
600
  return new Text(
599
601
  `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")} ${theme.fg("dim", preview)}`,
600
602
  0,
@@ -663,9 +665,9 @@ export default function (pi: ExtensionAPI): void {
663
665
  // parent reviewer; summarize() already carries the relationLabel.
664
666
  const head = r.groupId ? theme.fg("dim", " ↳ ") : " ";
665
667
  const note = r.annotation ? theme.fg("dim", ` · ${r.annotation}`) : "";
666
- lines.push(truncateToWidth(`${head}${icon} ${monitor.summarize(r)} · ${label}${note}`, width, ""));
668
+ lines.push(truncateToWidth(`${head}${icon} #${r.id} ${monitor.summarize(r)} · ${label}${note}`, width, ""));
667
669
  if (r.status === "queued" || r.status === "running") {
668
- lines.push(truncateToWidth(theme.fg("dim", ` task: ${formatTaskSummary(r.task)}`), width, ""));
670
+ lines.push(truncateToWidth(theme.fg("dim", ` task: ${formatTaskSummary(r.task, Math.max(20, width - 11))}`), width, ""));
669
671
  }
670
672
  // Activity sits one indent level below the agent name.
671
673
  if (r.activity) lines.push(truncateToWidth(theme.fg("dim", ` ${r.activity}`), width, ""));
package/src/monitor.ts CHANGED
@@ -56,23 +56,145 @@ export interface RunChainMeta {
56
56
 
57
57
  const TASK_SUMMARY_MAX = 80;
58
58
  const TASK_SUMMARY_ELLIPSIS = "…";
59
+ /** Columns reserved at the END of a truncated summary so the distinguishing
60
+ * keywords (paths, symbols, ...) survive; the head gets the rest. */
61
+ const TASK_SUMMARY_TAIL_MAX = 28;
62
+ /** Tail share of a non-default maxWidth (narrow widgets keep a usable tail). */
63
+ const TASK_SUMMARY_TAIL_SHARE = 0.35;
64
+ const TASK_SUMMARY_TAIL_MIN = 8;
65
+ const TASK_SUMMARY_KEY_SEP = " · ";
66
+ /** kebab/snake words that are task boilerplate, never distinguishing signal. */
67
+ const KEY_FRAGMENT_STOPWORDS = new Set([
68
+ "self-contained",
69
+ "read-only",
70
+ "write-only",
71
+ "auto-fix",
72
+ "re-review",
73
+ "one-line",
74
+ "pre-commit",
75
+ ]);
59
76
  const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
60
77
 
61
- /** One-line task preview, capped by terminal display columns (including the ellipsis). */
62
- export function formatTaskSummary(task: string): string {
63
- const oneLine = stripVTControlCharacters(task).replace(/\s+/g, " ").trim();
64
- if (visibleWidth(oneLine) <= TASK_SUMMARY_MAX) return oneLine;
78
+ interface KeyFragment {
79
+ text: string;
80
+ index: number;
81
+ }
65
82
 
66
- const prefixMax = TASK_SUMMARY_MAX - visibleWidth(TASK_SUMMARY_ELLIPSIS);
67
- let prefix = "";
68
- let prefixWidth = 0;
69
- for (const { segment } of graphemeSegmenter.segment(oneLine)) {
83
+ /**
84
+ * Pull the most distinguishing fragments out of a task: file paths, quoted
85
+ * phrases, camelCase/PascalCase symbols and kebab/snake compounds. Sorted by
86
+ * first occurrence and deduped (a path covers its own sub-fragments). These
87
+ * are what make parallel tasks of the same agent look different.
88
+ */
89
+ export function extractKeyFragments(text: string): string[] {
90
+ const fragments: KeyFragment[] = [];
91
+ const add = (re: RegExp, group = 0): void => {
92
+ for (const m of text.matchAll(re)) {
93
+ const g = m[group];
94
+ if (g === undefined) continue;
95
+ fragments.push({ text: g, index: m.index ?? 0 });
96
+ }
97
+ };
98
+ // Quoted phrases first (highest signal).
99
+ add(/["'`]([^"'`]{4,60})["'`]/g, 1);
100
+ // Paths with a known extension (src/index.ts, build/out.js.map).
101
+ add(/(?<![A-Za-z0-9_.-])[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,5}(?![A-Za-z0-9_.-])/g);
102
+ // Paths with a slash but no extension (src/components, .github/workflows).
103
+ add(/(?<![A-Za-z0-9_.-])[A-Za-z0-9_.-]+(?:[\\/][A-Za-z0-9_.-]+)+(?![\\/])/g);
104
+ // camelCase / PascalCase identifiers (function or type names).
105
+ add(/\b[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*\b/g);
106
+ // snake_case / kebab-case compound words.
107
+ add(/\b[a-z][a-z0-9]+[-_][a-z0-9][a-z0-9_-]*\b/g);
108
+
109
+ fragments.sort((a, b) => a.index - b.index);
110
+ const seen = new Set<string>();
111
+ const out: string[] = [];
112
+ for (const f of fragments) {
113
+ const t = f.text.trim();
114
+ if (t.length < 4 || KEY_FRAGMENT_STOPWORDS.has(t)) continue;
115
+ if (seen.has(t)) continue;
116
+ // A longer fragment (the full path) covers its own sub-fragments.
117
+ if (out.some((o) => o.includes(t) || t.includes(o))) continue;
118
+ seen.add(t);
119
+ out.push(t);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function takeGraphemes(segments: string[], maxWidth: number): string {
125
+ let width = 0;
126
+ const out: string[] = [];
127
+ for (const segment of segments) {
70
128
  const segmentWidth = visibleWidth(segment);
71
- if (prefixWidth + segmentWidth > prefixMax) break;
72
- prefix += segment;
73
- prefixWidth += segmentWidth;
129
+ if (width + segmentWidth > maxWidth) break;
130
+ out.push(segment);
131
+ width += segmentWidth;
132
+ }
133
+ return out.join("");
134
+ }
135
+
136
+ function tailGraphemes(segments: string[], maxWidth: number): string {
137
+ let width = 0;
138
+ const tail: string[] = [];
139
+ for (let i = segments.length - 1; i >= 0; i--) {
140
+ const segmentWidth = visibleWidth(segments[i]);
141
+ if (width + segmentWidth > maxWidth) break;
142
+ tail.unshift(segments[i]);
143
+ width += segmentWidth;
144
+ }
145
+ return tail.join("");
146
+ }
147
+
148
+ /**
149
+ * One-line task preview, capped by `maxWidth` display columns (default 80).
150
+ * `keysOnly` (default): extracted key fragments (paths, quoted phrases,
151
+ * symbols) are shown bare — the agent name is already displayed next to the
152
+ * task line, so templated prose ("explore: trace how ...") adds nothing.
153
+ * `keysOnly: false` keeps the prose as `head…tail` (used for completion
154
+ * messages, where the Task line is the reader's only context).
155
+ * Grapheme-safe — CJK, ZWJ emoji and combining sequences are never split.
156
+ */
157
+ export function formatTaskSummary(task: string, maxWidth: number = TASK_SUMMARY_MAX, keysOnly = true): string {
158
+ const oneLine = stripVTControlCharacters(task).replace(/\s+/g, " ").trim();
159
+ if (maxWidth <= 0 || visibleWidth(oneLine) <= maxWidth) return oneLine;
160
+
161
+ const segments = [...graphemeSegmenter.segment(oneLine)].map((s) => s.segment);
162
+ const ellipsisWidth = visibleWidth(TASK_SUMMARY_ELLIPSIS);
163
+
164
+ if (keysOnly) {
165
+ const fragments = extractKeyFragments(oneLine);
166
+ if (fragments.length > 0) {
167
+ const keyMax = maxWidth - 1;
168
+ let keys = "";
169
+ for (const fragment of fragments) {
170
+ const piece = keys ? `${TASK_SUMMARY_KEY_SEP}${fragment}` : fragment;
171
+ const total = keys + piece;
172
+ if (visibleWidth(total) > keyMax) {
173
+ // Budget exhausted: keep what fits, unless nothing fits yet.
174
+ if (!keys) {
175
+ // A single over-long fragment keeps its tail
176
+ // (extension/symbol) and is prefixed with the ellipsis.
177
+ const fragmentSegments = [...graphemeSegmenter.segment(piece)].map((s) => s.segment);
178
+ keys = `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(fragmentSegments, keyMax - ellipsisWidth)}`;
179
+ }
180
+ break;
181
+ }
182
+ keys = total;
183
+ }
184
+ return keys;
185
+ }
186
+ }
187
+
188
+ // No distinctive fragments (or prose mode): fall back to head…tail.
189
+ const tailMax = Math.max(
190
+ TASK_SUMMARY_TAIL_MIN,
191
+ Math.min(TASK_SUMMARY_TAIL_MAX, Math.round(maxWidth * TASK_SUMMARY_TAIL_SHARE)),
192
+ );
193
+ const headMax = maxWidth - ellipsisWidth - tailMax;
194
+ if (headMax <= 0) {
195
+ return `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(segments, maxWidth - ellipsisWidth)}`;
74
196
  }
75
- return `${prefix}${TASK_SUMMARY_ELLIPSIS}`;
197
+ return `${takeGraphemes(segments, headMax)}${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(segments, tailMax)}`;
76
198
  }
77
199
 
78
200
  function formatTokens(count: number): string {
package/src/setup.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  BUILTIN_AGENT_NAMES,
15
15
  DEFAULT_CONFIG,
16
16
  DEFAULT_ENABLED_AGENTS,
17
+ DEFAULT_IDLE_TIMEOUT_SEC,
17
18
  DEFAULT_MAX_CONCURRENCY,
18
19
  DEFAULT_MAX_FIX_ROUNDS,
19
20
  THINKING_LEVEL_VALUES,
@@ -197,6 +198,8 @@ async function pickInjection(ctx: ExtensionCommandContext, current: boolean): Pr
197
198
  const CONCURRENCY_STEPS = [1, 2, 3, 4, 6, 8, 12, 16];
198
199
  /** Preset rounds offered for the auto-fix loop (0 disables it). */
199
200
  const FIX_ROUNDS_STEPS = [0, 1, 2, 3, 5];
201
+ /** Preset seconds offered for the idle timeout (0 disables it). */
202
+ const IDLE_TIMEOUT_STEPS = [0, 30, 60, 90, 120, 180, 300, 600];
200
203
 
201
204
  async function pickCount(
202
205
  ctx: ExtensionCommandContext,
@@ -298,6 +301,15 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
298
301
  );
299
302
  if (maxFixRounds === undefined) return notifyCancelled(ctx);
300
303
 
304
+ const idleTimeoutSec = await pickCount(
305
+ ctx,
306
+ "Idle timeout in seconds? (0 = disabled, kills a sub-agent whose output goes silent)",
307
+ IDLE_TIMEOUT_STEPS,
308
+ base.idleTimeoutSec,
309
+ DEFAULT_IDLE_TIMEOUT_SEC,
310
+ );
311
+ if (idleTimeoutSec === undefined) return notifyCancelled(ctx);
312
+
301
313
  const next: SubagentsConfig = {
302
314
  enabledAgents: enabled,
303
315
  agentModels: repairStaleModels(ctx, picked.models),
@@ -309,6 +321,7 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
309
321
  agentScope: scope,
310
322
  maxConcurrency,
311
323
  maxFixRounds,
324
+ idleTimeoutSec,
312
325
  };
313
326
  await saveConfig(next, configPath);
314
327
  ctx.ui.notify(`pi-subagents configured. Saved to ${configPath}`, "info");
@@ -323,6 +336,7 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
323
336
  "Change agent scope",
324
337
  "Change max concurrent sub-agents",
325
338
  "Change max fix rounds",
339
+ "Change idle timeout",
326
340
  "Full re-setup",
327
341
  ]);
328
342
  if (choice === undefined) return notifyCancelled(ctx);
@@ -384,6 +398,16 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
384
398
  );
385
399
  if (maxFixRounds === undefined) return notifyCancelled(ctx);
386
400
  next.maxFixRounds = maxFixRounds;
401
+ } else if (choice.startsWith("Change idle")) {
402
+ const idleTimeoutSec = await pickCount(
403
+ ctx,
404
+ "Idle timeout in seconds? (0 = disabled, kills a sub-agent whose output goes silent)",
405
+ IDLE_TIMEOUT_STEPS,
406
+ config.idleTimeoutSec,
407
+ DEFAULT_IDLE_TIMEOUT_SEC,
408
+ );
409
+ if (idleTimeoutSec === undefined) return notifyCancelled(ctx);
410
+ next.idleTimeoutSec = idleTimeoutSec;
387
411
  }
388
412
 
389
413
  await saveConfig(next, configPath);
package/src/spawn.ts CHANGED
@@ -31,6 +31,10 @@ export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
31
31
  /** No default deadline: sub-agents may run until completion or explicit cancellation. */
32
32
  export const SUBAGENT_TIMEOUT_MS = 0;
33
33
  export const SUBAGENT_KILL_GRACE_MS = 5_000;
34
+ /** Default idle watchdog: terminate a child whose stdout goes silent for this
35
+ * many milliseconds. 0 disables it. The actual value comes from config
36
+ * (idleTimeoutSec); this constant is only a fallback for tests. */
37
+ export const SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS = 0;
34
38
 
35
39
  export interface UsageStats {
36
40
  input: number;
@@ -155,6 +159,10 @@ export function isFailedResult(result: SingleResult): boolean {
155
159
  export function isModelLevelFailure(result: SingleResult): boolean {
156
160
  if (!isFailedResult(result)) return false;
157
161
  if (result.stopReason === "aborted") return false;
162
+ // An idle timeout (stdout went silent) signals a stalled provider connection,
163
+ // not a task-level failure: allow model fallback even if the model produced
164
+ // partial output before going quiet.
165
+ if (result.errorMessage?.includes("idle timeout")) return true;
158
166
  // The model produced text: the failure belongs to the task, not the model.
159
167
  if (getFinalOutput(result.messages)) return false;
160
168
  if (result.errorMessage?.includes("timed out")) return false;
@@ -253,8 +261,11 @@ export interface RunSingleOptions {
253
261
  cwd?: string;
254
262
  /** Thinking level passed to the child pi process. */
255
263
  thinkingLevel?: ThinkingLevel;
256
- /** Optional timeout; zero (the default) disables it. Intended for tests and controlled callers. */
264
+ /** Optional total timeout; zero (the default) disables it. Intended for tests and controlled callers. */
257
265
  timeoutMs?: number;
266
+ /** Idle timeout in ms: terminate the child if its stdout produces no activity
267
+ * for this duration. 0 (the default) disables the idle watchdog. */
268
+ idleTimeoutMs?: number;
258
269
  signal?: AbortSignal;
259
270
  onUpdate?: OnUpdateCallback;
260
271
  onLive?: (e: SubagentLiveEvent) => void;
@@ -271,6 +282,7 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
271
282
  cwd,
272
283
  thinkingLevel = SUBAGENT_THINKING_LEVEL,
273
284
  timeoutMs = SUBAGENT_TIMEOUT_MS,
285
+ idleTimeoutMs = SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS,
274
286
  signal,
275
287
  onUpdate,
276
288
  onLive,
@@ -351,12 +363,15 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
351
363
  let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
352
364
  let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
353
365
  let abortHandler: (() => void) | undefined;
366
+ let lastActivityAt = Date.now();
367
+ let idleTimer: ReturnType<typeof setInterval> | undefined;
354
368
 
355
369
  const finish = (code: number | null): void => {
356
370
  if (closed) return;
357
371
  closed = true;
358
372
  if (forceKillTimer) clearTimeout(forceKillTimer);
359
373
  if (timeoutTimer) clearTimeout(timeoutTimer);
374
+ if (idleTimer) clearInterval(idleTimer);
360
375
  if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
361
376
  resolve(code ?? 1);
362
377
  };
@@ -466,6 +481,7 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
466
481
  // message (including a reviewer's verdict line) from parsing.
467
482
  const stdoutDecoder = new StringDecoder("utf8");
468
483
  proc.stdout.on("data", (data) => {
484
+ lastActivityAt = Date.now();
469
485
  buffer += stdoutDecoder.write(data);
470
486
  const lines = buffer.split("\n");
471
487
  buffer = lines.pop() || "";
@@ -519,6 +535,20 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
519
535
  }, timeoutMs);
520
536
  }
521
537
 
538
+ if (idleTimeoutMs > 0) {
539
+ const checkInterval = Math.min(10_000, Math.floor(idleTimeoutMs / 3));
540
+ idleTimer = setInterval(() => {
541
+ if (closed) return;
542
+ if (Date.now() - lastActivityAt >= idleTimeoutMs) {
543
+ if (idleTimer) clearInterval(idleTimer);
544
+ timedOut = true;
545
+ currentResult.stopReason = "error";
546
+ currentResult.errorMessage = `Subagent idle timeout: no activity for ${Math.ceil(idleTimeoutMs / 1000)} seconds.`;
547
+ terminate();
548
+ }
549
+ }, checkInterval);
550
+ }
551
+
522
552
  if (signal) {
523
553
  abortHandler = (): void => {
524
554
  wasAborted = true;