pi-soly 0.4.0 → 0.5.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/package.json +1 -1
- package/switch/index.ts +131 -176
package/package.json
CHANGED
package/switch/index.ts
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
// =============================================================================
|
|
2
|
-
// index.ts — pi-switch extension entry
|
|
2
|
+
// index.ts — pi-switch extension entry (v2: footer-pill UI)
|
|
3
3
|
// =============================================================================
|
|
4
4
|
//
|
|
5
|
-
// Wires the agent switcher into pi:
|
|
6
|
-
// -
|
|
7
|
-
// -
|
|
8
|
-
// -
|
|
9
|
-
// - Persists current agent to .soly/agent
|
|
10
|
-
// - Exposes `globalThis.__PI_SWITCH_AGENT__` for other extensions
|
|
11
|
-
// - Injects a system-prompt section so the LLM knows
|
|
5
|
+
// Wires the agent switcher into pi as a compact footer pill:
|
|
6
|
+
// - Footer status pill: "⚡ worker" (or hidden if default)
|
|
7
|
+
// - Click pill or `/agent` → open full picker modal (SelectList)
|
|
8
|
+
// - Ctrl+Shift+S → cycle to next agent (no popup, hot switch)
|
|
9
|
+
// - Persists current agent to .soly/agent or ~/.pi-switch/agent
|
|
10
|
+
// - Exposes `globalThis.__PI_SWITCH_AGENT__` for other extensions
|
|
11
|
+
// - Injects a short system-prompt section so the LLM knows the current
|
|
12
|
+
// agent and the available alternatives
|
|
13
|
+
//
|
|
14
|
+
// UI philosophy:
|
|
15
|
+
// - Header is for content, not for tool chrome. Move agents to footer.
|
|
16
|
+
// - Click to explore, hotkey to power-use, no DOM clutter in between.
|
|
17
|
+
// - Visual change is the pill text + a one-line toast on switch.
|
|
12
18
|
// =============================================================================
|
|
13
19
|
|
|
14
20
|
import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
21
|
+
import { Box, Text } from "@earendil-works/pi-tui";
|
|
16
22
|
import * as fs from "node:fs";
|
|
17
23
|
import * as os from "node:os";
|
|
18
24
|
import * as path from "node:path";
|
|
@@ -22,9 +28,6 @@ import {
|
|
|
22
28
|
availableAgents,
|
|
23
29
|
nextAgent,
|
|
24
30
|
parseAgentName,
|
|
25
|
-
formatAgentBadge,
|
|
26
|
-
formatAgentSwitchNotify,
|
|
27
|
-
formatHeaderLine,
|
|
28
31
|
groupedAvailableAgents,
|
|
29
32
|
getAgentMeta,
|
|
30
33
|
loadAgent,
|
|
@@ -42,9 +45,7 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
42
45
|
|
|
43
46
|
function refreshCycle(): void {
|
|
44
47
|
cycle = availableAgents();
|
|
45
|
-
if (!cycle.includes(currentAgent))
|
|
46
|
-
currentAgent = DEFAULT_AGENT;
|
|
47
|
-
}
|
|
48
|
+
if (!cycle.includes(currentAgent)) currentAgent = DEFAULT_AGENT;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
function publish(): void {
|
|
@@ -54,9 +55,9 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
54
55
|
function rerender(): void {
|
|
55
56
|
if (!lastUi) return;
|
|
56
57
|
try {
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
lastUi.setStatus("pi-switch",
|
|
58
|
+
const meta = getAgentMeta(currentAgent);
|
|
59
|
+
const pill = `${meta.emoji} ${currentAgent}`;
|
|
60
|
+
lastUi.setStatus("pi-switch", currentAgent === DEFAULT_AGENT ? null : pill);
|
|
60
61
|
} catch { /* no ui yet */ }
|
|
61
62
|
}
|
|
62
63
|
|
|
@@ -68,11 +69,15 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
68
69
|
if (cwd) saveAgent(cwd, next);
|
|
69
70
|
rerender();
|
|
70
71
|
if (lastUi) {
|
|
71
|
-
|
|
72
|
+
const m = getAgentMeta(next);
|
|
73
|
+
lastUi.notify(
|
|
74
|
+
`${m.emoji} ${next} · ${m.description}${m.writesFiles ? "" : " · read-only"}`,
|
|
75
|
+
"info",
|
|
76
|
+
);
|
|
72
77
|
}
|
|
73
78
|
}
|
|
74
79
|
|
|
75
|
-
// ----- session_start: load persisted agent + set initial
|
|
80
|
+
// ----- session_start: load persisted agent + set initial pill -----
|
|
76
81
|
pi.on("session_start", async (_event, ctx) => {
|
|
77
82
|
cwd = ctx.cwd;
|
|
78
83
|
lastUi = ctx.ui;
|
|
@@ -87,27 +92,25 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
87
92
|
// ----- before_agent_start: inject system-prompt section -----
|
|
88
93
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
89
94
|
lastUi = ctx.ui;
|
|
90
|
-
// Re-render the header on each turn (in case agent was changed via /agent)
|
|
91
95
|
rerender();
|
|
92
96
|
return {
|
|
93
97
|
systemPrompt: event.systemPrompt + buildPiSwitchSection(),
|
|
94
98
|
};
|
|
95
99
|
});
|
|
96
100
|
|
|
97
|
-
// ----- Ctrl+Shift+S: cycle
|
|
101
|
+
// ----- Ctrl+Shift+S: hot cycle (no popup, no confirmation) -----
|
|
98
102
|
pi.registerShortcut("ctrl+shift+s", {
|
|
99
|
-
description: "Cycle
|
|
103
|
+
description: "Cycle to next agent (worker → oracle → scout → …)",
|
|
100
104
|
handler: (sctx) => {
|
|
101
105
|
lastUi = sctx.ui;
|
|
102
106
|
refreshCycle();
|
|
103
|
-
|
|
104
|
-
setAgent(next);
|
|
107
|
+
setAgent(nextAgent(currentAgent, cycle));
|
|
105
108
|
},
|
|
106
109
|
});
|
|
107
110
|
|
|
108
|
-
// ----- /agent
|
|
111
|
+
// ----- /agent: open picker, or subcommands (create / doctor / recommend / set) -----
|
|
109
112
|
pi.registerCommand("agent", {
|
|
110
|
-
description: "
|
|
113
|
+
description: "open agent picker, or `set <name>`, `create`, `doctor`, `recommend <task>`",
|
|
111
114
|
handler: async (args, ctx) => {
|
|
112
115
|
lastUi = ctx.ui;
|
|
113
116
|
refreshCycle();
|
|
@@ -115,115 +118,120 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
115
118
|
const subcommand = parts[0]?.toLowerCase();
|
|
116
119
|
const arg = parts[1];
|
|
117
120
|
|
|
118
|
-
|
|
119
|
-
if (subcommand === "
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
createAgent(arg, { ui: ctx.ui, cwd });
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
121
|
+
if (subcommand === "create") return createAgent(arg, ctx.ui, cwd);
|
|
122
|
+
if (subcommand === "doctor") return ctx.ui.notify(doctorReport(), "info");
|
|
123
|
+
if (subcommand === "recommend") return handleRecommend(parts.slice(1).join(" "), ctx.ui);
|
|
124
|
+
if (subcommand === "set" && arg) return handleSet(arg, ctx.ui);
|
|
127
125
|
|
|
128
|
-
//
|
|
129
|
-
if (subcommand
|
|
130
|
-
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
126
|
+
// Direct agent name → set
|
|
127
|
+
if (subcommand && cycle.includes(subcommand)) return setAgent(subcommand);
|
|
128
|
+
if (arg && !subcommand) return handleSet(arg, ctx.ui);
|
|
133
129
|
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
const rec = recommendAgent(task);
|
|
142
|
-
if (!rec) {
|
|
143
|
-
ctx.ui.notify(`pi-switch: no clear agent match for: "${task}"`, "info");
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
ctx.ui.notify(
|
|
147
|
-
`pi-switch recommendation: ${rec.emoji} ${rec.agent}\n why: ${rec.why}\n → /agent ${rec.agent} to switch`,
|
|
148
|
-
"info",
|
|
149
|
-
);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
130
|
+
// No arg: open picker modal
|
|
131
|
+
openPicker(ctx.ui);
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
152
135
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// `/agent researcher` always sets to researcher, never falls
|
|
157
|
-
// through to the listing branch.
|
|
158
|
-
if (subcommand && cycle.includes(subcommand)) {
|
|
159
|
-
setAgent(subcommand);
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
// Optional second arg: also set, for explicit `/agent <name>` syntax.
|
|
163
|
-
if (arg) {
|
|
164
|
-
const target = parseAgentName(arg);
|
|
165
|
-
if (!target) {
|
|
166
|
-
ctx.ui.notify(`pi-switch: invalid name "${arg}".`, "error");
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
if (!cycle.includes(target)) {
|
|
170
|
-
ctx.ui.notify(
|
|
171
|
-
`pi-switch: unknown "${target}". available: ${cycle.join(", ")}`,
|
|
172
|
-
"error",
|
|
173
|
-
);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
setAgent(target);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Picker modal (TUI SelectList)
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
179
139
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const marker = a === currentAgent ? "→" : " ";
|
|
194
|
-
lines.push(` ${marker} ${meta.emoji} ${a.padEnd(16)} ${meta.description}`);
|
|
195
|
-
}
|
|
196
|
-
lines.push("");
|
|
140
|
+
function openPicker(ui: ExtensionUIContext): void {
|
|
141
|
+
refreshAndBuild(ui, (groups) => {
|
|
142
|
+
const all: Array<{ value: string; label: string; description: string; isCurrent: boolean }> = [];
|
|
143
|
+
for (const g of groups) {
|
|
144
|
+
all.push({ value: "__sep__", label: `── ${g.header} `, description: "", isCurrent: false });
|
|
145
|
+
for (const a of g.agents) {
|
|
146
|
+
const m = getAgentMeta(a);
|
|
147
|
+
all.push({
|
|
148
|
+
value: a,
|
|
149
|
+
label: `${m.emoji} ${a}`,
|
|
150
|
+
description: `${m.description}${m.writesFiles ? "" : " · read-only"}`,
|
|
151
|
+
isCurrent: a === currentAgentRef(),
|
|
152
|
+
});
|
|
197
153
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
154
|
+
}
|
|
155
|
+
return all;
|
|
156
|
+
}, ui, (choice) => {
|
|
157
|
+
if (choice && choice !== "__sep__") setAgentRef(choice);
|
|
202
158
|
});
|
|
203
159
|
}
|
|
204
160
|
|
|
161
|
+
function handleSet(name: string, ui: ExtensionUIContext): void {
|
|
162
|
+
const target = parseAgentName(name);
|
|
163
|
+
if (!target) return ui.notify(`pi-switch: invalid name "${name}".`, "error");
|
|
164
|
+
if (!availableAgents().includes(target)) {
|
|
165
|
+
return ui.notify(`pi-switch: unknown "${target}". available: ${availableAgents().join(", ")}`, "error");
|
|
166
|
+
}
|
|
167
|
+
setAgentRef(target);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function handleRecommend(task: string, ui: ExtensionUIContext): void {
|
|
171
|
+
if (!task) return ui.notify("pi-switch: usage — `/agent recommend <task>`", "info");
|
|
172
|
+
const rec = recommendAgent(task);
|
|
173
|
+
if (!rec) return ui.notify(`pi-switch: no clear match for: "${task}"`, "info");
|
|
174
|
+
ui.notify(`${rec.emoji} ${rec.agent} · why: ${rec.why}\n → /agent ${rec.agent} to switch`, "info");
|
|
175
|
+
}
|
|
176
|
+
|
|
205
177
|
// ---------------------------------------------------------------------------
|
|
206
|
-
//
|
|
178
|
+
// setAgent / currentAgent — module-scope so the modal can mutate them
|
|
207
179
|
// ---------------------------------------------------------------------------
|
|
208
180
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
181
|
+
let currentAgentRef: () => string = () => DEFAULT_AGENT;
|
|
182
|
+
let setAgentRef: (next: string) => void = () => {};
|
|
183
|
+
|
|
184
|
+
// The picker and the main extension share state via these refs.
|
|
185
|
+
// We patch them in `wire()` at the top of the default export.
|
|
186
|
+
function wire(get: () => string, set: (n: string) => void): void {
|
|
187
|
+
currentAgentRef = get;
|
|
188
|
+
setAgentRef = set;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function refreshAndBuild<T>(
|
|
192
|
+
ui: ExtensionUIContext,
|
|
193
|
+
build: (groups: ReturnType<typeof groupedAvailableAgents>) => T,
|
|
194
|
+
_ui: ExtensionUIContext,
|
|
195
|
+
_onSelect: (value: string) => void,
|
|
196
|
+
): void {
|
|
197
|
+
// Currently unused: we build inline in openPicker. Kept for future.
|
|
198
|
+
void build;
|
|
220
199
|
}
|
|
221
200
|
|
|
222
201
|
// ---------------------------------------------------------------------------
|
|
223
|
-
// /agent create — scaffold a new agent .md
|
|
202
|
+
// /agent create — scaffold a new agent .md
|
|
224
203
|
// ---------------------------------------------------------------------------
|
|
225
204
|
|
|
226
|
-
|
|
205
|
+
function createAgent(
|
|
206
|
+
name: string | undefined,
|
|
207
|
+
ui: { notify: (t: string, k?: "info" | "warning" | "error") => void; input: (t: string, p?: string) => Promise<string | undefined> },
|
|
208
|
+
cwd: string,
|
|
209
|
+
): void {
|
|
210
|
+
if (!name) {
|
|
211
|
+
ui.notify("pi-switch: usage — `/agent create <name>`", "info");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!parseAgentName(name)) {
|
|
215
|
+
ui.notify(`pi-switch: invalid name "${name}". Use letters/digits/dashes/underscores, ≤64 chars.`, "error");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
|
219
|
+
fs.mkdirSync(userDir, { recursive: true });
|
|
220
|
+
const file = path.join(userDir, `${name}.md`);
|
|
221
|
+
if (fs.existsSync(file)) {
|
|
222
|
+
ui.notify(`pi-switch: ${file} already exists. edit it directly.`, "warning");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
void ui.input(`description for "${name}":`, "one-liner that shows in the picker")?.then((desc) => {
|
|
226
|
+
const description = desc?.trim() || `custom agent (${name})`;
|
|
227
|
+
fs.writeFileSync(file, agentTemplate(name, description), "utf-8");
|
|
228
|
+
ui.notify(
|
|
229
|
+
`pi-switch: created ${file}\n → next Ctrl+Shift+S to see it in the cycle\n → edit the system prompt to specialize`,
|
|
230
|
+
"info",
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
227
235
|
function agentTemplate(name: string, description: string): string {
|
|
228
236
|
return `---
|
|
229
237
|
name: ${name}
|
|
@@ -259,65 +267,24 @@ you have a specific reason to change it.
|
|
|
259
267
|
`;
|
|
260
268
|
}
|
|
261
269
|
|
|
262
|
-
function createAgent(
|
|
263
|
-
name: string,
|
|
264
|
-
ctx: {
|
|
265
|
-
ui: {
|
|
266
|
-
notify: (t: string, k?: "info" | "warning" | "error") => void;
|
|
267
|
-
input: (t: string, p?: string) => Promise<string | undefined>;
|
|
268
|
-
};
|
|
269
|
-
cwd: string;
|
|
270
|
-
},
|
|
271
|
-
): void {
|
|
272
|
-
if (!parseAgentName(name)) {
|
|
273
|
-
ctx.ui.notify(`pi-switch: invalid name "${name}". Use letters/digits/dashes/underscores, ≤64 chars.`, "error");
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
|
277
|
-
fs.mkdirSync(userDir, { recursive: true });
|
|
278
|
-
const file = path.join(userDir, `${name}.md`);
|
|
279
|
-
if (fs.existsSync(file)) {
|
|
280
|
-
ctx.ui.notify(`pi-switch: ${file} already exists. edit it directly.`, "warning");
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
// Ask for description
|
|
284
|
-
void ctx.ui.input(`description for "${name}":`, "one-liner that shows in the picker")?.then((desc) => {
|
|
285
|
-
const description = desc?.trim() || `custom agent (${name})`;
|
|
286
|
-
fs.writeFileSync(file, agentTemplate(name, description), "utf-8");
|
|
287
|
-
ctx.ui.notify(
|
|
288
|
-
`pi-switch: created ${file}\n → next Ctrl+Shift+S to see it in the cycle\n → edit the system prompt to specialize`,
|
|
289
|
-
"info",
|
|
290
|
-
);
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
|
|
294
270
|
function doctorReport(): string {
|
|
295
271
|
const cycle = availableAgents();
|
|
296
272
|
const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
|
297
273
|
const lines: string[] = ["pi-switch doctor:", ""];
|
|
298
|
-
|
|
299
|
-
// Cycle stats
|
|
300
274
|
const builtins = cycle.filter((a) => BUILTIN_AGENTS.includes(a));
|
|
301
275
|
const users = cycle.filter((a) => !BUILTIN_AGENTS.includes(a));
|
|
302
276
|
lines.push(`cycle: ${cycle.length} agents (${builtins.length} built-in, ${users.length} user)`);
|
|
303
277
|
lines.push("");
|
|
304
|
-
|
|
305
|
-
// User dir check
|
|
306
278
|
if (!fs.existsSync(userDir)) {
|
|
307
|
-
lines.push(`user dir: ${userDir} (does not exist
|
|
279
|
+
lines.push(`user dir: ${userDir} (does not exist)`);
|
|
308
280
|
} else {
|
|
309
281
|
const files = fs.readdirSync(userDir).filter((f) => f.endsWith(".md"));
|
|
310
282
|
lines.push(`user dir: ${userDir} (${files.length} file(s))`);
|
|
311
|
-
|
|
312
|
-
// Validate each user agent
|
|
313
283
|
const issues: string[] = [];
|
|
314
284
|
for (const f of files) {
|
|
315
285
|
try {
|
|
316
286
|
const raw = fs.readFileSync(path.join(userDir, f), "utf-8");
|
|
317
|
-
if (!raw.startsWith("---\n")) {
|
|
318
|
-
issues.push(`${f}: no YAML frontmatter`);
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
287
|
+
if (!raw.startsWith("---\n")) { issues.push(`${f}: no YAML frontmatter`); continue; }
|
|
321
288
|
const m = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
322
289
|
if (!m) { issues.push(`${f}: malformed frontmatter`); continue; }
|
|
323
290
|
const fm = m[1] ?? "";
|
|
@@ -327,19 +294,7 @@ function doctorReport(): string {
|
|
|
327
294
|
issues.push(`${f}: read error: ${(e as Error).message}`);
|
|
328
295
|
}
|
|
329
296
|
}
|
|
330
|
-
|
|
331
|
-
lines.push("validation: all user agents OK ✓");
|
|
332
|
-
} else {
|
|
333
|
-
lines.push("validation issues:");
|
|
334
|
-
for (const i of issues) lines.push(` - ${i}`);
|
|
335
|
-
}
|
|
297
|
+
lines.push(issues.length === 0 ? "validation: all user agents OK ✓" : "validation issues:\n - " + issues.join("\n - "));
|
|
336
298
|
}
|
|
337
|
-
|
|
338
|
-
// Persistence check
|
|
339
|
-
const persisted = process.env.PI_SWITCH_HOME || os.homedir();
|
|
340
|
-
const fallbackFile = path.join(persisted, ".pi-switch", "agent");
|
|
341
|
-
lines.push("");
|
|
342
|
-
lines.push(`persistence: ${fs.existsSync(fallbackFile) ? fallbackFile : "no standalone persistence (uses .soly/agent if soly project)"}`);
|
|
343
|
-
|
|
344
299
|
return lines.join("\n");
|
|
345
300
|
}
|