pi-soly 1.3.0 → 1.4.1
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 +6 -30
- package/commands.ts +2 -2
- package/index.ts +1 -27
- package/package.json +1 -2
- package/skills/soly-framework/SKILL.md +24 -97
- package/switch/README.md +0 -104
- package/switch/core.ts +0 -229
- package/switch/index.ts +0 -365
- package/switch/package.json +0 -52
- package/switch/prompt.ts +0 -131
- package/switch/tests/core.test.ts +0 -210
- package/switch/tests/index.test.ts +0 -48
- package/switch/tests/prompt.test.ts +0 -108
- package/switch/tests/watcher.test.ts +0 -147
- package/switch/tsconfig.json +0 -28
- package/switch/watcher.ts +0 -112
package/switch/core.ts
DELETED
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
// =============================================================================
|
|
2
|
-
// core.ts — Generic subrotor 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; we use Ctrl+Tab
|
|
11
|
-
// as the primary shortcut, with F2 as fallback for terminals that don't
|
|
12
|
-
// pass Ctrl+Tab through).
|
|
13
|
-
//
|
|
14
|
-
// Communication with other extensions:
|
|
15
|
-
// - Writes `globalThis.__PI_SWITCH_ROTOR__` (in-process)
|
|
16
|
-
// - Reads/writes `.soly/agent` if it exists (cross-session persistence,
|
|
17
|
-
// shared with soly extension). If no soly project, persists to
|
|
18
|
-
// `~/.pi-switch/agent` instead.
|
|
19
|
-
// =============================================================================
|
|
20
|
-
|
|
21
|
-
import * as fs from "node:fs";
|
|
22
|
-
import * as os from "node:os";
|
|
23
|
-
import * as path from "node:path";
|
|
24
|
-
|
|
25
|
-
/** Default agent used when no override is set. */
|
|
26
|
-
export const DEFAULT_ROTOR = "worker";
|
|
27
|
-
|
|
28
|
-
/** Built-in pi-subagents that we always offer in the cycle. */
|
|
29
|
-
export const BUILTIN_ROTORS: readonly string[] = [
|
|
30
|
-
"worker",
|
|
31
|
-
"oracle",
|
|
32
|
-
"scout",
|
|
33
|
-
"reviewer",
|
|
34
|
-
] as const;
|
|
35
|
-
|
|
36
|
-
/** Visual metadata for every known agent. Used by the rich status badge,
|
|
37
|
-
* the header bar, and the multi-line switch notify. */
|
|
38
|
-
export interface RotorMeta {
|
|
39
|
-
emoji: string;
|
|
40
|
-
shortLabel: string;
|
|
41
|
-
description: string;
|
|
42
|
-
writesFiles: boolean;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export const ROTOR_META: Record<string, RotorMeta> = {
|
|
46
|
-
worker: { emoji: "\u26a1", shortLabel: "worker", description: "generic implementation, all tools", writesFiles: true },
|
|
47
|
-
oracle: { emoji: "\ud83d\udd2e", shortLabel: "oracle", description: "decision-consistency, no file edits", writesFiles: false },
|
|
48
|
-
scout: { emoji: "\ud83d\udd0d", shortLabel: "scout", description: "codebase recon, read-only", writesFiles: false },
|
|
49
|
-
reviewer: { emoji: "\ud83d\udc40", shortLabel: "reviewer", description: "adversarial code review", writesFiles: false },
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/** Get metadata for an agent. Falls back to a neutral entry for unknown. */
|
|
53
|
-
export function getRotorMeta(name: string): RotorMeta {
|
|
54
|
-
return ROTOR_META[name] ?? {
|
|
55
|
-
emoji: "\u2753",
|
|
56
|
-
shortLabel: name.length > 12 ? name.slice(0, 11) + "\u2026" : name,
|
|
57
|
-
description: "user-defined agent",
|
|
58
|
-
writesFiles: true,
|
|
59
|
-
};
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Validate an agent name. */
|
|
63
|
-
export function isValidRotorName(name: string): boolean {
|
|
64
|
-
return /^[a-zA-Z0-9_-]{1,64}$/.test(name);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Discover agent `.md` files in user dir. */
|
|
68
|
-
/** All known agent home directories, in priority order (project wins
|
|
69
|
-
* over user-home; user-home `.agents/` wins over pi-native).
|
|
70
|
-
* Project-level `.agents/` is a vendor-neutral per-project
|
|
71
|
-
* convention — same role as `.soly/` or the old `.claude/`.
|
|
72
|
-
* Agent .md files live DIRECTLY in the dir (not in a subfolder):
|
|
73
|
-
* .agents/reviewer.md (NOT .agents/agents/reviewer.md)
|
|
74
|
-
* Honors $HOME / $USERPROFILE for testability. */
|
|
75
|
-
export function rotorHomeDirs(cwd?: string): string[] {
|
|
76
|
-
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
77
|
-
const dirs: string[] = [];
|
|
78
|
-
if (cwd) {
|
|
79
|
-
dirs.push(path.join(cwd, ".agents")); // project (vendor-neutral, preferred)
|
|
80
|
-
dirs.push(path.join(cwd, ".pi", "agent", "agents")); // project (pi native, legacy)
|
|
81
|
-
}
|
|
82
|
-
dirs.push(path.join(home, ".agents")); // user (vendor-neutral)
|
|
83
|
-
dirs.push(path.join(home, ".pi", "agent", "agents")); // user (pi native, legacy)
|
|
84
|
-
return dirs;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Read all agent names from every home dir. Dedupes, first-occurrence
|
|
88
|
-
* wins. If cwd is provided, project dirs are scanned first. */
|
|
89
|
-
export function discoverUserRotors(cwd?: string): string[] {
|
|
90
|
-
const seen = new Set<string>();
|
|
91
|
-
const out: string[] = [];
|
|
92
|
-
for (const dir of rotorHomeDirs(cwd)) {
|
|
93
|
-
if (!fs.existsSync(dir)) continue;
|
|
94
|
-
let entries: string[];
|
|
95
|
-
try {
|
|
96
|
-
entries = fs.readdirSync(dir);
|
|
97
|
-
} catch {
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
for (const file of entries) {
|
|
101
|
-
if (!file.endsWith(".md")) continue;
|
|
102
|
-
try {
|
|
103
|
-
const raw = fs.readFileSync(path.join(dir, file), "utf-8");
|
|
104
|
-
const m = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
105
|
-
if (!m) continue;
|
|
106
|
-
const fm = m[1] ?? "";
|
|
107
|
-
const nameMatch = fm.match(/^name:\s*(.+)$/m);
|
|
108
|
-
if (nameMatch) {
|
|
109
|
-
const n = (nameMatch[1] ?? "").trim();
|
|
110
|
-
if (isValidRotorName(n) && !seen.has(n)) {
|
|
111
|
-
seen.add(n);
|
|
112
|
-
out.push(n);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
} catch { /* skip unreadable */ }
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return out;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Build the full cycle of available agents. Built-ins first, then
|
|
122
|
-
* project-level agents (if cwd given), then user-home agents.
|
|
123
|
-
* Dedupes while preserving first-occurrence order. */
|
|
124
|
-
export function availableAgents(cwd?: string): string[] {
|
|
125
|
-
const out: string[] = [];
|
|
126
|
-
const seen = new Set<string>();
|
|
127
|
-
const push = (n: string) => {
|
|
128
|
-
if (!seen.has(n)) {
|
|
129
|
-
seen.add(n);
|
|
130
|
-
out.push(n);
|
|
131
|
-
}
|
|
132
|
-
};
|
|
133
|
-
for (const a of BUILTIN_ROTORS) push(a);
|
|
134
|
-
for (const a of discoverUserRotors(cwd)) push(a);
|
|
135
|
-
return out;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Cycle order. */
|
|
139
|
-
export function nextAgent(current: string, cycle: readonly string[]): string {
|
|
140
|
-
if (cycle.length === 0) return DEFAULT_ROTOR;
|
|
141
|
-
const idx = cycle.indexOf(current);
|
|
142
|
-
if (idx < 0) return cycle[0]!;
|
|
143
|
-
return cycle[(idx + 1) % cycle.length]!;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Parse a user-supplied agent name. */
|
|
147
|
-
export function parseRotorName(raw: string): string | null {
|
|
148
|
-
const n = raw.trim();
|
|
149
|
-
if (!isValidRotorName(n)) return null;
|
|
150
|
-
return n;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Short badge: `<emoji> <name>`. Null for default (silent). */
|
|
154
|
-
export function formatAgentBadge(agent: string): string | null {
|
|
155
|
-
if (agent === DEFAULT_ROTOR) return null;
|
|
156
|
-
const meta = getRotorMeta(agent);
|
|
157
|
-
return `${meta.emoji} ${agent}`;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Multi-line switch notify. */
|
|
161
|
-
export function formatRotorSwitchNotify(prev: string, next: string): string {
|
|
162
|
-
const prevMeta = getRotorMeta(prev);
|
|
163
|
-
const nextMeta = getRotorMeta(next);
|
|
164
|
-
const lines: string[] = [
|
|
165
|
-
"pi-switch agent changed",
|
|
166
|
-
"",
|
|
167
|
-
` ${prevMeta.emoji} ${prev.padEnd(16)} → ${nextMeta.emoji} ${next}`,
|
|
168
|
-
` ${"".padEnd(16)} ${nextMeta.description}`,
|
|
169
|
-
"",
|
|
170
|
-
` writes files: ${nextMeta.writesFiles ? "yes" : "no (read-only)"} · next subagent call uses: ${next}`,
|
|
171
|
-
];
|
|
172
|
-
return lines.join("\n");
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Group agents: built-ins + user-defined. */
|
|
176
|
-
export function groupedAvailableRotors(cwd?: string): Array<{ header: string; agents: string[] }> {
|
|
177
|
-
const all = availableAgents(cwd);
|
|
178
|
-
const groups: Array<{ header: string; agents: string[] }> = [];
|
|
179
|
-
const builtin = all.filter((a) => BUILTIN_ROTORS.includes(a));
|
|
180
|
-
if (builtin.length > 0) groups.push({ header: "built-in", agents: builtin });
|
|
181
|
-
const user = all.filter((a) => !BUILTIN_ROTORS.includes(a));
|
|
182
|
-
if (user.length > 0) groups.push({ header: "user-defined", agents: user });
|
|
183
|
-
return groups;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Header line shown above chat. Persistent, dim, single line. */
|
|
187
|
-
export function formatHeaderLine(agent: string): string {
|
|
188
|
-
const meta = getRotorMeta(agent);
|
|
189
|
-
const writeTag = meta.writesFiles ? "" : " \u00b7 read-only";
|
|
190
|
-
return `${meta.emoji} ${agent} \u00b7 ${meta.description}${writeTag} [Ctrl+Tab to cycle]`;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// ---------------------------------------------------------------------------
|
|
194
|
-
// Persistence
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
|
|
197
|
-
/** Where to persist the current agent. Prefers `.soly/agent` if a soly
|
|
198
|
-
* project exists (shared with soly extension). Otherwise `~/.pi-switch/agent`. */
|
|
199
|
-
export function agentFilePath(cwd: string): string {
|
|
200
|
-
const solyAgent = path.join(cwd, ".soly", "agent");
|
|
201
|
-
if (fs.existsSync(path.join(cwd, ".soly"))) return solyAgent;
|
|
202
|
-
// Respect HOME/USERPROFILE for testability (otherwise os.homedir() ignores them on Windows)
|
|
203
|
-
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
204
|
-
const fallbackDir = path.join(home, ".pi-switch");
|
|
205
|
-
fs.mkdirSync(fallbackDir, { recursive: true });
|
|
206
|
-
return path.join(fallbackDir, "agent");
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** Read persisted agent from disk. Returns null if missing/invalid. */
|
|
210
|
-
export function loadAgent(cwd: string): string | null {
|
|
211
|
-
try {
|
|
212
|
-
const file = agentFilePath(cwd);
|
|
213
|
-
if (!fs.existsSync(file)) return null;
|
|
214
|
-
const raw = fs.readFileSync(file, "utf-8").trim();
|
|
215
|
-
if (!isValidRotorName(raw)) return null;
|
|
216
|
-
return raw;
|
|
217
|
-
} catch {
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Write current agent to disk. */
|
|
223
|
-
export function saveAgent(cwd: string, agent: string): void {
|
|
224
|
-
try {
|
|
225
|
-
const file = agentFilePath(cwd);
|
|
226
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
227
|
-
fs.writeFileSync(file, agent + "\n", "utf-8");
|
|
228
|
-
} catch { /* best-effort */ }
|
|
229
|
-
}
|
package/switch/index.ts
DELETED
|
@@ -1,365 +0,0 @@
|
|
|
1
|
-
// =============================================================================
|
|
2
|
-
// index.ts — pi-switch extension entry (v2: footer-pill UI)
|
|
3
|
-
// =============================================================================
|
|
4
|
-
//
|
|
5
|
-
// Wires the rotor switcher into pi as a compact footer pill:
|
|
6
|
-
// - Footer status pill: "▶ ⚡ worker" (or "· ⚡ worker" for the default)
|
|
7
|
-
// - Click pill or `/rotor` → open full picker modal (SelectList)
|
|
8
|
-
// - Ctrl+Tab → cycle to next rotor (no popup, hot switch)
|
|
9
|
-
// - F2 → same, fallback if your terminal doesn't pass Ctrl+Tab through
|
|
10
|
-
// - Persists current rotor to .soly/agent or ~/.pi-switch/agent
|
|
11
|
-
// - Exposes `globalThis.__PI_SWITCH_ROTOR__` for other extensions
|
|
12
|
-
// - Injects a short system-prompt section so the LLM knows the current
|
|
13
|
-
// rotor and the available alternatives
|
|
14
|
-
//
|
|
15
|
-
// UI philosophy:
|
|
16
|
-
// - Header is for content, not for tool chrome. Move rotors to footer.
|
|
17
|
-
// - Click to explore, hotkey to power-use, no DOM clutter in between.
|
|
18
|
-
// - Visual change is the pill text only. Chat stays clean.
|
|
19
|
-
// =============================================================================
|
|
20
|
-
|
|
21
|
-
import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
-
import { Box, Text } from "@earendil-works/pi-tui";
|
|
23
|
-
import * as fs from "node:fs";
|
|
24
|
-
import * as os from "node:os";
|
|
25
|
-
import * as path from "node:path";
|
|
26
|
-
import {
|
|
27
|
-
DEFAULT_ROTOR,
|
|
28
|
-
BUILTIN_ROTORS,
|
|
29
|
-
availableAgents,
|
|
30
|
-
nextAgent,
|
|
31
|
-
parseRotorName,
|
|
32
|
-
groupedAvailableRotors,
|
|
33
|
-
getRotorMeta,
|
|
34
|
-
loadAgent,
|
|
35
|
-
saveAgent,
|
|
36
|
-
} from "./core.ts";
|
|
37
|
-
import { buildPiSwitchSection, recommendAgent } from "./prompt.ts";
|
|
38
|
-
import { watchRotors, type WatcherHandle } from "./watcher.ts";
|
|
39
|
-
|
|
40
|
-
const GLOBAL_KEY = "__PI_SWITCH_ROTOR__";
|
|
41
|
-
|
|
42
|
-
export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
43
|
-
let cwd = "";
|
|
44
|
-
let currentRotor: string = DEFAULT_ROTOR;
|
|
45
|
-
let cycle: string[] = [DEFAULT_ROTOR];
|
|
46
|
-
let lastUi: ExtensionUIContext | null = null;
|
|
47
|
-
let rotorWatcher: WatcherHandle | null = null;
|
|
48
|
-
|
|
49
|
-
function startRotorWatcher(): void {
|
|
50
|
-
// Already running — restart to pick up new cwd
|
|
51
|
-
rotorWatcher?.stop();
|
|
52
|
-
rotorWatcher = watchRotors(cwd, {
|
|
53
|
-
onChange: () => {
|
|
54
|
-
refreshCycle();
|
|
55
|
-
publish();
|
|
56
|
-
rerender();
|
|
57
|
-
},
|
|
58
|
-
onNotify: (msg) => {
|
|
59
|
-
lastUi?.notify(`pi-switch: ${msg}`, "info");
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function refreshCycle(): void {
|
|
65
|
-
cycle = availableAgents(cwd);
|
|
66
|
-
if (!cycle.includes(currentRotor)) currentRotor = DEFAULT_ROTOR;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function publish(): void {
|
|
70
|
-
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = currentRotor;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function rerender(): void {
|
|
74
|
-
if (!lastUi) return;
|
|
75
|
-
try {
|
|
76
|
-
const meta = getRotorMeta(currentRotor);
|
|
77
|
-
// Persistent pill — always visible above the input, even for the
|
|
78
|
-
// default rotor. The user wants a constant mode indicator, not a
|
|
79
|
-
// transient one. Marker "▶" makes it scannable.
|
|
80
|
-
const marker = currentRotor === DEFAULT_ROTOR ? "·" : "▶";
|
|
81
|
-
const pill = `${marker} ${meta.emoji} ${currentRotor}`;
|
|
82
|
-
lastUi.setStatus("pi-switch", pill);
|
|
83
|
-
} catch { /* no ui yet */ }
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function setRotor(next: string): void {
|
|
87
|
-
const prev = currentRotor;
|
|
88
|
-
if (next === prev) return;
|
|
89
|
-
currentRotor = next;
|
|
90
|
-
publish();
|
|
91
|
-
if (cwd) saveAgent(cwd, next);
|
|
92
|
-
rerender();
|
|
93
|
-
// Footer pill is the only visible signal of the switch.
|
|
94
|
-
// Chat stays clean — rotor is plumbing, not conversation content.
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ----- session_start: load persisted agent + set initial pill -----
|
|
98
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
99
|
-
cwd = ctx.cwd;
|
|
100
|
-
lastUi = ctx.ui;
|
|
101
|
-
publish();
|
|
102
|
-
const restored = loadAgent(cwd);
|
|
103
|
-
if (restored) currentRotor = restored;
|
|
104
|
-
refreshCycle();
|
|
105
|
-
publish();
|
|
106
|
-
rerender();
|
|
107
|
-
startRotorWatcher();
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
// ----- before_agent_start: inject system-prompt section -----
|
|
111
|
-
pi.on("before_agent_start", async (event, ctx) => {
|
|
112
|
-
lastUi = ctx.ui;
|
|
113
|
-
rerender();
|
|
114
|
-
return {
|
|
115
|
-
systemPrompt: event.systemPrompt + buildPiSwitchSection(),
|
|
116
|
-
};
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
// ----- Hot cycle (no popup, no confirmation) -----
|
|
120
|
-
// Ctrl+Tab is the primary shortcut (most terminals support it).
|
|
121
|
-
// F2 is kept as a backup for terminals that don't pass Ctrl+Tab through.
|
|
122
|
-
// Debounced: 180ms — terminal key auto-repeat can fire the same key 5+
|
|
123
|
-
// times per second, which would spam the chat with the same agent
|
|
124
|
-
// notification. The window covers auto-repeat but allows deliberate
|
|
125
|
-
// sequential presses.
|
|
126
|
-
let lastCycleTs = 0;
|
|
127
|
-
const CYCLE_DEBOUNCE_MS = 180;
|
|
128
|
-
const cycleShortcut = (sctx: { ui: ExtensionUIContext }): void => {
|
|
129
|
-
const now = Date.now();
|
|
130
|
-
if (now - lastCycleTs < CYCLE_DEBOUNCE_MS) return;
|
|
131
|
-
lastCycleTs = now;
|
|
132
|
-
lastUi = sctx.ui;
|
|
133
|
-
refreshCycle();
|
|
134
|
-
setRotor(nextAgent(currentRotor, cycle));
|
|
135
|
-
};
|
|
136
|
-
pi.registerShortcut("ctrl+tab", {
|
|
137
|
-
description: "Cycle to next agent (worker → oracle → scout → …)",
|
|
138
|
-
handler: (sctx) => cycleShortcut(sctx),
|
|
139
|
-
});
|
|
140
|
-
pi.registerShortcut("f2", {
|
|
141
|
-
description: "Cycle to next agent (F2 fallback if Ctrl+Tab isn't passed by your terminal)",
|
|
142
|
-
handler: (sctx) => cycleShortcut(sctx),
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
// ----- /rotor: open picker, or subcommands (create / doctor / recommend / set) -----
|
|
146
|
-
// (formerly /agent — renamed to /rotor in 1.0.0 to match the "rotor" naming convention)
|
|
147
|
-
pi.registerCommand("rotor", {
|
|
148
|
-
description: "open rotor picker, or `set <name>`, `create`, `doctor`, `recommend <task>`",
|
|
149
|
-
handler: async (args, ctx) => {
|
|
150
|
-
lastUi = ctx.ui;
|
|
151
|
-
refreshCycle();
|
|
152
|
-
const parts = args.trim().split(/\s+/);
|
|
153
|
-
const subcommand = parts[0]?.toLowerCase();
|
|
154
|
-
const arg = parts[1];
|
|
155
|
-
|
|
156
|
-
if (subcommand === "create") return createAgent(arg, ctx.ui, cwd);
|
|
157
|
-
if (subcommand === "doctor") return ctx.ui.notify(doctorReport(), "info");
|
|
158
|
-
if (subcommand === "recommend") return handleRecommend(parts.slice(1).join(" "), ctx.ui);
|
|
159
|
-
if (subcommand === "set" && arg) return handleSet(arg, ctx.ui);
|
|
160
|
-
|
|
161
|
-
// Direct agent name → set
|
|
162
|
-
if (subcommand && cycle.includes(subcommand)) return setRotor(subcommand);
|
|
163
|
-
if (arg && !subcommand) return handleSet(arg, ctx.ui);
|
|
164
|
-
|
|
165
|
-
// No arg: open picker modal
|
|
166
|
-
openPicker(ctx.ui);
|
|
167
|
-
},
|
|
168
|
-
});
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ---------------------------------------------------------------------------
|
|
172
|
-
// Picker modal (TUI SelectList)
|
|
173
|
-
// ---------------------------------------------------------------------------
|
|
174
|
-
|
|
175
|
-
function openPicker(ui: ExtensionUIContext): void {
|
|
176
|
-
refreshAndBuild(ui, (groups) => {
|
|
177
|
-
const all: Array<{ value: string; label: string; description: string; isCurrent: boolean }> = [];
|
|
178
|
-
for (const g of groups) {
|
|
179
|
-
all.push({ value: "__sep__", label: `── ${g.header} `, description: "", isCurrent: false });
|
|
180
|
-
for (const a of g.agents) {
|
|
181
|
-
const m = getRotorMeta(a);
|
|
182
|
-
all.push({
|
|
183
|
-
value: a,
|
|
184
|
-
label: `${m.emoji} ${a}`,
|
|
185
|
-
description: `${m.description}${m.writesFiles ? "" : " · read-only"}`,
|
|
186
|
-
isCurrent: a === currentRotorRef(),
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
return all;
|
|
191
|
-
}, ui, (choice) => {
|
|
192
|
-
if (choice && choice !== "__sep__") setRotorRef(choice);
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function handleSet(name: string, ui: ExtensionUIContext): void {
|
|
197
|
-
const target = parseRotorName(name);
|
|
198
|
-
if (!target) return ui.notify(`pi-switch: invalid name "${name}".`, "error");
|
|
199
|
-
if (!availableAgents(cwd).includes(target)) {
|
|
200
|
-
return ui.notify(`pi-switch: unknown "${target}". available: ${availableAgents(cwd).join(", ")}`, "error");
|
|
201
|
-
}
|
|
202
|
-
setRotorRef(target);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function handleRecommend(task: string, ui: ExtensionUIContext): void {
|
|
206
|
-
if (!task) return ui.notify("pi-switch: usage — `/agent recommend <task>`", "info");
|
|
207
|
-
const rec = recommendAgent(task);
|
|
208
|
-
if (!rec) return ui.notify(`pi-switch: no clear match for: "${task}"`, "info");
|
|
209
|
-
ui.notify(`${rec.emoji} ${rec.agent} · why: ${rec.why}\n → /agent ${rec.agent} to switch`, "info");
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// ---------------------------------------------------------------------------
|
|
213
|
-
// setAgent / currentRotor — module-scope so the modal can mutate them
|
|
214
|
-
// ---------------------------------------------------------------------------
|
|
215
|
-
|
|
216
|
-
let currentRotorRef: () => string = () => DEFAULT_ROTOR;
|
|
217
|
-
let setRotorRef: (next: string) => void = () => {};
|
|
218
|
-
|
|
219
|
-
// The picker and the main extension share state via these refs.
|
|
220
|
-
// We patch them in `wire()` at the top of the default export.
|
|
221
|
-
function wire(get: () => string, set: (n: string) => void): void {
|
|
222
|
-
currentRotorRef = get;
|
|
223
|
-
setRotorRef = set;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function refreshAndBuild<T>(
|
|
227
|
-
ui: ExtensionUIContext,
|
|
228
|
-
build: (groups: ReturnType<typeof groupedAvailableRotors>) => T,
|
|
229
|
-
_ui: ExtensionUIContext,
|
|
230
|
-
_onSelect: (value: string) => void,
|
|
231
|
-
): void {
|
|
232
|
-
// Currently unused: we build inline in openPicker. Kept for future.
|
|
233
|
-
void build;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// ---------------------------------------------------------------------------
|
|
237
|
-
// /agent create — scaffold a new agent .md
|
|
238
|
-
// ---------------------------------------------------------------------------
|
|
239
|
-
|
|
240
|
-
function createAgent(
|
|
241
|
-
name: string | undefined,
|
|
242
|
-
ui: { notify: (t: string, k?: "info" | "warning" | "error") => void; input: (t: string, p?: string) => Promise<string | undefined> },
|
|
243
|
-
cwd: string,
|
|
244
|
-
): void {
|
|
245
|
-
if (!name) {
|
|
246
|
-
ui.notify("pi-switch: usage — `/agent create <name>`", "info");
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
if (!parseRotorName(name)) {
|
|
250
|
-
ui.notify(`pi-switch: invalid name "${name}". Use letters/digits/dashes/underscores, ≤64 chars.`, "error");
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
// Write to ~/.agents/ (vendor-neutral) first; fall back to ~/.pi/agent/agents/
|
|
254
|
-
// (pi's native) if .agents/ doesn't exist or is unwritable.
|
|
255
|
-
const home = os.homedir();
|
|
256
|
-
const candidates = [
|
|
257
|
-
path.join(home, ".agents"),
|
|
258
|
-
path.join(home, ".pi", "agent", "agents"),
|
|
259
|
-
];
|
|
260
|
-
const targetDir = candidates.find((d) => {
|
|
261
|
-
try {
|
|
262
|
-
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
263
|
-
// Probe write permission
|
|
264
|
-
const probe = path.join(d, ".pi-switch-write-probe");
|
|
265
|
-
fs.writeFileSync(probe, "");
|
|
266
|
-
fs.unlinkSync(probe);
|
|
267
|
-
return true;
|
|
268
|
-
} catch {
|
|
269
|
-
return false;
|
|
270
|
-
}
|
|
271
|
-
});
|
|
272
|
-
if (!targetDir) {
|
|
273
|
-
ui.notify(`pi-switch: could not find a writable agents dir (tried ${candidates.join(", ")}).`, "error");
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
const file = path.join(targetDir, `${name}.md`);
|
|
277
|
-
if (fs.existsSync(file)) {
|
|
278
|
-
ui.notify(`pi-switch: ${file} already exists. edit it directly.`, "warning");
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
void ui.input(`description for "${name}":`, "one-liner that shows in the picker")?.then((desc) => {
|
|
282
|
-
const description = desc?.trim() || `custom agent (${name})`;
|
|
283
|
-
try {
|
|
284
|
-
// Atomic write: tmp + rename. Avoids partial files and the
|
|
285
|
-
// race where two parallel createAgent calls would clobber each
|
|
286
|
-
// other's write after both pass the existsSync check.
|
|
287
|
-
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
288
|
-
fs.writeFileSync(tmp, rotorTemplate(name, description), "utf-8");
|
|
289
|
-
fs.renameSync(tmp, file);
|
|
290
|
-
ui.notify(
|
|
291
|
-
`pi-switch: created ${file}\n → next Ctrl+Tab to see it in the cycle\n → edit the system prompt to specialize`,
|
|
292
|
-
"info",
|
|
293
|
-
);
|
|
294
|
-
} catch (err) {
|
|
295
|
-
ui.notify(`pi-switch: failed to write ${file}: ${(err as Error).message}`, "error");
|
|
296
|
-
}
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function rotorTemplate(name: string, description: string): string {
|
|
301
|
-
return `---
|
|
302
|
-
name: ${name}
|
|
303
|
-
description: ${description}
|
|
304
|
-
thinking: medium
|
|
305
|
-
systemPromptMode: replace
|
|
306
|
-
inheritProjectContext: true
|
|
307
|
-
inheritSkills: false
|
|
308
|
-
tools: read, grep, find, ls, bash, edit, write
|
|
309
|
-
defaultContext: fork
|
|
310
|
-
---
|
|
311
|
-
|
|
312
|
-
You are \`${name}\`. Describe what you specialize in, your process, and
|
|
313
|
-
what you should NOT do. Keep the rest of this frontmatter as-is unless
|
|
314
|
-
you have a specific reason to change it.
|
|
315
|
-
|
|
316
|
-
# Your role
|
|
317
|
-
|
|
318
|
-
<!-- Replace with a one-paragraph description of what you're for. -->
|
|
319
|
-
|
|
320
|
-
# Process
|
|
321
|
-
|
|
322
|
-
1. Read the user's request carefully.
|
|
323
|
-
2. Form a hypothesis about the right approach.
|
|
324
|
-
3. Verify with tools (read, grep, bash) before writing.
|
|
325
|
-
4. Commit changes in narrow, reviewable diffs.
|
|
326
|
-
|
|
327
|
-
# What you should NOT do
|
|
328
|
-
|
|
329
|
-
- Edit other agents' files
|
|
330
|
-
- Run subagents yourself (you're already a subagent)
|
|
331
|
-
- Skip verification ("trust me bro" is not a process)
|
|
332
|
-
`;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function doctorReport(): string {
|
|
336
|
-
const cycle = availableAgents(cwd);
|
|
337
|
-
const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
|
338
|
-
const lines: string[] = ["pi-switch doctor:", ""];
|
|
339
|
-
const builtins = cycle.filter((a) => BUILTIN_ROTORS.includes(a));
|
|
340
|
-
const users = cycle.filter((a) => !BUILTIN_ROTORS.includes(a));
|
|
341
|
-
lines.push(`cycle: ${cycle.length} agents (${builtins.length} built-in, ${users.length} user)`);
|
|
342
|
-
lines.push("");
|
|
343
|
-
if (!fs.existsSync(userDir)) {
|
|
344
|
-
lines.push(`user dir: ${userDir} (does not exist)`);
|
|
345
|
-
} else {
|
|
346
|
-
const files = fs.readdirSync(userDir).filter((f) => f.endsWith(".md"));
|
|
347
|
-
lines.push(`user dir: ${userDir} (${files.length} file(s))`);
|
|
348
|
-
const issues: string[] = [];
|
|
349
|
-
for (const f of files) {
|
|
350
|
-
try {
|
|
351
|
-
const raw = fs.readFileSync(path.join(userDir, f), "utf-8");
|
|
352
|
-
if (!raw.startsWith("---\n")) { issues.push(`${f}: no YAML frontmatter`); continue; }
|
|
353
|
-
const m = raw.match(/^---\n([\s\S]*?)\n---/);
|
|
354
|
-
if (!m) { issues.push(`${f}: malformed frontmatter`); continue; }
|
|
355
|
-
const fm = m[1] ?? "";
|
|
356
|
-
if (!/^name:\s*\S/m.test(fm)) issues.push(`${f}: missing 'name:' in frontmatter`);
|
|
357
|
-
else if (!/^description:\s*\S/m.test(fm)) issues.push(`${f}: missing 'description:' in frontmatter`);
|
|
358
|
-
} catch (e) {
|
|
359
|
-
issues.push(`${f}: read error: ${(e as Error).message}`);
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
lines.push(issues.length === 0 ? "validation: all user agents OK ✓" : "validation issues:\n - " + issues.join("\n - "));
|
|
363
|
-
}
|
|
364
|
-
return lines.join("\n");
|
|
365
|
-
}
|
package/switch/package.json
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-agented",
|
|
3
|
-
"version": "0.2.0",
|
|
4
|
-
"description": "Generic subagent switcher for pi. Header bar above chat, Ctrl+Shift+S to cycle, /agent slash command. Works with any agent in ~/.pi/agent/agents/.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "index.ts",
|
|
7
|
-
"scripts": {
|
|
8
|
-
"test": "bun test",
|
|
9
|
-
"typecheck": "bun x tsc --noEmit"
|
|
10
|
-
},
|
|
11
|
-
"dependencies": {},
|
|
12
|
-
"peerDependencies": {
|
|
13
|
-
"@earendil-works/pi-coding-agent": "*",
|
|
14
|
-
"@earendil-works/pi-tui": "*"
|
|
15
|
-
},
|
|
16
|
-
"devDependencies": {
|
|
17
|
-
"@earendil-works/pi-coding-agent": "0.78.1",
|
|
18
|
-
"@earendil-works/pi-tui": "0.78.1",
|
|
19
|
-
"@types/node": "^25.9.1",
|
|
20
|
-
"bun-types": "^1.3.14",
|
|
21
|
-
"typebox": "1.1.38",
|
|
22
|
-
"typescript": "^6.0.3"
|
|
23
|
-
},
|
|
24
|
-
"files": [
|
|
25
|
-
"index.ts",
|
|
26
|
-
"core.ts",
|
|
27
|
-
"prompt.ts",
|
|
28
|
-
"README.md"
|
|
29
|
-
],
|
|
30
|
-
"keywords": [
|
|
31
|
-
"pi",
|
|
32
|
-
"pi-extension",
|
|
33
|
-
"pi-package",
|
|
34
|
-
"subagent",
|
|
35
|
-
"agent-switcher",
|
|
36
|
-
"task-routing"
|
|
37
|
-
],
|
|
38
|
-
"license": "MIT",
|
|
39
|
-
"pi": {
|
|
40
|
-
"extensions": [
|
|
41
|
-
"./index.ts"
|
|
42
|
-
]
|
|
43
|
-
},
|
|
44
|
-
"publishConfig": {
|
|
45
|
-
"registry": "https://registry.npmjs.org/"
|
|
46
|
-
},
|
|
47
|
-
"repository": {
|
|
48
|
-
"type": "git",
|
|
49
|
-
"url": "http://git.local.stbl/lowern1ght/pi-soly.framework.git",
|
|
50
|
-
"directory": "packages/pi-switch"
|
|
51
|
-
}
|
|
52
|
-
}
|