@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 +15 -6
- package/package.json +1 -1
- package/src/config.ts +22 -0
- package/src/index.ts +7 -5
- package/src/monitor.ts +134 -12
- package/src/setup.ts +24 -0
- package/src/spawn.ts +31 -1
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
|
|
28
|
-
Per-run only, never persisted: a transient provider
|
|
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.
|
|
379
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
78
|
+
interface KeyFragment {
|
|
79
|
+
text: string;
|
|
80
|
+
index: number;
|
|
81
|
+
}
|
|
65
82
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 (
|
|
72
|
-
|
|
73
|
-
|
|
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 `${
|
|
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;
|