@ferris1225/pi-subagents 0.15.0 → 0.16.1

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. 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,7 +382,9 @@ 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
385
+ (the model worked, the task failed) or aborts. Idle timeouts (the child's stdout goes silent
386
+ for `idleTimeoutSec` seconds) are treated as model-level failures and do trigger the fallback,
387
+ since a stalled SSE stream is usually a provider-side issue. Results carry a `model fell back
379
388
  from …` note when it happened.
380
389
 
381
390
  Thinking strength uses this precedence: `agentThinkingLevels` entry → agent frontmatter `thinking` → `thinkingLevel` default.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ferris1225/pi-subagents",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
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
@@ -12,7 +12,6 @@
12
12
  * runaway recursion and keeps child context windows clean.
13
13
  */
14
14
 
15
- import type { AgentToolResult } from "@earendil-works/pi-agent-core";
16
15
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
16
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
18
17
  import { Type } from "typebox";
@@ -31,7 +30,6 @@ import { buildDelegationDirective } from "./prompt.ts";
31
30
  import { runSetup } from "./setup.ts";
32
31
  import {
33
32
  currentSubagentDepth,
34
- getFinalOutput,
35
33
  getResultOutput,
36
34
  isFailedResult,
37
35
  reviewVerdict,
@@ -217,7 +215,7 @@ export default function (pi: ExtensionAPI): void {
217
215
  ],
218
216
  parameters: SubagentParams,
219
217
 
220
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
218
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
221
219
  monitor.beginTurn();
222
220
  let config = await loadConfig(configPath);
223
221
  // Pick up concurrency changes from /subagents-setup without a restart.
@@ -375,6 +373,7 @@ export default function (pi: ExtensionAPI): void {
375
373
  signal,
376
374
  onLive,
377
375
  makeDetails: makeDetails("single", true),
376
+ idleTimeoutMs: config.idleTimeoutSec * 1000,
378
377
  },
379
378
  sessionRef,
380
379
  );
@@ -477,6 +476,7 @@ export default function (pi: ExtensionAPI): void {
477
476
  signal: backgroundSignal,
478
477
  onLive,
479
478
  makeDetails: makeDetails("single", true),
479
+ idleTimeoutMs: config.idleTimeoutSec * 1000,
480
480
  },
481
481
  sessionRef,
482
482
  );
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
@@ -4,8 +4,7 @@
4
4
  * written to a temp file and passed via `--append-system-prompt` (which accepts a
5
5
  * file path). The task itself is sent through the child's stdin pipe, not another
6
6
  * temp file or command-line argument. Child stdout is a JSON-lines event stream;
7
- * we accumulate assistant messages from `message_end` events and stream partial
8
- * output back via onUpdate.
7
+ * we accumulate assistant messages from `message_end` events.
9
8
  *
10
9
  * Adapted from the official pi example `examples/extensions/subagent`.
11
10
  */
@@ -16,7 +15,6 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
16
15
  import { tmpdir } from "node:os";
17
16
  import { basename, join } from "node:path";
18
17
  import { StringDecoder } from "node:string_decoder";
19
- import type { AgentToolResult } from "@earendil-works/pi-agent-core";
20
18
  import type { Message } from "@earendil-works/pi-ai";
21
19
  import type { AgentConfig, AgentSource } from "./agents.ts";
22
20
  import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
@@ -28,9 +26,11 @@ import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
28
26
  /** Default thinking level for sub-agents. pi clamps it to the resolved model's support. */
29
27
  export const SUBAGENT_THINKING_LEVEL: ThinkingLevel = DEFAULT_THINKING_LEVEL;
30
28
  export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
31
- /** No default deadline: sub-agents may run until completion or explicit cancellation. */
32
- export const SUBAGENT_TIMEOUT_MS = 0;
33
29
  export const SUBAGENT_KILL_GRACE_MS = 5_000;
30
+ /** Default idle watchdog: terminate a child whose stdout goes silent for this
31
+ * many milliseconds. 0 disables it. The actual value comes from config
32
+ * (idleTimeoutSec); this constant is only a fallback for tests. */
33
+ export const SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS = 0;
34
34
 
35
35
  export interface UsageStats {
36
36
  input: number;
@@ -66,8 +66,6 @@ export interface SubagentDetails {
66
66
  background?: boolean;
67
67
  }
68
68
 
69
- export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
70
-
71
69
  export type SubagentLiveEvent =
72
70
  | { kind: "status"; status: "queued" | "running" | "done" | "failed" }
73
71
  | { kind: "usage"; usage: UsageStats; model?: string }
@@ -155,9 +153,12 @@ export function isFailedResult(result: SingleResult): boolean {
155
153
  export function isModelLevelFailure(result: SingleResult): boolean {
156
154
  if (!isFailedResult(result)) return false;
157
155
  if (result.stopReason === "aborted") return false;
156
+ // An idle timeout (stdout went silent) signals a stalled provider connection,
157
+ // not a task-level failure: allow model fallback even if the model produced
158
+ // partial output before going quiet.
159
+ if (result.errorMessage?.includes("idle timeout")) return true;
158
160
  // The model produced text: the failure belongs to the task, not the model.
159
161
  if (getFinalOutput(result.messages)) return false;
160
- if (result.errorMessage?.includes("timed out")) return false;
161
162
  // Require evidence the failure came from the model/provider (an error
162
163
  // message or stderr), not from the child process failing to start.
163
164
  return result.messages.length > 0 || result.stderr.trim().length > 0;
@@ -170,26 +171,6 @@ export function getResultOutput(result: SingleResult): string {
170
171
  return getFinalOutput(result.messages) || "(no output)";
171
172
  }
172
173
 
173
- export async function mapWithConcurrencyLimit<TIn, TOut>(
174
- items: TIn[],
175
- concurrency: number,
176
- fn: (item: TIn, index: number) => Promise<TOut>,
177
- ): Promise<TOut[]> {
178
- if (items.length === 0) return [];
179
- const limit = Math.max(1, Math.min(concurrency, items.length));
180
- const results: TOut[] = new Array(items.length);
181
- let nextIndex = 0;
182
- const workers = new Array(limit).fill(null).map(async () => {
183
- while (true) {
184
- const current = nextIndex++;
185
- if (current >= items.length) return;
186
- results[current] = await fn(items[current], current);
187
- }
188
- });
189
- await Promise.all(workers);
190
- return results;
191
- }
192
-
193
174
  async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
194
175
  const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
195
176
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
@@ -253,10 +234,10 @@ export interface RunSingleOptions {
253
234
  cwd?: string;
254
235
  /** Thinking level passed to the child pi process. */
255
236
  thinkingLevel?: ThinkingLevel;
256
- /** Optional timeout; zero (the default) disables it. Intended for tests and controlled callers. */
257
- timeoutMs?: number;
237
+ /** Idle timeout in ms: terminate the child if its stdout produces no activity
238
+ * for this duration. 0 (the default) disables the idle watchdog. */
239
+ idleTimeoutMs?: number;
258
240
  signal?: AbortSignal;
259
- onUpdate?: OnUpdateCallback;
260
241
  onLive?: (e: SubagentLiveEvent) => void;
261
242
  makeDetails: (results: SingleResult[]) => SubagentDetails;
262
243
  env?: NodeJS.ProcessEnv;
@@ -270,9 +251,8 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
270
251
  task,
271
252
  cwd,
272
253
  thinkingLevel = SUBAGENT_THINKING_LEVEL,
273
- timeoutMs = SUBAGENT_TIMEOUT_MS,
254
+ idleTimeoutMs = SUBAGENT_DEFAULT_IDLE_TIMEOUT_MS,
274
255
  signal,
275
- onUpdate,
276
256
  onLive,
277
257
  makeDetails,
278
258
  } = options;
@@ -312,13 +292,6 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
312
292
  thinking: thinkingLevel,
313
293
  };
314
294
 
315
- const emitUpdate = (): void => {
316
- onUpdate?.({
317
- content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
318
- details: makeDetails([currentResult]),
319
- });
320
- };
321
-
322
295
  try {
323
296
  if (agent.systemPrompt.trim()) {
324
297
  const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
@@ -328,7 +301,6 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
328
301
  }
329
302
 
330
303
  let wasAborted = false;
331
- let timedOut = false;
332
304
 
333
305
  // Increment depth so nested sub-agents can be guarded against runaway recursion.
334
306
  const childDepth = currentSubagentDepth(options.env) + 1;
@@ -349,14 +321,15 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
349
321
  let closed = false;
350
322
  let termSent = false;
351
323
  let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
352
- let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
353
324
  let abortHandler: (() => void) | undefined;
325
+ let lastActivityAt = Date.now();
326
+ let idleTimer: ReturnType<typeof setInterval> | undefined;
354
327
 
355
328
  const finish = (code: number | null): void => {
356
329
  if (closed) return;
357
330
  closed = true;
358
331
  if (forceKillTimer) clearTimeout(forceKillTimer);
359
- if (timeoutTimer) clearTimeout(timeoutTimer);
332
+ if (idleTimer) clearInterval(idleTimer);
360
333
  if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
361
334
  resolve(code ?? 1);
362
335
  };
@@ -446,12 +419,10 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
446
419
  onLive({ kind: "usage", usage: { ...currentResult.usage }, model: currentResult.model });
447
420
  } catch { /* never throw from event handling */ }
448
421
  }
449
- emitUpdate();
450
422
  }
451
423
 
452
424
  if (event.type === "tool_result_end" && event.message) {
453
425
  currentResult.messages.push(event.message as Message);
454
- emitUpdate();
455
426
  }
456
427
  };
457
428
  // Send the task through the child stdin pipe instead of the process
@@ -466,6 +437,7 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
466
437
  // message (including a reviewer's verdict line) from parsing.
467
438
  const stdoutDecoder = new StringDecoder("utf8");
468
439
  proc.stdout.on("data", (data) => {
440
+ lastActivityAt = Date.now();
469
441
  buffer += stdoutDecoder.write(data);
470
442
  const lines = buffer.split("\n");
471
443
  buffer = lines.pop() || "";
@@ -486,7 +458,6 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
486
458
  const failed =
487
459
  code !== 0 ||
488
460
  wasAborted ||
489
- timedOut ||
490
461
  (signal?.aborted ?? false) ||
491
462
  currentResult.stopReason === "error" ||
492
463
  currentResult.stopReason === "aborted";
@@ -510,13 +481,17 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
510
481
  finish(1);
511
482
  });
512
483
 
513
- if (timeoutMs > 0) {
514
- timeoutTimer = setTimeout(() => {
515
- timedOut = true;
516
- currentResult.stopReason = "error";
517
- currentResult.errorMessage = `Subagent timed out after ${Math.ceil(timeoutMs / 1000)} seconds.`;
518
- terminate();
519
- }, timeoutMs);
484
+ if (idleTimeoutMs > 0) {
485
+ const checkInterval = Math.min(10_000, Math.floor(idleTimeoutMs / 3));
486
+ idleTimer = setInterval(() => {
487
+ if (closed) return;
488
+ if (Date.now() - lastActivityAt >= idleTimeoutMs) {
489
+ if (idleTimer) clearInterval(idleTimer);
490
+ currentResult.stopReason = "error";
491
+ currentResult.errorMessage = `Subagent idle timeout: no activity for ${Math.ceil(idleTimeoutMs / 1000)} seconds.`;
492
+ terminate();
493
+ }
494
+ }, checkInterval);
520
495
  }
521
496
 
522
497
  if (signal) {