@ferris1225/pi-subagents 0.28.0 → 0.31.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 +270 -597
- package/agents/explore.md +1 -0
- package/agents/reviewer.md +1 -0
- package/agents/worker.md +1 -1
- package/package.json +1 -1
- package/src/completion.ts +28 -0
- package/src/config.ts +25 -0
- package/src/dispatch.ts +845 -0
- package/src/format.ts +149 -0
- package/src/index.ts +90 -1386
- package/src/models.ts +13 -0
- package/src/monitor.ts +27 -0
- package/src/prompt.ts +10 -0
- package/src/runtime.ts +145 -0
- package/src/setup.ts +50 -0
- package/src/spawn.ts +189 -68
- package/src/tools.ts +409 -0
- package/src/ui.ts +8 -3
- package/src/widget.ts +182 -0
package/src/models.ts
CHANGED
|
@@ -36,6 +36,19 @@ export function availableModelRefs(ctx: ModelContext): string[] {
|
|
|
36
36
|
return [currentRef, ...refs.filter((ref) => ref !== currentRef)];
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the model for a `vision: true` dispatch. The configured vision model
|
|
41
|
+
* wins when it is usable by the current session; otherwise the task falls back
|
|
42
|
+
* to the main window's current model (the documented behavior when the vision
|
|
43
|
+
* model is unset). Returns undefined only when neither exists — callers then
|
|
44
|
+
* keep the agent's own model as the last resort.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveVisionModelRef(ctx: ModelContext, visionModel?: string): string | undefined {
|
|
47
|
+
const configured = visionModel?.trim();
|
|
48
|
+
if (configured && availableModelRefs(ctx).includes(configured)) return configured;
|
|
49
|
+
return ctx.model ? modelRef(ctx.model) : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
/** Replace unavailable persisted overrides with a model usable by the main session. */
|
|
40
53
|
export function repairUnavailableModelOverrides(
|
|
41
54
|
ctx: ModelContext,
|
package/src/monitor.ts
CHANGED
|
@@ -39,6 +39,10 @@ export interface RunView {
|
|
|
39
39
|
id: number;
|
|
40
40
|
agent: string;
|
|
41
41
|
task: string;
|
|
42
|
+
/** Short content label derived from the task (paths/symbols), shown next to
|
|
43
|
+
* the agent name so concurrent same-agent runs are told apart by what they
|
|
44
|
+
* are doing, not just their run id. */
|
|
45
|
+
label?: string;
|
|
42
46
|
model?: string;
|
|
43
47
|
/** Effective thinking strength this run was launched with (frontmatter/config/global). */
|
|
44
48
|
thinking?: string;
|
|
@@ -228,6 +232,28 @@ export function formatTaskSummary(task: string, maxWidth: number = TASK_SUMMARY_
|
|
|
228
232
|
return `${takeGraphemes(segments, headMax)}${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(segments, tailMax)}`;
|
|
229
233
|
}
|
|
230
234
|
|
|
235
|
+
/** Max display width of a run's content label. */
|
|
236
|
+
export const RUN_LABEL_MAX = 32;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Short content label for a run, derived from its task: the single most
|
|
240
|
+
* distinguishing fragment (path, quoted phrase, symbol) so concurrent same-agent
|
|
241
|
+
* runs are told apart by WHAT they do, not just their run id. A long path keeps
|
|
242
|
+
* its tail (the filename is the recognisable part); a task with no recognizable
|
|
243
|
+
* fragment falls back to a head slice of its prose. Grapheme-safe.
|
|
244
|
+
*/
|
|
245
|
+
export function runLabel(task: string): string {
|
|
246
|
+
const fragment = extractKeyFragments(task)[0];
|
|
247
|
+
const src = fragment ?? stripVTControlCharacters(task).replace(/\s+/g, " ").trim();
|
|
248
|
+
if (visibleWidth(src) <= RUN_LABEL_MAX) return src;
|
|
249
|
+
const chars = [...graphemeSegmenter.segment(src)].map((s) => s.segment);
|
|
250
|
+
// A path/symbol fragment keeps its tail (filename/symbol is recognisable);
|
|
251
|
+
// a prose fallback keeps its head.
|
|
252
|
+
return fragment
|
|
253
|
+
? `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(chars, RUN_LABEL_MAX - 1)}`
|
|
254
|
+
: `${takeGraphemes(chars, RUN_LABEL_MAX - 1)}${TASK_SUMMARY_ELLIPSIS}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
231
257
|
function formatTokens(count: number): string {
|
|
232
258
|
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
|
233
259
|
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
|
|
@@ -401,6 +427,7 @@ export class MonitorStore {
|
|
|
401
427
|
id,
|
|
402
428
|
agent,
|
|
403
429
|
task,
|
|
430
|
+
label: runLabel(task),
|
|
404
431
|
model,
|
|
405
432
|
thinking,
|
|
406
433
|
status: "queued",
|
package/src/prompt.ts
CHANGED
|
@@ -62,6 +62,16 @@ ${hasMultiple ? "- Run INDEPENDENT tasks in parallel: one subagent call with a `
|
|
|
62
62
|
- Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool.
|
|
63
63
|
- Trust but verify: a sub-agent's summary describes intent, not outcome. Check the actual changes/results before reporting work done.
|
|
64
64
|
|
|
65
|
+
Vision tasks:
|
|
66
|
+
- Judge whether a delegated task may require viewing images (frontend screenshots, mockups, design files, visual regression comparisons). If it might, pass \`vision: true\` in the subagent call and give the sub-agent the exact image paths — it reads them with its read tool.
|
|
67
|
+
- \`vision: true\` runs the sub-agent on the vision-capable model configured in /subagents-setup; when none is configured it falls back to the main session's current model. Do not skip the flag because the agent's default model looks fast/cheap — a non-vision model cannot see the images.
|
|
68
|
+
|
|
69
|
+
Result handoff (do not re-state):
|
|
70
|
+
- A sub-agent's result arrives as a message that is already shown to the user. Do NOT restate, paraphrase, or re-summarize its findings in your reply — that just burns tokens duplicating what is already visible. The user can read the result above.
|
|
71
|
+
- Reply only with what you ADD: your own conclusion, the next action you are taking, or a one-line acknowledgement. When the result already answers the user, a single sentence is enough — then end your turn or proceed.
|
|
72
|
+
- Read the result and act on it (verify, continue, commit). Keep your own output short.
|
|
73
|
+
- A result arriving does NOT mean all work is finished: sub-agents run in the background and siblings may still be active (a delivery names any still-running runs). Do not report the overall task complete until no runs are active — call subagent_status to confirm before saying Done.
|
|
74
|
+
|
|
65
75
|
Review & verification:
|
|
66
76
|
- Never report an unrun check as passed; report it as unavailable or as a pre-existing failure.
|
|
67
77
|
${hasReviewer ? "- For non-trivial diffs, run one fresh read-only `reviewer` sub-agent before reporting done. Fix only concrete blockers and re-review at most once.\n- Use multi-model cross-review only when explicitly requested or for genuinely high-risk changes (security, unsafe/FFI, persistence-migration, concurrency). Reviewers are read-only; only the main agent edits.\n" : ""}- Commit or push only when explicitly requested, applicable checks pass, and no accepted blockers remain.`;
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared per-session runtime state for pi-subagents.
|
|
3
|
+
*
|
|
4
|
+
* The extension registers several tools (subagent, subagent_wait/status/stop) and
|
|
5
|
+
* a widget that all talk to one set of live structures: the background queue, the
|
|
6
|
+
* completion batcher, abort controllers per run, and the settled-results store.
|
|
7
|
+
* `createRuntime` builds those once per extension load and hands the same object
|
|
8
|
+
* to every registration site, so state stays in one place without globals.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { rmSync } from "node:fs";
|
|
13
|
+
import { BackgroundTaskQueue } from "./background.ts";
|
|
14
|
+
import {
|
|
15
|
+
completionGroupTriggersTurn,
|
|
16
|
+
createCompletionBatcher,
|
|
17
|
+
formatActiveRunsFooter,
|
|
18
|
+
formatCompletionMessage,
|
|
19
|
+
type CompletionBatcher,
|
|
20
|
+
type CompletionMessageItem,
|
|
21
|
+
} from "./completion.ts";
|
|
22
|
+
import { loadConfigSync } from "./config.ts";
|
|
23
|
+
import { monitor } from "./monitor.ts";
|
|
24
|
+
import type { SingleResult } from "./spawn.ts";
|
|
25
|
+
|
|
26
|
+
export interface SubagentRuntime {
|
|
27
|
+
configPath: string;
|
|
28
|
+
backgroundQueue: BackgroundTaskQueue;
|
|
29
|
+
/** False after session_shutdown; guards delivery and queue work. */
|
|
30
|
+
sessionActive: boolean;
|
|
31
|
+
/** Deliver a batch of completion messages to the main window, waking it only
|
|
32
|
+
* when the batch needs a turn. */
|
|
33
|
+
sendCompletionGroup: (items: CompletionMessageItem[]) => void;
|
|
34
|
+
completionBatcher: CompletionBatcher<CompletionMessageItem>;
|
|
35
|
+
/** Abort controllers per active run, so subagent_stop can cancel a run in-turn. */
|
|
36
|
+
runControllers: Map<number, AbortController>;
|
|
37
|
+
/** Final results keyed by run id, so subagent_wait can hand the model the
|
|
38
|
+
* actual result in-turn instead of it sleeping/polling for a wake-up message. */
|
|
39
|
+
settledRuns: Map<number, SingleResult>;
|
|
40
|
+
settledListeners: Map<number, Set<(result: SingleResult) => void>>;
|
|
41
|
+
registerRunResult: (runId: number, result: SingleResult) => void;
|
|
42
|
+
/** Sessions preserved on disk after a model-level handback (the run did real
|
|
43
|
+
* work but its model quota/auth failed), keyed by the original run id so a
|
|
44
|
+
* later `subagent({ resume: <runId> })` can continue in-context. Cleaned up
|
|
45
|
+
* on shutdown so a crashed/ended session never leaks temp session dirs. */
|
|
46
|
+
preservedSessions: Map<number, PreservedSession>;
|
|
47
|
+
/** Flip sessionActive off and release all session-scoped resources. */
|
|
48
|
+
shutdown: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PreservedSession {
|
|
52
|
+
sessionId: string;
|
|
53
|
+
sessionDir: string;
|
|
54
|
+
agentName: string;
|
|
55
|
+
task: string;
|
|
56
|
+
vision: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRuntime {
|
|
60
|
+
// Init-time decisions need the config synchronously; the full (migrating)
|
|
61
|
+
// async load runs per tool call.
|
|
62
|
+
const initialConfig = loadConfigSync(configPath);
|
|
63
|
+
const backgroundQueue = new BackgroundTaskQueue(initialConfig.maxConcurrency);
|
|
64
|
+
|
|
65
|
+
const runtime: SubagentRuntime = {
|
|
66
|
+
configPath,
|
|
67
|
+
backgroundQueue,
|
|
68
|
+
sessionActive: true,
|
|
69
|
+
sendCompletionGroup: (items) => {
|
|
70
|
+
if (!runtime.sessionActive || items.length === 0) return;
|
|
71
|
+
// A result arriving for one run does not mean sibling runs are done.
|
|
72
|
+
// Computing this at delivery (emit) time — not when the item was
|
|
73
|
+
// pushed — reflects the current monitor state, since finishing runs
|
|
74
|
+
// are removed from the monitor before their completion is pushed.
|
|
75
|
+
const active = monitor
|
|
76
|
+
.getRuns()
|
|
77
|
+
.filter((run) => run.status === "queued" || run.status === "running" || run.retained)
|
|
78
|
+
.map((run) => ({ id: run.id, agent: run.agent, label: run.label }));
|
|
79
|
+
const message = {
|
|
80
|
+
customType: "subagent-result",
|
|
81
|
+
content: formatCompletionMessage(items) + formatActiveRunsFooter(active),
|
|
82
|
+
display: true,
|
|
83
|
+
};
|
|
84
|
+
if (completionGroupTriggersTurn(items)) {
|
|
85
|
+
// steer: the result is injected after the current tool call even mid-turn,
|
|
86
|
+
// or starts a new turn when idle. followUp would sit in the queue until the
|
|
87
|
+
// whole turn ends — a main agent waiting for the result (sleep/poll) would
|
|
88
|
+
// never see it delivered, which is exactly the "returned but never woken"
|
|
89
|
+
// failure mode.
|
|
90
|
+
pi.sendMessage(message, { deliverAs: "steer", triggerTurn: true });
|
|
91
|
+
} else {
|
|
92
|
+
// No-wake delivery: nextTurn rides along with the next user turn and can
|
|
93
|
+
// never start a continuation by itself. followUp would auto-continue
|
|
94
|
+
// whenever pi is already streaming, defeating the opt-out.
|
|
95
|
+
pi.sendMessage(message, { deliverAs: "nextTurn" });
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
completionBatcher: undefined as unknown as CompletionBatcher<CompletionMessageItem>,
|
|
99
|
+
runControllers: new Map<number, AbortController>(),
|
|
100
|
+
settledRuns: new Map<number, SingleResult>(),
|
|
101
|
+
settledListeners: new Map<number, Set<(result: SingleResult) => void>>(),
|
|
102
|
+
preservedSessions: new Map<number, PreservedSession>(),
|
|
103
|
+
registerRunResult: (runId, result) => {
|
|
104
|
+
runtime.settledRuns.set(runId, result);
|
|
105
|
+
const listeners = runtime.settledListeners.get(runId);
|
|
106
|
+
if (listeners) {
|
|
107
|
+
runtime.settledListeners.delete(runId);
|
|
108
|
+
for (const listener of listeners) {
|
|
109
|
+
try {
|
|
110
|
+
listener(result);
|
|
111
|
+
} catch {
|
|
112
|
+
/* listener errors must never break settling */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
shutdown: () => {
|
|
118
|
+
runtime.sessionActive = false;
|
|
119
|
+
runtime.completionBatcher.dispose();
|
|
120
|
+
runtime.backgroundQueue.cancelAll();
|
|
121
|
+
runtime.settledRuns.clear();
|
|
122
|
+
runtime.settledListeners.clear();
|
|
123
|
+
runtime.runControllers.clear();
|
|
124
|
+
// Best-effort cleanup of preserved sub-agent session dirs so an
|
|
125
|
+
// ended/crashed session does not leak temp files; the OS reclaims
|
|
126
|
+
// tmpdir eventually, but this keeps things tidy between sessions.
|
|
127
|
+
for (const { sessionDir } of runtime.preservedSessions.values()) {
|
|
128
|
+
try {
|
|
129
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
130
|
+
} catch {
|
|
131
|
+
/* best-effort */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
runtime.preservedSessions.clear();
|
|
135
|
+
// Clear the monitor so stale runs from this session never leak into the
|
|
136
|
+
// next one (the module-level singleton survives across sessions).
|
|
137
|
+
monitor.clear();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
runtime.completionBatcher = createCompletionBatcher<CompletionMessageItem>({
|
|
142
|
+
emit: runtime.sendCompletionGroup,
|
|
143
|
+
});
|
|
144
|
+
return runtime;
|
|
145
|
+
}
|
package/src/setup.ts
CHANGED
|
@@ -103,6 +103,31 @@ async function pickAgentModel(
|
|
|
103
103
|
);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** Vision model pick for image tasks (screenshots/mockups); the inherit option
|
|
107
|
+
* leaves it unset, so vision-flagged dispatches fall back to the main session's
|
|
108
|
+
* current model. */
|
|
109
|
+
async function pickVisionModel(
|
|
110
|
+
ctx: ExtensionCommandContext,
|
|
111
|
+
currentRef: string | undefined,
|
|
112
|
+
refs: readonly string[],
|
|
113
|
+
): Promise<string | typeof INHERIT | undefined> {
|
|
114
|
+
const items = [
|
|
115
|
+
{
|
|
116
|
+
value: INHERIT,
|
|
117
|
+
label: currentRef
|
|
118
|
+
? `(not set — vision tasks fall back to the main session's model; drop "${currentRef}")`
|
|
119
|
+
: "(not set — vision tasks fall back to the main session's current model)",
|
|
120
|
+
},
|
|
121
|
+
...refs.map((ref) => ({ value: ref, label: ref === currentRef ? `${ref} (current)` : ref })),
|
|
122
|
+
];
|
|
123
|
+
return promptSelectOne(
|
|
124
|
+
ctx,
|
|
125
|
+
"Vision-capable model for image tasks (screenshots, mockups, designs)?",
|
|
126
|
+
"Type to filter • ↑/↓ • PgUp/PgDn • Enter selects • Esc cancels setup",
|
|
127
|
+
items,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
106
131
|
async function pickAgentModelsAndStrength(
|
|
107
132
|
ctx: ExtensionCommandContext,
|
|
108
133
|
enabledAgents: readonly string[],
|
|
@@ -337,6 +362,18 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
337
362
|
const picked = await pickAgentModelsAndStrength(ctx, enabled, base.agentModels, base.agentThinkingLevels, thinkingLevel, defaults);
|
|
338
363
|
if (picked === undefined) return notifyCancelled(ctx);
|
|
339
364
|
|
|
365
|
+
let nextVisionModel: string | undefined;
|
|
366
|
+
// No models available: keep the vision model unset (vision tasks then fall
|
|
367
|
+
// back to the main session's model) instead of showing a one-option picker.
|
|
368
|
+
const refs = availableModelRefs(ctx);
|
|
369
|
+
if (refs.length === 0) {
|
|
370
|
+
ctx.ui.notify("No Pi models are currently available; vision model left unset.", "warning");
|
|
371
|
+
} else {
|
|
372
|
+
const visionModel = await pickVisionModel(ctx, base.visionModel, refs);
|
|
373
|
+
if (visionModel === undefined) return notifyCancelled(ctx);
|
|
374
|
+
if (visionModel !== INHERIT) nextVisionModel = visionModel;
|
|
375
|
+
}
|
|
376
|
+
|
|
340
377
|
const injection = await pickInjection(ctx, base.proactiveInjection);
|
|
341
378
|
if (injection === undefined) return notifyCancelled(ctx);
|
|
342
379
|
|
|
@@ -382,7 +419,9 @@ async function runFullSetup(ctx: ExtensionCommandContext, configPath: string, ba
|
|
|
382
419
|
maxConcurrency,
|
|
383
420
|
maxFixRounds,
|
|
384
421
|
idleTimeoutSec,
|
|
422
|
+
announcedFeatures: base.announcedFeatures,
|
|
385
423
|
};
|
|
424
|
+
if (nextVisionModel !== undefined) next.visionModel = nextVisionModel;
|
|
386
425
|
await saveConfig(next, configPath);
|
|
387
426
|
ctx.ui.notify(`pi-subagents configured. Saved to ${configPath}`, "info");
|
|
388
427
|
}
|
|
@@ -391,6 +430,7 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
|
|
|
391
430
|
const choice = await ctx.ui.select("pi-subagents is already configured. What would you like to change?", [
|
|
392
431
|
"Enable/disable agents",
|
|
393
432
|
"Configure an agent (model + thinking)",
|
|
433
|
+
"Change vision model (image tasks)",
|
|
394
434
|
"Toggle proactive injection",
|
|
395
435
|
"Change agent scope",
|
|
396
436
|
"Change max concurrent sub-agents",
|
|
@@ -440,6 +480,16 @@ async function runMenu(ctx: ExtensionCommandContext, configPath: string, config:
|
|
|
440
480
|
const injection = await pickInjection(ctx, config.proactiveInjection);
|
|
441
481
|
if (injection === undefined) return notifyCancelled(ctx);
|
|
442
482
|
next.proactiveInjection = injection;
|
|
483
|
+
} else if (choice.startsWith("Change vision")) {
|
|
484
|
+
const refs = availableModelRefs(ctx);
|
|
485
|
+
if (refs.length === 0) {
|
|
486
|
+
ctx.ui.notify("No Pi models are currently available; vision model left unchanged.", "warning");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const visionModel = await pickVisionModel(ctx, config.visionModel, refs);
|
|
490
|
+
if (visionModel === undefined) return notifyCancelled(ctx);
|
|
491
|
+
if (visionModel === INHERIT) delete next.visionModel;
|
|
492
|
+
else next.visionModel = visionModel;
|
|
443
493
|
} else if (choice.startsWith("Change agent scope")) {
|
|
444
494
|
const scope = await pickScope(ctx, config.agentScope);
|
|
445
495
|
if (scope === undefined) return notifyCancelled(ctx);
|
package/src/spawn.ts
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
13
|
-
import {
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, unlinkSync, rmdirSync, writeFileSync } from "node:fs";
|
|
14
15
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
15
16
|
import { tmpdir } from "node:os";
|
|
16
17
|
import { basename, join } from "node:path";
|
|
@@ -99,6 +100,16 @@ export interface SingleResult {
|
|
|
99
100
|
* message must surface these so the main agent is never misled by a rosy final
|
|
100
101
|
* text (e.g. a worker that ended with "keep waiting" while its build failed). */
|
|
101
102
|
failedTools?: Array<{ toolName: string; error: string }>;
|
|
103
|
+
/** The pi session id this run used (every run is session-backed so a
|
|
104
|
+
* model-level failure can be resumed on another model without re-scanning). */
|
|
105
|
+
sessionId?: string;
|
|
106
|
+
/** Directory holding the run's pi session file. Preserved across the initial
|
|
107
|
+
* attempt and any resume attempts; kept on disk only when a model-level
|
|
108
|
+
* failure is handed back, so a later `resume` can pick up the context. */
|
|
109
|
+
sessionDir?: string;
|
|
110
|
+
/** True when the result was produced by resuming an earlier session (a
|
|
111
|
+
* model-level fallback or an explicit resume) rather than a fresh start. */
|
|
112
|
+
resumed?: boolean;
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
export interface SubagentDetails {
|
|
@@ -348,6 +359,44 @@ export function getResultOutput(result: SingleResult): string {
|
|
|
348
359
|
return getFinalOutput(result.messages) || "(no output)";
|
|
349
360
|
}
|
|
350
361
|
|
|
362
|
+
/**
|
|
363
|
+
* True when a pi session file for `sessionId` already exists in `sessionDir`.
|
|
364
|
+
* Every sub-agent run is session-backed; the FIRST attempt creates the session
|
|
365
|
+
* (`--session-id`) and every later attempt on the same session RESUMES it
|
|
366
|
+
* (`--session`), so a model-level retry or fallback picks up the prior context
|
|
367
|
+
* instead of re-scanning. The session file is named `<timestamp>Z_<id>.jsonl`
|
|
368
|
+
* (pi's convention), so a suffix match is exact and cheap.
|
|
369
|
+
*/
|
|
370
|
+
export function sessionExists(sessionDir: string, sessionId: string): boolean {
|
|
371
|
+
try {
|
|
372
|
+
return readdirSync(sessionDir).some((file) => file.endsWith(`_${sessionId}.jsonl`));
|
|
373
|
+
} catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Build the continuation prompt sent to a RESUMED sub-agent session. The model
|
|
380
|
+
* sees the full prior history (loaded by `--session`) plus this new user turn,
|
|
381
|
+
* so it continues from where it stopped. Steering it not to redo finished work
|
|
382
|
+
* is what saves the re-scan the user wants to avoid.
|
|
383
|
+
*
|
|
384
|
+
* `reason` is a short clause describing why the session is resuming
|
|
385
|
+
* ("a transient provider error" / "your previous model hit a quota or auth
|
|
386
|
+
* limit, so a different model is now continuing").
|
|
387
|
+
*/
|
|
388
|
+
export function buildResumePrompt(task: string, reason: string): string {
|
|
389
|
+
return `You are resuming an earlier sub-agent session after ${reason}. Your earlier work — searches, reads, edits, and reasoning — is preserved in this session's history above; review it before acting. Original task: ${task}. Pick up exactly where you left off and finish it. Do NOT redo searches, reads, or edits you already completed unless a step clearly failed. Continue now.`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Reason clause for resuming on a DIFFERENT model after the configured model
|
|
393
|
+
* failed at the provider level (quota/auth/overloaded/...). */
|
|
394
|
+
export function buildFallbackResumeReason(fromModel?: string): string {
|
|
395
|
+
return fromModel
|
|
396
|
+
? `your previous model (${fromModel}) hit a quota, billing, or auth limit, so a different model is now continuing`
|
|
397
|
+
: "your previous model became unavailable, so a different model is now continuing";
|
|
398
|
+
}
|
|
399
|
+
|
|
351
400
|
async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
|
|
352
401
|
const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
|
|
353
402
|
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
@@ -424,6 +473,21 @@ export interface RunSingleOptions {
|
|
|
424
473
|
* SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS; pass [] to disable (e.g. when an isolated
|
|
425
474
|
* test wants to assert only the fallback path runs once). */
|
|
426
475
|
runLevelRetryDelaysMs?: readonly number[];
|
|
476
|
+
/** Directory holding this run's pi session. When set (with sessionId), the
|
|
477
|
+
* child is session-backed: it creates the session on the first attempt and
|
|
478
|
+
* RESUMES it on any later attempt (model-level retry/fallback), so a model
|
|
479
|
+
* switch inherits the prior context. When unset, the child runs ephemerally
|
|
480
|
+
* (--no-session) and cannot be resumed. The caller owns the directory's
|
|
481
|
+
* lifecycle; runSingleAgent neither creates nor removes it. */
|
|
482
|
+
sessionDir?: string;
|
|
483
|
+
/** Pi session id paired with sessionDir. The first attempt creates it
|
|
484
|
+
* (--session-id); later attempts resume (--session) once the session file
|
|
485
|
+
* exists. */
|
|
486
|
+
sessionId?: string;
|
|
487
|
+
/** Text sent to the child via stdin. Defaults to `Task: ${task}`. A resumed
|
|
488
|
+
* attempt passes a continuation prompt (see buildResumePrompt) so the model
|
|
489
|
+
* picks up the prior session instead of starting the task over. */
|
|
490
|
+
stdinText?: string;
|
|
427
491
|
signal?: AbortSignal;
|
|
428
492
|
onLive?: (e: SubagentLiveEvent) => void;
|
|
429
493
|
makeDetails: (results: SingleResult[]) => SubagentDetails;
|
|
@@ -458,7 +522,20 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
458
522
|
|
|
459
523
|
// Defense in depth: even if another extension ignores the depth marker, a
|
|
460
524
|
// child process can never expose a tool named `subagent` back to its model.
|
|
461
|
-
const args: string[] = ["--mode", "json", "-p", "--
|
|
525
|
+
const args: string[] = ["--mode", "json", "-p", "--exclude-tools", "subagent"];
|
|
526
|
+
// Session-backed: every run persists its pi session so a model-level retry or
|
|
527
|
+
// fallback can RESUME it (--session) instead of re-scanning from scratch. The
|
|
528
|
+
// first attempt creates the session (--session-id); once the session file
|
|
529
|
+
// exists, later attempts resume it. No sessionDir → ephemeral (--no-session).
|
|
530
|
+
if (options.sessionDir && options.sessionId) {
|
|
531
|
+
args.push("--session-dir", options.sessionDir);
|
|
532
|
+
args.push(
|
|
533
|
+
sessionExists(options.sessionDir, options.sessionId) ? "--session" : "--session-id",
|
|
534
|
+
options.sessionId,
|
|
535
|
+
);
|
|
536
|
+
} else {
|
|
537
|
+
args.push("--no-session");
|
|
538
|
+
}
|
|
462
539
|
if (agent.model) args.push("--model", agent.model);
|
|
463
540
|
// The configured level is clamped adaptively per model by pi.
|
|
464
541
|
args.push("--thinking", thinkingLevel);
|
|
@@ -467,6 +544,7 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
467
544
|
let tmpPromptDir: string | null = null;
|
|
468
545
|
let tmpPromptPath: string | null = null;
|
|
469
546
|
|
|
547
|
+
const hasSession = Boolean(options.sessionDir && options.sessionId);
|
|
470
548
|
const currentResult: SingleResult = {
|
|
471
549
|
agent: agentName,
|
|
472
550
|
agentSource: agent.source,
|
|
@@ -477,6 +555,9 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
477
555
|
usage: emptyUsage(),
|
|
478
556
|
model: agent.model,
|
|
479
557
|
thinking: thinkingLevel,
|
|
558
|
+
sessionId: options.sessionId,
|
|
559
|
+
sessionDir: options.sessionDir,
|
|
560
|
+
resumed: hasSession && sessionExists(options.sessionDir as string, options.sessionId as string),
|
|
480
561
|
};
|
|
481
562
|
|
|
482
563
|
try {
|
|
@@ -618,11 +699,11 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
618
699
|
currentResult.messages.push(event.message as Message);
|
|
619
700
|
}
|
|
620
701
|
};
|
|
621
|
-
// Send the task
|
|
622
|
-
// command line. This avoids OS
|
|
623
|
-
//
|
|
702
|
+
// Send the task (or a continuation prompt for a resumed session) through
|
|
703
|
+
// the child stdin pipe instead of the command line. This avoids OS
|
|
704
|
+
// argument-length limits and requires no extra temp file for the data.
|
|
624
705
|
proc.stdin?.on("error", () => undefined);
|
|
625
|
-
proc.stdin?.end(`Task: ${task}`);
|
|
706
|
+
proc.stdin?.end(options.stdinText ?? `Task: ${task}`);
|
|
626
707
|
|
|
627
708
|
// Decode stdout through a StringDecoder so multi-byte UTF-8 characters
|
|
628
709
|
// (CJK, emoji) split across chunk boundaries never produce U+FFFD
|
|
@@ -725,7 +806,8 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
725
806
|
}
|
|
726
807
|
|
|
727
808
|
/**
|
|
728
|
-
* Run one agent with three layers of resilience
|
|
809
|
+
* Run one agent with three layers of resilience, all session-backed so a model
|
|
810
|
+
* switch RESUMES the prior context instead of re-scanning from scratch:
|
|
729
811
|
*
|
|
730
812
|
* 1. Startup retry (inner loop): a concurrent pi startup race can make the child
|
|
731
813
|
* exit before any model/tool activity. Relaunch with backoff so the startup
|
|
@@ -733,23 +815,19 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
733
815
|
* model — and only a clean, silent, zero-activity exit qualifies (see
|
|
734
816
|
* isRetryableStartupFailure), so retrying can never duplicate real work.
|
|
735
817
|
* 2. Run-level retry on the SAME configured model (middle): when the provider
|
|
736
|
-
* rejects the model
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
* tried (default 3 attempts) and given up.
|
|
745
|
-
* 3. Model fallback (outer): when the same model is still failing after all its
|
|
746
|
-
* run-level retries, retry once with the main window's current model. The
|
|
747
|
-
* fallback gets its own startup-retry loop, since a startup race can hit any
|
|
748
|
-
* relaunch regardless of model.
|
|
818
|
+
* rejects the model with a TRANSIENT error (503/429/timeout/network/...) —
|
|
819
|
+
* NOT a terminal one (quota/billing/invalid key/auth — see isTerminalModelError)
|
|
820
|
+
* — RESUME the session on the same model up to
|
|
821
|
+
* SUBAGENT_RUN_LEVEL_RETRY_DELAYS_MS.length more times with backoff. Resuming
|
|
822
|
+
* preserves any work done before the hiccup.
|
|
823
|
+
* 3. Model fallback (outer): when the same model still fails, RESUME the session
|
|
824
|
+
* once on the main window's current model — the fallback inherits the prior
|
|
825
|
+
* context, so the user never pays to re-scan after a model switch.
|
|
749
826
|
*
|
|
750
|
-
* Terminal model errors short-circuit
|
|
751
|
-
*
|
|
752
|
-
*
|
|
827
|
+
* Terminal model errors short-circuit to the caller: the account is the
|
|
828
|
+
* bottleneck, so neither same-model retry nor a same-account fallback can help.
|
|
829
|
+
* The session is preserved on disk (the result carries sessionId/sessionDir) so
|
|
830
|
+
* a later manual resume on a working model can continue without re-scanning.
|
|
753
831
|
*
|
|
754
832
|
* The fallback is per-run only and never persisted: a transient provider hiccup
|
|
755
833
|
* must not silently downgrade the configured agent model.
|
|
@@ -802,55 +880,98 @@ export async function runSingleAgentWithModelFallback(
|
|
|
802
880
|
}
|
|
803
881
|
};
|
|
804
882
|
|
|
805
|
-
|
|
883
|
+
// One pi session backs the whole logical run, shared by the initial attempt
|
|
884
|
+
// and every resume (model-level retry / fallback), so a model switch inherits
|
|
885
|
+
// the prior context instead of re-scanning. Created here unless the caller
|
|
886
|
+
// passed one in (an explicit resume of a handed-back session).
|
|
887
|
+
const sessionId = options.sessionId ?? randomUUID();
|
|
888
|
+
const sessionDir = options.sessionDir ?? (await mkdtemp(join(tmpdir(), "pi-subagent-session-")));
|
|
889
|
+
const baseOptions: RunSingleOptions = { ...options, sessionDir, sessionId };
|
|
806
890
|
|
|
807
|
-
// After a model-level failure, classify before reacting. A TERMINAL error
|
|
808
|
-
// (quota/billing/invalid key/auth) is account-scoped: neither same-model
|
|
809
|
-
// retry nor a same-account fallback can help, so hand the run straight back
|
|
810
|
-
// to the main agent instead of burning its time on a doomed retry.
|
|
811
|
-
if (agent && launchedRef && isModelLevelFailure(result) && isTerminalModelError(result)) return result;
|
|
812
|
-
|
|
813
|
-
// A TRANSIENT provider failure (503/429/timeout/network/stream/...) is usually
|
|
814
|
-
// a one-off hiccup. Relaunch the SAME configured model up to runDelays.length
|
|
815
|
-
// more times with backoff before degrading to a fallback model — the run's own
|
|
816
|
-
// provider retry already tried and failed, so each relaunch here is an
|
|
817
|
-
// independent, fresh attempt that can recover without losing the configured
|
|
818
|
-
// model's capability to review.
|
|
819
891
|
let modelRetries = 0;
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
892
|
+
let result: SingleResult | undefined;
|
|
893
|
+
try {
|
|
894
|
+
result = await runWithStartupRetry(baseOptions);
|
|
895
|
+
|
|
896
|
+
// A TERMINAL model error (quota/billing/invalid key/auth) is account-scoped:
|
|
897
|
+
// neither a same-model retry nor a same-account fallback can help. Skip the
|
|
898
|
+
// automatic retry/fallback and hand the run back — the session is preserved
|
|
899
|
+
// (see finally) so a later manual resume on a working model can continue
|
|
900
|
+
// without re-scanning.
|
|
901
|
+
const terminal = isModelLevelFailure(result) && isTerminalModelError(result);
|
|
902
|
+
|
|
903
|
+
// A TRANSIENT provider failure (503/429/timeout/network/...) usually
|
|
904
|
+
// recovers on a relaunch. RESUME the same session on the same configured
|
|
905
|
+
// model up to runDelays.length more times with backoff — resuming (not
|
|
906
|
+
// restarting) preserves any work done before the hiccup. This sits outside
|
|
907
|
+
// pi-ai's per-request provider retry, which by then already tried and gave up.
|
|
908
|
+
if (!terminal && agent && launchedRef && isModelLevelFailure(result) && runDelays.length > 0) {
|
|
909
|
+
const retryOpts: RunSingleOptions = {
|
|
910
|
+
...baseOptions,
|
|
911
|
+
stdinText: buildResumePrompt(options.task, "a transient provider error"),
|
|
912
|
+
};
|
|
913
|
+
for (let attempt = 0; ; attempt++) {
|
|
914
|
+
const delay = runDelays[attempt];
|
|
915
|
+
if (delay === undefined) break;
|
|
916
|
+
try {
|
|
917
|
+
options.onLive?.({ kind: "status", status: "running" });
|
|
918
|
+
} catch { /* never throw from event handling */ }
|
|
919
|
+
const shouldRetry = await waitForStartupRetry(delay, options.signal);
|
|
920
|
+
if (!shouldRetry) break;
|
|
921
|
+
const retried = await runWithStartupRetry(retryOpts);
|
|
922
|
+
modelRetries++;
|
|
923
|
+
// failedTools reflect ONLY the final attempt (each runSingleAgent call
|
|
924
|
+
// accumulates its own), so the completion message stays accurate.
|
|
925
|
+
result = retried;
|
|
926
|
+
if (!isModelLevelFailure(retried) || isTerminalModelError(retried)) break;
|
|
836
927
|
}
|
|
837
|
-
result = retried;
|
|
838
928
|
}
|
|
839
|
-
}
|
|
840
929
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
930
|
+
// Transient retries exhausted (or none) and still a model-level failure:
|
|
931
|
+
// RESUME the session on the main window's current model exactly once. The
|
|
932
|
+
// fallback inherits the prior context (no re-scan). Skipped when there is no
|
|
933
|
+
// fallback ref, it equals the configured model, or the failure is terminal
|
|
934
|
+
// (a same-account fallback would fail identically — leave it for manual resume).
|
|
935
|
+
if (
|
|
936
|
+
!terminal &&
|
|
937
|
+
agent &&
|
|
938
|
+
launchedRef &&
|
|
939
|
+
fallbackModelRef &&
|
|
940
|
+
launchedRef !== fallbackModelRef &&
|
|
941
|
+
isModelLevelFailure(result)
|
|
942
|
+
) {
|
|
943
|
+
const retried = await runWithStartupRetry({
|
|
944
|
+
...baseOptions,
|
|
945
|
+
stdinText: buildResumePrompt(options.task, buildFallbackResumeReason(launchedRef)),
|
|
946
|
+
agent: { ...agent, model: fallbackModelRef },
|
|
947
|
+
});
|
|
948
|
+
// The fallback replaces the result wholesale: failedTools reflect ONLY the
|
|
949
|
+
// fallback (final) attempt — stale build errors from the first model are
|
|
950
|
+
// not merged, so a clean final attempt is never misattributed a failure.
|
|
951
|
+
result = { ...retried, modelFallbackFrom: launchedRef };
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// A terminal failure takes the bare result (no retries or fallback ran, so
|
|
955
|
+
// modelRetries stays undefined — matching the contract callers assert).
|
|
956
|
+
return terminal ? result : { ...result, modelRetries };
|
|
957
|
+
} finally {
|
|
958
|
+
// Keep the session on disk only for a model-level failure that did real work
|
|
959
|
+
// and is being handed back, so a later `resume` can continue it. A provider
|
|
960
|
+
// rejection before any work (no messages/tools/output) has nothing to resume,
|
|
961
|
+
// so it is cleaned up along with every success and task-level failure. Never
|
|
962
|
+
// remove a caller-provided sessionDir (an explicit resume owns its dir).
|
|
963
|
+
if (result) {
|
|
964
|
+
result.sessionId ??= sessionId;
|
|
965
|
+
result.sessionDir ??= sessionDir;
|
|
966
|
+
}
|
|
967
|
+
const hasWork =
|
|
968
|
+
!!result &&
|
|
969
|
+
(result.messages.length > 1 ||
|
|
970
|
+
(result.failedTools?.length ?? 0) > 0 ||
|
|
971
|
+
Boolean(getFinalOutput(result.messages)));
|
|
972
|
+
const keep = !!result && isModelLevelFailure(result) && hasWork;
|
|
973
|
+
if (!keep && !options.sessionDir) {
|
|
974
|
+
await rm(sessionDir, { recursive: true, force: true }).catch(() => undefined);
|
|
975
|
+
}
|
|
854
976
|
}
|
|
855
|
-
return { ...result, modelRetries };
|
|
856
977
|
}
|