pi-soly 0.3.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.
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
+ "lib": [
7
+ "esnext"
8
+ ],
9
+ "types": [
10
+ "node"
11
+ ],
12
+ "skipLibCheck": true,
13
+ "noEmit": true,
14
+ "strict": true,
15
+ "esModuleInterop": true,
16
+ "allowSyntheticDefaultImports": true,
17
+ "resolveJsonModule": true,
18
+ "forceConsistentCasingInFileNames": true,
19
+ "noUncheckedIndexedAccess": false,
20
+ "allowImportingTsExtensions": true
21
+ },
22
+ "include": [
23
+ "**/*.ts"
24
+ ],
25
+ "exclude": [
26
+ "node_modules"
27
+ ]
28
+ }
package/index.ts CHANGED
@@ -62,6 +62,10 @@ import { detectEnv, buildEnvSection, type EnvSummary } from "./env.ts";
62
62
  import { buildCodeMap, buildCodeMapSection, type CodeMap } from "./codemap.ts";
63
63
  import { loadIntentDocs, buildIntentSection, loadInlineIntentBodies, type IntentDoc } from "./intent.ts";
64
64
 
65
+ // Built-in sub-features (merged from former pi-asked, pi-agented packages):
66
+ import piAskExtension from "./ask/index.ts";
67
+ import piSwitchExtension from "./switch/index.ts";
68
+
65
69
  export default function solyExtension(pi: ExtensionAPI) {
66
70
  // ============================================================================
67
71
  // State (module-local, lives for the duration of one extension instance)
@@ -715,4 +719,8 @@ export default function solyExtension(pi: ExtensionAPI) {
715
719
  updateStatus(ctx);
716
720
  }
717
721
  });
722
+
723
+ // Mount built-in sub-features
724
+ piAskExtension(pi);
725
+ piSwitchExtension(pi);
718
726
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Project management for pi — plans, state, subagent-driven execution. Inspired by GSD.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -38,6 +38,8 @@
38
38
  "tools.ts",
39
39
  "agents-install.ts",
40
40
  "agents",
41
+ "ask",
42
+ "switch",
41
43
  "workflows",
42
44
  "workflows-data"
43
45
  ],
@@ -47,7 +49,9 @@
47
49
  "pi-package",
48
50
  "project-management",
49
51
  "planning",
50
- "subagents"
52
+ "subagents",
53
+ "multi-question",
54
+ "agent-switcher"
51
55
  ],
52
56
  "license": "MIT",
53
57
  "pi": {
@@ -0,0 +1,107 @@
1
+ # pi-switch — generic subagent switcher for pi
2
+
3
+ A tiny pi extension that gives you a **persistent indicator of the current subagent** (header bar above chat) and lets you **cycle / set / create** agents. Generic — works with any agent in `~/.pi/agent/agents/`.
4
+
5
+ ## Features
6
+
7
+ - **Header bar above chat** — shows current agent with emoji + description
8
+ - **Ctrl+Shift+S** to cycle to next agent (Shift+Tab is taken by pi's thinking-level cycler)
9
+ - **`/agent`** slash command to show current + available
10
+ - **`/agent <name>`** to set explicitly
11
+ - **`/agent create <name>`** to scaffold a new user agent
12
+ - **`/agent doctor`** to diagnose
13
+ - **`/agent recommend <task>`** to suggest the right agent for a task
14
+ - **Task → agent heuristics** baked into the system prompt so the LLM picks the right agent for the task
15
+ - Persists to `.soly/agent` (if soly project) or `~/.pi-switch/agent` (standalone)
16
+ - Reads user agents from `~/.pi/agent/agents/*.md` on every cycle — drop a file and Ctrl+Shift+S to see it
17
+
18
+ ## How agents work
19
+
20
+ Agents are markdown files with YAML frontmatter. pi-subagents (and pi-switch) discover them from these locations:
21
+
22
+ | Path | Type | Editable |
23
+ |---|---|---|
24
+ | `~/.pi/agent/npm/node_modules/pi-subagents/agents/*.md` | built-in (worker, oracle, scout, ...) | ❌ |
25
+ | `~/.pi/agent/agents/*.md` | user-defined | ✅ |
26
+ | `~/.pi/agent/extensions/soly/agents/*.md` (auto-installed if `useSolyWorkerSubagents: true` in `.soly/config.json`) | soly-augmented (soly-worker, soly-debugger, ...) | ✅ source |
27
+
28
+ ### Frontmatter schema
29
+
30
+ ```markdown
31
+ ---
32
+ name: my-reviewer # required, unique, [a-zA-Z0-9_-]{1,64}
33
+ description: One-liner shown in picker
34
+ thinking: medium # off | minimal | low | medium | high | xhigh
35
+ systemPromptMode: replace # replace | append
36
+ inheritProjectContext: true
37
+ inheritSkills: false
38
+ tools: read, grep, find, ls, bash, edit, write
39
+ defaultContext: fork # fresh | fork
40
+ ---
41
+
42
+ You are `my-reviewer`. The system prompt goes here.
43
+ ```
44
+
45
+ ## Create a new agent
46
+
47
+ ### Option A: manually
48
+ Drop a markdown file in `~/.pi/agent/agents/<name>.md` (see schema above). Press `Ctrl+Shift+S` in pi — it joins the cycle.
49
+
50
+ ### Option B: via slash command
51
+ ```
52
+ /agent create my-debugger
53
+ ```
54
+ You'll be prompted for a one-liner description. Then edit the file to specialize the system prompt.
55
+
56
+ ## Test agents
57
+
58
+ | Action | How |
59
+ |---|---|
60
+ | See current + available | `/agent` |
61
+ | Cycle | `Ctrl+Shift+S` |
62
+ | Set explicitly | `/agent soly-debugger` |
63
+ | Diagnose | `/agent doctor` |
64
+ | Recommend for a task | `/agent recommend investigate React Server Components` |
65
+
66
+ The LLM can also auto-pick — see "Task → agent" below.
67
+
68
+ ## Task → agent heuristics
69
+
70
+ The LLM's system prompt includes a table mapping task keywords to agents. When the user request matches, the LLM should call `/agent <name>` first, then `subagent({ agent: <name>, ... })`.
71
+
72
+ | Keywords | Agent | Why |
73
+ |---|---|---|
74
+ | research, investigate, look up, find out, explore, compare libraries | 📚 researcher | external docs, ecosystem behavior |
75
+ | scout, scan, map, where is, locate, skim | 🔍 scout | codebase recon |
76
+ | plan, design, architect, outline, structure | 📋 planner | decompose into steps |
77
+ | review, audit, check, adversarial, critique, qa | 👀 reviewer | adversarial review |
78
+ | oracle, decision, tradeoff, which approach, drift | 🔮 oracle | decision consistency |
79
+ | debug, bug, fix, crash, error, repro | 🐞 soly-debugger | bug investigation |
80
+ | test, tests, coverage, spec, assert | 🧪 soly-tester | test-only work |
81
+ | refactor, clean up, simplify, extract, rename | 🔄 soly-refactor | pure refactoring |
82
+ | document, docs, readme, jsdoc | 📝 soly-documenter | doc updates |
83
+ | implement, build, write code, add feature | ⚡ worker | implementation |
84
+ | orchestrate, coordinate, dispatch, chain | 🤝 delegate | multi-agent |
85
+
86
+ Same keywords in Russian work (изучи, баг, тест, etc.).
87
+
88
+ ## Integration with other extensions
89
+
90
+ - **soly** reads `globalThis.__PI_SWITCH_AGENT__` to know which subagent to launch for `soly execute`. Falls back to `"worker"` if pi-switch isn't loaded.
91
+ - **Soly** also auto-installs soly-augmented agents (soly-worker, soly-debugger, etc.) to `~/.pi/agent/agents/` when `useSolyWorkerSubagents: true` in `.soly/config.json`.
92
+
93
+ ## Files
94
+
95
+ - `core.ts` — agent metadata, discovery, cycling, persistence
96
+ - `prompt.ts` — system-prompt section + task→agent heuristics + `recommendAgent`
97
+ - `index.ts` — header bar, Ctrl+Shift+S, `/agent` slash command, `/agent create`/`/agent doctor`/`/agent recommend`
98
+ - `tests/core.test.ts` — 21 tests for core logic
99
+ - `tests/prompt.test.ts` — 20 tests for prompt + recommendAgent
100
+
101
+ ## Development
102
+
103
+ ```bash
104
+ cd ~/.pi/agent/extensions/pi-switch
105
+ bun test # 41 tests
106
+ bun run typecheck # tsc --noEmit
107
+ ```
package/switch/core.ts ADDED
@@ -0,0 +1,202 @@
1
+ // =============================================================================
2
+ // core.ts — Generic subagent switcher for pi
3
+ // =============================================================================
4
+ //
5
+ // Lets the user pick which subagent the LLM uses (for `subagent(...)` calls
6
+ // in the pi-subagents system, and for any extension that reads the current
7
+ // agent). Generic — works with pi-subagents' built-ins (worker, oracle,
8
+ // scout, ...) AND any user-defined agent in `~/.pi/agent/agents/`.
9
+ //
10
+ // Cycle order (Shift+Tab in pi is taken by thinking-level, so we use
11
+ // Ctrl+Shift+S — mnemonic for "S"witch).
12
+ //
13
+ // Communication with other extensions:
14
+ // - Writes `globalThis.__PI_SWITCH_AGENT__` (in-process)
15
+ // - Reads/writes `.soly/agent` if it exists (cross-session persistence,
16
+ // shared with soly extension). If no soly project, persists to
17
+ // `~/.pi-switch/agent` instead.
18
+ // =============================================================================
19
+
20
+ import * as fs from "node:fs";
21
+ import * as os from "node:os";
22
+ import * as path from "node:path";
23
+
24
+ /** Default agent used when no override is set. */
25
+ export const DEFAULT_AGENT = "worker";
26
+
27
+ /** Built-in pi-subagents that we always offer in the cycle. */
28
+ export const BUILTIN_AGENTS: readonly string[] = [
29
+ "worker",
30
+ "oracle",
31
+ "scout",
32
+ "researcher",
33
+ "planner",
34
+ "context-builder",
35
+ "reviewer",
36
+ "delegate",
37
+ ] as const;
38
+
39
+ /** Visual metadata for every known agent. Used by the rich status badge,
40
+ * the header bar, and the multi-line switch notify. */
41
+ export interface AgentMeta {
42
+ emoji: string;
43
+ shortLabel: string;
44
+ description: string;
45
+ writesFiles: boolean;
46
+ }
47
+
48
+ export const AGENT_META: Record<string, AgentMeta> = {
49
+ worker: { emoji: "\u26a1", shortLabel: "worker", description: "generic implementation, all tools", writesFiles: true },
50
+ oracle: { emoji: "\ud83d\udd2e", shortLabel: "oracle", description: "decision-consistency, no file edits", writesFiles: false },
51
+ scout: { emoji: "\ud83d\udd0d", shortLabel: "scout", description: "codebase recon, read-only", writesFiles: false },
52
+ researcher: { emoji: "\ud83d\udcda", shortLabel: "researcher", description: "external docs / libraries", writesFiles: false },
53
+ planner: { emoji: "\ud83d\udccb", shortLabel: "planner", description: "planning + ordering, no code", writesFiles: false },
54
+ "context-builder": { emoji: "\ud83c\udfd7", shortLabel: "ctx-builder", description: "context handoff for other agents", writesFiles: true },
55
+ reviewer: { emoji: "\ud83d\udc40", shortLabel: "reviewer", description: "adversarial code review", writesFiles: false },
56
+ delegate: { emoji: "\ud83e\udd1d", shortLabel: "delegate", description: "pure orchestration, dispatches others", writesFiles: false },
57
+ };
58
+
59
+ /** Get metadata for an agent. Falls back to a neutral entry for unknown. */
60
+ export function getAgentMeta(name: string): AgentMeta {
61
+ return AGENT_META[name] ?? {
62
+ emoji: "\u2753",
63
+ shortLabel: name.length > 12 ? name.slice(0, 11) + "\u2026" : name,
64
+ description: "user-defined agent",
65
+ writesFiles: true,
66
+ };
67
+ }
68
+
69
+ /** Validate an agent name. */
70
+ export function isValidAgentName(name: string): boolean {
71
+ return /^[a-zA-Z0-9_-]{1,64}$/.test(name);
72
+ }
73
+
74
+ /** Discover agent `.md` files in user dir. */
75
+ export function discoverUserAgents(userDir: string = path.join(os.homedir(), ".pi", "agent", "agents")): string[] {
76
+ if (!fs.existsSync(userDir)) return [];
77
+ const names: string[] = [];
78
+ for (const file of fs.readdirSync(userDir)) {
79
+ if (!file.endsWith(".md")) continue;
80
+ try {
81
+ const raw = fs.readFileSync(path.join(userDir, file), "utf-8");
82
+ const m = raw.match(/^---\n([\s\S]*?)\n---/);
83
+ if (!m) continue;
84
+ const fm = m[1] ?? "";
85
+ const nameMatch = fm.match(/^name:\s*(.+)$/m);
86
+ if (nameMatch) {
87
+ const n = (nameMatch[1] ?? "").trim();
88
+ if (isValidAgentName(n)) names.push(n);
89
+ }
90
+ } catch { /* skip */ }
91
+ }
92
+ return names;
93
+ }
94
+
95
+ /** Build the full cycle of available agents. Built-ins first, then
96
+ * user-discovered. Dedupes while preserving first-occurrence order. */
97
+ export function availableAgents(userDir?: string): string[] {
98
+ const out: string[] = [];
99
+ const seen = new Set<string>();
100
+ const push = (n: string) => {
101
+ if (!seen.has(n)) {
102
+ seen.add(n);
103
+ out.push(n);
104
+ }
105
+ };
106
+ for (const a of BUILTIN_AGENTS) push(a);
107
+ for (const a of discoverUserAgents(userDir)) push(a);
108
+ return out;
109
+ }
110
+
111
+ /** Cycle order. */
112
+ export function nextAgent(current: string, cycle: readonly string[]): string {
113
+ if (cycle.length === 0) return DEFAULT_AGENT;
114
+ const idx = cycle.indexOf(current);
115
+ if (idx < 0) return cycle[0]!;
116
+ return cycle[(idx + 1) % cycle.length]!;
117
+ }
118
+
119
+ /** Parse a user-supplied agent name. */
120
+ export function parseAgentName(raw: string): string | null {
121
+ const n = raw.trim();
122
+ if (!isValidAgentName(n)) return null;
123
+ return n;
124
+ }
125
+
126
+ /** Short badge: `<emoji> <name>`. Null for default (silent). */
127
+ export function formatAgentBadge(agent: string): string | null {
128
+ if (agent === DEFAULT_AGENT) return null;
129
+ const meta = getAgentMeta(agent);
130
+ return `${meta.emoji} ${agent}`;
131
+ }
132
+
133
+ /** Multi-line switch notify. */
134
+ export function formatAgentSwitchNotify(prev: string, next: string): string {
135
+ const prevMeta = getAgentMeta(prev);
136
+ const nextMeta = getAgentMeta(next);
137
+ const lines: string[] = [
138
+ "pi-switch agent changed",
139
+ "",
140
+ ` ${prevMeta.emoji} ${prev.padEnd(16)} → ${nextMeta.emoji} ${next}`,
141
+ ` ${"".padEnd(16)} ${nextMeta.description}`,
142
+ "",
143
+ ` writes files: ${nextMeta.writesFiles ? "yes" : "no (read-only)"} · next subagent call uses: ${next}`,
144
+ ];
145
+ return lines.join("\n");
146
+ }
147
+
148
+ /** Group agents: built-ins + user-defined. */
149
+ export function groupedAvailableAgents(userDir?: string): Array<{ header: string; agents: string[] }> {
150
+ const all = availableAgents(userDir);
151
+ const groups: Array<{ header: string; agents: string[] }> = [];
152
+ const builtin = all.filter((a) => BUILTIN_AGENTS.includes(a));
153
+ if (builtin.length > 0) groups.push({ header: "built-in", agents: builtin });
154
+ const user = all.filter((a) => !BUILTIN_AGENTS.includes(a));
155
+ if (user.length > 0) groups.push({ header: "user-defined", agents: user });
156
+ return groups;
157
+ }
158
+
159
+ /** Header line shown above chat. Persistent, dim, single line. */
160
+ export function formatHeaderLine(agent: string): string {
161
+ const meta = getAgentMeta(agent);
162
+ const writeTag = meta.writesFiles ? "" : " \u00b7 read-only";
163
+ return `${meta.emoji} ${agent} \u00b7 ${meta.description}${writeTag} [Ctrl+Shift+S to cycle]`;
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Persistence
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /** Where to persist the current agent. Prefers `.soly/agent` if a soly
171
+ * project exists (shared with soly extension). Otherwise `~/.pi-switch/agent`. */
172
+ export function agentFilePath(cwd: string): string {
173
+ const solyAgent = path.join(cwd, ".soly", "agent");
174
+ if (fs.existsSync(path.join(cwd, ".soly"))) return solyAgent;
175
+ // Respect HOME/USERPROFILE for testability (otherwise os.homedir() ignores them on Windows)
176
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
177
+ const fallbackDir = path.join(home, ".pi-switch");
178
+ fs.mkdirSync(fallbackDir, { recursive: true });
179
+ return path.join(fallbackDir, "agent");
180
+ }
181
+
182
+ /** Read persisted agent from disk. Returns null if missing/invalid. */
183
+ export function loadAgent(cwd: string): string | null {
184
+ try {
185
+ const file = agentFilePath(cwd);
186
+ if (!fs.existsSync(file)) return null;
187
+ const raw = fs.readFileSync(file, "utf-8").trim();
188
+ if (!isValidAgentName(raw)) return null;
189
+ return raw;
190
+ } catch {
191
+ return null;
192
+ }
193
+ }
194
+
195
+ /** Write current agent to disk. */
196
+ export function saveAgent(cwd: string, agent: string): void {
197
+ try {
198
+ const file = agentFilePath(cwd);
199
+ fs.mkdirSync(path.dirname(file), { recursive: true });
200
+ fs.writeFileSync(file, agent + "\n", "utf-8");
201
+ } catch { /* best-effort */ }
202
+ }
@@ -0,0 +1,300 @@
1
+ // =============================================================================
2
+ // index.ts — pi-switch extension entry (v2: footer-pill UI)
3
+ // =============================================================================
4
+ //
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.
18
+ // =============================================================================
19
+
20
+ import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
21
+ import { Box, Text } from "@earendil-works/pi-tui";
22
+ import * as fs from "node:fs";
23
+ import * as os from "node:os";
24
+ import * as path from "node:path";
25
+ import {
26
+ DEFAULT_AGENT,
27
+ BUILTIN_AGENTS,
28
+ availableAgents,
29
+ nextAgent,
30
+ parseAgentName,
31
+ groupedAvailableAgents,
32
+ getAgentMeta,
33
+ loadAgent,
34
+ saveAgent,
35
+ } from "./core.ts";
36
+ import { buildPiSwitchSection, recommendAgent } from "./prompt.ts";
37
+
38
+ const GLOBAL_KEY = "__PI_SWITCH_AGENT__";
39
+
40
+ export default function piSwitchExtension(pi: ExtensionAPI) {
41
+ let cwd = "";
42
+ let currentAgent: string = DEFAULT_AGENT;
43
+ let cycle: string[] = [DEFAULT_AGENT];
44
+ let lastUi: ExtensionUIContext | null = null;
45
+
46
+ function refreshCycle(): void {
47
+ cycle = availableAgents();
48
+ if (!cycle.includes(currentAgent)) currentAgent = DEFAULT_AGENT;
49
+ }
50
+
51
+ function publish(): void {
52
+ (globalThis as Record<string, unknown>)[GLOBAL_KEY] = currentAgent;
53
+ }
54
+
55
+ function rerender(): void {
56
+ if (!lastUi) return;
57
+ try {
58
+ const meta = getAgentMeta(currentAgent);
59
+ const pill = `${meta.emoji} ${currentAgent}`;
60
+ lastUi.setStatus("pi-switch", currentAgent === DEFAULT_AGENT ? null : pill);
61
+ } catch { /* no ui yet */ }
62
+ }
63
+
64
+ function setAgent(next: string): void {
65
+ const prev = currentAgent;
66
+ if (next === prev) return;
67
+ currentAgent = next;
68
+ publish();
69
+ if (cwd) saveAgent(cwd, next);
70
+ rerender();
71
+ if (lastUi) {
72
+ const m = getAgentMeta(next);
73
+ lastUi.notify(
74
+ `${m.emoji} ${next} · ${m.description}${m.writesFiles ? "" : " · read-only"}`,
75
+ "info",
76
+ );
77
+ }
78
+ }
79
+
80
+ // ----- session_start: load persisted agent + set initial pill -----
81
+ pi.on("session_start", async (_event, ctx) => {
82
+ cwd = ctx.cwd;
83
+ lastUi = ctx.ui;
84
+ publish();
85
+ const restored = loadAgent(cwd);
86
+ if (restored) currentAgent = restored;
87
+ refreshCycle();
88
+ publish();
89
+ rerender();
90
+ });
91
+
92
+ // ----- before_agent_start: inject system-prompt section -----
93
+ pi.on("before_agent_start", async (event, ctx) => {
94
+ lastUi = ctx.ui;
95
+ rerender();
96
+ return {
97
+ systemPrompt: event.systemPrompt + buildPiSwitchSection(),
98
+ };
99
+ });
100
+
101
+ // ----- Ctrl+Shift+S: hot cycle (no popup, no confirmation) -----
102
+ pi.registerShortcut("ctrl+shift+s", {
103
+ description: "Cycle to next agent (worker → oracle → scout → …)",
104
+ handler: (sctx) => {
105
+ lastUi = sctx.ui;
106
+ refreshCycle();
107
+ setAgent(nextAgent(currentAgent, cycle));
108
+ },
109
+ });
110
+
111
+ // ----- /agent: open picker, or subcommands (create / doctor / recommend / set) -----
112
+ pi.registerCommand("agent", {
113
+ description: "open agent picker, or `set <name>`, `create`, `doctor`, `recommend <task>`",
114
+ handler: async (args, ctx) => {
115
+ lastUi = ctx.ui;
116
+ refreshCycle();
117
+ const parts = args.trim().split(/\s+/);
118
+ const subcommand = parts[0]?.toLowerCase();
119
+ const arg = parts[1];
120
+
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);
125
+
126
+ // Direct agent name → set
127
+ if (subcommand && cycle.includes(subcommand)) return setAgent(subcommand);
128
+ if (arg && !subcommand) return handleSet(arg, ctx.ui);
129
+
130
+ // No arg: open picker modal
131
+ openPicker(ctx.ui);
132
+ },
133
+ });
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Picker modal (TUI SelectList)
138
+ // ---------------------------------------------------------------------------
139
+
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
+ });
153
+ }
154
+ }
155
+ return all;
156
+ }, ui, (choice) => {
157
+ if (choice && choice !== "__sep__") setAgentRef(choice);
158
+ });
159
+ }
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
+
177
+ // ---------------------------------------------------------------------------
178
+ // setAgent / currentAgent — module-scope so the modal can mutate them
179
+ // ---------------------------------------------------------------------------
180
+
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;
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // /agent create — scaffold a new agent .md
203
+ // ---------------------------------------------------------------------------
204
+
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
+
235
+ function agentTemplate(name: string, description: string): string {
236
+ return `---
237
+ name: ${name}
238
+ description: ${description}
239
+ thinking: medium
240
+ systemPromptMode: replace
241
+ inheritProjectContext: true
242
+ inheritSkills: false
243
+ tools: read, grep, find, ls, bash, edit, write
244
+ defaultContext: fork
245
+ ---
246
+
247
+ You are \`${name}\`. Describe what you specialize in, your process, and
248
+ what you should NOT do. Keep the rest of this frontmatter as-is unless
249
+ you have a specific reason to change it.
250
+
251
+ # Your role
252
+
253
+ <!-- Replace with a one-paragraph description of what you're for. -->
254
+
255
+ # Process
256
+
257
+ 1. Read the user's request carefully.
258
+ 2. Form a hypothesis about the right approach.
259
+ 3. Verify with tools (read, grep, bash) before writing.
260
+ 4. Commit changes in narrow, reviewable diffs.
261
+
262
+ # What you should NOT do
263
+
264
+ - Edit other agents' files
265
+ - Run subagents yourself (you're already a subagent)
266
+ - Skip verification ("trust me bro" is not a process)
267
+ `;
268
+ }
269
+
270
+ function doctorReport(): string {
271
+ const cycle = availableAgents();
272
+ const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
273
+ const lines: string[] = ["pi-switch doctor:", ""];
274
+ const builtins = cycle.filter((a) => BUILTIN_AGENTS.includes(a));
275
+ const users = cycle.filter((a) => !BUILTIN_AGENTS.includes(a));
276
+ lines.push(`cycle: ${cycle.length} agents (${builtins.length} built-in, ${users.length} user)`);
277
+ lines.push("");
278
+ if (!fs.existsSync(userDir)) {
279
+ lines.push(`user dir: ${userDir} (does not exist)`);
280
+ } else {
281
+ const files = fs.readdirSync(userDir).filter((f) => f.endsWith(".md"));
282
+ lines.push(`user dir: ${userDir} (${files.length} file(s))`);
283
+ const issues: string[] = [];
284
+ for (const f of files) {
285
+ try {
286
+ const raw = fs.readFileSync(path.join(userDir, f), "utf-8");
287
+ if (!raw.startsWith("---\n")) { issues.push(`${f}: no YAML frontmatter`); continue; }
288
+ const m = raw.match(/^---\n([\s\S]*?)\n---/);
289
+ if (!m) { issues.push(`${f}: malformed frontmatter`); continue; }
290
+ const fm = m[1] ?? "";
291
+ if (!/^name:\s*\S/m.test(fm)) issues.push(`${f}: missing 'name:' in frontmatter`);
292
+ else if (!/^description:\s*\S/m.test(fm)) issues.push(`${f}: missing 'description:' in frontmatter`);
293
+ } catch (e) {
294
+ issues.push(`${f}: read error: ${(e as Error).message}`);
295
+ }
296
+ }
297
+ lines.push(issues.length === 0 ? "validation: all user agents OK ✓" : "validation issues:\n - " + issues.join("\n - "));
298
+ }
299
+ return lines.join("\n");
300
+ }