pi-subagents-j0k3r 1.0.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/.releaserc.json +14 -0
- package/CHANGELOG.md +18 -0
- package/LICENSE +21 -0
- package/README.md +433 -0
- package/index.ts +531 -0
- package/package.json +73 -0
- package/scripts/verify-package-files.mjs +41 -0
- package/skills/subagents-configuration/SKILL.md +182 -0
- package/src/config.ts +262 -0
- package/src/debug.ts +38 -0
- package/src/history.ts +254 -0
- package/src/interaction-channel.ts +197 -0
- package/src/manager.ts +533 -0
- package/src/model-profiles-ui.ts +609 -0
- package/src/profile-resolver.ts +60 -0
- package/src/runner.ts +688 -0
- package/src/thread-view.ts +477 -0
- package/src/tools.ts +492 -0
- package/src/types.ts +234 -0
- package/src/ui.ts +399 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadSubagents, readSubagentsConfig, resetGlobalSubagentModelProfileField, saveGlobalSubagentModelProfile } from './config.js';
|
|
4
|
+
import { resolveEffectiveSubagentProfile } from './profile-resolver.js';
|
|
5
|
+
import type { ModelRef, SubagentDefinition, SubagentModelProfile, SubagentModelProfiles, SubagentsConfig, ThinkingEffort } from './types.js';
|
|
6
|
+
|
|
7
|
+
export const KNOWN_SDD_PHASES = [
|
|
8
|
+
'sdd-explore',
|
|
9
|
+
'sdd-proposal',
|
|
10
|
+
'sdd-spec',
|
|
11
|
+
'sdd-design',
|
|
12
|
+
'sdd-task',
|
|
13
|
+
'sdd-apply',
|
|
14
|
+
'sdd-verify',
|
|
15
|
+
'sdd-archive',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const EFFORT_CHOICES: Array<ThinkingEffort | 'inherit'> = ['inherit', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh'];
|
|
19
|
+
|
|
20
|
+
type AvailableModel = { provider: string; id: string; label: string };
|
|
21
|
+
|
|
22
|
+
export type ModelProfileRow = {
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
kind: 'subagent' | 'sdd-phase';
|
|
26
|
+
modelLabel: string;
|
|
27
|
+
effortLabel: string;
|
|
28
|
+
effectiveModel?: ModelRef;
|
|
29
|
+
effectiveEffort?: ThinkingEffort;
|
|
30
|
+
explicitProfile: SubagentModelProfile;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function globalSubagentsConfigPath(agentDir = path.join(os.homedir(), '.pi', 'agent')): string {
|
|
34
|
+
return path.join(agentDir, 'subagents.json');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function modelKey(model: ModelRef): string {
|
|
38
|
+
return `${model.provider}/${model.id}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function modelFromAny(raw: any): AvailableModel | undefined {
|
|
42
|
+
const provider = typeof raw?.provider === 'string'
|
|
43
|
+
? raw.provider
|
|
44
|
+
: typeof raw?.provider?.id === 'string'
|
|
45
|
+
? raw.provider.id
|
|
46
|
+
: typeof raw?.provider?.name === 'string'
|
|
47
|
+
? raw.provider.name
|
|
48
|
+
: undefined;
|
|
49
|
+
const id = typeof raw?.id === 'string'
|
|
50
|
+
? raw.id
|
|
51
|
+
: typeof raw?.model === 'string'
|
|
52
|
+
? raw.model
|
|
53
|
+
: typeof raw?.name === 'string'
|
|
54
|
+
? raw.name
|
|
55
|
+
: undefined;
|
|
56
|
+
if (!provider || !id) return undefined;
|
|
57
|
+
return { provider, id, label: String(raw?.label ?? raw?.displayName ?? id) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function groupAvailableModelsByProvider(rawModels: any[] = []): Record<string, AvailableModel[]> {
|
|
61
|
+
const grouped: Record<string, AvailableModel[]> = {};
|
|
62
|
+
for (const raw of rawModels) {
|
|
63
|
+
const model = modelFromAny(raw);
|
|
64
|
+
if (!model) continue;
|
|
65
|
+
grouped[model.provider] ??= [];
|
|
66
|
+
if (!grouped[model.provider].some((existing) => existing.id === model.id)) grouped[model.provider].push(model);
|
|
67
|
+
}
|
|
68
|
+
for (const models of Object.values(grouped)) models.sort((a, b) => a.id.localeCompare(b.id));
|
|
69
|
+
return Object.fromEntries(Object.entries(grouped).sort(([a], [b]) => a.localeCompare(b)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function availableModelSet(rawModels: any[] = []): Set<string> {
|
|
73
|
+
return new Set(Object.values(groupAvailableModelsByProvider(rawModels)).flat().map((model) => modelKey(model)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function syntheticDefinition(name: string): SubagentDefinition {
|
|
77
|
+
return { name, description: `${name} SDD phase`, filePath: '', instructions: '', tools: [] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function buildModelProfileRows(input: {
|
|
81
|
+
definitions: SubagentDefinition[];
|
|
82
|
+
config: SubagentsConfig;
|
|
83
|
+
ctx: any;
|
|
84
|
+
availableModels?: any[];
|
|
85
|
+
}): ModelProfileRow[] {
|
|
86
|
+
const byName = new Map<string, SubagentDefinition>();
|
|
87
|
+
for (const definition of input.definitions) byName.set(definition.name, definition);
|
|
88
|
+
for (const phase of KNOWN_SDD_PHASES) if (!byName.has(phase)) byName.set(phase, syntheticDefinition(phase));
|
|
89
|
+
const available = input.availableModels ? availableModelSet(input.availableModels) : undefined;
|
|
90
|
+
|
|
91
|
+
return [...byName.values()]
|
|
92
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
93
|
+
.map((definition) => {
|
|
94
|
+
const resolved = resolveEffectiveSubagentProfile({ agentName: definition.name, definition, config: input.config, ctx: input.ctx });
|
|
95
|
+
const unavailable = resolved.model.value && available && !available.has(modelKey(resolved.model.value));
|
|
96
|
+
return {
|
|
97
|
+
name: definition.name,
|
|
98
|
+
description: definition.description,
|
|
99
|
+
kind: definition.name.startsWith('sdd-') ? 'sdd-phase' : 'subagent',
|
|
100
|
+
modelLabel: `${resolved.model.label}${unavailable ? ' (unavailable)' : ''}`,
|
|
101
|
+
effortLabel: resolved.effort.label,
|
|
102
|
+
effectiveModel: resolved.model.value,
|
|
103
|
+
effectiveEffort: resolved.effort.value,
|
|
104
|
+
explicitProfile: { ...(input.config.model_profiles[definition.name] ?? {}) },
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function stageModelProfileEdit(
|
|
110
|
+
current: SubagentModelProfiles,
|
|
111
|
+
edit: { agentName: string; model?: ModelRef; effort?: ThinkingEffort; reset?: 'model' | 'effort' | 'row' },
|
|
112
|
+
): SubagentModelProfiles {
|
|
113
|
+
const agentName = edit.agentName.trim().toLowerCase();
|
|
114
|
+
const next: SubagentModelProfiles = { ...current, [agentName]: { ...(current[agentName] ?? {}) } };
|
|
115
|
+
if (edit.reset === 'row') next[agentName] = {};
|
|
116
|
+
else {
|
|
117
|
+
if (edit.reset === 'model') delete next[agentName].model;
|
|
118
|
+
if (edit.reset === 'effort') delete next[agentName].effort;
|
|
119
|
+
if (edit.model) next[agentName].model = edit.model;
|
|
120
|
+
if (edit.effort) next[agentName].effort = edit.effort;
|
|
121
|
+
}
|
|
122
|
+
return next;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function cloneProfile(profile: SubagentModelProfile = {}): SubagentModelProfile {
|
|
126
|
+
return {
|
|
127
|
+
...(profile.model ? { model: { ...profile.model } } : {}),
|
|
128
|
+
...(profile.effort ? { effort: profile.effort } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function profilesEqual(a: SubagentModelProfile = {}, b: SubagentModelProfile = {}): boolean {
|
|
133
|
+
return a.model?.provider === b.model?.provider
|
|
134
|
+
&& a.model?.id === b.model?.id
|
|
135
|
+
&& a.effort === b.effort;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function applyDirtyProfileEdit(input: {
|
|
139
|
+
baseProfiles: SubagentModelProfiles;
|
|
140
|
+
dirtyProfiles: SubagentModelProfiles;
|
|
141
|
+
edit: { agentName: string; model?: ModelRef; effort?: ThinkingEffort; reset?: 'model' | 'effort' | 'row' };
|
|
142
|
+
}): SubagentModelProfiles {
|
|
143
|
+
const agentName = input.edit.agentName.trim().toLowerCase();
|
|
144
|
+
const baseProfile = cloneProfile(input.baseProfiles[agentName]);
|
|
145
|
+
const seededProfiles: SubagentModelProfiles = {
|
|
146
|
+
[agentName]: cloneProfile(input.dirtyProfiles[agentName] ?? baseProfile),
|
|
147
|
+
};
|
|
148
|
+
const stagedProfile = cloneProfile(stageModelProfileEdit(seededProfiles, input.edit)[agentName]);
|
|
149
|
+
const nextDirtyProfiles: SubagentModelProfiles = Object.fromEntries(
|
|
150
|
+
Object.entries(input.dirtyProfiles)
|
|
151
|
+
.filter(([name]) => name !== agentName)
|
|
152
|
+
.map(([name, profile]) => [name, cloneProfile(profile)]),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
if (!profilesEqual(stagedProfile, baseProfile)) nextDirtyProfiles[agentName] = stagedProfile;
|
|
156
|
+
return nextDirtyProfiles;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function commitStagedModelProfiles(input: { stagedProfiles: SubagentModelProfiles; save: boolean; agentDir?: string }): string {
|
|
160
|
+
if (!input.save) return `Cancelled. No changes written to ${globalSubagentsConfigPath(input.agentDir)}.`;
|
|
161
|
+
for (const [agentName, profile] of Object.entries(input.stagedProfiles)) {
|
|
162
|
+
if (profile.model || profile.effort) saveGlobalSubagentModelProfile({ agentName, profile, agentDir: input.agentDir });
|
|
163
|
+
else {
|
|
164
|
+
resetGlobalSubagentModelProfileField({ agentName, field: 'model', agentDir: input.agentDir });
|
|
165
|
+
resetGlobalSubagentModelProfileField({ agentName, field: 'effort', agentDir: input.agentDir });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return `Saved subagent model profiles to ${globalSubagentsConfigPath(input.agentDir)}.`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function buildNoChangesModelProfilesMessage(agentDir?: string): string {
|
|
172
|
+
return `No subagent model profile changes to save. Nothing written to ${globalSubagentsConfigPath(agentDir)}.`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type SubagentModelProfilesModalResult =
|
|
176
|
+
| { action: 'save'; dirtyProfiles: SubagentModelProfiles }
|
|
177
|
+
| { action: 'cancel' };
|
|
178
|
+
|
|
179
|
+
type ModalView = 'main' | 'model-provider' | 'model-model' | 'effort';
|
|
180
|
+
|
|
181
|
+
type ModalComponent = {
|
|
182
|
+
render(width: number): string[];
|
|
183
|
+
handleInput(data: string): void;
|
|
184
|
+
invalidate(): void;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
type ModalInput = {
|
|
188
|
+
rows: ModelProfileRow[];
|
|
189
|
+
availableModels?: any[];
|
|
190
|
+
tui?: { requestRender?: () => void };
|
|
191
|
+
done: (result: SubagentModelProfilesModalResult) => void;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
function stripTerminalEscapes(text: string): string {
|
|
195
|
+
return text.replace(/\u001b\[[0-9;]*m/g, '').replace(/\u001b\][^\u001b]*(?:\u001b\\|\u0007)/g, '');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function visibleWidth(text: string): number {
|
|
199
|
+
return stripTerminalEscapes(text).length;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function truncateToVisibleWidth(text: string, width: number): string {
|
|
203
|
+
if (width <= 0) return '';
|
|
204
|
+
if (visibleWidth(text) <= width) return text;
|
|
205
|
+
if (width === 1) return '…';
|
|
206
|
+
let output = '';
|
|
207
|
+
let visible = 0;
|
|
208
|
+
for (let index = 0; index < text.length;) {
|
|
209
|
+
if (text[index] === '\u001b') {
|
|
210
|
+
const csi = text.slice(index).match(/^\u001b\[[0-9;]*m/);
|
|
211
|
+
if (csi) {
|
|
212
|
+
output += csi[0];
|
|
213
|
+
index += csi[0].length;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const osc = text.slice(index).match(/^\u001b\][^\u001b]*(?:\u001b\\|\u0007)/);
|
|
217
|
+
if (osc) {
|
|
218
|
+
output += osc[0];
|
|
219
|
+
index += osc[0].length;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (visible >= width - 1) break;
|
|
224
|
+
output += text[index];
|
|
225
|
+
visible += 1;
|
|
226
|
+
index += 1;
|
|
227
|
+
}
|
|
228
|
+
return `${output}…`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function constrainLines(lines: string[], width: number): string[] {
|
|
232
|
+
const safeWidth = Math.max(1, Math.floor(width || 1));
|
|
233
|
+
return lines.map((line) => truncateToVisibleWidth(line, safeWidth));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function padToVisibleWidth(text: string, width: number): string {
|
|
237
|
+
const clipped = truncateToVisibleWidth(text, width);
|
|
238
|
+
return `${clipped}${' '.repeat(Math.max(0, width - visibleWidth(clipped)))}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function frameModal(title: string, body: string[], width: number): string[] {
|
|
242
|
+
const safeWidth = Math.max(1, Math.floor(width || 1));
|
|
243
|
+
if (safeWidth < 24) return constrainLines([title, ...body], safeWidth);
|
|
244
|
+
const innerWidth = safeWidth - 2;
|
|
245
|
+
const contentWidth = Math.max(1, innerWidth - 2);
|
|
246
|
+
const titleText = ` ${title} `;
|
|
247
|
+
const visibleTitle = truncateToVisibleWidth(titleText, Math.max(1, innerWidth));
|
|
248
|
+
const top = `╭${visibleTitle}${'─'.repeat(Math.max(0, innerWidth - visibleWidth(visibleTitle)))}╮`;
|
|
249
|
+
const bottom = `╰${'─'.repeat(innerWidth)}╯`;
|
|
250
|
+
return [top, ...body.map((line) => `│ ${padToVisibleWidth(line, contentWidth)} │`), bottom];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function pendingLabel(count: number): string {
|
|
254
|
+
if (count === 0) return 'pending: none';
|
|
255
|
+
return `pending: ${count} change${count === 1 ? '' : 's'}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function normalizeModalKey(data: string): string {
|
|
259
|
+
if (data === '\r' || data === '\n') return 'enter';
|
|
260
|
+
if (data === '\u001b') return 'esc';
|
|
261
|
+
if (data === '\u001b[A') return 'up';
|
|
262
|
+
if (data === '\u001b[B') return 'down';
|
|
263
|
+
if (data === '\u001b[H') return 'home';
|
|
264
|
+
if (data === '\u001b[F') return 'end';
|
|
265
|
+
return data;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function profileLabel(profile: SubagentModelProfile | undefined, field: 'model' | 'effort'): string | undefined {
|
|
269
|
+
if (!profile) return undefined;
|
|
270
|
+
if (field === 'model') return profile.model ? `${profile.model.provider}/${profile.model.id}` : undefined;
|
|
271
|
+
return profile.effort;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function cloneProfiles(profiles: SubagentModelProfiles): SubagentModelProfiles {
|
|
275
|
+
return Object.fromEntries(Object.entries(profiles).map(([name, profile]) => [name, cloneProfile(profile)]));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function createSubagentModelProfilesModal(input: ModalInput): ModalComponent {
|
|
279
|
+
const rows = input.rows;
|
|
280
|
+
const availableByProvider = groupAvailableModelsByProvider(input.availableModels ?? []);
|
|
281
|
+
const providerNames = Object.keys(availableByProvider);
|
|
282
|
+
const baseProfiles: SubagentModelProfiles = Object.fromEntries(rows.map((row) => [row.name.trim().toLowerCase(), cloneProfile(row.explicitProfile)]));
|
|
283
|
+
let selectedIndex = 0;
|
|
284
|
+
let scrollOffset = 0;
|
|
285
|
+
let view: ModalView = 'main';
|
|
286
|
+
let pickerIndex = 0;
|
|
287
|
+
let selectedProvider: string | undefined;
|
|
288
|
+
let dirtyProfiles: SubagentModelProfiles = {};
|
|
289
|
+
let completed = false;
|
|
290
|
+
|
|
291
|
+
const selectedRow = () => rows[Math.min(Math.max(selectedIndex, 0), Math.max(0, rows.length - 1))];
|
|
292
|
+
const requestRender = () => input.tui?.requestRender?.();
|
|
293
|
+
|
|
294
|
+
const finish = (result: SubagentModelProfilesModalResult) => {
|
|
295
|
+
if (completed) return;
|
|
296
|
+
completed = true;
|
|
297
|
+
input.done(result.action === 'save' ? { action: 'save', dirtyProfiles: cloneProfiles(result.dirtyProfiles) } : result);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const clampSelection = () => {
|
|
301
|
+
selectedIndex = Math.min(Math.max(selectedIndex, 0), Math.max(0, rows.length - 1));
|
|
302
|
+
if (selectedIndex < scrollOffset) scrollOffset = selectedIndex;
|
|
303
|
+
const maxVisibleRows = 10;
|
|
304
|
+
if (selectedIndex >= scrollOffset + maxVisibleRows) scrollOffset = selectedIndex - maxVisibleRows + 1;
|
|
305
|
+
scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, rows.length - 1)));
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const openPicker = (nextView: ModalView) => {
|
|
309
|
+
view = nextView;
|
|
310
|
+
pickerIndex = 0;
|
|
311
|
+
selectedProvider = undefined;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const applyEdit = (edit: { model?: ModelRef; effort?: ThinkingEffort; reset?: 'model' | 'effort' | 'row' }) => {
|
|
315
|
+
const row = selectedRow();
|
|
316
|
+
if (!row) return;
|
|
317
|
+
dirtyProfiles = applyDirtyProfileEdit({
|
|
318
|
+
baseProfiles,
|
|
319
|
+
dirtyProfiles,
|
|
320
|
+
edit: { agentName: row.name, ...edit },
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const rowKey = (row: ModelProfileRow): string => row.name.trim().toLowerCase();
|
|
325
|
+
const hasDirtyProfileFor = (row: ModelProfileRow): boolean => Object.prototype.hasOwnProperty.call(dirtyProfiles, rowKey(row));
|
|
326
|
+
const dirtyProfileFor = (row: ModelProfileRow): SubagentModelProfile | undefined => dirtyProfiles[rowKey(row)];
|
|
327
|
+
|
|
328
|
+
const rowModelText = (row: ModelProfileRow): string => {
|
|
329
|
+
if (!hasDirtyProfileFor(row)) return row.modelLabel;
|
|
330
|
+
const dirty = dirtyProfileFor(row);
|
|
331
|
+
const label = profileLabel(dirty, 'model');
|
|
332
|
+
if (label) return `staged: ${label}`;
|
|
333
|
+
return baseProfiles[rowKey(row)]?.model ? `staged: inherit/reset model (was ${row.modelLabel})` : row.modelLabel;
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const rowEffortText = (row: ModelProfileRow): string => {
|
|
337
|
+
if (!hasDirtyProfileFor(row)) return row.effortLabel;
|
|
338
|
+
const dirty = dirtyProfileFor(row);
|
|
339
|
+
const label = profileLabel(dirty, 'effort');
|
|
340
|
+
if (label) return `staged: ${label}`;
|
|
341
|
+
return baseProfiles[rowKey(row)]?.effort ? `staged: inherit/reset effort (was ${row.effortLabel})` : row.effortLabel;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const selectedSummaryLine = (): string => {
|
|
345
|
+
const row = selectedRow();
|
|
346
|
+
if (!row) return 'selected: (none)';
|
|
347
|
+
const availability = row.modelLabel.includes('(unavailable)') ? ' · unavailable model' : '';
|
|
348
|
+
return `selected: ${row.name}${availability} · model: ${rowModelText(row)} · effort: ${rowEffortText(row)}`;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const rowListLines = (width: number): string[] => {
|
|
352
|
+
const innerWidth = Math.max(1, Math.floor(width || 1) - 2);
|
|
353
|
+
const visibleRows = rows.slice(scrollOffset, scrollOffset + 10);
|
|
354
|
+
if (innerWidth >= 92) {
|
|
355
|
+
const nameWidth = 24;
|
|
356
|
+
const effortWidth = 22;
|
|
357
|
+
const modelWidth = Math.max(18, innerWidth - nameWidth - effortWidth - 6);
|
|
358
|
+
const lines = [`${padToVisibleWidth('agent/phase', nameWidth)} ${padToVisibleWidth('model', modelWidth)} ${padToVisibleWidth('effort', effortWidth)}`];
|
|
359
|
+
for (const [offset, item] of visibleRows.entries()) {
|
|
360
|
+
const index = scrollOffset + offset;
|
|
361
|
+
const marker = index === selectedIndex ? '›' : ' ';
|
|
362
|
+
const dirty = hasDirtyProfileFor(item) ? '*' : ' ';
|
|
363
|
+
lines.push(`${marker} ${dirty} ${padToVisibleWidth(item.name, nameWidth - 4)} ${padToVisibleWidth(rowModelText(item), modelWidth)} ${padToVisibleWidth(rowEffortText(item), effortWidth)}`);
|
|
364
|
+
}
|
|
365
|
+
return lines;
|
|
366
|
+
}
|
|
367
|
+
const lines = ['agent/phase · model · effort'];
|
|
368
|
+
for (const [offset, item] of visibleRows.entries()) {
|
|
369
|
+
const index = scrollOffset + offset;
|
|
370
|
+
const marker = index === selectedIndex ? '›' : ' ';
|
|
371
|
+
const dirty = hasDirtyProfileFor(item) ? '*' : ' ';
|
|
372
|
+
lines.push(`${marker} ${dirty} ${item.name} · ${rowModelText(item)} · ${rowEffortText(item)}`);
|
|
373
|
+
}
|
|
374
|
+
return lines;
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const renderMain = (width: number): string[] => {
|
|
378
|
+
const dirtyCount = Object.keys(dirtyProfiles).length;
|
|
379
|
+
const body = [
|
|
380
|
+
`target: global · ${pendingLabel(dirtyCount)}`,
|
|
381
|
+
'↑/↓/j/k move · enter/m model · e effort · M/E/r reset · s save · esc/q cancel',
|
|
382
|
+
'',
|
|
383
|
+
...rowListLines(width),
|
|
384
|
+
'',
|
|
385
|
+
selectedSummaryLine(),
|
|
386
|
+
];
|
|
387
|
+
return frameModal('Subagent model profiles', body, width);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const renderProviderPicker = (width: number): string[] => {
|
|
391
|
+
const row = selectedRow();
|
|
392
|
+
const lines = [
|
|
393
|
+
`Select model provider for ${row?.name ?? '(none)'}`,
|
|
394
|
+
'choose provider · enter: select · esc/q: back',
|
|
395
|
+
'',
|
|
396
|
+
];
|
|
397
|
+
const items = ['inherit/reset model', ...providerNames];
|
|
398
|
+
if (!providerNames.length) lines.push('No available models found; reset remains available.');
|
|
399
|
+
for (const [index, item] of items.entries()) lines.push(`${index === pickerIndex ? '›' : ' '} ${item}`);
|
|
400
|
+
return frameModal('Choose model provider', lines, width);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const renderModelPicker = (width: number): string[] => {
|
|
404
|
+
const row = selectedRow();
|
|
405
|
+
const models = selectedProvider ? (availableByProvider[selectedProvider] ?? []) : [];
|
|
406
|
+
const lines = [
|
|
407
|
+
`Select ${selectedProvider ?? ''} model for ${row?.name ?? '(none)'}`,
|
|
408
|
+
`provider: ${selectedProvider ?? '(none)'}`,
|
|
409
|
+
'choose model · enter: select · esc/q: back',
|
|
410
|
+
'',
|
|
411
|
+
];
|
|
412
|
+
if (!models.length) lines.push('No models available for this provider.');
|
|
413
|
+
for (const [index, model] of models.entries()) lines.push(`${index === pickerIndex ? '›' : ' '} ${model.label} (${model.provider}/${model.id})`);
|
|
414
|
+
return frameModal('Choose model', lines, width);
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const renderEffortPicker = (width: number): string[] => {
|
|
418
|
+
const row = selectedRow();
|
|
419
|
+
const lines = [
|
|
420
|
+
`row: ${row?.name ?? '(none)'}`,
|
|
421
|
+
'choose effort · enter: select · esc/q: back',
|
|
422
|
+
'',
|
|
423
|
+
];
|
|
424
|
+
const items = ['inherit/reset effort', ...EFFORT_CHOICES.filter((choice): choice is ThinkingEffort => choice !== 'inherit')];
|
|
425
|
+
for (const [index, item] of items.entries()) lines.push(`${index === pickerIndex ? '›' : ' '} ${item}`);
|
|
426
|
+
return frameModal('Choose effort', lines, width);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const movePicker = (delta: number) => {
|
|
430
|
+
const length = view === 'model-provider'
|
|
431
|
+
? 1 + providerNames.length
|
|
432
|
+
: view === 'model-model'
|
|
433
|
+
? (selectedProvider ? (availableByProvider[selectedProvider] ?? []).length : 0)
|
|
434
|
+
: 1 + EFFORT_CHOICES.filter((choice) => choice !== 'inherit').length;
|
|
435
|
+
pickerIndex = Math.min(Math.max(pickerIndex + delta, 0), Math.max(0, length - 1));
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const chooseProvider = () => {
|
|
439
|
+
if (pickerIndex === 0) {
|
|
440
|
+
applyEdit({ reset: 'model' });
|
|
441
|
+
view = 'main';
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
selectedProvider = providerNames[pickerIndex - 1];
|
|
445
|
+
pickerIndex = 0;
|
|
446
|
+
view = 'model-model';
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const chooseModel = () => {
|
|
450
|
+
const model = selectedProvider ? (availableByProvider[selectedProvider] ?? [])[pickerIndex] : undefined;
|
|
451
|
+
if (model) applyEdit({ model: { provider: model.provider, id: model.id } });
|
|
452
|
+
view = 'main';
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const chooseEffort = () => {
|
|
456
|
+
const efforts = EFFORT_CHOICES.filter((choice): choice is ThinkingEffort => choice !== 'inherit');
|
|
457
|
+
if (pickerIndex === 0) applyEdit({ reset: 'effort' });
|
|
458
|
+
else {
|
|
459
|
+
const effort = efforts[pickerIndex - 1];
|
|
460
|
+
if (effort) applyEdit({ effort });
|
|
461
|
+
}
|
|
462
|
+
view = 'main';
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
clampSelection();
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
render(width: number): string[] {
|
|
469
|
+
if (view === 'model-provider') return constrainLines(renderProviderPicker(width), width);
|
|
470
|
+
if (view === 'model-model') return constrainLines(renderModelPicker(width), width);
|
|
471
|
+
if (view === 'effort') return constrainLines(renderEffortPicker(width), width);
|
|
472
|
+
return constrainLines(renderMain(width), width);
|
|
473
|
+
},
|
|
474
|
+
handleInput(data: string): void {
|
|
475
|
+
if (completed) return;
|
|
476
|
+
const key = normalizeModalKey(data);
|
|
477
|
+
if (view !== 'main') {
|
|
478
|
+
if (key === 'esc' || key === 'q') view = 'main';
|
|
479
|
+
else if (key === 'up' || key === 'k') movePicker(-1);
|
|
480
|
+
else if (key === 'down' || key === 'j') movePicker(1);
|
|
481
|
+
else if (key === 'home' || key === 'g') pickerIndex = 0;
|
|
482
|
+
else if (key === 'end' || key === 'G') movePicker(Number.MAX_SAFE_INTEGER);
|
|
483
|
+
else if (key === 'enter') {
|
|
484
|
+
if (view === 'model-provider') chooseProvider();
|
|
485
|
+
else if (view === 'model-model') chooseModel();
|
|
486
|
+
else chooseEffort();
|
|
487
|
+
}
|
|
488
|
+
requestRender();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (key === 'up' || key === 'k') selectedIndex -= 1;
|
|
492
|
+
else if (key === 'down' || key === 'j') selectedIndex += 1;
|
|
493
|
+
else if (key === 'home' || key === 'g') selectedIndex = 0;
|
|
494
|
+
else if (key === 'end' || key === 'G') selectedIndex = rows.length - 1;
|
|
495
|
+
else if (key === 'enter' || key === 'm') openPicker('model-provider');
|
|
496
|
+
else if (key === 'e') openPicker('effort');
|
|
497
|
+
else if (key === 'M') applyEdit({ reset: 'model' });
|
|
498
|
+
else if (key === 'E') applyEdit({ reset: 'effort' });
|
|
499
|
+
else if (key === 'r') applyEdit({ reset: 'row' });
|
|
500
|
+
else if (key === 's') finish({ action: 'save', dirtyProfiles });
|
|
501
|
+
else if (key === 'esc' || key === 'q') finish({ action: 'cancel' });
|
|
502
|
+
clampSelection();
|
|
503
|
+
requestRender();
|
|
504
|
+
},
|
|
505
|
+
invalidate(): void {
|
|
506
|
+
requestRender();
|
|
507
|
+
},
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function buildNonTuiModelProfilesMessage(agentDir?: string): string {
|
|
512
|
+
return `subagent model profiles require Pi TUI. Edit global profiles manually in ${globalSubagentsConfigPath(agentDir)} under the model_profiles key.`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function rowChoice(row: ModelProfileRow): string {
|
|
516
|
+
return `${row.name} — model ${row.modelLabel}; effort ${row.effortLabel}`;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function getAvailableModels(ctx: any): Promise<any[]> {
|
|
520
|
+
try {
|
|
521
|
+
const available = await ctx?.modelRegistry?.getAvailable?.();
|
|
522
|
+
return Array.isArray(available) ? available : [];
|
|
523
|
+
} catch {
|
|
524
|
+
return [];
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async function chooseSave(ctx: any, stagedProfiles: SubagentModelProfiles, agentDir?: string): Promise<string> {
|
|
529
|
+
const decision = await ctx.ui.select('Save subagent model profile changes?', ['Save', 'Cancel']);
|
|
530
|
+
const message = commitStagedModelProfiles({ stagedProfiles, save: decision === 'Save', agentDir });
|
|
531
|
+
ctx.ui.notify?.(message, decision === 'Save' ? 'info' : 'warning');
|
|
532
|
+
return message;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export async function runSubagentModelsCommand(ctx: any = {}): Promise<string> {
|
|
536
|
+
const agentDir = ctx?.agentDir;
|
|
537
|
+
const hasCustomUi = typeof ctx?.ui?.custom === 'function';
|
|
538
|
+
const hasSelectUi = typeof ctx?.ui?.select === 'function';
|
|
539
|
+
if (!hasCustomUi && !hasSelectUi) return buildNonTuiModelProfilesMessage(agentDir);
|
|
540
|
+
|
|
541
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
542
|
+
const definitions = loadSubagents(cwd);
|
|
543
|
+
const config = readSubagentsConfig(cwd);
|
|
544
|
+
const availableModels = await getAvailableModels(ctx);
|
|
545
|
+
const rows = buildModelProfileRows({ definitions, config, ctx, availableModels });
|
|
546
|
+
|
|
547
|
+
if (hasCustomUi) {
|
|
548
|
+
const result = await ctx.ui.custom(
|
|
549
|
+
(tui: any, _theme: any, _keybindings: any, done: (result: SubagentModelProfilesModalResult) => void) => createSubagentModelProfilesModal({
|
|
550
|
+
rows,
|
|
551
|
+
availableModels,
|
|
552
|
+
tui,
|
|
553
|
+
done,
|
|
554
|
+
}),
|
|
555
|
+
{ overlay: true, overlayOptions: { anchor: 'center', width: '90%', maxHeight: '85%', minWidth: 74 } },
|
|
556
|
+
) as SubagentModelProfilesModalResult | undefined;
|
|
557
|
+
|
|
558
|
+
if (result?.action === 'save') {
|
|
559
|
+
const hasDirtyRows = Object.keys(result.dirtyProfiles).length > 0;
|
|
560
|
+
const message = hasDirtyRows
|
|
561
|
+
? commitStagedModelProfiles({ stagedProfiles: result.dirtyProfiles, save: true, agentDir })
|
|
562
|
+
: buildNoChangesModelProfilesMessage(agentDir);
|
|
563
|
+
ctx.ui.notify?.(message, 'info');
|
|
564
|
+
return message;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const message = commitStagedModelProfiles({ stagedProfiles: {}, save: false, agentDir });
|
|
568
|
+
ctx.ui.notify?.(message, 'warning');
|
|
569
|
+
return message;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const rowChoices = rows.map(rowChoice);
|
|
573
|
+
const selectedRowChoice = await ctx.ui.select('Select subagent or SDD phase to configure:', [...rowChoices, 'Cancel']);
|
|
574
|
+
if (!selectedRowChoice || selectedRowChoice === 'Cancel') return commitStagedModelProfiles({ stagedProfiles: {}, save: false, agentDir });
|
|
575
|
+
const row = rows[rowChoices.indexOf(selectedRowChoice)];
|
|
576
|
+
if (!row) return commitStagedModelProfiles({ stagedProfiles: {}, save: false, agentDir });
|
|
577
|
+
|
|
578
|
+
const action = await ctx.ui.select(`Configure ${row.name}:`, ['Set provider/model/effort', 'Reset model', 'Reset effort', 'Reset row', 'Cancel']);
|
|
579
|
+
let staged: SubagentModelProfiles = { [row.name]: { ...row.explicitProfile } };
|
|
580
|
+
if (action === 'Cancel') return commitStagedModelProfiles({ stagedProfiles: staged, save: false, agentDir });
|
|
581
|
+
if (action === 'Reset model') staged = stageModelProfileEdit(staged, { agentName: row.name, reset: 'model' });
|
|
582
|
+
else if (action === 'Reset effort') staged = stageModelProfileEdit(staged, { agentName: row.name, reset: 'effort' });
|
|
583
|
+
else if (action === 'Reset row') staged = stageModelProfileEdit(staged, { agentName: row.name, reset: 'row' });
|
|
584
|
+
else {
|
|
585
|
+
const grouped = groupAvailableModelsByProvider(availableModels);
|
|
586
|
+
const providers = Object.keys(grouped);
|
|
587
|
+
if (!providers.length) {
|
|
588
|
+
const message = 'No available models found in the current model registry. Reset the saved model or edit the global JSON manually.';
|
|
589
|
+
ctx.ui.notify?.(message, 'warning');
|
|
590
|
+
return message;
|
|
591
|
+
}
|
|
592
|
+
const provider = await ctx.ui.select(`Select provider for ${row.name}:`, [...providers, 'inherit/reset model', 'Cancel']);
|
|
593
|
+
if (provider === 'Cancel') return commitStagedModelProfiles({ stagedProfiles: staged, save: false, agentDir });
|
|
594
|
+
if (provider === 'inherit/reset model') staged = stageModelProfileEdit(staged, { agentName: row.name, reset: 'model' });
|
|
595
|
+
else {
|
|
596
|
+
const models = grouped[provider] ?? [];
|
|
597
|
+
const modelLabels = models.map((model) => model.label);
|
|
598
|
+
const modelLabel = await ctx.ui.select(`Select model for ${row.name}:`, [...modelLabels, 'Cancel']);
|
|
599
|
+
if (modelLabel === 'Cancel') return commitStagedModelProfiles({ stagedProfiles: staged, save: false, agentDir });
|
|
600
|
+
const selectedModel = models[modelLabels.indexOf(modelLabel)];
|
|
601
|
+
if (selectedModel) staged = stageModelProfileEdit(staged, { agentName: row.name, model: { provider: selectedModel.provider, id: selectedModel.id } });
|
|
602
|
+
}
|
|
603
|
+
const effort = await ctx.ui.select(`Select effort for ${row.name}:`, EFFORT_CHOICES);
|
|
604
|
+
if (effort === 'inherit') staged = stageModelProfileEdit(staged, { agentName: row.name, reset: 'effort' });
|
|
605
|
+
else staged = stageModelProfileEdit(staged, { agentName: row.name, effort });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return chooseSave(ctx, staged, agentDir);
|
|
609
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { EffectiveSubagentProfile, ModelRef, ProfileValueSource, ResolvedProfileField, SubagentDefinition, SubagentsConfig, ThinkingEffort } from './types.js';
|
|
2
|
+
|
|
3
|
+
function modelLabel(model: ModelRef): string {
|
|
4
|
+
return `${model.provider}/${model.id}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function effortFromCtx(ctx: any): ThinkingEffort | undefined {
|
|
8
|
+
const effort = ctx?.pi?.getThinkingLevel?.() ?? ctx?.getThinkingLevel?.() ?? ctx?.thinkingLevel;
|
|
9
|
+
return typeof effort === 'string' ? effort as ThinkingEffort : undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function modelFromCtx(ctx: any): ModelRef | undefined {
|
|
13
|
+
const model = ctx?.model;
|
|
14
|
+
if (!model || typeof model !== 'object') return undefined;
|
|
15
|
+
const provider = typeof model.provider === 'string' ? model.provider : undefined;
|
|
16
|
+
const id = typeof model.id === 'string' ? model.id : typeof model.name === 'string' ? model.name : undefined;
|
|
17
|
+
return provider && id ? { provider, id } : undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function profileSourceLabel<T>(source: ProfileValueSource, value: T | undefined, format: (value: T) => string): string {
|
|
21
|
+
return value === undefined ? 'unresolved' : `${source}: ${format(value)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function field<T>(source: ProfileValueSource, value: T | undefined, format: (value: T) => string): ResolvedProfileField<T> {
|
|
25
|
+
return { value, source, label: profileSourceLabel(source, value, format) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveModel(definition: SubagentDefinition, config: SubagentsConfig, ctx: any): ResolvedProfileField<ModelRef> {
|
|
29
|
+
const profile = config.model_profiles[definition.name];
|
|
30
|
+
if (profile?.model) return field('profile', profile.model, modelLabel);
|
|
31
|
+
if (definition.model) return field('definition', definition.model, modelLabel);
|
|
32
|
+
if (config.default_model) return field('default', config.default_model, modelLabel);
|
|
33
|
+
const orchestratorModel = modelFromCtx(ctx);
|
|
34
|
+
if (orchestratorModel) return field('orchestrator', orchestratorModel, modelLabel);
|
|
35
|
+
return field('unresolved', undefined, modelLabel);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function resolveEffort(definition: SubagentDefinition, config: SubagentsConfig, ctx: any): ResolvedProfileField<ThinkingEffort> {
|
|
39
|
+
const profile = config.model_profiles[definition.name];
|
|
40
|
+
if (profile?.effort) return field('profile', profile.effort, String);
|
|
41
|
+
if (definition.effort) return field('definition', definition.effort, String);
|
|
42
|
+
if (config.default_effort) return field('default', config.default_effort, String);
|
|
43
|
+
const orchestratorEffort = effortFromCtx(ctx);
|
|
44
|
+
if (orchestratorEffort) return field('orchestrator', orchestratorEffort, String);
|
|
45
|
+
return field('unresolved', undefined, String);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveEffectiveSubagentProfile(input: {
|
|
49
|
+
agentName: string;
|
|
50
|
+
definition: SubagentDefinition;
|
|
51
|
+
config: SubagentsConfig;
|
|
52
|
+
ctx: any;
|
|
53
|
+
}): EffectiveSubagentProfile {
|
|
54
|
+
const definition = { ...input.definition, name: input.agentName.toLowerCase() };
|
|
55
|
+
return {
|
|
56
|
+
agent: definition.name,
|
|
57
|
+
model: resolveModel(definition, input.config, input.ctx),
|
|
58
|
+
effort: resolveEffort(definition, input.config, input.ctx),
|
|
59
|
+
};
|
|
60
|
+
}
|