@ferris1225/pi-subagents 0.31.0 → 0.32.2
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 +122 -71
- package/package.json +1 -1
- package/src/agents.ts +8 -13
- package/src/background.ts +59 -6
- package/src/config.ts +14 -2
- package/src/dispatch.ts +1429 -396
- package/src/format.ts +29 -4
- package/src/index.ts +7 -4
- package/src/inspector-panel.ts +363 -0
- package/src/inspector.ts +369 -0
- package/src/models.ts +192 -50
- package/src/monitor.ts +114 -7
- package/src/prompt.ts +3 -2
- package/src/recovery.ts +145 -0
- package/src/rpc-run.ts +1016 -0
- package/src/runtime.ts +167 -27
- package/src/session-fork.ts +84 -0
- package/src/setup.ts +271 -184
- package/src/spawn.ts +562 -977
- package/src/tools.ts +404 -65
- package/src/trajectory.ts +503 -0
- package/src/ui.ts +32 -16
- package/src/widget.ts +17 -4
- package/src/worktree.ts +687 -0
package/src/inspector.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure view-model builder for the /subagents-inspect overlay.
|
|
3
|
+
*
|
|
4
|
+
* Joins three read-only sources into one snapshot:
|
|
5
|
+
* - monitor (live widget rows: status/usage/activity),
|
|
6
|
+
* - runtime.threads (thread state + control phase), and
|
|
7
|
+
* - inspectorStore (append-only trajectory + bounded transcript + retained
|
|
8
|
+
* snapshots for finished/parked threads whose monitor rows are gone).
|
|
9
|
+
*
|
|
10
|
+
* The overlay component renders this snapshot directly and never mutates
|
|
11
|
+
* runtime state; building it must therefore be side-effect free.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { stripVTControlCharacters } from "node:util";
|
|
15
|
+
import { formatUsageCompact, monitor, type RunStatus } from "./monitor.ts";
|
|
16
|
+
import { emptyUsage, type UsageStats } from "./rpc-run.ts";
|
|
17
|
+
import type { SubagentRuntime, ThreadState } from "./runtime.ts";
|
|
18
|
+
import { inspectorStore, type TrajectoryEvent } from "./trajectory.ts";
|
|
19
|
+
|
|
20
|
+
export interface InspectorRunItem {
|
|
21
|
+
id: number;
|
|
22
|
+
agent: string;
|
|
23
|
+
label: string;
|
|
24
|
+
status: RunStatus;
|
|
25
|
+
/** Thread state text ("running", "parked", "completed", ...) or the monitor
|
|
26
|
+
* status label for threads without a control record (chain internals). */
|
|
27
|
+
stateText: string;
|
|
28
|
+
generation?: number;
|
|
29
|
+
elapsedMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface InspectorToolEntry {
|
|
33
|
+
tool: string;
|
|
34
|
+
summary?: string;
|
|
35
|
+
isError?: boolean;
|
|
36
|
+
running?: boolean;
|
|
37
|
+
at: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface InspectorThreadView {
|
|
41
|
+
id: number;
|
|
42
|
+
agent: string;
|
|
43
|
+
label: string;
|
|
44
|
+
task: string;
|
|
45
|
+
status: RunStatus;
|
|
46
|
+
stateText: string;
|
|
47
|
+
/** Control phase of the current attempt (queued/starting/running/...). */
|
|
48
|
+
phase?: string;
|
|
49
|
+
generation?: number;
|
|
50
|
+
elapsedMs?: number;
|
|
51
|
+
startedAt?: number;
|
|
52
|
+
endedAt?: number;
|
|
53
|
+
model?: string;
|
|
54
|
+
/** Ordered candidate refs from the dispatch record (primary first). */
|
|
55
|
+
modelChain: string[];
|
|
56
|
+
modelFallbackFrom?: string;
|
|
57
|
+
thinking?: string;
|
|
58
|
+
isolation?: "shared" | "worktree";
|
|
59
|
+
integrationStatus?: string;
|
|
60
|
+
integrationApplied?: boolean;
|
|
61
|
+
originalCwd?: string;
|
|
62
|
+
isolationCwd?: string;
|
|
63
|
+
integrationWorktreePath?: string;
|
|
64
|
+
integrationPatchPath?: string;
|
|
65
|
+
integrationError?: string;
|
|
66
|
+
forkedFromRunId?: number;
|
|
67
|
+
forkChildRunIds: number[];
|
|
68
|
+
usage: UsageStats;
|
|
69
|
+
toolCount: number;
|
|
70
|
+
activity?: string;
|
|
71
|
+
currentTool?: string;
|
|
72
|
+
tools: InspectorToolEntry[];
|
|
73
|
+
trajectory: readonly TrajectoryEvent[];
|
|
74
|
+
trajectoryTotal: number;
|
|
75
|
+
transcript: { text: string; textTruncated: boolean; thinking: string; thinkingTruncated: boolean };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface InspectorSnapshot {
|
|
79
|
+
now: number;
|
|
80
|
+
items: InspectorRunItem[];
|
|
81
|
+
detail?: InspectorThreadView;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const TOOL_LIMIT = 6;
|
|
85
|
+
const TRAJECTORY_LIMIT = 10;
|
|
86
|
+
|
|
87
|
+
function monitorRunsSafe(): ReturnType<typeof monitor.getRuns> {
|
|
88
|
+
try {
|
|
89
|
+
return monitor.getRuns();
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function threadStatusToRunStatus(state: ThreadState): RunStatus {
|
|
96
|
+
switch (state) {
|
|
97
|
+
case "queued":
|
|
98
|
+
case "resuming":
|
|
99
|
+
return "queued";
|
|
100
|
+
case "running":
|
|
101
|
+
return "running";
|
|
102
|
+
case "steering":
|
|
103
|
+
return "steering";
|
|
104
|
+
case "interrupting":
|
|
105
|
+
return "interrupting";
|
|
106
|
+
case "parked":
|
|
107
|
+
return "parked";
|
|
108
|
+
case "completed":
|
|
109
|
+
return "done";
|
|
110
|
+
case "failed":
|
|
111
|
+
case "stopped":
|
|
112
|
+
return "failed";
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildInspectorSnapshot(options: {
|
|
117
|
+
runtime: Pick<SubagentRuntime, "threads">;
|
|
118
|
+
selectedId?: number;
|
|
119
|
+
now?: number;
|
|
120
|
+
toolLimit?: number;
|
|
121
|
+
trajectoryLimit?: number;
|
|
122
|
+
}): InspectorSnapshot {
|
|
123
|
+
const now = options.now ?? Date.now();
|
|
124
|
+
const toolLimit = options.toolLimit ?? TOOL_LIMIT;
|
|
125
|
+
const trajectoryLimit = options.trajectoryLimit ?? TRAJECTORY_LIMIT;
|
|
126
|
+
const monitorRuns = monitorRunsSafe();
|
|
127
|
+
|
|
128
|
+
// Union of the three sources keyed by id.
|
|
129
|
+
const ids = new Set<number>();
|
|
130
|
+
for (const id of options.runtime.threads.keys()) ids.add(id);
|
|
131
|
+
for (const state of inspectorStore.all()) ids.add(state.runId);
|
|
132
|
+
for (const run of monitorRuns) ids.add(run.id);
|
|
133
|
+
|
|
134
|
+
const items: InspectorRunItem[] = [];
|
|
135
|
+
let detail: InspectorThreadView | undefined;
|
|
136
|
+
|
|
137
|
+
for (const id of [...ids].sort((a, b) => a - b)) {
|
|
138
|
+
const monitorRun = monitorRuns.find((r) => r.id === id);
|
|
139
|
+
const thread = options.runtime.threads.get(id);
|
|
140
|
+
const inspect = inspectorStore.find(id);
|
|
141
|
+
|
|
142
|
+
const latestSettled = [...(inspect?.trajectory.getGenerationEvents() ?? [])]
|
|
143
|
+
.reverse()
|
|
144
|
+
.find((event): event is Extract<TrajectoryEvent, { kind: "settled" }> => event.kind === "settled");
|
|
145
|
+
const trajectoryStatus = latestSettled
|
|
146
|
+
? latestSettled.status === "done" ? "done" : "failed"
|
|
147
|
+
: undefined;
|
|
148
|
+
const agent = monitorRun?.agent ?? thread?.agentName ?? inspect?.agent ?? "?";
|
|
149
|
+
const label = monitorRun?.label ?? inspect?.label ?? "";
|
|
150
|
+
const task = monitorRun?.task ?? thread?.task ?? inspect?.task ?? "";
|
|
151
|
+
const status: RunStatus = monitorRun?.status ?? (thread
|
|
152
|
+
? threadStatusToRunStatus(thread.state)
|
|
153
|
+
: trajectoryStatus ?? statusFromText(inspect?.status));
|
|
154
|
+
const stateText = thread?.state ?? monitorRun?.status ?? latestSettled?.status ?? inspect?.status ?? "queued";
|
|
155
|
+
const startedAt = monitorRun?.startedAt ?? inspect?.startedAt;
|
|
156
|
+
const endedAt = monitorRun?.endedAt ?? inspect?.endedAt ?? inspect?.trajectory.summary().endedAt;
|
|
157
|
+
const elapsedMs = startedAt === undefined ? undefined : Math.max(0, (endedAt ?? now) - startedAt);
|
|
158
|
+
|
|
159
|
+
items.push({ id, agent, label, status, stateText, generation: thread?.generation ?? inspect?.generation, elapsedMs });
|
|
160
|
+
|
|
161
|
+
if (options.selectedId === id) {
|
|
162
|
+
detail = buildDetail({
|
|
163
|
+
id,
|
|
164
|
+
agent,
|
|
165
|
+
label,
|
|
166
|
+
task,
|
|
167
|
+
status,
|
|
168
|
+
stateText,
|
|
169
|
+
startedAt,
|
|
170
|
+
endedAt,
|
|
171
|
+
elapsedMs,
|
|
172
|
+
monitorRun,
|
|
173
|
+
thread,
|
|
174
|
+
inspect,
|
|
175
|
+
toolLimit,
|
|
176
|
+
trajectoryLimit,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { now, items, detail };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function statusFromText(text: string | undefined): RunStatus {
|
|
185
|
+
switch (text) {
|
|
186
|
+
case "done":
|
|
187
|
+
case "completed":
|
|
188
|
+
return "done";
|
|
189
|
+
case "failed":
|
|
190
|
+
case "stopped":
|
|
191
|
+
return "failed";
|
|
192
|
+
case "running":
|
|
193
|
+
return "running";
|
|
194
|
+
case "steering":
|
|
195
|
+
return "steering";
|
|
196
|
+
case "interrupting":
|
|
197
|
+
return "interrupting";
|
|
198
|
+
case "parked":
|
|
199
|
+
return "parked";
|
|
200
|
+
default:
|
|
201
|
+
return "queued";
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function buildDetail(input: {
|
|
206
|
+
id: number;
|
|
207
|
+
agent: string;
|
|
208
|
+
label: string;
|
|
209
|
+
task: string;
|
|
210
|
+
status: RunStatus;
|
|
211
|
+
stateText: string;
|
|
212
|
+
startedAt?: number;
|
|
213
|
+
endedAt?: number;
|
|
214
|
+
elapsedMs?: number;
|
|
215
|
+
monitorRun?: ReturnType<typeof monitor.getRuns>[number];
|
|
216
|
+
thread?: {
|
|
217
|
+
state: ThreadState;
|
|
218
|
+
generation: number;
|
|
219
|
+
control: { getPhase(): string };
|
|
220
|
+
isolation: "shared" | "worktree";
|
|
221
|
+
cwd: string;
|
|
222
|
+
executionCwd: string;
|
|
223
|
+
worktree?: { worktreePath: string; patchPath: string; state: string };
|
|
224
|
+
forkedFromRunId?: number;
|
|
225
|
+
forkChildRunIds: number[];
|
|
226
|
+
};
|
|
227
|
+
inspect?: ReturnType<typeof inspectorStore.find>;
|
|
228
|
+
toolLimit: number;
|
|
229
|
+
trajectoryLimit: number;
|
|
230
|
+
}): InspectorThreadView {
|
|
231
|
+
const { monitorRun, thread, inspect } = input;
|
|
232
|
+
const genEvents = inspect?.trajectory.getGenerationEvents() ?? [];
|
|
233
|
+
const allEvents = inspect?.trajectory.getEvents() ?? [];
|
|
234
|
+
|
|
235
|
+
// Model chain: primary first, then the recorded pool fallbacks. The actual
|
|
236
|
+
// model currently running is the trajectory summary's latest, or the live
|
|
237
|
+
// monitor model. modelFallbackFrom marks a pool advancement.
|
|
238
|
+
const dispatchEvent = [...genEvents].reverse().find((e): e is Extract<TrajectoryEvent, { kind: "dispatch" }> => e.kind === "dispatch");
|
|
239
|
+
const chain: string[] = [];
|
|
240
|
+
if (dispatchEvent?.model) chain.push(dispatchEvent.model);
|
|
241
|
+
for (const ref of dispatchEvent?.pool ?? []) if (ref && !chain.includes(ref)) chain.push(ref);
|
|
242
|
+
const model = monitorRun?.model ?? inspect?.trajectory.summary().model ?? inspect?.model;
|
|
243
|
+
const fallbackFrom = monitorRun?.modelFallbackFrom ?? inspect?.trajectory.summary().modelFallbackFrom;
|
|
244
|
+
if (model && !chain.includes(model)) chain.push(model);
|
|
245
|
+
|
|
246
|
+
// Recent tool entries from the current-generation trajectory.
|
|
247
|
+
const tools: InspectorToolEntry[] = [];
|
|
248
|
+
for (let i = genEvents.length - 1; i >= 0 && tools.length < input.toolLimit; i--) {
|
|
249
|
+
const e = genEvents[i];
|
|
250
|
+
if (e.kind === "tool_start") {
|
|
251
|
+
// Correlate parallel calls by Pi's stable toolCallId. Name matching is
|
|
252
|
+
// retained only for legacy events that predate id capture.
|
|
253
|
+
const ended = genEvents.slice(i + 1).find((later) =>
|
|
254
|
+
later.kind === "tool_end" &&
|
|
255
|
+
(e.toolCallId
|
|
256
|
+
? later.toolCallId === e.toolCallId
|
|
257
|
+
: later.toolCallId === undefined && later.tool === e.tool),
|
|
258
|
+
);
|
|
259
|
+
tools.unshift({
|
|
260
|
+
tool: e.tool,
|
|
261
|
+
summary: e.summary || undefined,
|
|
262
|
+
isError: ended && ended.kind === "tool_end" ? ended.isError : undefined,
|
|
263
|
+
running: ended === undefined,
|
|
264
|
+
at: e.at,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const trajectory = genEvents.slice(-input.trajectoryLimit);
|
|
270
|
+
const usage = monitorRun?.usage ?? inspect?.runInfo?.usage ?? emptyUsage();
|
|
271
|
+
const toolCount = monitorRun?.toolCount ?? inspect?.runInfo?.toolCount ?? inspect?.trajectory.summary().toolCount ?? 0;
|
|
272
|
+
const trajectorySummary = inspect?.trajectory.summary();
|
|
273
|
+
const lastWorktreeEvent = [...allEvents]
|
|
274
|
+
.reverse()
|
|
275
|
+
.find((event): event is Extract<TrajectoryEvent, { kind: "worktree" }> => event.kind === "worktree");
|
|
276
|
+
const isolation = thread?.isolation ?? monitorRun?.isolation ?? trajectorySummary?.isolation;
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
id: input.id,
|
|
280
|
+
agent: input.agent,
|
|
281
|
+
label: input.label,
|
|
282
|
+
task: input.task,
|
|
283
|
+
status: input.status,
|
|
284
|
+
stateText: input.stateText,
|
|
285
|
+
phase: thread?.control.getPhase(),
|
|
286
|
+
generation: thread?.generation ?? (genEvents.length > 0 ? inspect?.generation : undefined),
|
|
287
|
+
elapsedMs: input.elapsedMs,
|
|
288
|
+
startedAt: input.startedAt,
|
|
289
|
+
endedAt: input.endedAt,
|
|
290
|
+
model: model ?? undefined,
|
|
291
|
+
modelChain: chain,
|
|
292
|
+
modelFallbackFrom: fallbackFrom,
|
|
293
|
+
thinking: monitorRun?.thinking ?? trajectorySummary?.thinking ?? inspect?.thinking,
|
|
294
|
+
isolation,
|
|
295
|
+
integrationStatus: monitorRun?.integrationStatus ?? trajectorySummary?.integrationStatus ?? thread?.worktree?.state,
|
|
296
|
+
integrationApplied: lastWorktreeEvent?.integrated,
|
|
297
|
+
originalCwd: thread?.cwd ?? trajectorySummary?.originalCwd,
|
|
298
|
+
isolationCwd: thread?.executionCwd ?? trajectorySummary?.isolationCwd,
|
|
299
|
+
integrationWorktreePath: lastWorktreeEvent?.worktreePath ?? (thread?.worktree?.state === "retained" ? thread.worktree.worktreePath : undefined),
|
|
300
|
+
integrationPatchPath: lastWorktreeEvent?.patchPath ?? (thread?.worktree?.state === "retained" ? thread.worktree.patchPath : undefined),
|
|
301
|
+
integrationError: lastWorktreeEvent?.error,
|
|
302
|
+
forkedFromRunId: thread?.forkedFromRunId ?? trajectorySummary?.forkedFromRunId,
|
|
303
|
+
forkChildRunIds: [...(thread?.forkChildRunIds ?? trajectorySummary?.forkChildRunIds ?? [])],
|
|
304
|
+
usage,
|
|
305
|
+
toolCount,
|
|
306
|
+
activity: monitorRun?.activity ?? inspect?.runInfo?.activity ?? inspect?.trajectory.summary().activity,
|
|
307
|
+
currentTool: monitorRun?.currentTool ?? inspect?.runInfo?.currentTool ?? inspect?.trajectory.summary().currentTool,
|
|
308
|
+
tools,
|
|
309
|
+
trajectory,
|
|
310
|
+
trajectoryTotal: allEvents.length,
|
|
311
|
+
transcript: inspect?.transcript.snapshot() ?? { text: "", textTruncated: false, thinking: "", thinkingTruncated: false },
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** One-line event text for the trajectory pane (plain, pre-theme). */
|
|
316
|
+
export function formatTrajectoryEvent(event: TrajectoryEvent): string {
|
|
317
|
+
return stripVTControlCharacters(formatTrajectoryEventUnsafe(event));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function formatTrajectoryEventUnsafe(event: TrajectoryEvent): string {
|
|
321
|
+
switch (event.kind) {
|
|
322
|
+
case "dispatch":
|
|
323
|
+
return `dispatch ${event.agent}${event.resumed ? " (resumed)" : ""}${event.model ? ` · ${event.model}` : ""}`;
|
|
324
|
+
case "status":
|
|
325
|
+
return `status: ${event.status}`;
|
|
326
|
+
case "candidate":
|
|
327
|
+
return event.fallbackFrom ? `model: ${event.model ?? "?"} (pool fallback from ${event.fallbackFrom})` : `model: ${event.model ?? "?"}`;
|
|
328
|
+
case "retry":
|
|
329
|
+
return `retry${event.reason ? `: ${event.reason}` : ""}`;
|
|
330
|
+
case "steer":
|
|
331
|
+
return `steer: ${compactOne(event.instruction, 60)}`;
|
|
332
|
+
case "retarget":
|
|
333
|
+
return `retarget: ${compactOne(event.objective, 60)}`;
|
|
334
|
+
case "park":
|
|
335
|
+
return "parked at checkpoint";
|
|
336
|
+
case "resume":
|
|
337
|
+
return event.objective ? `resume: ${compactOne(event.objective, 60)}` : "resume from retained context";
|
|
338
|
+
case "fork":
|
|
339
|
+
return event.runId === event.sourceRunId
|
|
340
|
+
? `forked child #${event.childRunId}${event.objective ? `: ${compactOne(event.objective, 48)}` : ""}`
|
|
341
|
+
: `forked from #${event.sourceRunId}${event.objective ? `: ${compactOne(event.objective, 48)}` : ""}`;
|
|
342
|
+
case "stop":
|
|
343
|
+
return event.reason ? `stop: ${compactOne(event.reason, 60)}` : "stopped";
|
|
344
|
+
case "worktree":
|
|
345
|
+
return event.status === "created"
|
|
346
|
+
? `worktree created${event.isolationCwd ? ` · ${compactOne(event.isolationCwd, 48)}` : ""}`
|
|
347
|
+
: event.status === "retained" && event.integrated
|
|
348
|
+
? `worktree cleanup failed after apply${event.error ? `: ${compactOne(event.error, 48)}` : ""}`
|
|
349
|
+
: `worktree ${event.status}${event.error ? `: ${compactOne(event.error, 48)}` : ""}`;
|
|
350
|
+
case "settled":
|
|
351
|
+
return `settled: ${event.status}${event.model ? ` · ${event.model}` : ""}${event.integrationStatus ? ` · worktree ${event.integrationStatus}` : ""}`;
|
|
352
|
+
case "tool_start":
|
|
353
|
+
return `→ ${event.tool}${event.summary ? ` ${compactOne(event.summary, 48)}` : ""}`;
|
|
354
|
+
case "tool_end":
|
|
355
|
+
return `${e2(event)}${event.tool}`;
|
|
356
|
+
case "usage":
|
|
357
|
+
return `usage: ${formatUsageCompact(event.usage) || "—"}`;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function e2(event: Extract<TrajectoryEvent, { kind: "tool_end" }>): string {
|
|
362
|
+
return event.isError ? "✗ " : "✓ ";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function compactOne(text: string, max: number): string {
|
|
366
|
+
const oneLine = stripVTControlCharacters(text).replace(/\s+/g, " ").trim();
|
|
367
|
+
const chars = [...oneLine];
|
|
368
|
+
return chars.length > max ? `${chars.slice(0, max - 1).join("")}…` : oneLine;
|
|
369
|
+
}
|
package/src/models.ts
CHANGED
|
@@ -1,82 +1,224 @@
|
|
|
1
|
-
|
|
2
|
-
* Model
|
|
1
|
+
/*
|
|
2
|
+
* Model-pool resolution and setup-picker helpers.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* a
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Runtime pools deliberately do not filter configured references by current
|
|
5
|
+
* availability: a stale primary/backup is attempted and normal provider/model
|
|
6
|
+
* failure handling advances to the next candidate. Setup uses the same catalog
|
|
7
|
+
* only for honest availability labels; it never rewrites persisted choices.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
10
11
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
|
|
12
|
-
export type ModelContext = Pick<ExtensionContext, "model" | "
|
|
13
|
+
export type ModelContext = Pick<ExtensionContext, "model" | "modelRegistry"> &
|
|
14
|
+
Partial<Pick<ExtensionContext, "scopedModels">>;
|
|
15
|
+
|
|
16
|
+
export const CURRENT_MAIN_MODEL = "__current_main_model__";
|
|
17
|
+
|
|
18
|
+
export type ModelPoolSlot = "primary" | "backup";
|
|
19
|
+
export type ModelPickerSlot = ModelPoolSlot | "vision";
|
|
20
|
+
|
|
21
|
+
export interface ModelPickerItem {
|
|
22
|
+
value: string;
|
|
23
|
+
label: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
/** Visible for diagnosis/search, but cannot be selected. */
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ModelListEntry = Pick<
|
|
30
|
+
Model<Api>,
|
|
31
|
+
"provider" | "id" | "name" | "input" | "reasoning"
|
|
32
|
+
>;
|
|
33
|
+
|
|
34
|
+
export interface ResolvedAgentModelPool {
|
|
35
|
+
/** Effective first candidate. Undefined means let pi use its normal default. */
|
|
36
|
+
primaryRef?: string;
|
|
37
|
+
/** Ordered candidates after the primary, already deduplicated. */
|
|
38
|
+
fallbackModelRefs: string[];
|
|
39
|
+
/** All known references in runtime order, useful for tests/inspection. */
|
|
40
|
+
candidateRefs: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AgentModelPoolInput {
|
|
44
|
+
primaryRef?: string;
|
|
45
|
+
backupRef?: string;
|
|
46
|
+
mainRef?: string;
|
|
47
|
+
declaredDefaultRef?: string;
|
|
48
|
+
}
|
|
13
49
|
|
|
14
|
-
export interface
|
|
50
|
+
export interface AgentModelPoolMaps {
|
|
15
51
|
agentModels: Record<string, string>;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
52
|
+
agentBackupModels: Record<string, string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AgentModelPoolRow {
|
|
56
|
+
name: string;
|
|
57
|
+
primary: string;
|
|
58
|
+
backup: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function cleanModelRef(ref: string | undefined): string | undefined {
|
|
62
|
+
const trimmed = ref?.trim();
|
|
63
|
+
return trimmed || undefined;
|
|
20
64
|
}
|
|
21
65
|
|
|
22
66
|
export function modelRef(model: { provider: string; id: string }): string {
|
|
23
67
|
return `${model.provider}/${model.id}`;
|
|
24
68
|
}
|
|
25
69
|
|
|
70
|
+
export function currentModelRef(ctx: Pick<ModelContext, "model">): string | undefined {
|
|
71
|
+
return ctx.model ? modelRef(ctx.model) : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
26
74
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
75
|
+
* Models usable by this main window. Scoped models replace the full available
|
|
76
|
+
* registry, matching pi's built-in model picker semantics.
|
|
29
77
|
*/
|
|
30
78
|
export function availableModelRefs(ctx: ModelContext): string[] {
|
|
31
|
-
|
|
79
|
+
// scopedModels was added after the declared Pi 0.80.6 minimum. Treat a
|
|
80
|
+
// missing field exactly like an empty scope and use the registry fallback.
|
|
81
|
+
const scopedModels = ctx.scopedModels ?? [];
|
|
82
|
+
const scoped = scopedModels.length > 0 ? scopedModels.map((entry) => entry.model) : undefined;
|
|
32
83
|
const models = scoped ?? ctx.modelRegistry.getAvailable();
|
|
33
84
|
const refs = [...new Set(models.map(modelRef))];
|
|
34
|
-
const currentRef =
|
|
85
|
+
const currentRef = currentModelRef(ctx);
|
|
35
86
|
if (!currentRef) return refs;
|
|
36
87
|
return [currentRef, ...refs.filter((ref) => ref !== currentRef)];
|
|
37
88
|
}
|
|
38
89
|
|
|
39
90
|
/**
|
|
40
|
-
* Resolve
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
91
|
+
* Resolve one agent's ordered runtime pool:
|
|
92
|
+
*
|
|
93
|
+
* configured primary -> configured backup -> current main-window model
|
|
94
|
+
*
|
|
95
|
+
* Without a primary override, the current main model remains the primary; an
|
|
96
|
+
* agent-declared default is used only when no main model exists. Equal refs are
|
|
97
|
+
* removed without consulting availability, so stale refs stay in the chain and
|
|
98
|
+
* fail normally at runtime instead of being silently repaired.
|
|
45
99
|
*/
|
|
46
|
-
export function
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
100
|
+
export function resolveAgentModelPool(input: AgentModelPoolInput): ResolvedAgentModelPool {
|
|
101
|
+
const mainRef = cleanModelRef(input.mainRef);
|
|
102
|
+
const primaryRef = cleanModelRef(input.primaryRef) ?? mainRef ?? cleanModelRef(input.declaredDefaultRef);
|
|
103
|
+
const ordered = [primaryRef, cleanModelRef(input.backupRef), mainRef];
|
|
104
|
+
const seen = new Set<string>();
|
|
105
|
+
const candidateRefs: string[] = [];
|
|
106
|
+
for (const ref of ordered) {
|
|
107
|
+
if (!ref || seen.has(ref)) continue;
|
|
108
|
+
seen.add(ref);
|
|
109
|
+
candidateRefs.push(ref);
|
|
110
|
+
}
|
|
111
|
+
const fallbackModelRefs = candidateRefs.filter((ref) => ref !== primaryRef);
|
|
112
|
+
return { primaryRef, fallbackModelRefs, candidateRefs };
|
|
50
113
|
}
|
|
51
114
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
115
|
+
function modelCapabilities(model: ModelListEntry): string {
|
|
116
|
+
const capabilities = [model.input.includes("image") ? "vision" : "text-only"];
|
|
117
|
+
if (model.reasoning) capabilities.push("reasoning");
|
|
118
|
+
return capabilities.join(" + ");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Build the single searchable model list shared by primary/backup/vision picks. */
|
|
122
|
+
export function buildModelPickerItems(options: {
|
|
123
|
+
models: readonly ModelListEntry[];
|
|
124
|
+
availableRefs: readonly string[];
|
|
125
|
+
slot: ModelPickerSlot;
|
|
126
|
+
configuredRef?: string;
|
|
127
|
+
mainRef?: string;
|
|
128
|
+
}): ModelPickerItem[] {
|
|
129
|
+
const configuredRef = cleanModelRef(options.configuredRef);
|
|
130
|
+
const mainRef = cleanModelRef(options.mainRef);
|
|
131
|
+
const available = new Set(options.availableRefs.map((ref) => ref.trim()));
|
|
132
|
+
const byRef = new Map<string, ModelListEntry>();
|
|
133
|
+
for (const model of options.models) {
|
|
134
|
+
const ref = modelRef(model);
|
|
135
|
+
if (!byRef.has(ref)) byRef.set(ref, model);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const refs = [...byRef.keys()]
|
|
139
|
+
.filter((ref) =>
|
|
140
|
+
options.slot !== "vision" ||
|
|
141
|
+
byRef.get(ref)?.input.includes("image") === true ||
|
|
142
|
+
ref === configuredRef,
|
|
143
|
+
)
|
|
144
|
+
.sort((left, right) => {
|
|
145
|
+
const leftRank = left === configuredRef ? 0 : left === mainRef ? 1 : 2;
|
|
146
|
+
const rightRank = right === configuredRef ? 0 : right === mainRef ? 1 : 2;
|
|
147
|
+
return leftRank - rightRank || left.localeCompare(right);
|
|
148
|
+
});
|
|
149
|
+
if (configuredRef && !byRef.has(configuredRef)) refs.unshift(configuredRef);
|
|
150
|
+
|
|
151
|
+
const dynamic = options.slot === "backup"
|
|
152
|
+
? {
|
|
153
|
+
value: CURRENT_MAIN_MODEL,
|
|
154
|
+
label: "Current main model (default)",
|
|
155
|
+
description: "Clear configured backup; use the main-window model dynamically",
|
|
70
156
|
}
|
|
157
|
+
: {
|
|
158
|
+
value: CURRENT_MAIN_MODEL,
|
|
159
|
+
label: "Current main model (dynamic)",
|
|
160
|
+
description: options.slot === "vision"
|
|
161
|
+
? "Clear vision override; use the main-window model for vision tasks"
|
|
162
|
+
: "Clear primary override; use the main-window model dynamically",
|
|
163
|
+
};
|
|
71
164
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
165
|
+
const items: ModelPickerItem[] = [dynamic];
|
|
166
|
+
for (const ref of refs) {
|
|
167
|
+
const model = byRef.get(ref);
|
|
168
|
+
const tags = [available.has(ref) ? "available" : "unavailable"];
|
|
169
|
+
if (ref === configuredRef) tags.push("configured");
|
|
170
|
+
if (ref === mainRef) tags.push("current main");
|
|
171
|
+
if (!model) {
|
|
172
|
+
const compatibility = options.slot === "vision"
|
|
173
|
+
? "incompatible with vision (capability unknown)"
|
|
174
|
+
: undefined;
|
|
175
|
+
items.push({
|
|
176
|
+
value: ref,
|
|
177
|
+
label: ref,
|
|
178
|
+
description: [...tags, compatibility, "saved model reference"].filter(Boolean).join(" · "),
|
|
179
|
+
...(options.slot === "vision" ? { disabled: true } : {}),
|
|
180
|
+
});
|
|
181
|
+
continue;
|
|
78
182
|
}
|
|
183
|
+
const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
|
|
184
|
+
const compatibility = options.slot === "vision" && !model.input.includes("image")
|
|
185
|
+
? "incompatible with vision"
|
|
186
|
+
: undefined;
|
|
187
|
+
items.push({
|
|
188
|
+
value: ref,
|
|
189
|
+
label: ref,
|
|
190
|
+
description: [name, modelCapabilities(model), compatibility, ...tags].filter(Boolean).join(" · "),
|
|
191
|
+
...(compatibility ? { disabled: true } : {}),
|
|
192
|
+
});
|
|
79
193
|
}
|
|
194
|
+
return items;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Pure pool update: the dynamic/default choice removes the persisted override. */
|
|
198
|
+
export function applyModelPoolChoice(
|
|
199
|
+
current: AgentModelPoolMaps,
|
|
200
|
+
agentName: string,
|
|
201
|
+
slot: ModelPoolSlot,
|
|
202
|
+
choice: string,
|
|
203
|
+
): AgentModelPoolMaps {
|
|
204
|
+
const next: AgentModelPoolMaps = {
|
|
205
|
+
agentModels: { ...current.agentModels },
|
|
206
|
+
agentBackupModels: { ...current.agentBackupModels },
|
|
207
|
+
};
|
|
208
|
+
const target = slot === "primary" ? next.agentModels : next.agentBackupModels;
|
|
209
|
+
if (choice === CURRENT_MAIN_MODEL) delete target[agentName];
|
|
210
|
+
else target[agentName] = choice.trim();
|
|
211
|
+
return next;
|
|
212
|
+
}
|
|
80
213
|
|
|
81
|
-
|
|
214
|
+
/** Pure rows used by the overview component and focused helper tests. */
|
|
215
|
+
export function buildAgentModelPoolRows(
|
|
216
|
+
agentNames: readonly string[],
|
|
217
|
+
pools: AgentModelPoolMaps,
|
|
218
|
+
): AgentModelPoolRow[] {
|
|
219
|
+
return agentNames.map((name) => ({
|
|
220
|
+
name,
|
|
221
|
+
primary: pools.agentModels[name] ?? "Main (dynamic)",
|
|
222
|
+
backup: pools.agentBackupModels[name] ?? "Main (default)",
|
|
223
|
+
}));
|
|
82
224
|
}
|