@kushalkhemka/opensec-pi-subagents 0.19.8 → 0.19.10

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/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.19.10] - 2026-10-07
11
+
12
+ ### Fixed
13
+
14
+ - **A hung tool call held a subagent forever.** Turn limits only count finished turns, and a turn whose tool never returns (a `find /`, a server that never exits) never finishes, so `max_turns` and the grace turns could not stop it. Subagent `bash` calls now get a timeout (the new `toolTimeoutSeconds` setting, default 1200 s, `0` = off): Pi kills the command's whole process tree and the agent sees the timeout and carries on. Any other tool still running a minute past the limit is stopped by a watchdog and the agent is resumed with a note to split the work into bounded steps, up to 3 times per run (nested `Agent` / workflow calls are exempt). Resumed agents get the same protection.
15
+
16
+ ## [0.19.9] - 2026-10-03
17
+
18
+ ### Fixed
19
+
20
+ - **No load warning on Pi 1.0.** `@sinclair/typebox` and `typebox` were listed in `dependencies`, which Pi 1.0 reports as host-provided packages that can bypass its extension loader. They are now optional `"*"` peer dependencies (Pi supplies both on 0.87.1 and 1.0) and stay devDependencies for tests and typecheck.
21
+
22
+ ### Changed
23
+
24
+ - The development pins of `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` move from 0.84.2 to 1.0.0, so `npm test` and `prepublishOnly` run the e2e tests that need Pi 1.0 APIs.
25
+
10
26
  ## [0.19.8] - 2026-10-03
11
27
 
12
28
  The Node build now carries the same fixes as the Bun build (0.19.5-bun.x).
package/README.md CHANGED
@@ -82,6 +82,7 @@ model and thinking level unless their definition or call overrides them.
82
82
  | Setting | Default | Meaning |
83
83
  |---|---|---|
84
84
  | Background concurrency (`/agents → Settings`) | `10` | How many background agents run at once. |
85
+ | Tool timeout (`/agents → Settings`, `toolTimeoutSeconds`) | `1200` | Seconds one subagent tool call may run (`0` = no limit). A `bash` call is killed at the limit and the agent sees the timeout; any other tool still running a minute later is stopped and the agent is told to continue another way (up to 3 times per run). |
85
86
 
86
87
  ## Notes
87
88
 
@@ -157,6 +157,31 @@ export declare function setRememberAgents(b: boolean): void;
157
157
  export declare function getGraceTurns(): number;
158
158
  /** Set the grace turns value (minimum 1). */
159
159
  export declare function setGraceTurns(n: number): void;
160
+ export declare const TOOL_TIMEOUT_CEILING = 86400;
161
+ /** Get the per-tool-call time limit in seconds (0 = no limit). */
162
+ export declare function getToolTimeoutSeconds(): number;
163
+ /** Set the per-tool-call time limit in seconds (0 = no limit). */
164
+ export declare function setToolTimeoutSeconds(n: number): void;
165
+ /** Times one run is resumed after a stuck tool before it is left stopped. */
166
+ export declare const MAX_TOOL_RECOVERIES = 3;
167
+ export interface HungTool {
168
+ toolName: string;
169
+ seconds: number;
170
+ }
171
+ /** The prompt that resumes an agent whose tool call the watchdog stopped. */
172
+ export declare function hungToolPrompt(hung: HungTool): string;
173
+ /**
174
+ * Bound a subagent session's tool calls (see `toolTimeoutSeconds`).
175
+ *
176
+ * `bash` calls without a timeout, or with a longer one, get the limit. The
177
+ * watchdog covers every tool: past the limit (plus slack) it aborts the run,
178
+ * which kills a running command's process tree, and `takeHung()` then reports
179
+ * the call so the caller can resume the agent with `hungToolPrompt()`.
180
+ */
181
+ export declare function installToolTimeouts(session: AgentSession, limitSeconds?: number): {
182
+ takeHung(): HungTool | undefined;
183
+ dispose(): void;
184
+ };
160
185
  /**
161
186
  * Try to find the right model for an agent type.
162
187
  * Priority: explicit option > config.model > parent model.
@@ -348,6 +348,101 @@ let graceTurns = 5;
348
348
  export function getGraceTurns() { return graceTurns; }
349
349
  /** Set the grace turns value (minimum 1). */
350
350
  export function setGraceTurns(n) { graceTurns = Math.max(1, n); }
351
+ /**
352
+ * Longest a single tool call may run, in seconds. 0 = no limit.
353
+ *
354
+ * Turn limits only count finished turns, and a turn whose tool never returns
355
+ * never finishes: a `find /` or a server that never closes would hold the agent
356
+ * forever. So a subagent's `bash` calls get this as their timeout (pi kills the
357
+ * whole process tree and reports the timeout to the model, which carries on),
358
+ * and a watchdog stops any other tool that overruns it and tells the agent to
359
+ * continue another way.
360
+ */
361
+ let toolTimeoutSeconds = 1200;
362
+ export const TOOL_TIMEOUT_CEILING = 86_400;
363
+ /** Get the per-tool-call time limit in seconds (0 = no limit). */
364
+ export function getToolTimeoutSeconds() { return toolTimeoutSeconds; }
365
+ /** Set the per-tool-call time limit in seconds (0 = no limit). */
366
+ export function setToolTimeoutSeconds(n) {
367
+ toolTimeoutSeconds = Math.min(TOOL_TIMEOUT_CEILING, Math.max(0, Math.floor(n)));
368
+ }
369
+ /** How long past the limit the watchdog waits, so a timing-out `bash` reports its own timeout first. */
370
+ const WATCHDOG_SLACK_MS = 60_000;
371
+ const WATCHDOG_INTERVAL_MS = 5_000;
372
+ /** Times one run is resumed after a stuck tool before it is left stopped. */
373
+ export const MAX_TOOL_RECOVERIES = 3;
374
+ const WATCHDOG_EXEMPT_TOOLS = new Set(EXCLUDED_TOOL_NAMES);
375
+ function formatDuration(seconds) {
376
+ if (seconds < 120)
377
+ return `${seconds} seconds`;
378
+ return `${Math.round(seconds / 60)} minutes`;
379
+ }
380
+ /** The prompt that resumes an agent whose tool call the watchdog stopped. */
381
+ export function hungToolPrompt(hung) {
382
+ return `Your ${hung.toolName} call ran for ${formatDuration(hung.seconds)} without finishing, so it was stopped and its `
383
+ + "output is lost. Continue the task without repeating that call: split the work into smaller, bounded steps "
384
+ + "(search specific folders rather than the whole filesystem, limit depth and output, add timeouts).";
385
+ }
386
+ /**
387
+ * Bound a subagent session's tool calls (see `toolTimeoutSeconds`).
388
+ *
389
+ * `bash` calls without a timeout, or with a longer one, get the limit. The
390
+ * watchdog covers every tool: past the limit (plus slack) it aborts the run,
391
+ * which kills a running command's process tree, and `takeHung()` then reports
392
+ * the call so the caller can resume the agent with `hungToolPrompt()`.
393
+ */
394
+ export function installToolTimeouts(session, limitSeconds = toolTimeoutSeconds) {
395
+ if (limitSeconds <= 0)
396
+ return { takeHung: () => undefined, dispose: () => { } };
397
+ const priorBeforeToolCall = session.agent.beforeToolCall;
398
+ session.agent.beforeToolCall = async (context, signal) => {
399
+ if (context.toolCall.name === "bash" && context.args && typeof context.args === "object") {
400
+ const args = context.args;
401
+ if (typeof args.timeout !== "number" || !(args.timeout > 0) || args.timeout > limitSeconds) {
402
+ args.timeout = limitSeconds;
403
+ }
404
+ }
405
+ return priorBeforeToolCall?.(context, signal);
406
+ };
407
+ const running = new Map();
408
+ let hung;
409
+ const unsubscribe = session.subscribe((event) => {
410
+ // Nested agents and workflows wait on child runs with their own limits.
411
+ if (event.type === "tool_execution_start" && !WATCHDOG_EXEMPT_TOOLS.has(event.toolName)) {
412
+ running.set(event.toolCallId, { toolName: event.toolName, started: Date.now() });
413
+ }
414
+ if (event.type === "tool_execution_end")
415
+ running.delete(event.toolCallId);
416
+ if (event.type === "agent_end")
417
+ running.clear();
418
+ });
419
+ const timer = setInterval(() => {
420
+ if (hung)
421
+ return;
422
+ const now = Date.now();
423
+ for (const call of running.values()) {
424
+ if (now - call.started < limitSeconds * 1000 + WATCHDOG_SLACK_MS)
425
+ continue;
426
+ hung = { toolName: call.toolName, seconds: Math.round((now - call.started) / 1000) };
427
+ running.clear();
428
+ void session.abort();
429
+ return;
430
+ }
431
+ }, WATCHDOG_INTERVAL_MS);
432
+ timer.unref?.();
433
+ return {
434
+ takeHung() {
435
+ const out = hung;
436
+ hung = undefined;
437
+ return out;
438
+ },
439
+ dispose() {
440
+ clearInterval(timer);
441
+ unsubscribe();
442
+ session.agent.beforeToolCall = priorBeforeToolCall;
443
+ },
444
+ };
445
+ }
351
446
  /**
352
447
  * Try to find the right model for an agent type.
353
448
  * Priority: explicit option > config.model > parent model.
@@ -867,6 +962,7 @@ export async function runAgent(ctx, type, prompt, options) {
867
962
  });
868
963
  }
869
964
  options.onSessionCreated?.(session);
965
+ const toolTimeouts = installToolTimeouts(session);
870
966
  // Track turns for graceful max_turns enforcement
871
967
  let turnCount = 0;
872
968
  const maxTurns = resolveEffectiveMaxTurns(type, options.maxTurns);
@@ -937,6 +1033,16 @@ export async function runAgent(ctx, type, prompt, options) {
937
1033
  return { responseText: "", session, aborted: true, steered: false };
938
1034
  }
939
1035
  await session.prompt(effectivePrompt);
1036
+ for (let recoveries = 0;; recoveries++) {
1037
+ const hung = toolTimeouts.takeHung();
1038
+ if (!hung)
1039
+ break;
1040
+ if (aborted || options.signal?.aborted || recoveries >= MAX_TOOL_RECOVERIES) {
1041
+ aborted = true;
1042
+ break;
1043
+ }
1044
+ await session.prompt(hungToolPrompt(hung));
1045
+ }
940
1046
  // One more prompt when a schema was asked for and nothing usable came back
941
1047
  // — the model answered in prose, or only ever called the tool invalidly.
942
1048
  // Inside this `try`, so the turn tracking, the text collector and above all
@@ -949,6 +1055,7 @@ export async function runAgent(ctx, type, prompt, options) {
949
1055
  }
950
1056
  }
951
1057
  finally {
1058
+ toolTimeouts.dispose();
952
1059
  unsubTurns();
953
1060
  collector.unsubscribe();
954
1061
  cleanupAbort();
@@ -1004,12 +1111,20 @@ export async function resumeAgent(session, prompt, options = {}) {
1004
1111
  }
1005
1112
  })
1006
1113
  : () => { };
1114
+ const toolTimeouts = installToolTimeouts(session);
1007
1115
  try {
1008
1116
  if (options.signal?.aborted)
1009
1117
  return { text: "" };
1010
1118
  await session.prompt(prompt);
1119
+ for (let recoveries = 0; recoveries < MAX_TOOL_RECOVERIES && !options.signal?.aborted; recoveries++) {
1120
+ const hung = toolTimeouts.takeHung();
1121
+ if (!hung)
1122
+ break;
1123
+ await session.prompt(hungToolPrompt(hung));
1124
+ }
1011
1125
  }
1012
1126
  finally {
1127
+ toolTimeouts.dispose();
1013
1128
  collector.unsubscribe();
1014
1129
  unsubEvents();
1015
1130
  cleanupAbort();
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ import { abortable } from "./abortable.js";
18
18
  import { hasAgentBadge, renderAgentName } from "./agent-color.js";
19
19
  import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
20
20
  import { AgentManager, isTopLevelAgent } from "./agent-manager.js";
21
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
21
+ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, getToolTimeoutSeconds, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, setToolTimeoutSeconds, steerAgent } from "./agent-runner.js";
22
22
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
23
23
  import { inChildSessionContext } from "./child-context.js";
24
24
  import { registerRpcHandlers } from "./cross-extension-rpc.js";
@@ -1351,6 +1351,7 @@ export default function (pi) {
1351
1351
  setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
1352
1352
  setDefaultMaxTurns,
1353
1353
  setGraceTurns,
1354
+ setToolTimeoutSeconds,
1354
1355
  setDefaultJoinMode,
1355
1356
  setBackgroundByDefault,
1356
1357
  setSchedulingEnabled,
@@ -3241,6 +3242,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
3241
3242
  // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
3242
3243
  defaultMaxTurns: getDefaultMaxTurns() ?? 0,
3243
3244
  graceTurns: getGraceTurns(),
3245
+ toolTimeoutSeconds: getToolTimeoutSeconds(),
3244
3246
  defaultJoinMode: getDefaultJoinMode(),
3245
3247
  backgroundByDefault: getBackgroundByDefault(),
3246
3248
  schedulingEnabled: isSchedulingEnabled(),
@@ -3277,7 +3279,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
3277
3279
  const _settingsSnapshotIsComplete = true;
3278
3280
  void _settingsSnapshotIsComplete;
3279
3281
  const NUMERIC_IDS = new Set([
3280
- "maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
3282
+ "maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "toolTimeoutSeconds", "maxSubagentDepth",
3281
3283
  ]);
3282
3284
  async function showSettings(ctx) {
3283
3285
  function buildItems() {
@@ -3285,6 +3287,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
3285
3287
  const mcf = manager.getMaxConcurrentForeground();
3286
3288
  const dmt = getDefaultMaxTurns() ?? 0;
3287
3289
  const gt = getGraceTurns();
3290
+ const tts = getToolTimeoutSeconds();
3288
3291
  const msd = getMaxSubagentDepth();
3289
3292
  // Label what unset actually does — it targets general-purpose even when
3290
3293
  // that is unregistered (the permissive hardcoded tier), so showing "none"
@@ -3322,6 +3325,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
3322
3325
  currentValue: String(gt),
3323
3326
  values: [String(gt)],
3324
3327
  },
3328
+ {
3329
+ id: "toolTimeoutSeconds",
3330
+ label: "Tool timeout",
3331
+ description: "Seconds one subagent tool call may run before it is stopped and the agent continues (0 = no limit, Enter to type)",
3332
+ currentValue: String(tts),
3333
+ values: [String(tts)],
3334
+ },
3325
3335
  {
3326
3336
  id: "maxSubagentDepth",
3327
3337
  label: "Nested depth",
@@ -3501,6 +3511,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
3501
3511
  notifyApplied(ctx, `Grace turns set to ${n}`);
3502
3512
  }
3503
3513
  }
3514
+ else if (id === "toolTimeoutSeconds") {
3515
+ const n = parseInt(value, 10);
3516
+ if (n >= 0) {
3517
+ setToolTimeoutSeconds(n);
3518
+ notifyApplied(ctx, n === 0 ? "Tool timeout off" : `Tool timeout set to ${getToolTimeoutSeconds()} s`);
3519
+ }
3520
+ }
3504
3521
  else if (id === "maxSubagentDepth") {
3505
3522
  const n = parseInt(value, 10);
3506
3523
  if (n >= 0) {
@@ -3671,7 +3688,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
3671
3688
  ? String(getDefaultMaxTurns() ?? 0)
3672
3689
  : result === "maxSubagentDepth"
3673
3690
  ? String(getMaxSubagentDepth())
3674
- : String(getGraceTurns());
3691
+ : result === "toolTimeoutSeconds"
3692
+ ? String(getToolTimeoutSeconds())
3693
+ : String(getGraceTurns());
3675
3694
  const label = result === "maxConcurrent"
3676
3695
  ? "Max concurrency (1+)"
3677
3696
  : result === "maxConcurrentForeground"
@@ -3680,7 +3699,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
3680
3699
  ? "Default max turns (0 = unlimited)"
3681
3700
  : result === "maxSubagentDepth"
3682
3701
  ? "Nested depth (0/1 = nesting off)"
3683
- : "Grace turns (1+)";
3702
+ : result === "toolTimeoutSeconds"
3703
+ ? "Tool timeout in seconds (0 = no limit)"
3704
+ : "Grace turns (1+)";
3684
3705
  // Loop until user enters a valid integer or cancels (Esc / null).
3685
3706
  // Silently trims whitespace; rejects non-numeric input by re-prompting.
3686
3707
  let input = await ctx.ui.input(label, current);
@@ -30,6 +30,11 @@ export interface SubagentsSettings {
30
30
  */
31
31
  defaultMaxTurns?: number;
32
32
  graceTurns?: number;
33
+ /**
34
+ * Longest one tool call of a subagent may run, in seconds (0 = no limit,
35
+ * default 1200). See `toolTimeoutSeconds` in agent-runner.ts.
36
+ */
37
+ toolTimeoutSeconds?: number;
33
38
  defaultJoinMode?: JoinMode;
34
39
  /**
35
40
  * Whether a top-level `Agent` spawn that doesn't say runs detached.
@@ -300,6 +305,7 @@ export interface SettingsAppliers {
300
305
  setMaxConcurrentForeground: (n: number) => void;
301
306
  setDefaultMaxTurns: (n: number) => void;
302
307
  setGraceTurns: (n: number) => void;
308
+ setToolTimeoutSeconds: (n: number) => void;
303
309
  setDefaultJoinMode: (mode: JoinMode) => void;
304
310
  setBackgroundByDefault: (b: boolean) => void;
305
311
  setSchedulingEnabled: (b: boolean) => void;
package/dist/settings.js CHANGED
@@ -17,6 +17,7 @@ const VALID_AGENT_MENTION_MODES = new Set(["model", "direct", "off"]);
17
17
  const MAX_CONCURRENT_CEILING = 1024;
18
18
  const MAX_TURNS_CEILING = 10_000;
19
19
  const GRACE_TURNS_CEILING = 1_000;
20
+ const TOOL_TIMEOUT_CEILING = 86_400;
20
21
  const SUBAGENT_DEPTH_CEILING = 16;
21
22
  /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
22
23
  function sanitize(raw) {
@@ -46,6 +47,11 @@ function sanitize(raw) {
46
47
  r.graceTurns <= GRACE_TURNS_CEILING) {
47
48
  out.graceTurns = r.graceTurns;
48
49
  }
50
+ if (Number.isInteger(r.toolTimeoutSeconds) &&
51
+ r.toolTimeoutSeconds >= 0 &&
52
+ r.toolTimeoutSeconds <= TOOL_TIMEOUT_CEILING) {
53
+ out.toolTimeoutSeconds = r.toolTimeoutSeconds;
54
+ }
49
55
  if (Number.isInteger(r.maxSubagentDepth) &&
50
56
  r.maxSubagentDepth >= 0 &&
51
57
  r.maxSubagentDepth <= SUBAGENT_DEPTH_CEILING) {
@@ -178,6 +184,8 @@ export function applySettings(s, appliers) {
178
184
  appliers.setDefaultMaxTurns(s.defaultMaxTurns);
179
185
  if (typeof s.graceTurns === "number")
180
186
  appliers.setGraceTurns(s.graceTurns);
187
+ if (typeof s.toolTimeoutSeconds === "number")
188
+ appliers.setToolTimeoutSeconds(s.toolTimeoutSeconds);
181
189
  if (typeof s.maxSubagentDepth === "number")
182
190
  appliers.setMaxSubagentDepth(s.maxSubagentDepth);
183
191
  if (typeof s.fallbackSubagent === "string")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kushalkhemka/opensec-pi-subagents",
3
- "version": "0.19.8",
3
+ "version": "0.19.10",
4
4
  "description": "Fork of @tintinweb/pi-subagents with parent model/thinking inheritance and cached agent discovery.",
5
5
  "author": "tintinweb",
6
6
  "contributors": [
@@ -30,13 +30,21 @@
30
30
  "peerDependencies": {
31
31
  "@earendil-works/pi-ai": ">=0.84.0",
32
32
  "@earendil-works/pi-coding-agent": ">=0.84.0",
33
- "@earendil-works/pi-tui": ">=0.84.0"
33
+ "@earendil-works/pi-tui": ">=0.84.0",
34
+ "@sinclair/typebox": "*",
35
+ "typebox": "*"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@sinclair/typebox": {
39
+ "optional": true
40
+ },
41
+ "typebox": {
42
+ "optional": true
43
+ }
34
44
  },
35
45
  "dependencies": {
36
- "@sinclair/typebox": "^0.34.49",
37
46
  "croner": "^10.0.1",
38
- "nanoid": "^5.1.16",
39
- "typebox": "^1.3.7"
47
+ "nanoid": "^5.1.16"
40
48
  },
41
49
  "scripts": {
42
50
  "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
@@ -54,11 +62,13 @@
54
62
  },
55
63
  "devDependencies": {
56
64
  "@biomejs/biome": "^2.4.14",
57
- "@earendil-works/pi-ai": "0.84.2",
58
- "@earendil-works/pi-coding-agent": "0.84.2",
59
- "@earendil-works/pi-tui": "0.84.2",
65
+ "@earendil-works/pi-ai": "1.0.0",
66
+ "@earendil-works/pi-coding-agent": "1.0.0",
67
+ "@earendil-works/pi-tui": "1.0.0",
68
+ "@sinclair/typebox": "^0.34.49",
60
69
  "@types/node": "^25.5.0",
61
70
  "@vitest/coverage-istanbul": "^4.1.10",
71
+ "typebox": "^1.3.7",
62
72
  "typescript": "^6.0.0",
63
73
  "vitest": "^4.0.18"
64
74
  },
@@ -405,6 +405,112 @@ export function getGraceTurns(): number { return graceTurns; }
405
405
  /** Set the grace turns value (minimum 1). */
406
406
  export function setGraceTurns(n: number): void { graceTurns = Math.max(1, n); }
407
407
 
408
+ /**
409
+ * Longest a single tool call may run, in seconds. 0 = no limit.
410
+ *
411
+ * Turn limits only count finished turns, and a turn whose tool never returns
412
+ * never finishes: a `find /` or a server that never closes would hold the agent
413
+ * forever. So a subagent's `bash` calls get this as their timeout (pi kills the
414
+ * whole process tree and reports the timeout to the model, which carries on),
415
+ * and a watchdog stops any other tool that overruns it and tells the agent to
416
+ * continue another way.
417
+ */
418
+ let toolTimeoutSeconds = 1200;
419
+ export const TOOL_TIMEOUT_CEILING = 86_400;
420
+
421
+ /** Get the per-tool-call time limit in seconds (0 = no limit). */
422
+ export function getToolTimeoutSeconds(): number { return toolTimeoutSeconds; }
423
+ /** Set the per-tool-call time limit in seconds (0 = no limit). */
424
+ export function setToolTimeoutSeconds(n: number): void {
425
+ toolTimeoutSeconds = Math.min(TOOL_TIMEOUT_CEILING, Math.max(0, Math.floor(n)));
426
+ }
427
+
428
+ /** How long past the limit the watchdog waits, so a timing-out `bash` reports its own timeout first. */
429
+ const WATCHDOG_SLACK_MS = 60_000;
430
+ const WATCHDOG_INTERVAL_MS = 5_000;
431
+ /** Times one run is resumed after a stuck tool before it is left stopped. */
432
+ export const MAX_TOOL_RECOVERIES = 3;
433
+ const WATCHDOG_EXEMPT_TOOLS: ReadonlySet<string> = new Set(EXCLUDED_TOOL_NAMES);
434
+
435
+ export interface HungTool {
436
+ toolName: string;
437
+ seconds: number;
438
+ }
439
+
440
+ function formatDuration(seconds: number): string {
441
+ if (seconds < 120) return `${seconds} seconds`;
442
+ return `${Math.round(seconds / 60)} minutes`;
443
+ }
444
+
445
+ /** The prompt that resumes an agent whose tool call the watchdog stopped. */
446
+ export function hungToolPrompt(hung: HungTool): string {
447
+ return `Your ${hung.toolName} call ran for ${formatDuration(hung.seconds)} without finishing, so it was stopped and its `
448
+ + "output is lost. Continue the task without repeating that call: split the work into smaller, bounded steps "
449
+ + "(search specific folders rather than the whole filesystem, limit depth and output, add timeouts).";
450
+ }
451
+
452
+ /**
453
+ * Bound a subagent session's tool calls (see `toolTimeoutSeconds`).
454
+ *
455
+ * `bash` calls without a timeout, or with a longer one, get the limit. The
456
+ * watchdog covers every tool: past the limit (plus slack) it aborts the run,
457
+ * which kills a running command's process tree, and `takeHung()` then reports
458
+ * the call so the caller can resume the agent with `hungToolPrompt()`.
459
+ */
460
+ export function installToolTimeouts(session: AgentSession, limitSeconds = toolTimeoutSeconds): {
461
+ takeHung(): HungTool | undefined;
462
+ dispose(): void;
463
+ } {
464
+ if (limitSeconds <= 0) return { takeHung: () => undefined, dispose: () => {} };
465
+
466
+ const priorBeforeToolCall = session.agent.beforeToolCall;
467
+ session.agent.beforeToolCall = async (context, signal) => {
468
+ if (context.toolCall.name === "bash" && context.args && typeof context.args === "object") {
469
+ const args = context.args as { timeout?: unknown };
470
+ if (typeof args.timeout !== "number" || !(args.timeout > 0) || args.timeout > limitSeconds) {
471
+ args.timeout = limitSeconds;
472
+ }
473
+ }
474
+ return priorBeforeToolCall?.(context, signal);
475
+ };
476
+
477
+ const running = new Map<string, { toolName: string; started: number }>();
478
+ let hung: HungTool | undefined;
479
+ const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
480
+ // Nested agents and workflows wait on child runs with their own limits.
481
+ if (event.type === "tool_execution_start" && !WATCHDOG_EXEMPT_TOOLS.has(event.toolName)) {
482
+ running.set(event.toolCallId, { toolName: event.toolName, started: Date.now() });
483
+ }
484
+ if (event.type === "tool_execution_end") running.delete(event.toolCallId);
485
+ if (event.type === "agent_end") running.clear();
486
+ });
487
+ const timer = setInterval(() => {
488
+ if (hung) return;
489
+ const now = Date.now();
490
+ for (const call of running.values()) {
491
+ if (now - call.started < limitSeconds * 1000 + WATCHDOG_SLACK_MS) continue;
492
+ hung = { toolName: call.toolName, seconds: Math.round((now - call.started) / 1000) };
493
+ running.clear();
494
+ void session.abort();
495
+ return;
496
+ }
497
+ }, WATCHDOG_INTERVAL_MS);
498
+ (timer as { unref?: () => void }).unref?.();
499
+
500
+ return {
501
+ takeHung() {
502
+ const out = hung;
503
+ hung = undefined;
504
+ return out;
505
+ },
506
+ dispose() {
507
+ clearInterval(timer);
508
+ unsubscribe();
509
+ session.agent.beforeToolCall = priorBeforeToolCall;
510
+ },
511
+ };
512
+ }
513
+
408
514
  /**
409
515
  * Try to find the right model for an agent type.
410
516
  * Priority: explicit option > config.model > parent model.
@@ -1108,6 +1214,8 @@ export async function runAgent(
1108
1214
 
1109
1215
  options.onSessionCreated?.(session);
1110
1216
 
1217
+ const toolTimeouts = installToolTimeouts(session);
1218
+
1111
1219
  // Track turns for graceful max_turns enforcement
1112
1220
  let turnCount = 0;
1113
1221
  const maxTurns = resolveEffectiveMaxTurns(type, options.maxTurns);
@@ -1180,6 +1288,15 @@ export async function runAgent(
1180
1288
  return { responseText: "", session, aborted: true, steered: false };
1181
1289
  }
1182
1290
  await session.prompt(effectivePrompt);
1291
+ for (let recoveries = 0; ; recoveries++) {
1292
+ const hung = toolTimeouts.takeHung();
1293
+ if (!hung) break;
1294
+ if (aborted || options.signal?.aborted || recoveries >= MAX_TOOL_RECOVERIES) {
1295
+ aborted = true;
1296
+ break;
1297
+ }
1298
+ await session.prompt(hungToolPrompt(hung));
1299
+ }
1183
1300
 
1184
1301
  // One more prompt when a schema was asked for and nothing usable came back
1185
1302
  // — the model answered in prose, or only ever called the tool invalidly.
@@ -1192,6 +1309,7 @@ export async function runAgent(
1192
1309
  await session.prompt(structuredRetryPrompt(structuredCapture));
1193
1310
  }
1194
1311
  } finally {
1312
+ toolTimeouts.dispose();
1195
1313
  unsubTurns();
1196
1314
  collector.unsubscribe();
1197
1315
  cleanupAbort();
@@ -1257,10 +1375,17 @@ export async function resumeAgent(
1257
1375
  })
1258
1376
  : () => {};
1259
1377
 
1378
+ const toolTimeouts = installToolTimeouts(session);
1260
1379
  try {
1261
1380
  if (options.signal?.aborted) return { text: "" };
1262
1381
  await session.prompt(prompt);
1382
+ for (let recoveries = 0; recoveries < MAX_TOOL_RECOVERIES && !options.signal?.aborted; recoveries++) {
1383
+ const hung = toolTimeouts.takeHung();
1384
+ if (!hung) break;
1385
+ await session.prompt(hungToolPrompt(hung));
1386
+ }
1263
1387
  } finally {
1388
+ toolTimeouts.dispose();
1264
1389
  collector.unsubscribe();
1265
1390
  unsubEvents();
1266
1391
  cleanupAbort();
package/src/index.ts CHANGED
@@ -19,7 +19,7 @@ import { abortable } from "./abortable.js";
19
19
  import { hasAgentBadge, renderAgentName } from "./agent-color.js";
20
20
  import { buildNewAgentFile, disableInContent, enableInContent, isEmptyStub, locateAgentFile, personalAgentsDir, projectAgentsDir, serializeAgentFile } from "./agent-file-toggle.js";
21
21
  import { AgentManager, isTopLevelAgent } from "./agent-manager.js";
22
- import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, steerAgent } from "./agent-runner.js";
22
+ import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, getRememberAgents, getToolTimeoutSeconds, normalizeMaxTurns, resolveEffectiveMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, setRememberAgents, setToolTimeoutSeconds, steerAgent } from "./agent-runner.js";
23
23
  import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getConfig, getFallbackSubagent, isDefaultsDisabled, NO_FALLBACK, registerAgents, resolveSpawnType, resolveType, setDefaultsDisabled, setFallbackSubagent } from "./agent-types.js";
24
24
  import { inChildSessionContext } from "./child-context.js";
25
25
  import { type RpcHandle, registerRpcHandlers } from "./cross-extension-rpc.js";
@@ -1462,6 +1462,7 @@ export default function (pi: ExtensionAPI) {
1462
1462
  setMaxConcurrentForeground: (n) => manager.setMaxConcurrentForeground(n),
1463
1463
  setDefaultMaxTurns,
1464
1464
  setGraceTurns,
1465
+ setToolTimeoutSeconds,
1465
1466
  setDefaultJoinMode,
1466
1467
  setBackgroundByDefault,
1467
1468
  setSchedulingEnabled,
@@ -3506,6 +3507,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
3506
3507
  // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined).
3507
3508
  defaultMaxTurns: getDefaultMaxTurns() ?? 0,
3508
3509
  graceTurns: getGraceTurns(),
3510
+ toolTimeoutSeconds: getToolTimeoutSeconds(),
3509
3511
  defaultJoinMode: getDefaultJoinMode(),
3510
3512
  backgroundByDefault: getBackgroundByDefault(),
3511
3513
  schedulingEnabled: isSchedulingEnabled(),
@@ -3552,7 +3554,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
3552
3554
  void _settingsSnapshotIsComplete;
3553
3555
 
3554
3556
  const NUMERIC_IDS = new Set([
3555
- "maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "maxSubagentDepth",
3557
+ "maxConcurrent", "maxConcurrentForeground", "defaultMaxTurns", "graceTurns", "toolTimeoutSeconds", "maxSubagentDepth",
3556
3558
  ]);
3557
3559
 
3558
3560
  async function showSettings(ctx: ExtensionCommandContext) {
@@ -3561,6 +3563,7 @@ Write the file using the write tool. Only write the file, nothing else.`;
3561
3563
  const mcf = manager.getMaxConcurrentForeground();
3562
3564
  const dmt = getDefaultMaxTurns() ?? 0;
3563
3565
  const gt = getGraceTurns();
3566
+ const tts = getToolTimeoutSeconds();
3564
3567
  const msd = getMaxSubagentDepth();
3565
3568
  // Label what unset actually does — it targets general-purpose even when
3566
3569
  // that is unregistered (the permissive hardcoded tier), so showing "none"
@@ -3599,6 +3602,13 @@ Write the file using the write tool. Only write the file, nothing else.`;
3599
3602
  currentValue: String(gt),
3600
3603
  values: [String(gt)],
3601
3604
  },
3605
+ {
3606
+ id: "toolTimeoutSeconds",
3607
+ label: "Tool timeout",
3608
+ description: "Seconds one subagent tool call may run before it is stopped and the agent continues (0 = no limit, Enter to type)",
3609
+ currentValue: String(tts),
3610
+ values: [String(tts)],
3611
+ },
3602
3612
  {
3603
3613
  id: "maxSubagentDepth",
3604
3614
  label: "Nested depth",
@@ -3780,6 +3790,12 @@ Write the file using the write tool. Only write the file, nothing else.`;
3780
3790
  setGraceTurns(n);
3781
3791
  notifyApplied(ctx, `Grace turns set to ${n}`);
3782
3792
  }
3793
+ } else if (id === "toolTimeoutSeconds") {
3794
+ const n = parseInt(value, 10);
3795
+ if (n >= 0) {
3796
+ setToolTimeoutSeconds(n);
3797
+ notifyApplied(ctx, n === 0 ? "Tool timeout off" : `Tool timeout set to ${getToolTimeoutSeconds()} s`);
3798
+ }
3783
3799
  } else if (id === "maxSubagentDepth") {
3784
3800
  const n = parseInt(value, 10);
3785
3801
  if (n >= 0) {
@@ -3964,7 +3980,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
3964
3980
  ? String(getDefaultMaxTurns() ?? 0)
3965
3981
  : result === "maxSubagentDepth"
3966
3982
  ? String(getMaxSubagentDepth())
3967
- : String(getGraceTurns());
3983
+ : result === "toolTimeoutSeconds"
3984
+ ? String(getToolTimeoutSeconds())
3985
+ : String(getGraceTurns());
3968
3986
 
3969
3987
  const label = result === "maxConcurrent"
3970
3988
  ? "Max concurrency (1+)"
@@ -3974,7 +3992,9 @@ Write the file using the write tool. Only write the file, nothing else.`;
3974
3992
  ? "Default max turns (0 = unlimited)"
3975
3993
  : result === "maxSubagentDepth"
3976
3994
  ? "Nested depth (0/1 = nesting off)"
3977
- : "Grace turns (1+)";
3995
+ : result === "toolTimeoutSeconds"
3996
+ ? "Tool timeout in seconds (0 = no limit)"
3997
+ : "Grace turns (1+)";
3978
3998
 
3979
3999
  // Loop until user enters a valid integer or cancels (Esc / null).
3980
4000
  // Silently trims whitespace; rejects non-numeric input by re-prompting.
package/src/settings.ts CHANGED
@@ -40,6 +40,11 @@ export interface SubagentsSettings {
40
40
  */
41
41
  defaultMaxTurns?: number;
42
42
  graceTurns?: number;
43
+ /**
44
+ * Longest one tool call of a subagent may run, in seconds (0 = no limit,
45
+ * default 1200). See `toolTimeoutSeconds` in agent-runner.ts.
46
+ */
47
+ toolTimeoutSeconds?: number;
43
48
  defaultJoinMode?: JoinMode;
44
49
  /**
45
50
  * Whether a top-level `Agent` spawn that doesn't say runs detached.
@@ -313,6 +318,7 @@ export interface SettingsAppliers {
313
318
  setMaxConcurrentForeground: (n: number) => void;
314
319
  setDefaultMaxTurns: (n: number) => void;
315
320
  setGraceTurns: (n: number) => void;
321
+ setToolTimeoutSeconds: (n: number) => void;
316
322
  setDefaultJoinMode: (mode: JoinMode) => void;
317
323
  setBackgroundByDefault: (b: boolean) => void;
318
324
  setSchedulingEnabled: (b: boolean) => void;
@@ -350,6 +356,7 @@ const VALID_AGENT_MENTION_MODES: ReadonlySet<string> = new Set<AgentMentionMode>
350
356
  const MAX_CONCURRENT_CEILING = 1024;
351
357
  const MAX_TURNS_CEILING = 10_000;
352
358
  const GRACE_TURNS_CEILING = 1_000;
359
+ const TOOL_TIMEOUT_CEILING = 86_400;
353
360
  const SUBAGENT_DEPTH_CEILING = 16;
354
361
 
355
362
  /** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
@@ -387,6 +394,13 @@ function sanitize(raw: unknown): SubagentsSettings {
387
394
  ) {
388
395
  out.graceTurns = r.graceTurns as number;
389
396
  }
397
+ if (
398
+ Number.isInteger(r.toolTimeoutSeconds) &&
399
+ (r.toolTimeoutSeconds as number) >= 0 &&
400
+ (r.toolTimeoutSeconds as number) <= TOOL_TIMEOUT_CEILING
401
+ ) {
402
+ out.toolTimeoutSeconds = r.toolTimeoutSeconds as number;
403
+ }
390
404
  if (
391
405
  Number.isInteger(r.maxSubagentDepth) &&
392
406
  (r.maxSubagentDepth as number) >= 0 &&
@@ -523,6 +537,7 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
523
537
  }
524
538
  if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
525
539
  if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
540
+ if (typeof s.toolTimeoutSeconds === "number") appliers.setToolTimeoutSeconds(s.toolTimeoutSeconds);
526
541
  if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
527
542
  if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
528
543
  if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);