@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 +14 -5
- package/package.json +1 -1
- package/src/config.ts +22 -0
- package/src/index.ts +3 -3
- package/src/setup.ts +24 -0
- package/src/spawn.ts +28 -53
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. 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)
|
|
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.
|
|
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,
|
|
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
|
|
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
|
-
/**
|
|
257
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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) {
|