@mjasnikovs/pi-task 0.28.3 → 0.29.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
@@ -184,6 +184,7 @@ Run `/task-config` to toggle pi-task's behavior in an editor dialog. Settings pe
184
184
  | **stuck reply retry** | 10 min | Inactivity ceiling on the **model stream**. A hung or silently-dropped stream throws nothing at all, so neither the connection-error retry (it needs a reported error) nor the **command timeout** (tool calls only) nor the dead-backend stall guard (a reachable endpoint reads as proof of life) can see it — an mx5 run lost ~2.9h to three of them while the model server stayed healthy. Measured as time since the **last stream event of any kind**, so a slow model emitting one token every 30s is never touched, and it pauses while a tool runs. On expiry the main session aborts the turn (through the same channel the command watchdog uses) and posts a resume reminder; a child is killed and routed into the existing connection-error retry. Choices: 5/10/20/30 min or **off**. Keep it generous on local backends — prompt processing on a large context legitimately emits nothing for minutes. |
185
185
  | **yolo mode** | off | **Unattended runs.** Wherever pi-task would stop and ask, it takes the option already marked RECOMMENDED, stamps the artifact `(YOLO)` so an audit can tell a machine decided, and shows no prompt at all — clarify/grill answers, the verify-FAIL picker (auto-**Accept**, recorded as a yolo debt), and the final-gate picker (autofix while the budget lasts, then leave the run FAILED). A question with no recommendation is **skipped**, never invented. For throwaway/test projects nobody is watching; a real run should decide these itself. |
186
186
  | **debug logs** | events | How much of a run is written to `.pi-tasks/*-debug.log`. **`events`** keeps decisions and guard actions — which phase ran, why a worker was retried, what the git-state guard restored, what a write-capable child changed on disk, why a gate returned FAIL — a few lines per task. **`full`** adds every line the child model emitted and every tool result; that's ~85% of the bytes (a real 247 KB `verify-debug.log` is 1315 lines, 521 of them tool dumps) and is what you want while actively debugging. **`off`** writes nothing. Nothing in pi-task ever reads these files back, so the setting cannot change how a run behaves — only whether you can explain it afterwards, and a log not written can't be recovered later. |
187
+ | **watch: …** | all on | One toggle per tool in the live session, deciding whether **command timeout** applies to it. The list is discovered from `pi.getAllTools()` when the menu opens — built-ins first, then each extension's tools with the owning entry-point path in the description — so nothing is typed by hand and an uninstalled tool just stops being listed. Turn one **off** only for a tool that already owns a longer bounded, cancellable contract of its own (the guard exists because pi's `bash` has an optional timeout with *no* default — that reasoning doesn't transfer to a tool that has one). Two things to know before you do: a genuine hang in an unwatched tool is caught by nothing, since **stuck reply retry** is paused for the whole time any tool runs; and an unwatched tool is still killed as collateral if a *watched* sibling in the same turn overruns, because pi runs sibling tool calls concurrently and the abort ends the whole turn. Stored as exemptions, so the default and every tool pi-task has never seen stay guarded. |
187
188
  | **ext: …** | all off | One toggle per installed host `pi` extension, loading it into every child session by explicit path. Children otherwise run with extensions off, so a provider registered by an extension (e.g. `pi-lmstudio`) doesn't exist in them and they can't resolve the default model. Children also inherit the extension's tools and hooks, so only enable ones you trust. The list is strictly additive (discovery stays off), and an entry whose file is gone is skipped at spawn time, never fatal. |
188
189
 
189
190
  ## Configuration
@@ -61,6 +61,22 @@ export interface PiTaskConfig {
61
61
  * a true hang doesn't cost half an hour of dead time.
62
62
  */
63
63
  requestTimeoutMs: number;
64
+ /**
65
+ * Tools the generic command watchdog must NOT arm on, by exact name — for
66
+ * tools that own a stronger, domain-specific timeout and cancellation
67
+ * contract of their own and would otherwise be aborted mid-transaction at
68
+ * the generic ceiling. `bash` and every unlisted tool stay guarded.
69
+ *
70
+ * Stored as the EXEMPTIONS, not the guarded set, so the default (`[]`) and
71
+ * every tool pi-task has never seen are guarded — a new or renamed tool can
72
+ * never silently lose its watchdog.
73
+ *
74
+ * Populated from `/task-config`'s `watch:` rows, which are discovered from
75
+ * the live session via `pi.getAllTools()` (see config/tool-list.ts), so the
76
+ * names here are pi's own and never hand-typed. A stale entry left behind by
77
+ * an uninstalled tool matches nothing and is harmless.
78
+ */
79
+ commandTimeoutExemptTools: string[];
64
80
  /**
65
81
  * Inactivity ceiling (ms) on the MODEL STREAM before the stream watchdog
66
82
  * aborts the request (shared/stream-watchdog.ts). A hung or silently-dropped
@@ -148,6 +164,8 @@ export declare const COMMAND_TIMEOUT_OPTIONS: ReadonlyArray<{
148
164
  * one of the offered choices so the watchdog never arms on a nonsense value.
149
165
  */
150
166
  export declare function sanitizeRequestTimeoutMs(value: unknown): number;
167
+ /** Keep only exact, unique Pi tool names from an advanced config override. */
168
+ export declare function sanitizeCommandTimeoutExemptTools(value: unknown): string[];
151
169
  /**
152
170
  * The stream-watchdog choices offered by /task-config, in cycle order. Every
153
171
  * option is minutes, not seconds: the failure this guards costs hours, and the
@@ -43,6 +43,23 @@ export function sanitizeRequestTimeoutMs(value) {
43
43
  value
44
44
  : DEFAULT_REQUEST_TIMEOUT_MS;
45
45
  }
46
+ /** Keep only exact, unique Pi tool names from an advanced config override. */
47
+ export function sanitizeCommandTimeoutExemptTools(value) {
48
+ if (!Array.isArray(value))
49
+ return [];
50
+ const result = [];
51
+ const seen = new Set();
52
+ for (const item of value) {
53
+ if (typeof item !== 'string')
54
+ continue;
55
+ const name = item.trim();
56
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.:-]*$/.test(name) || seen.has(name))
57
+ continue;
58
+ seen.add(name);
59
+ result.push(name);
60
+ }
61
+ return result;
62
+ }
46
63
  /**
47
64
  * The stream-watchdog choices offered by /task-config, in cycle order. Every
48
65
  * option is minutes, not seconds: the failure this guards costs hours, and the
@@ -76,6 +93,7 @@ const DEFAULTS = {
76
93
  searchProvider: 'exa',
77
94
  extensionWhitelist: [],
78
95
  requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
96
+ commandTimeoutExemptTools: [],
79
97
  streamInactivityMs: DEFAULT_STREAM_INACTIVITY_MS,
80
98
  // OFF: auto-answering is for unattended throwaway runs only.
81
99
  yoloMode: false,
@@ -110,6 +128,7 @@ if (!G.loaded) {
110
128
  delete parsed.searchProvider;
111
129
  parsed.extensionWhitelist = sanitizeExtensionWhitelist(parsed.extensionWhitelist);
112
130
  parsed.requestTimeoutMs = sanitizeRequestTimeoutMs(parsed.requestTimeoutMs);
131
+ parsed.commandTimeoutExemptTools = sanitizeCommandTimeoutExemptTools(parsed.commandTimeoutExemptTools);
113
132
  parsed.streamInactivityMs = sanitizeStreamInactivityMs(parsed.streamInactivityMs);
114
133
  // A hand-edited `"yoloMode": "false"` is a truthy string — it must not
115
134
  // silently switch a watched run into unattended auto-pick. Only a real
@@ -2,6 +2,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-c
2
2
  import type { Component } from '@earendil-works/pi-tui';
3
3
  import { type PiTaskConfig } from './config.js';
4
4
  import { type InstalledExtension } from './extension-list.js';
5
+ import { type GuardableTool } from './tool-list.js';
5
6
  type Theme = ExtensionCommandContext['ui']['theme'];
6
7
  /**
7
8
  * Frames a child component (the settings list) in a rounded border with a title
@@ -46,6 +47,15 @@ export declare function extensionItems(extensions: InstalledExtension[], whiteli
46
47
  }[];
47
48
  /** Apply an extension toggle to the config's whitelist (idempotent both ways). */
48
49
  export declare function applyExtensionToggle(whitelist: readonly string[], entryPath: string, on: boolean): string[];
50
+ export declare function toolItems(tools: readonly GuardableTool[], exempt: readonly string[]): {
51
+ id: string;
52
+ label: string;
53
+ description: string;
54
+ currentValue: string;
55
+ values: string[];
56
+ }[];
57
+ /** Apply a per-tool watchdog toggle to the exemption list (idempotent both ways). */
58
+ export declare function applyToolToggle(exempt: readonly string[], toolName: string, watched: boolean): string[];
49
59
  /**
50
60
  * Tallest body the settings list can render, so {@link BorderedBox} can pad
51
61
  * every frame to it and hold the border still. Mirrors SettingsList's own
@@ -68,6 +78,6 @@ export type PanelItem = {
68
78
  */
69
79
  export declare function createSettingsPanel(items: PanelItem[], theme: Theme, onChange: (id: string, newValue: string) => void, onCancel: () => void): BorderedBox;
70
80
  /** The full settings row list for the current config, in menu order. */
71
- export declare function panelItems(cfg: PiTaskConfig, installed: InstalledExtension[]): PanelItem[];
81
+ export declare function panelItems(cfg: PiTaskConfig, installed: InstalledExtension[], tools?: readonly GuardableTool[]): PanelItem[];
72
82
  export declare function registerConfig(pi: ExtensionAPI): void;
73
83
  export {};
@@ -4,6 +4,7 @@ import { readPkgVersion } from '../shared/pkg-version.js';
4
4
  import { SEARCH_PROVIDERS, SEARCH_PROVIDER_LABELS, providerForLabel } from '../workers/search-types.js';
5
5
  import { COMMAND_TIMEOUT_OPTIONS, DEBUG_LOG_OPTIONS, getConfig, sanitizeDebugLogs, saveConfig, STREAM_INACTIVITY_OPTIONS } from './config.js';
6
6
  import { listInstalledExtensions } from './extension-list.js';
7
+ import { listGuardableTools } from './tool-list.js';
7
8
  // Version in the title so a bug report or screenshot says which build it came
8
9
  // from without anyone having to go look it up.
9
10
  const CONFIG_TITLE = `pi-task ${readPkgVersion()} settings`;
@@ -215,6 +216,35 @@ export function applyExtensionToggle(whitelist, entryPath, on) {
215
216
  const rest = whitelist.filter(p => p !== entryPath);
216
217
  return on ? [...rest, entryPath] : rest;
217
218
  }
219
+ /**
220
+ * One /task-config toggle per tool in the live session, so the command watchdog
221
+ * can be turned off for a single tool without unguarding `bash` with it.
222
+ *
223
+ * The list is DISCOVERED (see tool-list.ts), never typed: the id carries the
224
+ * exact tool name pi reports, which is the same string the watchdog matches on.
225
+ * A tool that is uninstalled simply stops being listed, and a stale name left in
226
+ * the config matches nothing.
227
+ */
228
+ const TOOL_ID_PREFIX = 'tool:';
229
+ export function toolItems(tools, exempt) {
230
+ return tools.map(t => ({
231
+ id: TOOL_ID_PREFIX + t.name,
232
+ label: `watch: ${t.name}`,
233
+ description: `Apply the command timeout to this tool. Leave it on unless the tool runs its own `
234
+ + `bounded, cancellable work for longer than the timeout — turning it off means a `
235
+ + `genuine hang in this tool will never be caught, and nothing else is watching `
236
+ + `while a tool runs. ${t.origin}`,
237
+ // Stored inverted: the config records the EXEMPTIONS, so an empty list
238
+ // (and any tool pi-task has never heard of) stays guarded by default.
239
+ currentValue: exempt.includes(t.name) ? 'off' : 'on',
240
+ values: ['on', 'off']
241
+ }));
242
+ }
243
+ /** Apply a per-tool watchdog toggle to the exemption list (idempotent both ways). */
244
+ export function applyToolToggle(exempt, toolName, watched) {
245
+ const rest = exempt.filter(n => n !== toolName);
246
+ return watched ? rest : [...rest, toolName];
247
+ }
218
248
  /** Overlay width; the list gets `- 4` of it, the description `- 4` again. */
219
249
  const OVERLAY_WIDTH = 68;
220
250
  /** Settings rows shown at once before the list scrolls. */
@@ -259,7 +289,7 @@ export function createSettingsPanel(items, theme, onChange, onCancel) {
259
289
  return new BorderedBox(list, CONFIG_TITLE, s => theme.fg('borderMuted', s), s => theme.fg('accent', theme.bold(s)), settingsBodyHeight(items.map(i => i.description), MAX_VISIBLE, OVERLAY_WIDTH - 8));
260
290
  }
261
291
  /** The full settings row list for the current config, in menu order. */
262
- export function panelItems(cfg, installed) {
292
+ export function panelItems(cfg, installed, tools = []) {
263
293
  return [
264
294
  ...ITEMS.map(({ id, label, description, values }) => ({
265
295
  id: id,
@@ -268,17 +298,30 @@ export function panelItems(cfg, installed) {
268
298
  currentValue: displayValue(cfg, id, Boolean(values)),
269
299
  values: values ?? ['on', 'off']
270
300
  })),
301
+ ...toolItems(tools, cfg.commandTimeoutExemptTools),
271
302
  ...extensionItems(installed, cfg.extensionWhitelist)
272
303
  ];
273
304
  }
274
- async function handleTaskConfig(_args, ctx) {
275
- const cfg = { ...getConfig(), extensionWhitelist: [...getConfig().extensionWhitelist] };
305
+ async function handleTaskConfig(_args, ctx, getTools = () => []) {
306
+ const cfg = {
307
+ ...getConfig(),
308
+ extensionWhitelist: [...getConfig().extensionWhitelist],
309
+ commandTimeoutExemptTools: [...getConfig().commandTimeoutExemptTools]
310
+ };
276
311
  // Enumerated live at open so an installed extension appears and an
277
312
  // uninstalled one vanishes without pi-task doing any bookkeeping. A failed
278
313
  // enumeration only costs the extension toggles, never the whole menu.
279
314
  const installed = await listInstalledExtensions({ cwd: ctx.cwd }).catch(() => []);
315
+ // Same contract for tools, and for the same reason — plus one pi-specific
316
+ // one: getAllTools() throws until the extension runtime is initialized, so
317
+ // it can only be read here, when the menu opens, never at registration.
318
+ const tools = getTools();
280
319
  if (ctx.mode !== 'tui') {
281
320
  const lines = ITEMS.map(({ id, label, values }) => `${label.padEnd(22)} ${displayValue(cfg, id, Boolean(values))}`);
321
+ for (const t of tools) {
322
+ const state = cfg.commandTimeoutExemptTools.includes(t.name) ? 'off' : 'on';
323
+ lines.push(`${('watch: ' + t.name).padEnd(22)} ${state}`);
324
+ }
282
325
  for (const e of installed) {
283
326
  const state = cfg.extensionWhitelist.includes(e.path) ? 'on' : 'off';
284
327
  lines.push(`${('ext: ' + e.label).padEnd(22)} ${state}`);
@@ -286,10 +329,13 @@ async function handleTaskConfig(_args, ctx) {
286
329
  ctx.ui.notify(lines.join(' | '), 'info');
287
330
  return;
288
331
  }
289
- await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(panelItems(cfg, installed), theme, (id, newValue) => {
332
+ await ctx.ui.custom((_tui, theme, _kb, done) => createSettingsPanel(panelItems(cfg, installed, tools), theme, (id, newValue) => {
290
333
  if (id.startsWith(EXT_ID_PREFIX)) {
291
334
  cfg.extensionWhitelist = applyExtensionToggle(cfg.extensionWhitelist, id.slice(EXT_ID_PREFIX.length), newValue === 'on');
292
335
  }
336
+ else if (id.startsWith(TOOL_ID_PREFIX)) {
337
+ cfg.commandTimeoutExemptTools = applyToolToggle(cfg.commandTimeoutExemptTools, id.slice(TOOL_ID_PREFIX.length), newValue === 'on');
338
+ }
293
339
  else if (id === 'searchProvider') {
294
340
  const provider = providerForLabel(newValue);
295
341
  if (provider)
@@ -321,7 +367,11 @@ async function handleTaskConfig(_args, ctx) {
321
367
  export function registerConfig(pi) {
322
368
  registerBridgeCommand(pi, 'task-config', {
323
369
  description: 'Configure pi-task settings (remote control, auto-commit, verify work, enforce '
324
- + 'guidelines, research, timeouts, extensions for helper sessions).',
325
- handler: handleTaskConfig
370
+ + 'guidelines, research, timeouts, per-tool command watchdog, extensions for helper '
371
+ + 'sessions).',
372
+ // `pi` is closed over rather than taken from ctx: ExtensionCommandContext
373
+ // has no tool accessor, and the read must happen inside the handler
374
+ // anyway (getAllTools throws until the runtime is initialized).
375
+ handler: (args, ctx) => handleTaskConfig(args, ctx, () => listGuardableTools(pi))
326
376
  });
327
377
  }
@@ -0,0 +1,50 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ /**
3
+ * The tools /task-config offers a command-watchdog toggle for, discovered from
4
+ * the live session rather than named by hand.
5
+ *
6
+ * WHY DISCOVERY AND NOT A HAND-EDITED NAME LIST: the watchdog arms on ANY tool
7
+ * (see task/command-watchdog.ts), which is correct for `bash` — pi's bash takes
8
+ * an OPTIONAL `timeout` with no default, so an unbounded command runs forever —
9
+ * but wrong for an extension tool that already owns a longer, bounded contract
10
+ * of its own. Those tools get aborted mid-transaction at the generic ceiling.
11
+ * The operator has to be able to say "not this one", and the only identity that
12
+ * survives a rename or an uninstall is the one pi itself reports.
13
+ *
14
+ * MEASURED against pi 0.83.0 (`pi.getAllTools()`), not assumed:
15
+ * - built-ins report `source: "builtin"`, `path: "<builtin:bash>"`
16
+ * - extension tools report the extension's real entry-point path — the SAME
17
+ * identity `extensionWhitelist` keys on (see extension-list.ts)
18
+ * - `getAllTools()` THROWS during extension loading ("Extension runtime not
19
+ * initialized"); it is only callable from `session_start` onward, which is
20
+ * why this is read when the menu opens and never at registration.
21
+ * - `getActiveTools()` is a strict SUBSET (4 of 7 built-ins in a plain
22
+ * session), so it is the wrong source: a tool the model isn't currently
23
+ * offered is still a tool the watchdog would arm on if it ran.
24
+ */
25
+ export interface GuardableTool {
26
+ /** Exact tool name — the watchdog's key, and what the config stores. */
27
+ name: string;
28
+ /** Provenance shown in the menu, e.g. "built in" or "npm:pi-fable". */
29
+ origin: string;
30
+ }
31
+ /**
32
+ * Every tool in the live session, built-ins first and each extension's tools in
33
+ * registration order after them, so the menu reads owner-by-owner.
34
+ *
35
+ * Pure over the ToolInfo list so the ordering and labelling are unit-testable
36
+ * without a running pi; {@link listGuardableTools} supplies the live one.
37
+ */
38
+ export declare function toGuardableTools(tools: readonly {
39
+ name: string;
40
+ sourceInfo: {
41
+ source: string;
42
+ path: string;
43
+ };
44
+ }[]): GuardableTool[];
45
+ /**
46
+ * The live tool list, or `[]` if pi cannot answer. Enumeration failing must cost
47
+ * only the per-tool rows, never the whole settings menu — the same contract
48
+ * listInstalledExtensions() has in the command handler.
49
+ */
50
+ export declare function listGuardableTools(pi: ExtensionAPI): GuardableTool[];
@@ -0,0 +1,53 @@
1
+ /** `source` values pi reports for its own tools rather than an extension's. */
2
+ const BUILTIN_SOURCE = 'builtin';
3
+ /**
4
+ * Human provenance for a tool's source metadata. `source` is pi's own word for
5
+ * where the owner came from ("builtin", "npm:...", "auto", "cli"); the two
6
+ * discovery-shaped values are spelled out because "auto" tells the operator
7
+ * nothing about which extension they are about to unguard.
8
+ */
9
+ function toolOrigin(source, path) {
10
+ if (source === BUILTIN_SOURCE)
11
+ return 'built in';
12
+ if (source === 'auto')
13
+ return `discovered (${path})`;
14
+ if (source === 'cli')
15
+ return `-e flag (${path})`;
16
+ return `${source} (${path})`;
17
+ }
18
+ /**
19
+ * Every tool in the live session, built-ins first and each extension's tools in
20
+ * registration order after them, so the menu reads owner-by-owner.
21
+ *
22
+ * Pure over the ToolInfo list so the ordering and labelling are unit-testable
23
+ * without a running pi; {@link listGuardableTools} supplies the live one.
24
+ */
25
+ export function toGuardableTools(tools) {
26
+ const builtins = [];
27
+ const rest = [];
28
+ const seen = new Set();
29
+ for (const t of tools) {
30
+ // A duplicate name cannot be told apart by the watchdog (it only ever
31
+ // sees `toolName`), so a second registration must not add a second row
32
+ // that silently toggles the first one's guard.
33
+ if (seen.has(t.name))
34
+ continue;
35
+ seen.add(t.name);
36
+ const entry = { name: t.name, origin: toolOrigin(t.sourceInfo.source, t.sourceInfo.path) };
37
+ (t.sourceInfo.source === BUILTIN_SOURCE ? builtins : rest).push(entry);
38
+ }
39
+ return [...builtins, ...rest];
40
+ }
41
+ /**
42
+ * The live tool list, or `[]` if pi cannot answer. Enumeration failing must cost
43
+ * only the per-tool rows, never the whole settings menu — the same contract
44
+ * listInstalledExtensions() has in the command handler.
45
+ */
46
+ export function listGuardableTools(pi) {
47
+ try {
48
+ return toGuardableTools(pi.getAllTools());
49
+ }
50
+ catch {
51
+ return [];
52
+ }
53
+ }
@@ -18,8 +18,15 @@
18
18
  * TIMER STATE MACHINE is identical, and lives here once. What differs is only
19
19
  * the `onFire` side effect, which each adapter supplies:
20
20
  *
21
- * main session — ctx.abort() cancels just that tool call and the session
22
- * survives to receive a follow-up reminder turn.
21
+ * main session — ctx.abort() ends the whole agent operation, not just the one
22
+ * tool call (pi types it "Abort the current agent operation"),
23
+ * and pi runs sibling tool calls CONCURRENTLY by default. So an
24
+ * overrun kills every tool in flight in that turn — including
25
+ * one exempted via commandTimeoutExemptTools, which only stops
26
+ * a timer being armed FOR that tool, not its being collateral
27
+ * when a guarded sibling trips. There is no per-call
28
+ * cancellation channel to do better with. The session itself
29
+ * survives to receive the follow-up reminder turn.
23
30
  * child — there is no per-tool cancellation channel into a child, so
24
31
  * the whole child is killed and re-spawned with
25
32
  * {@link commandTimeoutHint} prepended. Coarser by necessity:
@@ -38,6 +45,8 @@ export interface WatchdogDeps {
38
45
  * 0 (or any non-positive value) means the watchdog is off and never arms.
39
46
  */
40
47
  getTimeoutMs: () => number;
48
+ /** Optional exact policy for tools with their own bounded execution contract. */
49
+ shouldWatch?: (toolName: string) => boolean;
41
50
  schedule: (fn: () => void, ms: number) => TimerHandle;
42
51
  cancel: (handle: TimerHandle) => void;
43
52
  /** Invoked when a command overruns: each adapter aborts/kills here. */
@@ -18,8 +18,15 @@
18
18
  * TIMER STATE MACHINE is identical, and lives here once. What differs is only
19
19
  * the `onFire` side effect, which each adapter supplies:
20
20
  *
21
- * main session — ctx.abort() cancels just that tool call and the session
22
- * survives to receive a follow-up reminder turn.
21
+ * main session — ctx.abort() ends the whole agent operation, not just the one
22
+ * tool call (pi types it "Abort the current agent operation"),
23
+ * and pi runs sibling tool calls CONCURRENTLY by default. So an
24
+ * overrun kills every tool in flight in that turn — including
25
+ * one exempted via commandTimeoutExemptTools, which only stops
26
+ * a timer being armed FOR that tool, not its being collateral
27
+ * when a guarded sibling trips. There is no per-call
28
+ * cancellation channel to do better with. The session itself
29
+ * survives to receive the follow-up reminder turn.
23
30
  * child — there is no per-tool cancellation channel into a child, so
24
31
  * the whole child is killed and re-spawned with
25
32
  * {@link commandTimeoutHint} prepended. Coarser by necessity:
@@ -105,11 +112,14 @@ export class CommandWatchdog {
105
112
  }
106
113
  /** Arm a timer for a starting tool. No-op when the watchdog is off. */
107
114
  onStart(toolCallId, toolName) {
115
+ // Disarm first so a live policy/config change cannot leave a timer from
116
+ // a duplicate start armed after the tool becomes exempt or watchdog-off.
117
+ this.disarm(toolCallId);
118
+ if (this.deps.shouldWatch?.(toolName) === false)
119
+ return;
108
120
  const ms = this.deps.getTimeoutMs();
109
121
  if (!(ms > 0))
110
122
  return;
111
- // A duplicate start for the same id must not leak the previous timer.
112
- this.disarm(toolCallId);
113
123
  const handle = this.deps.schedule(() => this.fire(toolCallId, toolName, ms), ms);
114
124
  this.active.set(toolCallId, handle);
115
125
  }
@@ -15,8 +15,11 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
15
15
  * the whole process tree on abort — then a follow-up user turn tells the model
16
16
  * what happened so it retries with a timeout instead of hanging again.
17
17
  *
18
- * Tool-agnostic: it arms on ANY tool, honouring "any command can run forever",
19
- * though in practice only bash runs long enough to trip it.
18
+ * Tool-agnostic by default: it arms on every tool except exact names listed in
19
+ * `commandTimeoutExemptTools`, which /task-config fills from the live tool list
20
+ * (config/tool-list.ts). Exemptions are for tools that already own a bounded
21
+ * timeout and cancellation contract — the guard's whole justification is that
22
+ * pi's bash has NO default timeout, which says nothing about a tool that does.
20
23
  *
21
24
  * SCOPE — this covers the main session ONLY, which is where the implementation
22
25
  * turn runs (orchestrator hands the spec off via sendUserMessage). Gate
@@ -16,8 +16,11 @@ import { CommandWatchdog, realTimerDeps, reminderMessage } from '../shared/comma
16
16
  * the whole process tree on abort — then a follow-up user turn tells the model
17
17
  * what happened so it retries with a timeout instead of hanging again.
18
18
  *
19
- * Tool-agnostic: it arms on ANY tool, honouring "any command can run forever",
20
- * though in practice only bash runs long enough to trip it.
19
+ * Tool-agnostic by default: it arms on every tool except exact names listed in
20
+ * `commandTimeoutExemptTools`, which /task-config fills from the live tool list
21
+ * (config/tool-list.ts). Exemptions are for tools that already own a bounded
22
+ * timeout and cancellation contract — the guard's whole justification is that
23
+ * pi's bash has NO default timeout, which says nothing about a tool that does.
21
24
  *
22
25
  * SCOPE — this covers the main session ONLY, which is where the implementation
23
26
  * turn runs (orchestrator hands the spec off via sendUserMessage). Gate
@@ -64,6 +67,7 @@ export function registerCommandWatchdog(pi) {
64
67
  const ctxByCall = new Map();
65
68
  const watchdog = new CommandWatchdog({
66
69
  getTimeoutMs: () => getConfig().requestTimeoutMs,
70
+ shouldWatch: toolName => !getConfig().commandTimeoutExemptTools.includes(toolName),
67
71
  ...realTimerDeps,
68
72
  onFire: (toolCallId, toolName, timeoutMs) => {
69
73
  const ctx = ctxByCall.get(toolCallId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mjasnikovs/pi-task",
3
- "version": "0.28.3",
3
+ "version": "0.29.0",
4
4
  "description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",