pi-soly 0.9.0 → 1.0.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 +7 -7
- package/agents-install.ts +6 -5
- package/commands.ts +2 -2
- package/index.ts +4 -4
- package/package.json +1 -1
- package/skills/soly-framework/SKILL.md +9 -7
- package/switch/core.ts +35 -33
- package/switch/index.ts +48 -47
- package/switch/tests/core.test.ts +38 -38
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**The project management framework for [pi-coding-agent](https://github.com/nicobailon/pi-coding-agent).**
|
|
6
6
|
|
|
7
|
-
Plans · State · Subagents · Multi-question picker ·
|
|
7
|
+
Plans · State · Subagents · Multi-question picker · Rotor switcher · Live task list.
|
|
8
8
|
|
|
9
9
|
One `npm install`. Zero config. Pure magic.
|
|
10
10
|
|
|
@@ -30,7 +30,7 @@ That's it. Restart pi, and you have:
|
|
|
30
30
|
|
|
31
31
|
- **A complete project management engine** — plans, state, subagent-driven execution
|
|
32
32
|
- **A multi-question picker** — `ask_pro` tool for the LLM
|
|
33
|
-
- **
|
|
33
|
+
- **A rotor switcher** — `Ctrl+Tab` to cycle, footer pill always visible
|
|
34
34
|
- **A live task list** — `todo_update` tool renders in the footer
|
|
35
35
|
- **7 soly agents** installed on first run
|
|
36
36
|
|
|
@@ -43,7 +43,7 @@ That's it. Restart pi, and you have:
|
|
|
43
43
|
| Write your own planning workflow | `/plan`, `/execute`, `/resume`, `/inspect` — ready |
|
|
44
44
|
| Manually dispatch subagents | `useSolyWorkerSubagents: true` — automatic routing |
|
|
45
45
|
| 3 different packages for pickers/tasks/agents | One package, one config, one install |
|
|
46
|
-
|
|
|
46
|
+
| Rotor name as free text in slash commands | Footer pill + `Ctrl+Tab` + `/rotor` picker |
|
|
47
47
|
| Re-invent the state machine | `.soly/STATE.md` + auto-managed phases |
|
|
48
48
|
|
|
49
49
|
---
|
|
@@ -94,7 +94,7 @@ ask_pro({
|
|
|
94
94
|
})
|
|
95
95
|
```
|
|
96
96
|
|
|
97
|
-
### 🎛
|
|
97
|
+
### 🎛 Rotor Switcher
|
|
98
98
|
|
|
99
99
|
Footer pill that's always there. `Ctrl+Tab` to cycle. No popup, no friction.
|
|
100
100
|
|
|
@@ -171,11 +171,11 @@ todo_update({
|
|
|
171
171
|
▼
|
|
172
172
|
┌──────────────────┐
|
|
173
173
|
│ switch/ │
|
|
174
|
-
│
|
|
174
|
+
│ rotor switcher │
|
|
175
175
|
│ │
|
|
176
176
|
│ Ctrl+Tab │
|
|
177
177
|
│ footer pill │
|
|
178
|
-
│ /
|
|
178
|
+
│ /rotor picker │
|
|
179
179
|
└────────┬─────────┘
|
|
180
180
|
│
|
|
181
181
|
▼
|
|
@@ -196,7 +196,7 @@ todo_update({
|
|
|
196
196
|
|
|
197
197
|
## 📚 Documentation
|
|
198
198
|
|
|
199
|
-
- **Slash commands** — `/plan`, `/execute`, `/resume`, `/inspect`, `/discuss <N>`, `/quick <task>`, `/
|
|
199
|
+
- **Slash commands** — `/plan`, `/execute`, `/resume`, `/inspect`, `/discuss <N>`, `/quick <task>`, `/rotor`
|
|
200
200
|
- **Tools** — `ask_pro(question[])` and `todo_update(todo[])`
|
|
201
201
|
- **Events** — `session_start`, `before_agent_start`, `message_end`, `tool_call`, `tool_result`
|
|
202
202
|
- **State files** — `.soly/STATE.md`, `.soly/ROADMAP.md`, `.soly/phases/<N>-<slug>/<N>-PLAN.md`
|
package/agents-install.ts
CHANGED
|
@@ -35,13 +35,14 @@ const SHIPPED_SKILLS = [
|
|
|
35
35
|
|
|
36
36
|
/** Where pi looks for user agents. Respects HOME/USERPROFILE for
|
|
37
37
|
* testability (otherwise we'd always write to the real user home).
|
|
38
|
-
*
|
|
39
|
-
*
|
|
38
|
+
* Agent files live directly in the dir (no `agents/` subfolder):
|
|
39
|
+
* ~/.agents/reviewer.md (NOT ~/.agents/agents/reviewer.md)
|
|
40
|
+
* This keeps the path clean and matches the project-level convention. */
|
|
40
41
|
function userAgentsDirs(): string[] {
|
|
41
42
|
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
42
43
|
return [
|
|
43
|
-
path.join(home, ".agents"
|
|
44
|
-
path.join(home, ".pi", "agent", "agents"), // pi native
|
|
44
|
+
path.join(home, ".agents"), // vendor-neutral (preferred)
|
|
45
|
+
path.join(home, ".pi", "agent", "agents"), // pi native (legacy)
|
|
45
46
|
];
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -94,7 +95,7 @@ function copyDirIfMissing(from: string, to: string): "installed" | "skipped" | "
|
|
|
94
95
|
|
|
95
96
|
/** Install shipped soly agents to `~/.agents/` (vendor-neutral,
|
|
96
97
|
* preferred). Legacy `~/.pi/agent/agents/` copies are left alone —
|
|
97
|
-
* `
|
|
98
|
+
* `discoverUserRotors` reads both, so old installs still work. */
|
|
98
99
|
export function installSolyAgents(extensionRoot: string): InstallResult {
|
|
99
100
|
const result: InstallResult = { installed: [], skipped: [], errors: [] };
|
|
100
101
|
const src = shippedAgentsDir(extensionRoot);
|
package/commands.ts
CHANGED
|
@@ -371,8 +371,8 @@ What must the LLM do?
|
|
|
371
371
|
};
|
|
372
372
|
const subcommands: Record<string, SolySub> = {
|
|
373
373
|
// `agent` subcommand REMOVED — moved to the separate `pi-switch`
|
|
374
|
-
// extension as the `/
|
|
375
|
-
// Soly no longer owns the
|
|
374
|
+
// extension as the `/rotor` slash command (footer pill + Ctrl+Tab).
|
|
375
|
+
// Soly no longer owns the rotor switcher UI.
|
|
376
376
|
config: {
|
|
377
377
|
description: "show merged config (per-project + global + defaults); edit .soly/config.json or ~/.soly/config.json",
|
|
378
378
|
run: () => {
|
package/index.ts
CHANGED
|
@@ -80,12 +80,12 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
80
80
|
let sessionCwd = "";
|
|
81
81
|
|
|
82
82
|
// ============================================================================
|
|
83
|
-
//
|
|
83
|
+
// Rotor switcher (Shift+Tab cycles through available rotors)
|
|
84
84
|
// ============================================================================
|
|
85
85
|
|
|
86
86
|
// ============================================================================
|
|
87
|
-
//
|
|
88
|
-
// separate `pi-switch` extension (footer pill + Ctrl+Tab + /
|
|
87
|
+
// Rotor switcher: REMOVED. The rotor cycler is now owned by the
|
|
88
|
+
// separate `pi-switch` extension (footer pill + Ctrl+Tab + /rotor slash).
|
|
89
89
|
// Soly owns a single subagent (soly-manager.md) and the auto-install on
|
|
90
90
|
// opt-in. Workflows read the current agent from
|
|
91
91
|
// globalThis.__PI_SWITCH_AGENT__ (set by pi-switch).
|
|
@@ -328,7 +328,7 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
328
328
|
// and mnemonic for "A"gent.)
|
|
329
329
|
// ============================================================================
|
|
330
330
|
// Agent switcher REMOVED — moved to the separate `pi-switch` extension.
|
|
331
|
-
// Soly no longer owns Ctrl+Tab, the footer pill, or /
|
|
331
|
+
// Soly no longer owns Ctrl+Tab, the footer pill, or /rotor slash.
|
|
332
332
|
// The current agent is read by soly workflows from
|
|
333
333
|
// globalThis.__PI_SWITCH_AGENT__ (set by pi-switch), with a fallback
|
|
334
334
|
// to "worker" if pi-switch isn't installed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-soly",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Project management framework for pi-coding-agent. Workflows, planning, multi-question picker, agent switcher, live task list — one npm install, zero config.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -35,7 +35,7 @@ The **soly** extension adds project-management workflow to [pi-coding-agent](htt
|
|
|
35
35
|
| `/quick <task>` | One-shot task that doesn't need a full plan — direct dispatch |
|
|
36
36
|
| `/soly` | Project state inspection (alias for `/inspect`) |
|
|
37
37
|
| `/why` | Show what context the LLM's last turn was based on |
|
|
38
|
-
| `/
|
|
38
|
+
| `/rotor [name]` | Switch the current rotor (or open picker) |
|
|
39
39
|
|
|
40
40
|
`/soly <verb>` plain-text aliases also work for some verbs (legacy compat).
|
|
41
41
|
|
|
@@ -198,16 +198,18 @@ The only legal sequence for finishing a plan:
|
|
|
198
198
|
|
|
199
199
|
Once production commits exist, returning without a committed `SUMMARY.md` is an **illegal partial-plan state** — the next `/execute` will detect it and refuse to start.
|
|
200
200
|
|
|
201
|
-
## Cycle
|
|
201
|
+
## Cycle rotors (4 built-in)
|
|
202
202
|
|
|
203
|
-
|
|
|
203
|
+
| Rotor | Writes | Use for |
|
|
204
204
|
|---|---|---|
|
|
205
205
|
| `worker` | ✅ | Generic implementation, full tools |
|
|
206
206
|
| `oracle` | ❌ | Decision-consistency, no file edits |
|
|
207
207
|
| `scout` | ❌ | Codebase recon, read-only |
|
|
208
208
|
| `reviewer` | ❌ | Adversarial code review |
|
|
209
209
|
|
|
210
|
-
Switch with `/
|
|
210
|
+
Switch with `/rotor <name>` or `Ctrl+Tab` (cycles through). Footer pill shows current: `· ⚡ worker` / `▶ 🐢 oracle`.
|
|
211
|
+
|
|
212
|
+
**Why "rotors"?** Because they *rotate* — `Ctrl+Tab` cycles through them. The word emphasizes the cycling behavior. Subagents (like `soly-manager`) are still called agents — they're a different concept (spawned for a task, not cycled through).
|
|
211
213
|
|
|
212
214
|
## Subagent: soly-manager (single, mode-switching)
|
|
213
215
|
|
|
@@ -300,7 +302,7 @@ Intent docs are 0-point — written BEFORE any plan, by humans. They define the
|
|
|
300
302
|
|
|
301
303
|
### Add project-specific agents
|
|
302
304
|
|
|
303
|
-
Drop a markdown file in `.agents
|
|
305
|
+
Drop a markdown file in `.agents/<name>.md` (project) or `~/.agents/<name>.md` (user):
|
|
304
306
|
|
|
305
307
|
```markdown
|
|
306
308
|
---
|
|
@@ -314,9 +316,9 @@ You are a data scientist. ...
|
|
|
314
316
|
```
|
|
315
317
|
|
|
316
318
|
**Discovered from 4 locations** (priority order):
|
|
317
|
-
1. `<project>/.agents
|
|
319
|
+
1. `<project>/.agents/` — project vendor-neutral (preferred)
|
|
318
320
|
2. `<project>/.pi/agent/agents/` — project pi native (legacy)
|
|
319
|
-
3. `~/.agents
|
|
321
|
+
3. `~/.agents/` — user vendor-neutral (preferred)
|
|
320
322
|
4. `~/.pi/agent/agents/` — user pi native (legacy)
|
|
321
323
|
|
|
322
324
|
`Ctrl+Tab` to see them in the cycle.
|
package/switch/core.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// =============================================================================
|
|
2
|
-
// core.ts — Generic
|
|
2
|
+
// core.ts — Generic subrotor switcher for pi
|
|
3
3
|
// =============================================================================
|
|
4
4
|
//
|
|
5
5
|
// Lets the user pick which subagent the LLM uses (for `subagent(...)` calls
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// pass Ctrl+Tab through).
|
|
13
13
|
//
|
|
14
14
|
// Communication with other extensions:
|
|
15
|
-
// - Writes `globalThis.
|
|
15
|
+
// - Writes `globalThis.__PI_SWITCH_ROTOR__` (in-process)
|
|
16
16
|
// - Reads/writes `.soly/agent` if it exists (cross-session persistence,
|
|
17
17
|
// shared with soly extension). If no soly project, persists to
|
|
18
18
|
// `~/.pi-switch/agent` instead.
|
|
@@ -23,10 +23,10 @@ import * as os from "node:os";
|
|
|
23
23
|
import * as path from "node:path";
|
|
24
24
|
|
|
25
25
|
/** Default agent used when no override is set. */
|
|
26
|
-
export const
|
|
26
|
+
export const DEFAULT_ROTOR = "worker";
|
|
27
27
|
|
|
28
28
|
/** Built-in pi-subagents that we always offer in the cycle. */
|
|
29
|
-
export const
|
|
29
|
+
export const BUILTIN_ROTORS: readonly string[] = [
|
|
30
30
|
"worker",
|
|
31
31
|
"oracle",
|
|
32
32
|
"scout",
|
|
@@ -35,14 +35,14 @@ export const BUILTIN_AGENTS: readonly string[] = [
|
|
|
35
35
|
|
|
36
36
|
/** Visual metadata for every known agent. Used by the rich status badge,
|
|
37
37
|
* the header bar, and the multi-line switch notify. */
|
|
38
|
-
export interface
|
|
38
|
+
export interface RotorMeta {
|
|
39
39
|
emoji: string;
|
|
40
40
|
shortLabel: string;
|
|
41
41
|
description: string;
|
|
42
42
|
writesFiles: boolean;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
export const
|
|
45
|
+
export const ROTOR_META: Record<string, RotorMeta> = {
|
|
46
46
|
worker: { emoji: "\u26a1", shortLabel: "worker", description: "generic implementation, all tools", writesFiles: true },
|
|
47
47
|
oracle: { emoji: "\ud83d\udd2e", shortLabel: "oracle", description: "decision-consistency, no file edits", writesFiles: false },
|
|
48
48
|
scout: { emoji: "\ud83d\udd0d", shortLabel: "scout", description: "codebase recon, read-only", writesFiles: false },
|
|
@@ -50,8 +50,8 @@ export const AGENT_META: Record<string, AgentMeta> = {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
/** Get metadata for an agent. Falls back to a neutral entry for unknown. */
|
|
53
|
-
export function
|
|
54
|
-
return
|
|
53
|
+
export function getRotorMeta(name: string): RotorMeta {
|
|
54
|
+
return ROTOR_META[name] ?? {
|
|
55
55
|
emoji: "\u2753",
|
|
56
56
|
shortLabel: name.length > 12 ? name.slice(0, 11) + "\u2026" : name,
|
|
57
57
|
description: "user-defined agent",
|
|
@@ -60,34 +60,36 @@ export function getAgentMeta(name: string): AgentMeta {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/** Validate an agent name. */
|
|
63
|
-
export function
|
|
63
|
+
export function isValidRotorName(name: string): boolean {
|
|
64
64
|
return /^[a-zA-Z0-9_-]{1,64}$/.test(name);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Discover agent `.md` files in user dir. */
|
|
68
68
|
/** All known agent home directories, in priority order (project wins
|
|
69
69
|
* over user-home; user-home `.agents/` wins over pi-native).
|
|
70
|
-
* Project-level `.agents
|
|
70
|
+
* Project-level `.agents/` is a vendor-neutral per-project
|
|
71
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)
|
|
72
74
|
* Honors $HOME / $USERPROFILE for testability. */
|
|
73
|
-
export function
|
|
75
|
+
export function rotorHomeDirs(cwd?: string): string[] {
|
|
74
76
|
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
75
77
|
const dirs: string[] = [];
|
|
76
78
|
if (cwd) {
|
|
77
|
-
dirs.push(path.join(cwd, ".agents"
|
|
78
|
-
dirs.push(path.join(cwd, ".pi", "agent", "agents"));
|
|
79
|
+
dirs.push(path.join(cwd, ".agents")); // project (vendor-neutral, preferred)
|
|
80
|
+
dirs.push(path.join(cwd, ".pi", "agent", "agents")); // project (pi native, legacy)
|
|
79
81
|
}
|
|
80
|
-
dirs.push(path.join(home, ".agents"
|
|
81
|
-
dirs.push(path.join(home, ".pi", "agent", "agents"));
|
|
82
|
+
dirs.push(path.join(home, ".agents")); // user (vendor-neutral)
|
|
83
|
+
dirs.push(path.join(home, ".pi", "agent", "agents")); // user (pi native, legacy)
|
|
82
84
|
return dirs;
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
/** Read all agent names from every home dir. Dedupes, first-occurrence
|
|
86
88
|
* wins. If cwd is provided, project dirs are scanned first. */
|
|
87
|
-
export function
|
|
89
|
+
export function discoverUserRotors(cwd?: string): string[] {
|
|
88
90
|
const seen = new Set<string>();
|
|
89
91
|
const out: string[] = [];
|
|
90
|
-
for (const dir of
|
|
92
|
+
for (const dir of rotorHomeDirs(cwd)) {
|
|
91
93
|
if (!fs.existsSync(dir)) continue;
|
|
92
94
|
let entries: string[];
|
|
93
95
|
try {
|
|
@@ -105,7 +107,7 @@ export function discoverUserAgents(cwd?: string): string[] {
|
|
|
105
107
|
const nameMatch = fm.match(/^name:\s*(.+)$/m);
|
|
106
108
|
if (nameMatch) {
|
|
107
109
|
const n = (nameMatch[1] ?? "").trim();
|
|
108
|
-
if (
|
|
110
|
+
if (isValidRotorName(n) && !seen.has(n)) {
|
|
109
111
|
seen.add(n);
|
|
110
112
|
out.push(n);
|
|
111
113
|
}
|
|
@@ -128,37 +130,37 @@ export function availableAgents(cwd?: string): string[] {
|
|
|
128
130
|
out.push(n);
|
|
129
131
|
}
|
|
130
132
|
};
|
|
131
|
-
for (const a of
|
|
132
|
-
for (const a of
|
|
133
|
+
for (const a of BUILTIN_ROTORS) push(a);
|
|
134
|
+
for (const a of discoverUserRotors(cwd)) push(a);
|
|
133
135
|
return out;
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
/** Cycle order. */
|
|
137
139
|
export function nextAgent(current: string, cycle: readonly string[]): string {
|
|
138
|
-
if (cycle.length === 0) return
|
|
140
|
+
if (cycle.length === 0) return DEFAULT_ROTOR;
|
|
139
141
|
const idx = cycle.indexOf(current);
|
|
140
142
|
if (idx < 0) return cycle[0]!;
|
|
141
143
|
return cycle[(idx + 1) % cycle.length]!;
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
/** Parse a user-supplied agent name. */
|
|
145
|
-
export function
|
|
147
|
+
export function parseRotorName(raw: string): string | null {
|
|
146
148
|
const n = raw.trim();
|
|
147
|
-
if (!
|
|
149
|
+
if (!isValidRotorName(n)) return null;
|
|
148
150
|
return n;
|
|
149
151
|
}
|
|
150
152
|
|
|
151
153
|
/** Short badge: `<emoji> <name>`. Null for default (silent). */
|
|
152
154
|
export function formatAgentBadge(agent: string): string | null {
|
|
153
|
-
if (agent ===
|
|
154
|
-
const meta =
|
|
155
|
+
if (agent === DEFAULT_ROTOR) return null;
|
|
156
|
+
const meta = getRotorMeta(agent);
|
|
155
157
|
return `${meta.emoji} ${agent}`;
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
/** Multi-line switch notify. */
|
|
159
|
-
export function
|
|
160
|
-
const prevMeta =
|
|
161
|
-
const nextMeta =
|
|
161
|
+
export function formatRotorSwitchNotify(prev: string, next: string): string {
|
|
162
|
+
const prevMeta = getRotorMeta(prev);
|
|
163
|
+
const nextMeta = getRotorMeta(next);
|
|
162
164
|
const lines: string[] = [
|
|
163
165
|
"pi-switch agent changed",
|
|
164
166
|
"",
|
|
@@ -171,19 +173,19 @@ export function formatAgentSwitchNotify(prev: string, next: string): string {
|
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
/** Group agents: built-ins + user-defined. */
|
|
174
|
-
export function
|
|
176
|
+
export function groupedAvailableRotors(cwd?: string): Array<{ header: string; agents: string[] }> {
|
|
175
177
|
const all = availableAgents(cwd);
|
|
176
178
|
const groups: Array<{ header: string; agents: string[] }> = [];
|
|
177
|
-
const builtin = all.filter((a) =>
|
|
179
|
+
const builtin = all.filter((a) => BUILTIN_ROTORS.includes(a));
|
|
178
180
|
if (builtin.length > 0) groups.push({ header: "built-in", agents: builtin });
|
|
179
|
-
const user = all.filter((a) => !
|
|
181
|
+
const user = all.filter((a) => !BUILTIN_ROTORS.includes(a));
|
|
180
182
|
if (user.length > 0) groups.push({ header: "user-defined", agents: user });
|
|
181
183
|
return groups;
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
/** Header line shown above chat. Persistent, dim, single line. */
|
|
185
187
|
export function formatHeaderLine(agent: string): string {
|
|
186
|
-
const meta =
|
|
188
|
+
const meta = getRotorMeta(agent);
|
|
187
189
|
const writeTag = meta.writesFiles ? "" : " \u00b7 read-only";
|
|
188
190
|
return `${meta.emoji} ${agent} \u00b7 ${meta.description}${writeTag} [Ctrl+Tab to cycle]`;
|
|
189
191
|
}
|
|
@@ -210,7 +212,7 @@ export function loadAgent(cwd: string): string | null {
|
|
|
210
212
|
const file = agentFilePath(cwd);
|
|
211
213
|
if (!fs.existsSync(file)) return null;
|
|
212
214
|
const raw = fs.readFileSync(file, "utf-8").trim();
|
|
213
|
-
if (!
|
|
215
|
+
if (!isValidRotorName(raw)) return null;
|
|
214
216
|
return raw;
|
|
215
217
|
} catch {
|
|
216
218
|
return null;
|
package/switch/index.ts
CHANGED
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
// index.ts — pi-switch extension entry (v2: footer-pill UI)
|
|
3
3
|
// =============================================================================
|
|
4
4
|
//
|
|
5
|
-
// Wires the
|
|
5
|
+
// Wires the rotor switcher into pi as a compact footer pill:
|
|
6
6
|
// - Footer status pill: "▶ ⚡ worker" (or "· ⚡ worker" for the default)
|
|
7
|
-
// - Click pill or `/
|
|
8
|
-
// - Ctrl+Tab → cycle to next
|
|
7
|
+
// - Click pill or `/rotor` → open full picker modal (SelectList)
|
|
8
|
+
// - Ctrl+Tab → cycle to next rotor (no popup, hot switch)
|
|
9
9
|
// - F2 → same, fallback if your terminal doesn't pass Ctrl+Tab through
|
|
10
|
-
// - Persists current
|
|
11
|
-
// - Exposes `globalThis.
|
|
10
|
+
// - Persists current rotor to .soly/agent or ~/.pi-switch/agent
|
|
11
|
+
// - Exposes `globalThis.__PI_SWITCH_ROTOR__` for other extensions
|
|
12
12
|
// - Injects a short system-prompt section so the LLM knows the current
|
|
13
|
-
//
|
|
13
|
+
// rotor and the available alternatives
|
|
14
14
|
//
|
|
15
15
|
// UI philosophy:
|
|
16
|
-
// - Header is for content, not for tool chrome. Move
|
|
16
|
+
// - Header is for content, not for tool chrome. Move rotors to footer.
|
|
17
17
|
// - Click to explore, hotkey to power-use, no DOM clutter in between.
|
|
18
18
|
// - Visual change is the pill text only. Chat stays clean.
|
|
19
19
|
// =============================================================================
|
|
@@ -24,57 +24,57 @@ import * as fs from "node:fs";
|
|
|
24
24
|
import * as os from "node:os";
|
|
25
25
|
import * as path from "node:path";
|
|
26
26
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
DEFAULT_ROTOR,
|
|
28
|
+
BUILTIN_ROTORS,
|
|
29
29
|
availableAgents,
|
|
30
30
|
nextAgent,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
parseRotorName,
|
|
32
|
+
groupedAvailableRotors,
|
|
33
|
+
getRotorMeta,
|
|
34
34
|
loadAgent,
|
|
35
35
|
saveAgent,
|
|
36
36
|
} from "./core.ts";
|
|
37
37
|
import { buildPiSwitchSection, recommendAgent } from "./prompt.ts";
|
|
38
38
|
|
|
39
|
-
const GLOBAL_KEY = "
|
|
39
|
+
const GLOBAL_KEY = "__PI_SWITCH_ROTOR__";
|
|
40
40
|
|
|
41
41
|
export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
42
42
|
let cwd = "";
|
|
43
|
-
let
|
|
44
|
-
let cycle: string[] = [
|
|
43
|
+
let currentRotor: string = DEFAULT_ROTOR;
|
|
44
|
+
let cycle: string[] = [DEFAULT_ROTOR];
|
|
45
45
|
let lastUi: ExtensionUIContext | null = null;
|
|
46
46
|
|
|
47
47
|
function refreshCycle(): void {
|
|
48
48
|
cycle = availableAgents(cwd);
|
|
49
|
-
if (!cycle.includes(
|
|
49
|
+
if (!cycle.includes(currentRotor)) currentRotor = DEFAULT_ROTOR;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function publish(): void {
|
|
53
|
-
(globalThis as Record<string, unknown>)[GLOBAL_KEY] =
|
|
53
|
+
(globalThis as Record<string, unknown>)[GLOBAL_KEY] = currentRotor;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
function rerender(): void {
|
|
57
57
|
if (!lastUi) return;
|
|
58
58
|
try {
|
|
59
|
-
const meta =
|
|
59
|
+
const meta = getRotorMeta(currentRotor);
|
|
60
60
|
// Persistent pill — always visible above the input, even for the
|
|
61
|
-
// default
|
|
61
|
+
// default rotor. The user wants a constant mode indicator, not a
|
|
62
62
|
// transient one. Marker "▶" makes it scannable.
|
|
63
|
-
const marker =
|
|
64
|
-
const pill = `${marker} ${meta.emoji} ${
|
|
63
|
+
const marker = currentRotor === DEFAULT_ROTOR ? "·" : "▶";
|
|
64
|
+
const pill = `${marker} ${meta.emoji} ${currentRotor}`;
|
|
65
65
|
lastUi.setStatus("pi-switch", pill);
|
|
66
66
|
} catch { /* no ui yet */ }
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function
|
|
70
|
-
const prev =
|
|
69
|
+
function setRotor(next: string): void {
|
|
70
|
+
const prev = currentRotor;
|
|
71
71
|
if (next === prev) return;
|
|
72
|
-
|
|
72
|
+
currentRotor = next;
|
|
73
73
|
publish();
|
|
74
74
|
if (cwd) saveAgent(cwd, next);
|
|
75
75
|
rerender();
|
|
76
76
|
// Footer pill is the only visible signal of the switch.
|
|
77
|
-
// Chat stays clean —
|
|
77
|
+
// Chat stays clean — rotor is plumbing, not conversation content.
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
// ----- session_start: load persisted agent + set initial pill -----
|
|
@@ -83,7 +83,7 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
83
83
|
lastUi = ctx.ui;
|
|
84
84
|
publish();
|
|
85
85
|
const restored = loadAgent(cwd);
|
|
86
|
-
if (restored)
|
|
86
|
+
if (restored) currentRotor = restored;
|
|
87
87
|
refreshCycle();
|
|
88
88
|
publish();
|
|
89
89
|
rerender();
|
|
@@ -113,7 +113,7 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
113
113
|
lastCycleTs = now;
|
|
114
114
|
lastUi = sctx.ui;
|
|
115
115
|
refreshCycle();
|
|
116
|
-
|
|
116
|
+
setRotor(nextAgent(currentRotor, cycle));
|
|
117
117
|
};
|
|
118
118
|
pi.registerShortcut("ctrl+tab", {
|
|
119
119
|
description: "Cycle to next agent (worker → oracle → scout → …)",
|
|
@@ -124,9 +124,10 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
124
124
|
handler: (sctx) => cycleShortcut(sctx),
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
-
// ----- /
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
// ----- /rotor: open picker, or subcommands (create / doctor / recommend / set) -----
|
|
128
|
+
// (formerly /agent — renamed to /rotor in 1.0.0 to match the "rotor" naming convention)
|
|
129
|
+
pi.registerCommand("rotor", {
|
|
130
|
+
description: "open rotor picker, or `set <name>`, `create`, `doctor`, `recommend <task>`",
|
|
130
131
|
handler: async (args, ctx) => {
|
|
131
132
|
lastUi = ctx.ui;
|
|
132
133
|
refreshCycle();
|
|
@@ -140,7 +141,7 @@ export default function piSwitchExtension(pi: ExtensionAPI) {
|
|
|
140
141
|
if (subcommand === "set" && arg) return handleSet(arg, ctx.ui);
|
|
141
142
|
|
|
142
143
|
// Direct agent name → set
|
|
143
|
-
if (subcommand && cycle.includes(subcommand)) return
|
|
144
|
+
if (subcommand && cycle.includes(subcommand)) return setRotor(subcommand);
|
|
144
145
|
if (arg && !subcommand) return handleSet(arg, ctx.ui);
|
|
145
146
|
|
|
146
147
|
// No arg: open picker modal
|
|
@@ -159,28 +160,28 @@ function openPicker(ui: ExtensionUIContext): void {
|
|
|
159
160
|
for (const g of groups) {
|
|
160
161
|
all.push({ value: "__sep__", label: `── ${g.header} `, description: "", isCurrent: false });
|
|
161
162
|
for (const a of g.agents) {
|
|
162
|
-
const m =
|
|
163
|
+
const m = getRotorMeta(a);
|
|
163
164
|
all.push({
|
|
164
165
|
value: a,
|
|
165
166
|
label: `${m.emoji} ${a}`,
|
|
166
167
|
description: `${m.description}${m.writesFiles ? "" : " · read-only"}`,
|
|
167
|
-
isCurrent: a ===
|
|
168
|
+
isCurrent: a === currentRotorRef(),
|
|
168
169
|
});
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
return all;
|
|
172
173
|
}, ui, (choice) => {
|
|
173
|
-
if (choice && choice !== "__sep__")
|
|
174
|
+
if (choice && choice !== "__sep__") setRotorRef(choice);
|
|
174
175
|
});
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
function handleSet(name: string, ui: ExtensionUIContext): void {
|
|
178
|
-
const target =
|
|
179
|
+
const target = parseRotorName(name);
|
|
179
180
|
if (!target) return ui.notify(`pi-switch: invalid name "${name}".`, "error");
|
|
180
181
|
if (!availableAgents(cwd).includes(target)) {
|
|
181
182
|
return ui.notify(`pi-switch: unknown "${target}". available: ${availableAgents(cwd).join(", ")}`, "error");
|
|
182
183
|
}
|
|
183
|
-
|
|
184
|
+
setRotorRef(target);
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
function handleRecommend(task: string, ui: ExtensionUIContext): void {
|
|
@@ -191,22 +192,22 @@ function handleRecommend(task: string, ui: ExtensionUIContext): void {
|
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
// ---------------------------------------------------------------------------
|
|
194
|
-
// setAgent /
|
|
195
|
+
// setAgent / currentRotor — module-scope so the modal can mutate them
|
|
195
196
|
// ---------------------------------------------------------------------------
|
|
196
197
|
|
|
197
|
-
let
|
|
198
|
-
let
|
|
198
|
+
let currentRotorRef: () => string = () => DEFAULT_ROTOR;
|
|
199
|
+
let setRotorRef: (next: string) => void = () => {};
|
|
199
200
|
|
|
200
201
|
// The picker and the main extension share state via these refs.
|
|
201
202
|
// We patch them in `wire()` at the top of the default export.
|
|
202
203
|
function wire(get: () => string, set: (n: string) => void): void {
|
|
203
|
-
|
|
204
|
-
|
|
204
|
+
currentRotorRef = get;
|
|
205
|
+
setRotorRef = set;
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
function refreshAndBuild<T>(
|
|
208
209
|
ui: ExtensionUIContext,
|
|
209
|
-
build: (groups: ReturnType<typeof
|
|
210
|
+
build: (groups: ReturnType<typeof groupedAvailableRotors>) => T,
|
|
210
211
|
_ui: ExtensionUIContext,
|
|
211
212
|
_onSelect: (value: string) => void,
|
|
212
213
|
): void {
|
|
@@ -227,7 +228,7 @@ function createAgent(
|
|
|
227
228
|
ui.notify("pi-switch: usage — `/agent create <name>`", "info");
|
|
228
229
|
return;
|
|
229
230
|
}
|
|
230
|
-
if (!
|
|
231
|
+
if (!parseRotorName(name)) {
|
|
231
232
|
ui.notify(`pi-switch: invalid name "${name}". Use letters/digits/dashes/underscores, ≤64 chars.`, "error");
|
|
232
233
|
return;
|
|
233
234
|
}
|
|
@@ -266,7 +267,7 @@ function createAgent(
|
|
|
266
267
|
// race where two parallel createAgent calls would clobber each
|
|
267
268
|
// other's write after both pass the existsSync check.
|
|
268
269
|
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
269
|
-
fs.writeFileSync(tmp,
|
|
270
|
+
fs.writeFileSync(tmp, rotorTemplate(name, description), "utf-8");
|
|
270
271
|
fs.renameSync(tmp, file);
|
|
271
272
|
ui.notify(
|
|
272
273
|
`pi-switch: created ${file}\n → next Ctrl+Tab to see it in the cycle\n → edit the system prompt to specialize`,
|
|
@@ -278,7 +279,7 @@ function createAgent(
|
|
|
278
279
|
});
|
|
279
280
|
}
|
|
280
281
|
|
|
281
|
-
function
|
|
282
|
+
function rotorTemplate(name: string, description: string): string {
|
|
282
283
|
return `---
|
|
283
284
|
name: ${name}
|
|
284
285
|
description: ${description}
|
|
@@ -317,8 +318,8 @@ function doctorReport(): string {
|
|
|
317
318
|
const cycle = availableAgents(cwd);
|
|
318
319
|
const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
|
|
319
320
|
const lines: string[] = ["pi-switch doctor:", ""];
|
|
320
|
-
const builtins = cycle.filter((a) =>
|
|
321
|
-
const users = cycle.filter((a) => !
|
|
321
|
+
const builtins = cycle.filter((a) => BUILTIN_ROTORS.includes(a));
|
|
322
|
+
const users = cycle.filter((a) => !BUILTIN_ROTORS.includes(a));
|
|
322
323
|
lines.push(`cycle: ${cycle.length} agents (${builtins.length} built-in, ${users.length} user)`);
|
|
323
324
|
lines.push("");
|
|
324
325
|
if (!fs.existsSync(userDir)) {
|
|
@@ -8,58 +8,58 @@ import * as fs from "node:fs";
|
|
|
8
8
|
import * as os from "node:os";
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
DEFAULT_ROTOR,
|
|
12
|
+
BUILTIN_ROTORS,
|
|
13
|
+
ROTOR_META,
|
|
14
|
+
getRotorMeta,
|
|
15
|
+
isValidRotorName,
|
|
16
|
+
discoverUserRotors,
|
|
17
17
|
availableAgents,
|
|
18
18
|
nextAgent,
|
|
19
|
-
|
|
19
|
+
parseRotorName,
|
|
20
20
|
formatAgentBadge,
|
|
21
|
-
|
|
21
|
+
formatRotorSwitchNotify,
|
|
22
22
|
formatHeaderLine,
|
|
23
|
-
|
|
23
|
+
groupedAvailableRotors,
|
|
24
24
|
agentFilePath,
|
|
25
25
|
loadAgent,
|
|
26
26
|
saveAgent,
|
|
27
27
|
} from "../core.js";
|
|
28
28
|
|
|
29
|
-
describe("
|
|
29
|
+
describe("DEFAULT_ROTOR", () => {
|
|
30
30
|
test("is 'worker'", () => {
|
|
31
|
-
expect(
|
|
31
|
+
expect(DEFAULT_ROTOR).toBe("worker");
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
-
describe("
|
|
35
|
+
describe("isValidRotorName", () => {
|
|
36
36
|
test("accepts simple names", () => {
|
|
37
|
-
expect(
|
|
38
|
-
expect(
|
|
37
|
+
expect(isValidRotorName("worker")).toBe(true);
|
|
38
|
+
expect(isValidRotorName("my_agent")).toBe(true);
|
|
39
39
|
});
|
|
40
40
|
test("rejects invalid", () => {
|
|
41
|
-
expect(
|
|
42
|
-
expect(
|
|
43
|
-
expect(
|
|
41
|
+
expect(isValidRotorName("with space")).toBe(false);
|
|
42
|
+
expect(isValidRotorName("")).toBe(false);
|
|
43
|
+
expect(isValidRotorName("a".repeat(65))).toBe(false);
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
46
|
|
|
47
|
-
describe("
|
|
47
|
+
describe("ROTOR_META", () => {
|
|
48
48
|
test("every built-in has metadata", () => {
|
|
49
|
-
for (const a of
|
|
50
|
-
expect(
|
|
51
|
-
expect(
|
|
49
|
+
for (const a of BUILTIN_ROTORS) {
|
|
50
|
+
expect(ROTOR_META[a]).toBeDefined();
|
|
51
|
+
expect(ROTOR_META[a]!.emoji.length).toBeGreaterThan(0);
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
54
|
test("meta has writesFiles flag", () => {
|
|
55
|
-
expect(
|
|
56
|
-
expect(
|
|
55
|
+
expect(ROTOR_META.worker!.writesFiles).toBe(true);
|
|
56
|
+
expect(ROTOR_META.oracle!.writesFiles).toBe(false);
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
describe("
|
|
60
|
+
describe("getRotorMeta", () => {
|
|
61
61
|
test("returns fallback for unknown", () => {
|
|
62
|
-
const m =
|
|
62
|
+
const m = getRotorMeta("zzz");
|
|
63
63
|
expect(m.emoji.length).toBeGreaterThan(0);
|
|
64
64
|
});
|
|
65
65
|
});
|
|
@@ -74,16 +74,16 @@ describe("nextAgent", () => {
|
|
|
74
74
|
});
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
describe("
|
|
77
|
+
describe("parseRotorName", () => {
|
|
78
78
|
test("trims and validates", () => {
|
|
79
|
-
expect(
|
|
80
|
-
expect(
|
|
79
|
+
expect(parseRotorName(" oracle ")).toBe("oracle");
|
|
80
|
+
expect(parseRotorName("with space")).toBeNull();
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
describe("formatAgentBadge", () => {
|
|
85
85
|
test("null for default", () => {
|
|
86
|
-
expect(formatAgentBadge(
|
|
86
|
+
expect(formatAgentBadge(DEFAULT_ROTOR)).toBeNull();
|
|
87
87
|
});
|
|
88
88
|
test("emoji + name for non-default", () => {
|
|
89
89
|
const b = formatAgentBadge("oracle");
|
|
@@ -91,9 +91,9 @@ describe("formatAgentBadge", () => {
|
|
|
91
91
|
});
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
-
describe("
|
|
94
|
+
describe("formatRotorSwitchNotify", () => {
|
|
95
95
|
test("multi-line: old → new + capability", () => {
|
|
96
|
-
const out =
|
|
96
|
+
const out = formatRotorSwitchNotify("worker", "oracle");
|
|
97
97
|
expect(out).toContain("pi-switch agent changed");
|
|
98
98
|
expect(out).toContain("worker");
|
|
99
99
|
expect(out).toContain("oracle");
|
|
@@ -117,23 +117,23 @@ describe("formatHeaderLine", () => {
|
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
-
describe("
|
|
120
|
+
describe("groupedAvailableRotors", () => {
|
|
121
121
|
test("includes built-in group", () => {
|
|
122
|
-
const groups =
|
|
122
|
+
const groups = groupedAvailableRotors("/nonexistent");
|
|
123
123
|
expect(groups[0]?.header).toBe("built-in");
|
|
124
124
|
});
|
|
125
125
|
test("includes user group when present", () => {
|
|
126
|
-
// Use HOME override so the new ~.agents/
|
|
126
|
+
// Use HOME override so the new ~.agents/ scan picks up our fixture
|
|
127
127
|
const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
128
128
|
const prevHome = process.env.HOME;
|
|
129
129
|
const prevUserProfile = process.env.USERPROFILE;
|
|
130
130
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pis-home-"));
|
|
131
131
|
process.env.HOME = tmp;
|
|
132
132
|
process.env.USERPROFILE = tmp;
|
|
133
|
-
const agentsDir = path.join(tmp, ".agents"
|
|
133
|
+
const agentsDir = path.join(tmp, ".agents");
|
|
134
134
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
135
135
|
fs.writeFileSync(path.join(agentsDir, "my.md"), "---\nname: my-helper\n---\n# body\n");
|
|
136
|
-
const groups =
|
|
136
|
+
const groups = groupedAvailableRotors();
|
|
137
137
|
const userGroup = groups.find((g) => g.header === "user-defined");
|
|
138
138
|
expect(userGroup?.agents).toContain("my-helper");
|
|
139
139
|
// restore
|
|
@@ -144,10 +144,10 @@ describe("groupedAvailableAgents", () => {
|
|
|
144
144
|
|
|
145
145
|
test("includes project agent when present (cwd scope)", () => {
|
|
146
146
|
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "pis-proj-"));
|
|
147
|
-
const agentsDir = path.join(projectDir, ".agents"
|
|
147
|
+
const agentsDir = path.join(projectDir, ".agents");
|
|
148
148
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
149
149
|
fs.writeFileSync(path.join(agentsDir, "proj.md"), "---\nname: project-helper\n---\n# body\n");
|
|
150
|
-
const groups =
|
|
150
|
+
const groups = groupedAvailableRotors(projectDir);
|
|
151
151
|
const userGroup = groups.find((g) => g.header === "user-defined");
|
|
152
152
|
expect(userGroup?.agents).toContain("project-helper");
|
|
153
153
|
fs.rmSync(projectDir, { recursive: true, force: true });
|