pi-herdr-agents 0.0.3 → 0.1.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
|
@@ -314,7 +314,14 @@ exact IDs from your authenticated model catalog:
|
|
|
314
314
|
`subagent({ agent: ... })`. Explicit `model` tool arguments take precedence,
|
|
315
315
|
followed by agent frontmatter, per-agent config, the global default, and finally
|
|
316
316
|
the parent model. Model values must be exact authenticated `provider/model-id`
|
|
317
|
-
references.
|
|
317
|
+
references. A value can contain an ordered comma-separated fallback list, for
|
|
318
|
+
example `provider/preferred, provider/fallback`. The extension validates every
|
|
319
|
+
candidate before launch, retries the preferred model normally, then launches
|
|
320
|
+
later candidates only after a provider/agent request failure. A completed child
|
|
321
|
+
result, including a negative task result, never switches models. Completion
|
|
322
|
+
metadata and the status widget report the model actually used; an exhausted
|
|
323
|
+
list reports every attempted model. Workflow metadata accepts one exact model
|
|
324
|
+
only, to keep approved workflow runtimes deterministic.
|
|
318
325
|
|
|
319
326
|
`config.json` is gitignored in the source tree so local overrides are not
|
|
320
327
|
committed from a checkout. On an installed package root, treat it as disposable
|
|
@@ -356,7 +363,7 @@ subagent({
|
|
|
356
363
|
| `agent` | string | — | Load defaults from agent definition |
|
|
357
364
|
| `fork` | boolean | `false` | Force the full-context fork mode for this spawn, overriding any agent `session-mode` frontmatter |
|
|
358
365
|
| `interactive` | boolean | derived | Mark this spawn as interactive (don't wake the parent on stall/recovery). Defaults to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit`. |
|
|
359
|
-
| `model` | string | configured or parent | Exact authenticated `provider/model-id
|
|
366
|
+
| `model` | string | configured or parent | Exact authenticated `provider/model-id`, or an ordered comma-separated Pi fallback list; unavailable for Claude CLI and worktree spawns. Resolution is tool argument → agent frontmatter → per-agent config → global config → parent |
|
|
360
367
|
| `thinking` | string | parent level | Pi thinking level (`off` through `max`); omit to inherit the parent |
|
|
361
368
|
| `systemPrompt` | string | — | Role/system-prompt text for a bare spawn; overrides the body for Claude CLI agents, while named Pi agents keep their definition body |
|
|
362
369
|
| `resumeSessionId` | string | — | Claude CLI session ID to resume; separate from the Pi `subagent_resume` tool |
|
|
@@ -683,7 +690,7 @@ and verify them with `/subagent list` plus a smoke launch.
|
|
|
683
690
|
| ------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
684
691
|
| `name` | string | Optional explicit agent name used in `agent: "my-agent"`; defaults to the filename stem and must match it in role packs |
|
|
685
692
|
| `description` | string | Shown in `subagents_list` output |
|
|
686
|
-
| `model` | string | Optional exact authenticated Pi model default; omit to use per-agent config, global config, then the parent
|
|
693
|
+
| `model` | string | Optional exact authenticated Pi model default or ordered comma-separated fallback list; omit to use per-agent config, global config, then the parent |
|
|
687
694
|
| `cli` | string | Set to `claude` to launch the Claude CLI instead of Pi |
|
|
688
695
|
| `cli-model` | string | Optional model name passed to a Claude CLI agent; separate from Pi model routing |
|
|
689
696
|
| `thinking` | string | Optional Pi thinking default (`off` through `max`); omit to inherit the parent. Thinking overrides are not supported for Claude CLI agents |
|
|
@@ -46,7 +46,7 @@ For a worktree launch:
|
|
|
46
46
|
- Uncommitted and untracked files from the parent checkout are not copied. Commit anything the child must see before spawning it, or pass the needed context in the task.
|
|
47
47
|
- Worktree creation does not steal terminal focus.
|
|
48
48
|
|
|
49
|
-
`worktree` cannot be set in agent frontmatter and is not exposed by the `/subagent <agent> <task>` shorthand. It is selected per call to the `subagent` tool.
|
|
49
|
+
`worktree` cannot be set in agent frontmatter and is not exposed by the `/subagent <agent> <task>` shorthand. It is selected per call to the `subagent` tool. Ordered model fallback lists are not supported for worktree subagents: a failed attempt retains its worktree and branch for review, so a retry cannot safely reuse the requested branch.
|
|
50
50
|
|
|
51
51
|
## Parent and worker responsibilities
|
|
52
52
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { shellQuote } from "./terminal.ts";
|
|
12
|
+
|
|
13
|
+
const CLAUDE_SESSIONS_DIR = join(
|
|
14
|
+
process.env.HOME ?? "/tmp",
|
|
15
|
+
".pi",
|
|
16
|
+
"agent",
|
|
17
|
+
"sessions",
|
|
18
|
+
"claude-code",
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
export interface ClaudeWorkspace {
|
|
22
|
+
baseline?: Set<string>;
|
|
23
|
+
cwd?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ClaudeLaunchParams {
|
|
27
|
+
cwd: string;
|
|
28
|
+
sentinelFile: string;
|
|
29
|
+
pluginDir: string;
|
|
30
|
+
model?: string;
|
|
31
|
+
systemPrompt?: string;
|
|
32
|
+
resumeSessionId?: string;
|
|
33
|
+
task: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ClaudeCompletionParams extends ClaudeWorkspace {
|
|
37
|
+
sentinelFile: string;
|
|
38
|
+
exitCode: number;
|
|
39
|
+
readTerminal: () => string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ClaudeCompletion {
|
|
43
|
+
summary: string;
|
|
44
|
+
sessionId?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function requireClaudeAdapter(cli?: string): void {
|
|
48
|
+
if (cli && cli !== "claude") {
|
|
49
|
+
throw new Error(`Unsupported subagent CLI ${JSON.stringify(cli)}.`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function captureClaudeWorkspaceBaseline(cwd: string): Set<string> | undefined {
|
|
54
|
+
try {
|
|
55
|
+
const output = execFileSync(
|
|
56
|
+
"git",
|
|
57
|
+
["status", "--porcelain=v1", "--untracked-files=all", "-z"],
|
|
58
|
+
{ cwd, encoding: "utf8" },
|
|
59
|
+
);
|
|
60
|
+
return new Set(
|
|
61
|
+
output
|
|
62
|
+
.split("\0")
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.map((entry) => entry.slice(3)),
|
|
65
|
+
);
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function cleanupClaudeWorkspace({ baseline, cwd }: ClaudeWorkspace): string | undefined {
|
|
72
|
+
if (!baseline || !cwd) return;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const output = execFileSync(
|
|
76
|
+
"git",
|
|
77
|
+
["status", "--porcelain=v1", "--untracked-files=all", "-z"],
|
|
78
|
+
{ cwd, encoding: "utf8" },
|
|
79
|
+
);
|
|
80
|
+
const changed = output
|
|
81
|
+
.split("\0")
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map((entry) => ({ status: entry.slice(0, 2), path: entry.slice(3) }));
|
|
84
|
+
const cleaned: string[] = [];
|
|
85
|
+
|
|
86
|
+
for (const { status, path } of changed) {
|
|
87
|
+
if (baseline.has(path) || path.startsWith(".reviews/")) continue;
|
|
88
|
+
if (status === "??") {
|
|
89
|
+
rmSync(join(cwd, path), { recursive: true, force: true });
|
|
90
|
+
} else {
|
|
91
|
+
execFileSync("git", ["restore", "--staged", "--worktree", "--", path], {
|
|
92
|
+
cwd,
|
|
93
|
+
stdio: "ignore",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
cleaned.push(path);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return cleaned.length > 0
|
|
100
|
+
? `Claude workspace guard reverted newly introduced paths: ${cleaned.join(", ")}`
|
|
101
|
+
: undefined;
|
|
102
|
+
} catch (error: any) {
|
|
103
|
+
return `Claude workspace guard failed: ${error?.message ?? String(error)}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function buildClaudeLaunchCommand(params: ClaudeLaunchParams): string {
|
|
108
|
+
const parts = [
|
|
109
|
+
`PI_CLAUDE_SENTINEL=${shellQuote(params.sentinelFile)}`,
|
|
110
|
+
"claude",
|
|
111
|
+
"--dangerously-skip-permissions",
|
|
112
|
+
];
|
|
113
|
+
if (existsSync(params.pluginDir)) {
|
|
114
|
+
parts.push("--plugin-dir", shellQuote(params.pluginDir));
|
|
115
|
+
}
|
|
116
|
+
if (params.model) parts.push("--model", shellQuote(params.model));
|
|
117
|
+
if (params.systemPrompt) {
|
|
118
|
+
parts.push("--append-system-prompt", shellQuote(params.systemPrompt));
|
|
119
|
+
}
|
|
120
|
+
if (params.resumeSessionId) parts.push("--resume", shellQuote(params.resumeSessionId));
|
|
121
|
+
parts.push(shellQuote(params.task));
|
|
122
|
+
return `cd ${shellQuote(params.cwd)} && ${parts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function copyClaudeSession(sentinelFile: string): string | undefined {
|
|
126
|
+
try {
|
|
127
|
+
const transcriptFile = `${sentinelFile}.transcript`;
|
|
128
|
+
if (!existsSync(transcriptFile)) return;
|
|
129
|
+
const transcriptPath = readFileSync(transcriptFile, "utf-8").trim();
|
|
130
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return;
|
|
131
|
+
mkdirSync(CLAUDE_SESSIONS_DIR, { recursive: true });
|
|
132
|
+
const filename = transcriptPath.split("/").pop() ?? `claude-${Date.now()}.jsonl`;
|
|
133
|
+
copyFileSync(transcriptPath, join(CLAUDE_SESSIONS_DIR, filename));
|
|
134
|
+
return filename;
|
|
135
|
+
} catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function completeClaudeRun(params: ClaudeCompletionParams): ClaudeCompletion {
|
|
141
|
+
const guardMessage = cleanupClaudeWorkspace(params);
|
|
142
|
+
let summary = "";
|
|
143
|
+
try {
|
|
144
|
+
summary = readFileSync(params.sentinelFile, "utf-8").trim();
|
|
145
|
+
} catch {}
|
|
146
|
+
if (!summary) {
|
|
147
|
+
summary = params.readTerminal().replace(/__SUBAGENT_DONE_\d+__/, "").trimEnd();
|
|
148
|
+
}
|
|
149
|
+
if (!summary) {
|
|
150
|
+
summary =
|
|
151
|
+
params.exitCode !== 0
|
|
152
|
+
? `Claude Code exited with code ${params.exitCode}`
|
|
153
|
+
: "Claude Code exited without output";
|
|
154
|
+
}
|
|
155
|
+
if (guardMessage) summary += `\n\n${guardMessage}`;
|
|
156
|
+
|
|
157
|
+
const sessionId = copyClaudeSession(params.sentinelFile);
|
|
158
|
+
for (const file of [params.sentinelFile, `${params.sentinelFile}.transcript`]) {
|
|
159
|
+
try {
|
|
160
|
+
unlinkSync(file);
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
return { summary, ...(sessionId ? { sessionId } : {}) };
|
|
164
|
+
}
|
|
@@ -21,8 +21,6 @@ import {
|
|
|
21
21
|
writeFileSync,
|
|
22
22
|
existsSync,
|
|
23
23
|
mkdirSync,
|
|
24
|
-
copyFileSync,
|
|
25
|
-
unlinkSync,
|
|
26
24
|
rmSync,
|
|
27
25
|
renameSync,
|
|
28
26
|
statSync,
|
|
@@ -45,9 +43,17 @@ import {
|
|
|
45
43
|
waitForProcessesExit,
|
|
46
44
|
} from "./terminal.ts";
|
|
47
45
|
import { waitForCompletion } from "./completion.ts";
|
|
46
|
+
import {
|
|
47
|
+
buildClaudeLaunchCommand,
|
|
48
|
+
captureClaudeWorkspaceBaseline,
|
|
49
|
+
cleanupClaudeWorkspace,
|
|
50
|
+
completeClaudeRun,
|
|
51
|
+
requireClaudeAdapter,
|
|
52
|
+
} from "./claude.ts";
|
|
48
53
|
import {
|
|
49
54
|
buildAuthenticatedModelCatalog,
|
|
50
55
|
resolveRuntimePlan,
|
|
56
|
+
resolveRuntimePlans,
|
|
51
57
|
wrapPiModelRegistry,
|
|
52
58
|
THINKING_LEVELS,
|
|
53
59
|
isThinkingLevel,
|
|
@@ -197,7 +203,7 @@ const SubagentParams = Type.Object({
|
|
|
197
203
|
model: Type.Optional(
|
|
198
204
|
Type.String({
|
|
199
205
|
description:
|
|
200
|
-
"Exact authenticated provider/model-id. Omit to inherit the parent model.
|
|
206
|
+
"Exact authenticated provider/model-id, or an ordered comma-separated fallback list. Omit to inherit the parent model. Fallbacks are Pi-backed only and cannot be used with worktrees.",
|
|
201
207
|
}),
|
|
202
208
|
),
|
|
203
209
|
thinking: Type.Optional(ThinkingLevelSchema),
|
|
@@ -804,69 +810,6 @@ function formatElapsed(seconds: number): string {
|
|
|
804
810
|
* (for example direnv/devenv), so the delay is configurable for users who hit
|
|
805
811
|
* dropped commands. Keep the historical default at 500ms.
|
|
806
812
|
*/
|
|
807
|
-
function captureWorkspaceBaseline(cwd: string): Set<string> | undefined {
|
|
808
|
-
try {
|
|
809
|
-
const output = execFileSync(
|
|
810
|
-
"git",
|
|
811
|
-
["status", "--porcelain=v1", "--untracked-files=all", "-z"],
|
|
812
|
-
{ cwd, encoding: "utf8" },
|
|
813
|
-
);
|
|
814
|
-
return new Set(
|
|
815
|
-
output
|
|
816
|
-
.split("\0")
|
|
817
|
-
.filter(Boolean)
|
|
818
|
-
.map((entry) => entry.slice(3)),
|
|
819
|
-
);
|
|
820
|
-
} catch {
|
|
821
|
-
return undefined;
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
function guardClaudeWorkspace(running: RunningSubagent): string | undefined {
|
|
826
|
-
if (!running.workspaceBaseline || !running.workspaceCwd) return;
|
|
827
|
-
|
|
828
|
-
try {
|
|
829
|
-
const output = execFileSync(
|
|
830
|
-
"git",
|
|
831
|
-
["status", "--porcelain=v1", "--untracked-files=all", "-z"],
|
|
832
|
-
{ cwd: running.workspaceCwd, encoding: "utf8" },
|
|
833
|
-
);
|
|
834
|
-
const changed = output
|
|
835
|
-
.split("\0")
|
|
836
|
-
.filter(Boolean)
|
|
837
|
-
.map((entry) => ({
|
|
838
|
-
status: entry.slice(0, 2),
|
|
839
|
-
path: entry.slice(3),
|
|
840
|
-
}));
|
|
841
|
-
const newPaths = changed.filter(
|
|
842
|
-
({ path }) => !running.workspaceBaseline!.has(path),
|
|
843
|
-
);
|
|
844
|
-
const cleaned: string[] = [];
|
|
845
|
-
|
|
846
|
-
for (const { status, path } of newPaths) {
|
|
847
|
-
if (path.startsWith(".reviews/")) continue;
|
|
848
|
-
if (status === "??") {
|
|
849
|
-
rmSync(`${running.workspaceCwd}/${path}`, {
|
|
850
|
-
recursive: true,
|
|
851
|
-
force: true,
|
|
852
|
-
});
|
|
853
|
-
} else {
|
|
854
|
-
execFileSync("git", ["restore", "--staged", "--worktree", "--", path], {
|
|
855
|
-
cwd: running.workspaceCwd,
|
|
856
|
-
stdio: "ignore",
|
|
857
|
-
});
|
|
858
|
-
}
|
|
859
|
-
cleaned.push(path);
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
return cleaned.length > 0
|
|
863
|
-
? `Claude workspace guard reverted newly introduced paths: ${cleaned.join(", ")}`
|
|
864
|
-
: undefined;
|
|
865
|
-
} catch (error: any) {
|
|
866
|
-
return `Claude workspace guard failed: ${error?.message ?? String(error)}`;
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
|
|
870
813
|
function getShellReadyDelayMs(): number {
|
|
871
814
|
const raw = process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS?.trim();
|
|
872
815
|
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
|
|
@@ -1205,6 +1148,7 @@ function resolveResultPresentation(
|
|
|
1205
1148
|
| "summary"
|
|
1206
1149
|
| "sessionFile"
|
|
1207
1150
|
| "errorMessage"
|
|
1151
|
+
| "fallbackAttempts"
|
|
1208
1152
|
| "worktree"
|
|
1209
1153
|
>,
|
|
1210
1154
|
name: string,
|
|
@@ -1231,6 +1175,9 @@ function resolveResultPresentation(
|
|
|
1231
1175
|
: `Sub-agent "${name}" completed (${formatElapsed(result.elapsed)}).\n\n${result.summary}`;
|
|
1232
1176
|
}
|
|
1233
1177
|
|
|
1178
|
+
if (result.fallbackAttempts && result.fallbackAttempts.length > 1) {
|
|
1179
|
+
body += `\n\nModels attempted: ${result.fallbackAttempts.join(", ")}`;
|
|
1180
|
+
}
|
|
1234
1181
|
if (result.worktree) body += `\n\n${formatWorktreeHandoff(result.worktree)}`;
|
|
1235
1182
|
const runtimeWarning = runtimeMismatch
|
|
1236
1183
|
? `\n\nRuntime warning: ${runtimeMismatch}`
|
|
@@ -1273,6 +1220,8 @@ interface SubagentResult {
|
|
|
1273
1220
|
error?: string;
|
|
1274
1221
|
/** Provider/agent error message when auto-retry exhausted (overload, rate limit, etc.). */
|
|
1275
1222
|
errorMessage?: string;
|
|
1223
|
+
/** Ordered models launched for this run, including failed fallback attempts. */
|
|
1224
|
+
fallbackAttempts?: string[];
|
|
1276
1225
|
ping?: { name: string; message: string };
|
|
1277
1226
|
worktree?: WorktreeHandoff;
|
|
1278
1227
|
}
|
|
@@ -2103,10 +2052,10 @@ async function launchSubagent(
|
|
|
2103
2052
|
};
|
|
2104
2053
|
},
|
|
2105
2054
|
parentThinking: ThinkingLevel,
|
|
2106
|
-
options?: { surface?: string },
|
|
2055
|
+
options?: { surface?: string; runtimePlan?: ResolvedRuntimePlan; id?: string },
|
|
2107
2056
|
): Promise<RunningSubagent> {
|
|
2108
2057
|
const startTime = Date.now();
|
|
2109
|
-
const id = Math.random().toString(16).slice(2, 10);
|
|
2058
|
+
const id = options?.id ?? Math.random().toString(16).slice(2, 10);
|
|
2110
2059
|
|
|
2111
2060
|
const agentDefs = params.agent
|
|
2112
2061
|
? loadAgentDefaults(params.agent, runtime.pi)
|
|
@@ -2119,21 +2068,24 @@ async function launchSubagent(
|
|
|
2119
2068
|
diagnostic?.message ?? `Agent "${params.agent}" was not found.`,
|
|
2120
2069
|
);
|
|
2121
2070
|
}
|
|
2071
|
+
requireClaudeAdapter(agentDefs?.cli);
|
|
2122
2072
|
if (!ctx.model)
|
|
2123
2073
|
throw new Error("Subagent launch requires a resolved parent model");
|
|
2124
|
-
const runtimePlan =
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
model:
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2074
|
+
const runtimePlan =
|
|
2075
|
+
options?.runtimePlan ??
|
|
2076
|
+
resolveRuntimePlan(
|
|
2077
|
+
{ model: params.model, thinking: params.thinking },
|
|
2078
|
+
{
|
|
2079
|
+
model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig),
|
|
2080
|
+
thinking: agentDefs?.thinking,
|
|
2081
|
+
},
|
|
2082
|
+
{
|
|
2083
|
+
provider: ctx.model.provider,
|
|
2084
|
+
modelId: ctx.model.id,
|
|
2085
|
+
thinking: parentThinking,
|
|
2086
|
+
},
|
|
2087
|
+
wrapPiModelRegistry(ctx.modelRegistry),
|
|
2088
|
+
);
|
|
2137
2089
|
const effectiveModel = runtimePlan.model;
|
|
2138
2090
|
const effectiveTools = params.tools ?? agentDefs?.tools;
|
|
2139
2091
|
const effectiveSkills = params.skills ?? agentDefs?.skills;
|
|
@@ -2244,7 +2196,7 @@ async function launchSubagent(
|
|
|
2244
2196
|
);
|
|
2245
2197
|
const workspaceBaseline =
|
|
2246
2198
|
agentDefs?.cli === "claude" && !worktree
|
|
2247
|
-
?
|
|
2199
|
+
? captureClaudeWorkspaceBaseline(targetCwdForSession)
|
|
2248
2200
|
: undefined;
|
|
2249
2201
|
|
|
2250
2202
|
// Generate a deterministic session file path for this subagent.
|
|
@@ -2312,35 +2264,15 @@ async function launchSubagent(
|
|
|
2312
2264
|
const sentinelFile = `/tmp/pi-claude-${id}-done`;
|
|
2313
2265
|
const pluginDir = join(SUBAGENTS_DIR, "plugin");
|
|
2314
2266
|
|
|
2315
|
-
const
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
const cliModel = agentDefs.cliModel ?? effectiveModel;
|
|
2325
|
-
if (cliModel) {
|
|
2326
|
-
cmdParts.push("--model", shellQuote(cliModel));
|
|
2327
|
-
}
|
|
2328
|
-
|
|
2329
|
-
const sp = params.systemPrompt ?? agentDefs.body;
|
|
2330
|
-
if (sp) {
|
|
2331
|
-
cmdParts.push("--append-system-prompt", shellQuote(sp));
|
|
2332
|
-
}
|
|
2333
|
-
|
|
2334
|
-
if (params.resumeSessionId) {
|
|
2335
|
-
cmdParts.push("--resume", shellQuote(params.resumeSessionId));
|
|
2336
|
-
}
|
|
2337
|
-
|
|
2338
|
-
// Always pass the task as the prompt — even for resumed sessions,
|
|
2339
|
-
// the caller's task is the follow-up instruction.
|
|
2340
|
-
cmdParts.push(shellQuote(params.task));
|
|
2341
|
-
|
|
2342
|
-
const cdPrefix = `cd ${shellQuote(targetCwdForSession)} && `;
|
|
2343
|
-
const command = `${cdPrefix}${cmdParts.join(" ")}; echo '__SUBAGENT_DONE_'$?'__'`;
|
|
2267
|
+
const command = buildClaudeLaunchCommand({
|
|
2268
|
+
cwd: targetCwdForSession,
|
|
2269
|
+
sentinelFile,
|
|
2270
|
+
pluginDir,
|
|
2271
|
+
model: agentDefs.cliModel ?? effectiveModel,
|
|
2272
|
+
systemPrompt: params.systemPrompt ?? agentDefs.body,
|
|
2273
|
+
resumeSessionId: params.resumeSessionId,
|
|
2274
|
+
task: params.task,
|
|
2275
|
+
});
|
|
2344
2276
|
|
|
2345
2277
|
const launchScriptName = `${
|
|
2346
2278
|
(params.name || "subagent")
|
|
@@ -2561,29 +2493,65 @@ async function launchSubagent(
|
|
|
2561
2493
|
* the summary from the session file, and closes ordinary panes. Worktree
|
|
2562
2494
|
* workspaces are retained for parent review.
|
|
2563
2495
|
*/
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
)
|
|
2496
|
+
function resolveSubagentRuntimePlans(
|
|
2497
|
+
params: typeof SubagentParams.static,
|
|
2498
|
+
ctx: Parameters<typeof launchSubagent>[1],
|
|
2499
|
+
parentThinking: ThinkingLevel,
|
|
2500
|
+
): ResolvedRuntimePlan[] {
|
|
2501
|
+
const agentDefs = params.agent
|
|
2502
|
+
? loadAgentDefaults(params.agent, runtime.pi)
|
|
2503
|
+
: null;
|
|
2504
|
+
if (params.agent && !agentDefs) {
|
|
2505
|
+
const diagnostic = discoverAgentCatalog(runtime.pi).diagnostics.find(
|
|
2506
|
+
(candidate) => candidate.agentName === params.agent,
|
|
2507
|
+
);
|
|
2508
|
+
throw new Error(
|
|
2509
|
+
diagnostic?.message ?? `Agent "${params.agent}" was not found.`,
|
|
2510
|
+
);
|
|
2511
|
+
}
|
|
2512
|
+
if (!ctx.model) throw new Error("Subagent launch requires a resolved parent model");
|
|
2513
|
+
const plans = resolveRuntimePlans(
|
|
2514
|
+
{ model: params.model, thinking: params.thinking },
|
|
2515
|
+
{
|
|
2516
|
+
model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig),
|
|
2517
|
+
thinking: agentDefs?.thinking,
|
|
2518
|
+
},
|
|
2519
|
+
{
|
|
2520
|
+
provider: ctx.model.provider,
|
|
2521
|
+
modelId: ctx.model.id,
|
|
2522
|
+
thinking: parentThinking,
|
|
2523
|
+
},
|
|
2524
|
+
wrapPiModelRegistry(ctx.modelRegistry),
|
|
2525
|
+
);
|
|
2526
|
+
if (agentDefs?.cli === "claude" && plans.length > 1) {
|
|
2527
|
+
throw new Error("Model fallbacks are supported only for Pi-backed subagents.");
|
|
2528
|
+
}
|
|
2529
|
+
if (params.worktree && plans.length > 1) {
|
|
2530
|
+
throw new Error("Model fallbacks are not supported for worktree subagents.");
|
|
2531
|
+
}
|
|
2532
|
+
return plans;
|
|
2533
|
+
}
|
|
2571
2534
|
|
|
2572
|
-
function
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2535
|
+
async function launchSubagentWithFallbacks(
|
|
2536
|
+
params: typeof SubagentParams.static,
|
|
2537
|
+
ctx: Parameters<typeof launchSubagent>[1],
|
|
2538
|
+
parentThinking: ThinkingLevel,
|
|
2539
|
+
plans: ResolvedRuntimePlan[],
|
|
2540
|
+
): Promise<{ running: RunningSubagent; index: number }> {
|
|
2541
|
+
const failures: string[] = [];
|
|
2542
|
+
for (const [index, plan] of plans.entries()) {
|
|
2543
|
+
try {
|
|
2544
|
+
return {
|
|
2545
|
+
running: await launchSubagent(params, ctx, parentThinking, { runtimePlan: plan }),
|
|
2546
|
+
index,
|
|
2547
|
+
};
|
|
2548
|
+
} catch (error) {
|
|
2549
|
+
failures.push(`${plan.model}: ${error instanceof Error ? error.message : String(error)}`);
|
|
2550
|
+
}
|
|
2586
2551
|
}
|
|
2552
|
+
throw new Error(
|
|
2553
|
+
`Subagent could not launch with any configured model. Attempted: ${plans.map((plan) => plan.model).join(", ")}. ${failures.join("; ")}`,
|
|
2554
|
+
);
|
|
2587
2555
|
}
|
|
2588
2556
|
|
|
2589
2557
|
async function watchSubagent(
|
|
@@ -2623,41 +2591,13 @@ async function watchSubagent(
|
|
|
2623
2591
|
const elapsed = Math.floor((detectedAt - startTime) / 1000);
|
|
2624
2592
|
|
|
2625
2593
|
if (running.cli === "claude") {
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
} catch {}
|
|
2634
|
-
}
|
|
2635
|
-
|
|
2636
|
-
if (!summary) {
|
|
2637
|
-
summary = readPane(surface, 200)
|
|
2638
|
-
.replace(/__SUBAGENT_DONE_\d+__/, "")
|
|
2639
|
-
.trimEnd();
|
|
2640
|
-
}
|
|
2641
|
-
|
|
2642
|
-
if (!summary) {
|
|
2643
|
-
summary =
|
|
2644
|
-
result.exitCode !== 0
|
|
2645
|
-
? `Claude Code exited with code ${result.exitCode}`
|
|
2646
|
-
: "Claude Code exited without output";
|
|
2647
|
-
}
|
|
2648
|
-
if (guardMessage) summary += `\n\n${guardMessage}`;
|
|
2649
|
-
|
|
2650
|
-
// Copy Claude session transcript
|
|
2651
|
-
let sessionId: string | null = null;
|
|
2652
|
-
if (running.sentinelFile) {
|
|
2653
|
-
sessionId = copyClaudeSession(running.sentinelFile);
|
|
2654
|
-
try {
|
|
2655
|
-
unlinkSync(running.sentinelFile);
|
|
2656
|
-
} catch {}
|
|
2657
|
-
try {
|
|
2658
|
-
unlinkSync(running.sentinelFile + ".transcript");
|
|
2659
|
-
} catch {}
|
|
2660
|
-
}
|
|
2594
|
+
const claudeCompletion = completeClaudeRun({
|
|
2595
|
+
sentinelFile: running.sentinelFile!,
|
|
2596
|
+
exitCode: result.exitCode,
|
|
2597
|
+
baseline: running.workspaceBaseline,
|
|
2598
|
+
cwd: running.workspaceCwd,
|
|
2599
|
+
readTerminal: () => readPane(surface, 200),
|
|
2600
|
+
});
|
|
2661
2601
|
|
|
2662
2602
|
const worktreeHandoff = finalizeSubagentSurface(
|
|
2663
2603
|
running,
|
|
@@ -2668,7 +2608,7 @@ async function watchSubagent(
|
|
|
2668
2608
|
? markCompleted(running.lifecycle, Date.now())
|
|
2669
2609
|
: markFailed(
|
|
2670
2610
|
running.lifecycle,
|
|
2671
|
-
result.errorMessage ?? summary,
|
|
2611
|
+
result.errorMessage ?? claudeCompletion.summary,
|
|
2672
2612
|
Date.now(),
|
|
2673
2613
|
result.exitCode,
|
|
2674
2614
|
);
|
|
@@ -2676,10 +2616,12 @@ async function watchSubagent(
|
|
|
2676
2616
|
return {
|
|
2677
2617
|
name,
|
|
2678
2618
|
task,
|
|
2679
|
-
summary,
|
|
2619
|
+
summary: claudeCompletion.summary,
|
|
2680
2620
|
exitCode: result.exitCode,
|
|
2681
2621
|
elapsed,
|
|
2682
|
-
...(sessionId
|
|
2622
|
+
...(claudeCompletion.sessionId
|
|
2623
|
+
? { claudeSessionId: claudeCompletion.sessionId }
|
|
2624
|
+
: {}),
|
|
2683
2625
|
...(worktreeHandoff ? { worktree: worktreeHandoff } : {}),
|
|
2684
2626
|
};
|
|
2685
2627
|
}
|
|
@@ -2761,7 +2703,12 @@ async function watchSubagent(
|
|
|
2761
2703
|
};
|
|
2762
2704
|
} catch (err: any) {
|
|
2763
2705
|
const guardMessage =
|
|
2764
|
-
running.cli === "claude"
|
|
2706
|
+
running.cli === "claude"
|
|
2707
|
+
? cleanupClaudeWorkspace({
|
|
2708
|
+
baseline: running.workspaceBaseline,
|
|
2709
|
+
cwd: running.workspaceCwd,
|
|
2710
|
+
})
|
|
2711
|
+
: undefined;
|
|
2765
2712
|
const worktreeHandoff = finalizeSubagentSurface(running, "failed", true);
|
|
2766
2713
|
running.lifecycle = markFailed(
|
|
2767
2714
|
running.lifecycle,
|
|
@@ -2799,6 +2746,64 @@ async function watchSubagent(
|
|
|
2799
2746
|
}
|
|
2800
2747
|
}
|
|
2801
2748
|
|
|
2749
|
+
async function watchSubagentWithFallbacks(
|
|
2750
|
+
initial: RunningSubagent,
|
|
2751
|
+
initialPlanIndex: number,
|
|
2752
|
+
params: typeof SubagentParams.static,
|
|
2753
|
+
ctx: Parameters<typeof launchSubagent>[1],
|
|
2754
|
+
parentThinking: ThinkingLevel,
|
|
2755
|
+
plans: ResolvedRuntimePlan[],
|
|
2756
|
+
signal: AbortSignal,
|
|
2757
|
+
): Promise<{ running: RunningSubagent; result: SubagentResult }> {
|
|
2758
|
+
let running = initial;
|
|
2759
|
+
let nextPlan = initialPlanIndex + 1;
|
|
2760
|
+
const attempts = [running.runtimePlan?.model].filter(
|
|
2761
|
+
(model): model is string => !!model,
|
|
2762
|
+
);
|
|
2763
|
+
|
|
2764
|
+
for (;;) {
|
|
2765
|
+
const result = await watchSubagent(running, signal);
|
|
2766
|
+
const shouldRetry = !!result.errorMessage && nextPlan < plans.length;
|
|
2767
|
+
if (!shouldRetry) {
|
|
2768
|
+
return { running, result: { ...result, fallbackAttempts: attempts } };
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
runningSubagents.delete(running.id);
|
|
2772
|
+
updateWidget();
|
|
2773
|
+
const launchErrors: string[] = [];
|
|
2774
|
+
let launchedFallback = false;
|
|
2775
|
+
while (nextPlan < plans.length) {
|
|
2776
|
+
const plan = plans[nextPlan++];
|
|
2777
|
+
attempts.push(plan.model);
|
|
2778
|
+
try {
|
|
2779
|
+
running = await launchSubagent(params, ctx, parentThinking, {
|
|
2780
|
+
runtimePlan: plan,
|
|
2781
|
+
id: initial.id,
|
|
2782
|
+
});
|
|
2783
|
+
running.abortController = initial.abortController;
|
|
2784
|
+
launchedFallback = true;
|
|
2785
|
+
startWidgetRefresh();
|
|
2786
|
+
startStatusRefresh(runtime.pi!);
|
|
2787
|
+
break;
|
|
2788
|
+
} catch (error) {
|
|
2789
|
+
launchErrors.push(
|
|
2790
|
+
`${plan.model}: ${error instanceof Error ? error.message : String(error)}`,
|
|
2791
|
+
);
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
if (!launchedFallback) {
|
|
2795
|
+
return {
|
|
2796
|
+
running,
|
|
2797
|
+
result: {
|
|
2798
|
+
...result,
|
|
2799
|
+
errorMessage: `${result.errorMessage}\n\nFallback launch failures: ${launchErrors.join("; ")}`,
|
|
2800
|
+
fallbackAttempts: attempts,
|
|
2801
|
+
},
|
|
2802
|
+
};
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2802
2807
|
export default function subagentsExtension(pi: ExtensionAPI) {
|
|
2803
2808
|
runtime.pi = pi;
|
|
2804
2809
|
let btwChild: BtwChild | undefined;
|
|
@@ -3727,7 +3732,17 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
3727
3732
|
`Unsupported parent thinking level: ${parentThinking}`,
|
|
3728
3733
|
);
|
|
3729
3734
|
}
|
|
3730
|
-
const
|
|
3735
|
+
const runtimePlans = resolveSubagentRuntimePlans(
|
|
3736
|
+
params,
|
|
3737
|
+
ctx,
|
|
3738
|
+
parentThinking,
|
|
3739
|
+
);
|
|
3740
|
+
const { running, index: initialPlanIndex } = await launchSubagentWithFallbacks(
|
|
3741
|
+
params,
|
|
3742
|
+
ctx,
|
|
3743
|
+
parentThinking,
|
|
3744
|
+
runtimePlans,
|
|
3745
|
+
);
|
|
3731
3746
|
|
|
3732
3747
|
// Create a separate AbortController for the watcher
|
|
3733
3748
|
// (the tool's signal completes when we return)
|
|
@@ -3739,16 +3754,24 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
3739
3754
|
startStatusRefresh(pi);
|
|
3740
3755
|
|
|
3741
3756
|
// Fire-and-forget: start watching in background
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3757
|
+
watchSubagentWithFallbacks(
|
|
3758
|
+
running,
|
|
3759
|
+
initialPlanIndex,
|
|
3760
|
+
params,
|
|
3761
|
+
ctx,
|
|
3762
|
+
parentThinking,
|
|
3763
|
+
runtimePlans,
|
|
3764
|
+
watcherAbort.signal,
|
|
3765
|
+
)
|
|
3766
|
+
.then(({ running: completedRunning, result }) => {
|
|
3767
|
+
if (!shouldDeliverSubagentCompletion(completedRunning)) {
|
|
3768
|
+
completedRunning.lifecycle = markDelivery(completedRunning.lifecycle, "suppressed");
|
|
3769
|
+
runningSubagents.delete(completedRunning.id);
|
|
3747
3770
|
updateWidget();
|
|
3748
3771
|
return;
|
|
3749
3772
|
}
|
|
3750
|
-
|
|
3751
|
-
runningSubagents.delete(
|
|
3773
|
+
completedRunning.lifecycle = markDelivery(completedRunning.lifecycle, "delivered");
|
|
3774
|
+
runningSubagents.delete(completedRunning.id);
|
|
3752
3775
|
updateWidget();
|
|
3753
3776
|
const completionApi = selectCompletionApi(pi, runtime.pi);
|
|
3754
3777
|
|
|
@@ -3778,26 +3801,29 @@ export default function subagentsExtension(pi: ExtensionAPI) {
|
|
|
3778
3801
|
|
|
3779
3802
|
const presentation = resolveResultPresentation(
|
|
3780
3803
|
result,
|
|
3781
|
-
|
|
3782
|
-
|
|
3804
|
+
completedRunning.name,
|
|
3805
|
+
completedRunning.runtimePlan?.runtimeMismatch,
|
|
3783
3806
|
);
|
|
3784
3807
|
|
|
3785
3808
|
sendSubagentResult(completionApi, presentation, {
|
|
3786
|
-
name:
|
|
3787
|
-
task:
|
|
3788
|
-
agent:
|
|
3809
|
+
name: completedRunning.name,
|
|
3810
|
+
task: completedRunning.task,
|
|
3811
|
+
agent: completedRunning.agent,
|
|
3789
3812
|
exitCode: result.exitCode,
|
|
3790
3813
|
elapsed: result.elapsed,
|
|
3791
3814
|
sessionFile: result.sessionFile,
|
|
3792
3815
|
...(result.errorMessage
|
|
3793
3816
|
? { errorMessage: result.errorMessage }
|
|
3794
3817
|
: {}),
|
|
3818
|
+
...(result.fallbackAttempts
|
|
3819
|
+
? { fallbackAttempts: result.fallbackAttempts }
|
|
3820
|
+
: {}),
|
|
3795
3821
|
...(result.claudeSessionId
|
|
3796
3822
|
? { claudeSessionId: result.claudeSessionId }
|
|
3797
3823
|
: {}),
|
|
3798
3824
|
...(result.worktree ? { worktree: result.worktree } : {}),
|
|
3799
|
-
...(
|
|
3800
|
-
? { runtimePlan:
|
|
3825
|
+
...(completedRunning.runtimePlan
|
|
3826
|
+
? { runtimePlan: completedRunning.runtimePlan }
|
|
3801
3827
|
: {}),
|
|
3802
3828
|
});
|
|
3803
3829
|
})
|
|
@@ -190,6 +190,16 @@ function selectField(
|
|
|
190
190
|
return { source: "parent" };
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
export function parseModelFallbacks(reference: string): string[] {
|
|
194
|
+
const candidates = reference.split(",").map((candidate) => candidate.trim());
|
|
195
|
+
if (candidates.some((candidate) => candidate === "")) {
|
|
196
|
+
throw new RuntimeResolutionError(
|
|
197
|
+
`model fallback list ${JSON.stringify(reference)} cannot contain an empty candidate`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
return candidates;
|
|
201
|
+
}
|
|
202
|
+
|
|
193
203
|
export function resolveRuntimePlan(
|
|
194
204
|
request: RuntimeRequest,
|
|
195
205
|
agentDefaults: RuntimeRequest,
|
|
@@ -272,6 +282,30 @@ export function resolveRuntimePlan(
|
|
|
272
282
|
};
|
|
273
283
|
}
|
|
274
284
|
|
|
285
|
+
/** Resolve every configured fallback before launching the first child. */
|
|
286
|
+
export function resolveRuntimePlans(
|
|
287
|
+
request: RuntimeRequest,
|
|
288
|
+
agentDefaults: RuntimeRequest,
|
|
289
|
+
parent: ParentRuntime,
|
|
290
|
+
registry: ModelRegistryAdapter,
|
|
291
|
+
): ResolvedRuntimePlan[] {
|
|
292
|
+
const selection = selectField(request.model, agentDefaults.model);
|
|
293
|
+
if (!selection.value) {
|
|
294
|
+
return [resolveRuntimePlan(request, agentDefaults, parent, registry)];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return parseModelFallbacks(selection.value).map((model) =>
|
|
298
|
+
resolveRuntimePlan(
|
|
299
|
+
selection.source === "request" ? { ...request, model } : { ...request, model: undefined },
|
|
300
|
+
selection.source === "agent"
|
|
301
|
+
? { ...agentDefaults, model }
|
|
302
|
+
: agentDefaults,
|
|
303
|
+
parent,
|
|
304
|
+
registry,
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
275
309
|
function formatTokenCount(value: number | undefined): string | undefined {
|
|
276
310
|
if (!value || value <= 0) return undefined;
|
|
277
311
|
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}m`;
|