pi-agents-switch 0.2.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/index.ts ADDED
@@ -0,0 +1,760 @@
1
+ /**
2
+ * pi-agents-switch — Primary Agent Switching for Pi
3
+ *
4
+ * Inspired by OpenCode's Tab agent cycling.
5
+ * Each agent is defined by a single AGENTS.md with YAML frontmatter
6
+ * (metadata) and Markdown body (system prompt).
7
+ *
8
+ * Features:
9
+ * - F9 hotkey to cycle agents (PI → custom agents and back)
10
+ * - /switch command: picker UI with provider/model shown
11
+ * - /switch-config: create/delete agents, set hotkey, init builder
12
+ * - Status bar shows agent name + provider/model
13
+ * - before_agent_start: injects agent system prompt + applies model/thinking/tools
14
+ * - before_provider_request: injects temperature / topP if configured
15
+ * - Fallback chain: agent AGENTS.md → project AGENTS.md → PI defaults
16
+ * - Inheritance: agent tools = PI tools - excluded_tools + tools
17
+ *
18
+ * Default "PI" agent uses your existing pi config (no changes).
19
+ * Custom agents have isolated profile dirs under ~/.pi/agents/<name>/
20
+ */
21
+
22
+ import type {
23
+ ExtensionAPI,
24
+ ExtensionContext,
25
+ } from "@earendil-works/pi-coding-agent";
26
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
27
+ import {
28
+ Container,
29
+ Key,
30
+ type KeyId,
31
+ type SelectItem,
32
+ SelectList,
33
+ Text,
34
+ } from "@earendil-works/pi-tui";
35
+
36
+ import { ProfileManager, type PIState } from "./profile-manager";
37
+ import type {
38
+ AgentFrontmatter,
39
+ AgentsConfig,
40
+ ResolvedAgentConfig,
41
+ ThinkingLevel,
42
+ } from "./types";
43
+
44
+ // ─── Constants ─────────────────────────────────────────
45
+
46
+ const PI_AGENT_NAME = "PI";
47
+ const PI_LABEL = "🤖 PI";
48
+ const PI_DESCRIPTION =
49
+ "Default Pi agent — inherits your standard AGENTS.md and full config";
50
+
51
+ // The default role line in Pi's system prompt — we replace this with
52
+ // the agent's own identity when a custom agent is active.
53
+ const DEFAULT_ROLE_LINE =
54
+ "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.";
55
+
56
+ interface HotkeyChoice {
57
+ key: KeyId;
58
+ label: string;
59
+ }
60
+
61
+ const HOTKEY_CHOICES: HotkeyChoice[] = [
62
+ { key: Key.f9, label: "F9" },
63
+ { key: Key.f8, label: "F8" },
64
+ { key: Key.ctrlShift("a"), label: "Ctrl+Shift+A" },
65
+ { key: Key.ctrlAlt("a"), label: "Ctrl+Alt+A" },
66
+ ];
67
+
68
+ const DEFAULT_HOTKEY: KeyId = Key.f9;
69
+
70
+ // Default agent shipped with the extension: a builder that
71
+ // understands the plugin format and can create other agents.
72
+ const DEFAULT_AGENTS: Record<string, AgentFrontmatter> = {
73
+ builder: {
74
+ name: "🔨 Builder",
75
+ description:
76
+ "Build new agents. Ask me to create an agent and I'll write the AGENTS.md.",
77
+ },
78
+ };
79
+
80
+ // ─── Builder Agent System Prompt ────────────────────────
81
+
82
+ const BUILDER_SYSTEM_PROMPT = `# 🔨 Builder — Agent Factory
83
+
84
+ You are the **Builder** agent for **pi-agents-switch**, a Pi extension that lets users switch between custom AI agents. Your job: build new agents on demand.
85
+
86
+ ## What is an Agent?
87
+
88
+ Each agent is defined by a single file: \`~/.pi/agents/<name>/AGENTS.md\`.
89
+
90
+ This file has TWO parts:
91
+ 1. **YAML frontmatter** (between \`---\` markers) — metadata
92
+ 2. **Markdown body** (after the last \`---\`) — the system prompt injected before each turn
93
+
94
+ ### YAML Frontmatter Reference
95
+
96
+ All available fields (all optional except \`name\`):
97
+
98
+ \`\`\`yaml
99
+ ---
100
+ name: "🎯 Agent Name" # REQUIRED — display name (emojis welcome)
101
+ description: "What it does" # Short description shown in the picker
102
+ provider: anthropic # Model provider (e.g. anthropic, openai)
103
+ model: claude-sonnet-4-5 # Model ID
104
+ thinkingLevel: medium # off | minimal | low | medium | high | xhigh
105
+
106
+ excluded_tools: # Tools to REMOVE from PI's default set
107
+ -what you dont want
108
+
109
+ tools: # Tools to ADD
110
+ -what you want
111
+
112
+ excluded_extensions: # Extensions to REMOVE
113
+ - annoying-ext
114
+
115
+ extensions: # Extensions to ADD
116
+ - my-extension
117
+
118
+ excluded_skills: # Skills to REMOVE
119
+ - noisy-skill
120
+
121
+ skills: # Skills to ADD
122
+ - my-skill
123
+ ---
124
+
125
+ # System prompt goes here
126
+
127
+ You are the **Agent Name** agent.
128
+ Your behavior instructions...
129
+ \`\`\`
130
+
131
+ ### How Inheritance Works
132
+
133
+ Every agent starts with what PI already has, then:
134
+
135
+ 1. **Remove** items in \`excluded_tools\` / \`excluded_extensions\` / \`excluded_skills\`
136
+ 2. **Add** items in \`tools\` / \`extensions\` / \`skills\`
137
+ 3. - **Order matters.** If the same item appears in both an exclude field and an add field, the later declaration wins.
138
+ - \`excluded_tools: [read]\` then \`tools: [read]\` → \`read\` is added.
139
+ - \`tools: [read]\` then \`excluded_tools: [read]\` → \`read\` is removed.
140
+ - \`*\` means "all" for that category.
141
+
142
+ If you want to keep only a small set of tools/skills/extensions, exclude everything with \`*\` and add back what you need.
143
+ If you want to exclude just a few items, add \`*\` to the include list and use the exclude to remove the unwanted ones.
144
+
145
+ ### Agent Directory Structure
146
+
147
+ \`\`\`
148
+ ~/.pi/agents/<name>/
149
+ ├── AGENTS.md ← YAML frontmatter + system prompt (THE ONLY REQUIRED FILE)
150
+ ├── extensions/ ← Agent-specific extensions (mkdir)
151
+ ├── skills/ ← Agent-specific skills (mkdir)
152
+ └── prompts/ ← Agent-specific prompt templates (mkdir)
153
+ \`\`\`
154
+
155
+ ## Workflow: How to create an agent
156
+
157
+ Follow these phases in order when building an agent.
158
+
159
+ ### Phase 0 — Catalog environment (always first, before any questions)
160
+
161
+ Inventory what's available so you can make grounded recommendations:
162
+
163
+ 1. **Models**: Read \`~/.pi/agent/models.json\` → record \`{provider, modelId}\` for every model
164
+ 2. **Existing agents**: \`ls ~/.pi/agents/\` (and project \`.pi/agents/\` if applicable) → avoid name collisions
165
+ 3. **Tools currently available**: list names from your system context tool definitions
166
+ 4. **Skills currently available**: check \`<available_skills>\` block in your context, or \`ls ~/.pi/agent/skills/\` and \`ls ~/.agents/skills/\`
167
+ 5. **Extensions installed**: \`ls ~/.pi/agent/npm/node_modules/\` for pi-* packages
168
+
169
+ **Never** recommend models, tools, skills, or extensions the user doesn't have installed.
170
+
171
+ ### Phase 1 — Gather requirements (single \`ask_user_question\` call, 4-6 questions)
172
+
173
+ Cover every parameter the agent needs. Skip this phase only if the user's request is already exhaustive.
174
+
175
+ **Must-cover dimensions:**
176
+
177
+ | # | Dimension | Determine |
178
+ |---|---|---|
179
+ | 1 | **Identity** | Folder name (slug), display name (emoji OK), one-line description |
180
+ | 2 | **Role** | Concrete scope: code review? planning? debugging? research? scaffolding? deploy? combined? |
181
+ | 3 | **Model** | Provider + model (from Phase 0 catalog); \`thinkingLevel\`: off / minimal / low / medium / high / xhigh |
182
+ | 4 | **Tools** | Which to add/remove? Start from a preset, let user customize |
183
+ | 5 | **Skills** | Which to add (\`skills\`) or remove (\`excluded_skills\`)? Reference Phase 0 catalog |
184
+ | 6 | **Extensions** | Which to add (\`extensions\`) or remove (\`excluded_extensions\`)? Reference Phase 0 catalog |
185
+
186
+ **Tool presets** (offer as starting points):
187
+
188
+ | Preset | \`tools\` | \`excluded_tools\` | Use for |
189
+ |---|---|---|---|
190
+ | 🔒 Read-only | \`[read, bash, grep, find, ls, web_search, code_search, fetch_content]\` | \`[edit, write, ast_grep_replace]\` | Research, code exploration, documentation |
191
+ | 🔍 Reviewer | Read-only + \`[lsp_diagnostics, lsp_navigation, ast_grep_search, lens_diagnostics]\` | \`[edit, write, ast_grep_replace]\` | Code review, diagnostics, static analysis |
192
+ | 🛠️ Full-access | (inherit all PI defaults) | — | Building, refactoring, full-stack work |
193
+ | 📦 Minimal | \`[bash, read, write]\` | everything else | Shell scripting, simple file ops |
194
+
195
+ If user picks a preset, still confirm skills/extensions/model choices.
196
+
197
+ ### Phase 2 — Build
198
+
199
+ 1. **Compose YAML frontmatter** from Phase 1 decisions — every field that was explicitly chosen
200
+ 2. **Learn From Internet** use \`web_search\` to understand how others create a good agent. You can learn from "claude code" or "opencode"
201
+ 3. **Write system prompt body** (tight, ≤50 lines): clear role description, constraints, concrete do/don't examples
202
+ 4. **Write** \`~/.pi/agents/<name>/AGENTS.md\` using the \`write\` tool — frontmatter first, then the body
203
+
204
+ ### Phase 3 — Verify & deliver
205
+
206
+ - Confirm file exists; spot-check YAML frontmatter
207
+ - Report: display name, switch command (\`/switch <name>\`), provider/model, tool preset, skills/extensions delta
208
+ - Offer: "Switch now with \`/switch <name>\` to test."
209
+ `;
210
+
211
+
212
+ // ─── PI state snapshot ─────────────────────────────────
213
+
214
+ interface PIStateSnapshot {
215
+ model: string | undefined;
216
+ thinkingLevel: ThinkingLevel;
217
+ tools: string[];
218
+ }
219
+
220
+ // ─── Extension Entry Point ────────────────────────────
221
+
222
+ export default function agentsSwitch(pi: ExtensionAPI) {
223
+ let cwd: string | undefined;
224
+ let pm: ProfileManager;
225
+
226
+ function initPM(newCwd?: string) {
227
+ cwd = newCwd;
228
+ pm = new ProfileManager(cwd);
229
+ }
230
+ initPM();
231
+
232
+ // ─── Runtime state ──────────────────────────────────
233
+ let currentAgent: string = PI_AGENT_NAME;
234
+ let currentResolved: ResolvedAgentConfig | undefined;
235
+ let piSnapshot: PIStateSnapshot | undefined;
236
+
237
+ // ─── Helpers ────────────────────────────────────────
238
+
239
+ function loadCurrentAgent(config: AgentsConfig): void {
240
+ currentAgent = pm.getActive(config);
241
+ }
242
+
243
+ function getAgentSystemPromptSection(): string | undefined {
244
+ if (currentAgent === PI_AGENT_NAME) return undefined;
245
+ const prompt = pm.getSystemPrompt(currentAgent, cwd);
246
+ if (!prompt) return undefined;
247
+ return `# 🎯 Agent Mode: ${currentAgent}\n\n${prompt}`;
248
+ }
249
+
250
+ function getStatusLabel(): string {
251
+ if (currentAgent === PI_AGENT_NAME) return PI_LABEL;
252
+ if (currentResolved) {
253
+ const modelInfo = currentResolved.model
254
+ ? ` (${currentResolved.model})`
255
+ : "";
256
+ return `🎯 ${currentResolved.label}${modelInfo}`;
257
+ }
258
+ return `🎯 ${currentAgent}`;
259
+ }
260
+
261
+ function updateStatus(
262
+ ctx?:
263
+ | ExtensionContext
264
+ | { ui: { setStatus: (key: string, text: string | undefined) => void } },
265
+ ): void {
266
+ if (ctx) ctx.ui.setStatus("agent-switch", getStatusLabel());
267
+ }
268
+
269
+ function getPIState(): PIState {
270
+ return {
271
+ tools: pi.getActiveTools(),
272
+ extensions: [],
273
+ skills: [],
274
+ };
275
+ }
276
+
277
+ // ─── State snapshot / restore ───────────────────────
278
+
279
+ function snapshotPIState(): void {
280
+ if (piSnapshot) return;
281
+ piSnapshot = {
282
+ model: undefined,
283
+ thinkingLevel: pi.getThinkingLevel() as ThinkingLevel,
284
+ tools: pi.getActiveTools(),
285
+ };
286
+ }
287
+
288
+ function capturePIModel(ctx?: {
289
+ model?: { provider: string; id: string };
290
+ }): void {
291
+ if (!piSnapshot && ctx?.model) {
292
+ piSnapshot = {
293
+ model: `${ctx.model.provider}/${ctx.model.id}`,
294
+ thinkingLevel: pi.getThinkingLevel() as ThinkingLevel,
295
+ tools: pi.getActiveTools(),
296
+ };
297
+ } else if (piSnapshot && !piSnapshot.model && ctx?.model) {
298
+ piSnapshot.model = `${ctx.model.provider}/${ctx.model.id}`;
299
+ }
300
+ }
301
+
302
+ async function restorePIState(ctx?: {
303
+ modelRegistry: { find: (provider: string, id: string) => any };
304
+ }): Promise<void> {
305
+ if (!piSnapshot) return;
306
+ if (piSnapshot.model && ctx) {
307
+ const [provider, ...idParts] = piSnapshot.model.split("/");
308
+ const modelId = idParts.join("/");
309
+ if (provider && modelId) {
310
+ const model = ctx.modelRegistry.find(provider, modelId);
311
+ if (model) await pi.setModel(model);
312
+ }
313
+ }
314
+ pi.setThinkingLevel(piSnapshot.thinkingLevel);
315
+ if (piSnapshot.tools.length > 0) pi.setActiveTools(piSnapshot.tools);
316
+ piSnapshot = undefined;
317
+ }
318
+
319
+ // ─── Apply agent settings ───────────────────────────
320
+
321
+ async function applyAgentSettings(ctx?: {
322
+ modelRegistry: { find: (provider: string, id: string) => any };
323
+ model?: { provider: string; id: string };
324
+ }): Promise<void> {
325
+ if (currentAgent === PI_AGENT_NAME) {
326
+ await restorePIState(ctx);
327
+ currentResolved = undefined;
328
+ return;
329
+ }
330
+
331
+ const resolved = pm.resolveAgent(currentAgent, getPIState(), cwd);
332
+ if (!resolved) {
333
+ currentResolved = undefined;
334
+ return;
335
+ }
336
+
337
+ resolved.systemPrompt = pm.getSystemPrompt(currentAgent, cwd) ?? "";
338
+ currentResolved = resolved;
339
+
340
+ capturePIModel(ctx);
341
+ snapshotPIState();
342
+
343
+ if (resolved.model && ctx) {
344
+ const [provider, ...idParts] = resolved.model.split("/");
345
+ const modelId = idParts.join("/");
346
+ if (provider && modelId) {
347
+ const model = ctx.modelRegistry.find(provider, modelId);
348
+ if (model) {
349
+ const ok = await pi.setModel(model);
350
+ if (!ok)
351
+ console.error(
352
+ `[agents-switch] Failed to set model ${resolved.model}`,
353
+ );
354
+ } else {
355
+ console.error(`[agents-switch] Model ${resolved.model} not found`);
356
+ }
357
+ }
358
+ }
359
+
360
+ if (resolved.thinkingLevel) pi.setThinkingLevel(resolved.thinkingLevel);
361
+
362
+ if (resolved.tools.length > 0) {
363
+ const allNames = new Set(pi.getAllTools().map((t) => t.name));
364
+ const valid = resolved.tools.filter((t) => allNames.has(t));
365
+ if (valid.length > 0) pi.setActiveTools(valid);
366
+ }
367
+ }
368
+
369
+ // ─── Agent cycling / switching ──────────────────────
370
+
371
+ async function cycleAgent(ctx?: any): Promise<void> {
372
+ const config = pm.loadConfig();
373
+ const names = [PI_AGENT_NAME, ...pm.getAgentNames()];
374
+ const idx = names.indexOf(currentAgent);
375
+ const next = names[(idx + 1) % names.length];
376
+ config.active = next === PI_AGENT_NAME ? PI_AGENT_NAME : next;
377
+ pm.saveConfig(config);
378
+ currentAgent = next;
379
+ await applyAgentSettings(ctx);
380
+ updateStatus(ctx);
381
+ }
382
+
383
+ async function switchToAgent(name: string, ctx?: any): Promise<void> {
384
+ const config = pm.loadConfig();
385
+ if (name === PI_AGENT_NAME) {
386
+ config.active = PI_AGENT_NAME;
387
+ pm.saveConfig(config);
388
+ currentAgent = PI_AGENT_NAME;
389
+ await applyAgentSettings(ctx);
390
+ updateStatus(ctx);
391
+ return;
392
+ }
393
+ if (!pm.exists(name)) {
394
+ ctx?.ui?.notify?.(
395
+ `Agent "${name}" not found. Create it with /switch-config`,
396
+ "error",
397
+ );
398
+ return;
399
+ }
400
+ config.active = name;
401
+ pm.saveConfig(config);
402
+ currentAgent = name;
403
+ await applyAgentSettings(ctx);
404
+ updateStatus(ctx);
405
+ }
406
+
407
+ // ─── SelectList helper ──────────────────────────────
408
+
409
+ /** Show a SelectList dialog and return the chosen value (or null if cancelled). */
410
+ async function showSelectDialog(
411
+ ctx: ExtensionContext,
412
+ title: string,
413
+ items: SelectItem[],
414
+ ): Promise<string | null> {
415
+ return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
416
+ const container = new Container();
417
+ container.addChild(
418
+ new DynamicBorder((s: string) => theme.fg("accent", s)),
419
+ );
420
+ container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
421
+
422
+ const list = new SelectList(items, Math.min(items.length, 12), {
423
+ selectedPrefix: (t) => theme.fg("accent", t),
424
+ selectedText: (t) => theme.fg("accent", t),
425
+ description: (t) => theme.fg("muted", t),
426
+ scrollInfo: (t) => theme.fg("dim", t),
427
+ noMatch: (t) => theme.fg("warning", t),
428
+ });
429
+ list.onSelect = (item) => done(item.value);
430
+ list.onCancel = () => done(null);
431
+ container.addChild(list);
432
+
433
+ container.addChild(
434
+ new Text(
435
+ theme.fg("dim", "↑↓ navigate • enter select • esc cancel"),
436
+ 1,
437
+ 0,
438
+ ),
439
+ );
440
+ container.addChild(
441
+ new DynamicBorder((s: string) => theme.fg("accent", s)),
442
+ );
443
+
444
+ return {
445
+ render: (w) => container.render(w),
446
+ invalidate: () => container.invalidate(),
447
+ handleInput: (data) => {
448
+ list.handleInput(data);
449
+ tui.requestRender();
450
+ },
451
+ };
452
+ });
453
+ }
454
+
455
+ // ─── Command: /switch ───────────────────────────────
456
+
457
+ pi.registerCommand("switch", {
458
+ description: "Switch primary agent",
459
+ handler: async (_args, ctx) => {
460
+ initPM(ctx.cwd);
461
+ const config = pm.loadConfig();
462
+ const active = pm.getActive(config);
463
+ const diskProfiles = pm.list();
464
+
465
+ const items: SelectItem[] = [
466
+ {
467
+ value: PI_AGENT_NAME,
468
+ label: active === PI_AGENT_NAME ? `${PI_LABEL} ★` : PI_LABEL,
469
+ description: PI_DESCRIPTION,
470
+ },
471
+ ];
472
+
473
+ for (const profile of diskProfiles) {
474
+ const resolved = pm.resolveAgent(profile.name, getPIState(), ctx.cwd);
475
+ const label = resolved
476
+ ? `${resolved.label}${active === profile.name ? " ★" : ""}`
477
+ : `${profile.name}${active === profile.name ? " ★" : ""}`;
478
+ const parts: string[] = [];
479
+ if (resolved?.description) parts.push(resolved.description);
480
+ if (resolved?.model) parts.push(resolved.model);
481
+ if (resolved?.thinkingLevel)
482
+ parts.push(`thinking:${resolved.thinkingLevel}`);
483
+ items.push({
484
+ value: profile.name,
485
+ label,
486
+ description: parts.join(" · "),
487
+ });
488
+ }
489
+
490
+ const choice = await showSelectDialog(
491
+ ctx,
492
+ `Switch agent (current: ${currentAgent})`,
493
+ items,
494
+ );
495
+ if (!choice) return;
496
+
497
+ await switchToAgent(choice, ctx);
498
+ ctx.ui.notify(
499
+ choice === PI_AGENT_NAME
500
+ ? "Switched to PI (default)"
501
+ : `Switched to agent: ${choice}`,
502
+ "info",
503
+ );
504
+ },
505
+ });
506
+
507
+ // ─── Command: /switch-config ────────────────────────
508
+
509
+ pi.registerCommand("switch-config", {
510
+ description: "Configure agents (create, delete, set hotkey)",
511
+ handler: async (_args, ctx) => {
512
+ initPM(ctx.cwd);
513
+
514
+ if (!ctx.hasUI) {
515
+ const config = pm.loadConfig();
516
+ const active = pm.getActive(config);
517
+ const diskProfiles = pm.list();
518
+ const lines = diskProfiles.map((p) => {
519
+ const resolved = pm.resolveAgent(p.name, getPIState(), ctx.cwd);
520
+ const modelInfo = resolved?.model ? ` (${resolved.model})` : "";
521
+ const activeMarker = active === p.name ? " ★" : "";
522
+ return ` ${resolved?.label ?? p.name}${modelInfo}${activeMarker} — ${resolved?.description ?? ""}`;
523
+ });
524
+ pi.sendMessage({
525
+ customType: "agent-switch-config",
526
+ content: `# Agents\n\nActive: ${active}\n\n${lines.join("\n")}\n\nUse /switch to switch.`,
527
+ display: true,
528
+ });
529
+ return;
530
+ }
531
+
532
+ const actionLabels = [
533
+ "➕ Create a new agent",
534
+ "🗑️ Delete an agent",
535
+ "⌨️ Change hotkey",
536
+ "📋 List all agents",
537
+ "🎨 Initialize default agent (builder)",
538
+ ];
539
+ const actionChoice = await ctx.ui.select("Agents config", actionLabels);
540
+ if (!actionChoice) return;
541
+
542
+ const actionIdx = actionLabels.indexOf(actionChoice);
543
+ const actions = [
544
+ "create",
545
+ "delete",
546
+ "hotkey",
547
+ "list",
548
+ "init-defaults",
549
+ ] as const;
550
+ const action = actions[actionIdx];
551
+ if (!action) return;
552
+
553
+ switch (action) {
554
+ case "create": {
555
+ const name = await ctx.ui.input(
556
+ "Agent name (e.g. debug, reviewer):",
557
+ "",
558
+ );
559
+ if (!name) return;
560
+ const displayName = await ctx.ui.input(
561
+ "Display name (e.g. 🐛 Debug):",
562
+ name,
563
+ );
564
+ if (!displayName) return;
565
+ const description = await ctx.ui.input("Short description:", "");
566
+ if (!description) return;
567
+ await createAgent(name, displayName, description, ctx);
568
+ break;
569
+ }
570
+ case "delete": {
571
+ const names = pm.getAgentNames();
572
+ if (names.length === 0) {
573
+ ctx.ui.notify("No custom agents to delete", "warning");
574
+ return;
575
+ }
576
+ const name = await ctx.ui.select("Pick agent to delete:", names);
577
+ if (!name) return;
578
+ const confirmed = await ctx.ui.confirm(
579
+ "Delete agent?",
580
+ `Delete "${name}" and its profile? This cannot be undone.`,
581
+ );
582
+ if (!confirmed) return;
583
+ try {
584
+ pm.delete(name);
585
+ if (currentAgent === name) {
586
+ currentAgent = PI_AGENT_NAME;
587
+ await restorePIState(ctx);
588
+ currentResolved = undefined;
589
+ }
590
+ updateStatus(ctx);
591
+ ctx.ui.notify(`Deleted agent: ${name}`, "info");
592
+ } catch (e: any) {
593
+ ctx.ui.notify(`Failed to delete: ${e.message}`, "error");
594
+ }
595
+ break;
596
+ }
597
+ case "hotkey": {
598
+ const keys: SelectItem[] = HOTKEY_CHOICES.map((h) => ({
599
+ value: h.key,
600
+ label: h.label,
601
+ }));
602
+ const key = await showSelectDialog(
603
+ ctx,
604
+ "Pick hotkey for agent cycling:",
605
+ keys,
606
+ );
607
+ if (!key) return;
608
+ const config = pm.loadConfig();
609
+ config.hotkey = key as KeyId;
610
+ pm.saveConfig(config);
611
+ ctx.ui.notify(
612
+ `Hotkey set to ${key}. Run /reload for it to take effect.`,
613
+ "info",
614
+ );
615
+ break;
616
+ }
617
+ case "list": {
618
+ const config = pm.loadConfig();
619
+ const active = pm.getActive(config);
620
+ const diskProfiles = pm.list();
621
+ const lines = diskProfiles.map((p) => {
622
+ const resolved = pm.resolveAgent(p.name, getPIState(), ctx.cwd);
623
+ const modelInfo = resolved?.model ? ` (${resolved.model})` : "";
624
+ const activeMarker = active === p.name ? " ★" : "";
625
+ return ` ${resolved?.label ?? p.name}${modelInfo}${activeMarker} — ${resolved?.description ?? ""}`;
626
+ });
627
+ pi.sendMessage({
628
+ customType: "agent-switch",
629
+ content: `# Agents\n\n${lines.join("\n")}`,
630
+ display: true,
631
+ });
632
+ break;
633
+ }
634
+ case "init-defaults": {
635
+ await initDefaults(ctx);
636
+ break;
637
+ }
638
+ }
639
+ },
640
+ });
641
+
642
+ // ─── Agent creation helpers ─────────────────────────
643
+
644
+ async function createAgent(
645
+ name: string,
646
+ displayName: string,
647
+ description: string,
648
+ ctx: ExtensionContext,
649
+ ): Promise<void> {
650
+ try {
651
+ pm.create(name, { name: displayName, description });
652
+ ctx.ui.notify(
653
+ `Created agent "${name}". Edit ~/.pi/agents/${name}/AGENTS.md to customize.`,
654
+ "info",
655
+ );
656
+ } catch (e: any) {
657
+ ctx.ui.notify(e.message, "error");
658
+ }
659
+ }
660
+
661
+ async function initDefaults(ctx: ExtensionContext): Promise<void> {
662
+ let count = 0;
663
+ for (const [name, fm] of Object.entries(DEFAULT_AGENTS)) {
664
+ if (pm.exists(name)) continue;
665
+ try {
666
+ const prompt =
667
+ name === "builder"
668
+ ? BUILDER_SYSTEM_PROMPT
669
+ : `# ${fm.name ?? name}\n\n${fm.description ?? ""}\n\nYou are the **${fm.name ?? name}** agent.\n`;
670
+ pm.create(name, fm, prompt);
671
+ count++;
672
+ } catch (e: any) {
673
+ ctx.ui.notify(`Failed to create ${name}: ${e.message}`, "error");
674
+ }
675
+ }
676
+ if (count > 0) {
677
+ loadCurrentAgent(pm.loadConfig());
678
+ ctx.ui.notify(`Initialized ${count} default agent(s)`, "info");
679
+ } else {
680
+ ctx.ui.notify("Default agents already initialized", "info");
681
+ }
682
+ }
683
+
684
+ // ─── Hotkey ─────────────────────────────────────────
685
+
686
+ function getHotkey(): KeyId {
687
+ const config = pm.loadConfig();
688
+ return (config.hotkey as KeyId | undefined) || DEFAULT_HOTKEY;
689
+ }
690
+
691
+ pi.registerShortcut(getHotkey(), {
692
+ description: "Cycle primary agent (PI → custom agents)",
693
+ handler: async (ctx) => {
694
+ initPM(ctx.cwd);
695
+ await cycleAgent(ctx);
696
+ },
697
+ });
698
+
699
+ // ─── before_agent_start ─────────────────────────────
700
+
701
+ pi.on("before_agent_start", async (event, ctx) => {
702
+ initPM(ctx.cwd);
703
+ loadCurrentAgent(pm.loadConfig());
704
+ await applyAgentSettings(ctx);
705
+ updateStatus(ctx);
706
+ const section = getAgentSystemPromptSection();
707
+ if (!section) return;
708
+
709
+ // Replace the default role line with the agent's identity banner.
710
+ // Everything else (tools, guidelines, skills, etc.) stays intact.
711
+ const roleStart = event.systemPrompt.indexOf(DEFAULT_ROLE_LINE);
712
+ if (roleStart >= 0) {
713
+ const roleEnd = roleStart + DEFAULT_ROLE_LINE.length;
714
+ const newPrompt =
715
+ event.systemPrompt.substring(0, roleStart) +
716
+ section +
717
+ event.systemPrompt.substring(roleEnd);
718
+ return { systemPrompt: newPrompt };
719
+ }
720
+
721
+ // Fallback: if the role line wasn't found (e.g. custom SYSTEM.md),
722
+ // prepend the agent section at the top instead of appending.
723
+ return { systemPrompt: section + "\n\n" + event.systemPrompt };
724
+ });
725
+
726
+ // ─── before_provider_request ────────────────────────
727
+
728
+ pi.on("before_provider_request", (event) => {
729
+ if (!currentResolved) return;
730
+ const payload = event.payload as Record<string, unknown>;
731
+ let changed = false;
732
+ if (currentResolved.temperature !== undefined) {
733
+ payload.temperature = currentResolved.temperature;
734
+ changed = true;
735
+ }
736
+ if (currentResolved.topP !== undefined) {
737
+ payload.top_p = currentResolved.topP;
738
+ changed = true;
739
+ }
740
+ if (changed) return payload;
741
+ });
742
+
743
+ // ─── Lifecycle ──────────────────────────────────────
744
+
745
+ pi.on("session_start", async (_event, ctx) => {
746
+ initPM(ctx.cwd);
747
+ loadCurrentAgent(pm.loadConfig());
748
+ if (currentAgent !== PI_AGENT_NAME)
749
+ currentResolved = pm.resolveAgent(currentAgent, getPIState(), ctx.cwd);
750
+ updateStatus(ctx);
751
+ });
752
+
753
+ pi.on("model_select", async (_event, ctx) => {
754
+ updateStatus(ctx);
755
+ });
756
+
757
+ pi.on("session_shutdown", async (_event, ctx) => {
758
+ ctx?.ui?.setStatus?.("agent-switch", undefined);
759
+ });
760
+ }