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/README.md +186 -0
- package/frontmatter.ts +172 -0
- package/index.ts +760 -0
- package/package.json +26 -0
- package/profile-manager.ts +590 -0
- package/types.ts +151 -0
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
|
+
}
|