pi-soly 0.9.0 → 1.1.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 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 · Agent switcher · Live task list.
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
- - **An agent switcher** — `Ctrl+Tab` to cycle, footer pill always visible
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
- | Agent name as free text in slash commands | Footer pill + `Ctrl+Tab` + `/agent` picker |
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
- ### 🎛 Agent Switcher
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
- agent switcher │
174
+ rotor switcher │
175
175
  │ │
176
176
  │ Ctrl+Tab │
177
177
  │ footer pill │
178
- │ /agent picker │
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>`, `/agent`
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
- * Path scheme: `<root>/.agents/agents/` (vendor-neutral) and
39
- * `<root>/.pi/agent/agents/` (pi native, legacy). */
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", "agents"), // vendor-neutral (preferred)
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
- * `discoverUserAgents` reads both, so old installs still work. */
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
@@ -26,6 +26,7 @@ import {
26
26
  formatAnalyticsFull,
27
27
  formatTok,
28
28
  readIfExists,
29
+ solyDirFor,
29
30
  type RuleFile,
30
31
  type SolyState,
31
32
  } from "./core.ts";
@@ -204,7 +205,7 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
204
205
  }
205
206
  const cat = categories[choice];
206
207
  if (!cat) return;
207
- const dir = path.join(cwd, ".soly", "rules", cat.name);
208
+ const dir = path.join(solyDirFor(cwd), "rules", cat.name);
208
209
  try {
209
210
  fs.mkdirSync(dir, { recursive: true });
210
211
  } catch {}
@@ -296,7 +297,7 @@ What must the LLM do?
296
297
  const lastSeg = parsed.pathname.split("/").filter(Boolean).pop() ?? "rule.md";
297
298
  const safeName = lastSeg.replace(/[^A-Za-z0-9._-]/g, "_");
298
299
  const fileName = safeName.endsWith(".md") ? safeName : `${safeName}.md`;
299
- const rulesRoot = path.join(process.cwd(), ".soly", "rules");
300
+ const rulesRoot = path.join(solyDirFor(process.cwd()), "rules");
300
301
  fs.mkdirSync(rulesRoot, { recursive: true });
301
302
  const targetFile = path.join(rulesRoot, fileName);
302
303
  // Refuse to overwrite without warning
@@ -371,8 +372,8 @@ What must the LLM do?
371
372
  };
372
373
  const subcommands: Record<string, SolySub> = {
373
374
  // `agent` subcommand REMOVED — moved to the separate `pi-switch`
374
- // extension as the `/agent` slash command (footer pill + Ctrl+Tab).
375
- // Soly no longer owns the agent switcher UI.
375
+ // extension as the `/rotor` slash command (footer pill + Ctrl+Tab).
376
+ // Soly no longer owns the rotor switcher UI.
376
377
  config: {
377
378
  description: "show merged config (per-project + global + defaults); edit .soly/config.json or ~/.soly/config.json",
378
379
  run: () => {
package/core.ts CHANGED
@@ -1521,12 +1521,27 @@ export function buildStatusLine(
1521
1521
  // Soly dir helper
1522
1522
  // ============================================================================
1523
1523
 
1524
- /** Default soly dir relative to cwd. */
1525
- export const SOLY_DIRNAME = ".soly";
1524
+ /** Preferred soly dir name (vendor-neutral). */
1525
+ export const SOLY_DIRNAME = ".agents";
1526
1526
 
1527
- /** Build the .soly dir path for a given cwd. */
1527
+ /** Legacy soly dir name. Kept for backward compat with existing projects. */
1528
+ export const LEGACY_SOLY_DIRNAME = ".soly";
1529
+
1530
+ /** Which project subdir name is currently in use. Returns the first
1531
+ * one that exists, preferring `.agents/`. Falls back to `.soly/` if
1532
+ * no `.agents/` exists. If neither exists, returns `.agents/` (so
1533
+ * new writes go to the new location). */
1528
1534
  export function solyDirFor(cwd: string): string {
1529
- return path.join(cwd, SOLY_DIRNAME);
1535
+ if (fs.existsSync(path.join(cwd, SOLY_DIRNAME))) return path.join(cwd, SOLY_DIRNAME);
1536
+ if (fs.existsSync(path.join(cwd, LEGACY_SOLY_DIRNAME))) return path.join(cwd, LEGACY_SOLY_DIRNAME);
1537
+ return path.join(cwd, SOLY_DIRNAME); // default to new for new projects
1538
+ }
1539
+
1540
+ /** True if the legacy `.soly/` dir is in active use (and `.agents/` isn't). */
1541
+ export function isLegacySolyDir(cwd: string): boolean {
1542
+ const newPath = path.join(cwd, SOLY_DIRNAME);
1543
+ const oldPath = path.join(cwd, LEGACY_SOLY_DIRNAME);
1544
+ return !fs.existsSync(newPath) && fs.existsSync(oldPath);
1530
1545
  }
1531
1546
 
1532
1547
  // ============================================================================
package/index.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  loadProjectState,
39
39
  STATUS_ID,
40
40
  solyDirFor,
41
+ isLegacySolyDir,
41
42
  buildNextHint,
42
43
  buildDriftReminder,
43
44
  type RuleFile,
@@ -53,6 +54,7 @@ import {
53
54
  type SolyConfig,
54
55
  } from "./config.ts";
55
56
  import { classifyTaskHeuristics, buildNudgeSection } from "./nudge.ts";
57
+ import { notifyNudge, notifyDeprecation } from "./notification.ts";
56
58
  import { registerCommands, type CommandUI } from "./commands.ts";
57
59
  import { registerTools } from "./tools.ts";
58
60
  import { registerWorkflows } from "./workflows/index.ts";
@@ -80,12 +82,12 @@ export default function solyExtension(pi: ExtensionAPI) {
80
82
  let sessionCwd = "";
81
83
 
82
84
  // ============================================================================
83
- // Agent switcher (Shift+Tab cycles through available subagents)
85
+ // Rotor switcher (Shift+Tab cycles through available rotors)
84
86
  // ============================================================================
85
87
 
86
88
  // ============================================================================
87
- // Agent switcher: REMOVED. The agent cycler is now owned by the
88
- // separate `pi-switch` extension (footer pill + Ctrl+Tab + /agent slash).
89
+ // Rotor switcher: REMOVED. The rotor cycler is now owned by the
90
+ // separate `pi-switch` extension (footer pill + Ctrl+Tab + /rotor slash).
89
91
  // Soly owns a single subagent (soly-manager.md) and the auto-install on
90
92
  // opt-in. Workflows read the current agent from
91
93
  // globalThis.__PI_SWITCH_AGENT__ (set by pi-switch).
@@ -328,7 +330,7 @@ export default function solyExtension(pi: ExtensionAPI) {
328
330
  // and mnemonic for "A"gent.)
329
331
  // ============================================================================
330
332
  // Agent switcher REMOVED — moved to the separate `pi-switch` extension.
331
- // Soly no longer owns Ctrl+Tab, the footer pill, or /agent slash.
333
+ // Soly no longer owns Ctrl+Tab, the footer pill, or /rotor slash.
332
334
  // The current agent is read by soly workflows from
333
335
  // globalThis.__PI_SWITCH_AGENT__ (set by pi-switch), with a fallback
334
336
  // to "worker" if pi-switch isn't installed.
@@ -350,6 +352,17 @@ export default function solyExtension(pi: ExtensionAPI) {
350
352
  // ============================================================================
351
353
 
352
354
  pi.on("session_start", async (event, ctx) => {
355
+ // Deprecation warning: if the project still uses `.soly/`, nudge the
356
+ // user toward `.agents/`. One-time per session.
357
+ if (isLegacySolyDir(ctx.cwd)) {
358
+ notifyDeprecation(
359
+ ctx.ui,
360
+ `.soly/ (legacy)`,
361
+ `.agents/ (vendor-neutral)`,
362
+ `Run \`mv .soly .agents\` to migrate. Both names work for now.`,
363
+ );
364
+ }
365
+
353
366
  // Rules sources (priority order, higher wins on relPath collision).
354
367
  // Project rules always beat global rules. .soly/rules.local/ is
355
368
  // gitignored — for personal overrides on top of the project's rules.
@@ -663,11 +676,7 @@ export default function solyExtension(pi: ExtensionAPI) {
663
676
  const angle =
664
677
  heuristics.suggestedAngles[0] ?? "want me to confirm assumptions before I start?";
665
678
 
666
- const label = heuristics.researchHeavy
667
- ? "soly: research-heavy prompt — clarifying question?"
668
- : "soly: non-trivial prompt — clarifying question?";
669
-
670
- ctx.ui.notify(`${label} ${angle}`, "info");
679
+ notifyNudge(ctx.ui, heuristics.researchHeavy ? "research" : "nonTrivial", angle);
671
680
  nudgeActiveForTask = true;
672
681
  lastNudgePromptKey = text.slice(0, 200);
673
682
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "0.9.0",
3
+ "version": "1.1.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
- | `/agent [name]` | Switch the current cycle agent (or open picker) |
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
 
@@ -81,11 +81,13 @@ The **soly** extension adds project-management workflow to [pi-coding-agent](htt
81
81
  │ └── data-scientist.md
82
82
  ```
83
83
 
84
- **Two parallel conventions:** `.soly/` is soly-specific state (phases, plans). `.agents/` is vendor-neutral agent config (works with any AI tool that follows the AGENTS.md standard). The two coexist:
84
+ **Two parallel conventions (migration in progress):** `.soly/` is the legacy soly state. `.agents/` is the new vendor-neutral home. The two work side-by-side:
85
85
 
86
- - Use `.soly/` for soly workflow artifacts (PLAN.md, SUMMARY.md, etc.)
87
- - Use `.agents/` for things other AI tools should also see (rules, skills, agents)
88
- - Use `AGENTS.md` for top-level project-wide agent conventions
86
+ - **Use `.agents/`** for new projects (recommended)
87
+ - **Use `.soly/`** for legacy projects (still works, will show a deprecation warning)
88
+ - **Use `AGENTS.md`** for top-level project-wide agent conventions
89
+
90
+ To migrate: `mv .soly .agents` — soly picks up the new location automatically. No data loss.
89
91
 
90
92
  ## Frontmatter conventions
91
93
 
@@ -198,16 +200,18 @@ The only legal sequence for finishing a plan:
198
200
 
199
201
  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
202
 
201
- ## Cycle agents (4 built-in)
203
+ ## Cycle rotors (4 built-in)
202
204
 
203
- | Agent | Writes | Use for |
205
+ | Rotor | Writes | Use for |
204
206
  |---|---|---|
205
207
  | `worker` | ✅ | Generic implementation, full tools |
206
208
  | `oracle` | ❌ | Decision-consistency, no file edits |
207
209
  | `scout` | ❌ | Codebase recon, read-only |
208
210
  | `reviewer` | ❌ | Adversarial code review |
209
211
 
210
- Switch with `/agent <name>` or `Ctrl+Tab` (cycles through). Footer pill shows current: `· ⚡ worker` / `▶ 🐢 oracle`.
212
+ Switch with `/rotor <name>` or `Ctrl+Tab` (cycles through). Footer pill shows current: `· ⚡ worker` / `▶ 🐢 oracle`.
213
+
214
+ **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
215
 
212
216
  ## Subagent: soly-manager (single, mode-switching)
213
217
 
@@ -300,7 +304,7 @@ Intent docs are 0-point — written BEFORE any plan, by humans. They define the
300
304
 
301
305
  ### Add project-specific agents
302
306
 
303
- Drop a markdown file in `.agents/agents/<name>.md` (project) or `~/.agents/agents/<name>.md` (user):
307
+ Drop a markdown file in `.agents/<name>.md` (project) or `~/.agents/<name>.md` (user):
304
308
 
305
309
  ```markdown
306
310
  ---
@@ -314,9 +318,9 @@ You are a data scientist. ...
314
318
  ```
315
319
 
316
320
  **Discovered from 4 locations** (priority order):
317
- 1. `<project>/.agents/agents/` — project vendor-neutral (preferred)
321
+ 1. `<project>/.agents/` — project vendor-neutral (preferred)
318
322
  2. `<project>/.pi/agent/agents/` — project pi native (legacy)
319
- 3. `~/.agents/agents/` — user vendor-neutral (preferred)
323
+ 3. `~/.agents/` — user vendor-neutral (preferred)
320
324
  4. `~/.pi/agent/agents/` — user pi native (legacy)
321
325
 
322
326
  `Ctrl+Tab` to see them in the cycle.
package/switch/core.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // =============================================================================
2
- // core.ts — Generic subagent switcher for pi
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.__PI_SWITCH_AGENT__` (in-process)
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 DEFAULT_AGENT = "worker";
26
+ export const DEFAULT_ROTOR = "worker";
27
27
 
28
28
  /** Built-in pi-subagents that we always offer in the cycle. */
29
- export const BUILTIN_AGENTS: readonly string[] = [
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 AgentMeta {
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 AGENT_META: Record<string, AgentMeta> = {
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 getAgentMeta(name: string): AgentMeta {
54
- return AGENT_META[name] ?? {
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 isValidAgentName(name: string): boolean {
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/agents/` is a vendor-neutral per-project
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 agentHomeDirs(cwd?: string): string[] {
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", "agents")); // project (vendor-neutral, preferred)
78
- dirs.push(path.join(cwd, ".pi", "agent", "agents")); // project (pi native, legacy)
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", "agents")); // user (vendor-neutral)
81
- dirs.push(path.join(home, ".pi", "agent", "agents")); // user (pi native, legacy)
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 discoverUserAgents(cwd?: string): string[] {
89
+ export function discoverUserRotors(cwd?: string): string[] {
88
90
  const seen = new Set<string>();
89
91
  const out: string[] = [];
90
- for (const dir of agentHomeDirs(cwd)) {
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 (isValidAgentName(n) && !seen.has(n)) {
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 BUILTIN_AGENTS) push(a);
132
- for (const a of discoverUserAgents(cwd)) push(a);
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 DEFAULT_AGENT;
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 parseAgentName(raw: string): string | null {
147
+ export function parseRotorName(raw: string): string | null {
146
148
  const n = raw.trim();
147
- if (!isValidAgentName(n)) return null;
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 === DEFAULT_AGENT) return null;
154
- const meta = getAgentMeta(agent);
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 formatAgentSwitchNotify(prev: string, next: string): string {
160
- const prevMeta = getAgentMeta(prev);
161
- const nextMeta = getAgentMeta(next);
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 groupedAvailableAgents(cwd?: string): Array<{ header: string; agents: string[] }> {
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) => BUILTIN_AGENTS.includes(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) => !BUILTIN_AGENTS.includes(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 = getAgentMeta(agent);
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 (!isValidAgentName(raw)) return null;
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 agent switcher into pi as a compact footer pill:
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 `/agent` → open full picker modal (SelectList)
8
- // - Ctrl+Tab → cycle to next agent (no popup, hot switch)
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 agent to .soly/agent or ~/.pi-switch/agent
11
- // - Exposes `globalThis.__PI_SWITCH_AGENT__` for other extensions
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
- // agent and the available alternatives
13
+ // rotor and the available alternatives
14
14
  //
15
15
  // UI philosophy:
16
- // - Header is for content, not for tool chrome. Move agents to footer.
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
- DEFAULT_AGENT,
28
- BUILTIN_AGENTS,
27
+ DEFAULT_ROTOR,
28
+ BUILTIN_ROTORS,
29
29
  availableAgents,
30
30
  nextAgent,
31
- parseAgentName,
32
- groupedAvailableAgents,
33
- getAgentMeta,
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 = "__PI_SWITCH_AGENT__";
39
+ const GLOBAL_KEY = "__PI_SWITCH_ROTOR__";
40
40
 
41
41
  export default function piSwitchExtension(pi: ExtensionAPI) {
42
42
  let cwd = "";
43
- let currentAgent: string = DEFAULT_AGENT;
44
- let cycle: string[] = [DEFAULT_AGENT];
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(currentAgent)) currentAgent = DEFAULT_AGENT;
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] = currentAgent;
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 = getAgentMeta(currentAgent);
59
+ const meta = getRotorMeta(currentRotor);
60
60
  // Persistent pill — always visible above the input, even for the
61
- // default agent. The user wants a constant mode indicator, not a
61
+ // default rotor. The user wants a constant mode indicator, not a
62
62
  // transient one. Marker "▶" makes it scannable.
63
- const marker = currentAgent === DEFAULT_AGENT ? "·" : "▶";
64
- const pill = `${marker} ${meta.emoji} ${currentAgent}`;
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 setAgent(next: string): void {
70
- const prev = currentAgent;
69
+ function setRotor(next: string): void {
70
+ const prev = currentRotor;
71
71
  if (next === prev) return;
72
- currentAgent = next;
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 — agent is plumbing, not conversation content.
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) currentAgent = 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
- setAgent(nextAgent(currentAgent, cycle));
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
- // ----- /agent: open picker, or subcommands (create / doctor / recommend / set) -----
128
- pi.registerCommand("agent", {
129
- description: "open agent picker, or `set <name>`, `create`, `doctor`, `recommend <task>`",
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 setAgent(subcommand);
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 = getAgentMeta(a);
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 === currentAgentRef(),
168
+ isCurrent: a === currentRotorRef(),
168
169
  });
169
170
  }
170
171
  }
171
172
  return all;
172
173
  }, ui, (choice) => {
173
- if (choice && choice !== "__sep__") setAgentRef(choice);
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 = parseAgentName(name);
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
- setAgentRef(target);
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 / currentAgent — module-scope so the modal can mutate them
195
+ // setAgent / currentRotor — module-scope so the modal can mutate them
195
196
  // ---------------------------------------------------------------------------
196
197
 
197
- let currentAgentRef: () => string = () => DEFAULT_AGENT;
198
- let setAgentRef: (next: string) => void = () => {};
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
- currentAgentRef = get;
204
- setAgentRef = set;
204
+ currentRotorRef = get;
205
+ setRotorRef = set;
205
206
  }
206
207
 
207
208
  function refreshAndBuild<T>(
208
209
  ui: ExtensionUIContext,
209
- build: (groups: ReturnType<typeof groupedAvailableAgents>) => T,
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 (!parseAgentName(name)) {
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, agentTemplate(name, description), "utf-8");
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 agentTemplate(name: string, description: string): string {
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) => BUILTIN_AGENTS.includes(a));
321
- const users = cycle.filter((a) => !BUILTIN_AGENTS.includes(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
- DEFAULT_AGENT,
12
- BUILTIN_AGENTS,
13
- AGENT_META,
14
- getAgentMeta,
15
- isValidAgentName,
16
- discoverUserAgents,
11
+ DEFAULT_ROTOR,
12
+ BUILTIN_ROTORS,
13
+ ROTOR_META,
14
+ getRotorMeta,
15
+ isValidRotorName,
16
+ discoverUserRotors,
17
17
  availableAgents,
18
18
  nextAgent,
19
- parseAgentName,
19
+ parseRotorName,
20
20
  formatAgentBadge,
21
- formatAgentSwitchNotify,
21
+ formatRotorSwitchNotify,
22
22
  formatHeaderLine,
23
- groupedAvailableAgents,
23
+ groupedAvailableRotors,
24
24
  agentFilePath,
25
25
  loadAgent,
26
26
  saveAgent,
27
27
  } from "../core.js";
28
28
 
29
- describe("DEFAULT_AGENT", () => {
29
+ describe("DEFAULT_ROTOR", () => {
30
30
  test("is 'worker'", () => {
31
- expect(DEFAULT_AGENT).toBe("worker");
31
+ expect(DEFAULT_ROTOR).toBe("worker");
32
32
  });
33
33
  });
34
34
 
35
- describe("isValidAgentName", () => {
35
+ describe("isValidRotorName", () => {
36
36
  test("accepts simple names", () => {
37
- expect(isValidAgentName("worker")).toBe(true);
38
- expect(isValidAgentName("my_agent")).toBe(true);
37
+ expect(isValidRotorName("worker")).toBe(true);
38
+ expect(isValidRotorName("my_agent")).toBe(true);
39
39
  });
40
40
  test("rejects invalid", () => {
41
- expect(isValidAgentName("with space")).toBe(false);
42
- expect(isValidAgentName("")).toBe(false);
43
- expect(isValidAgentName("a".repeat(65))).toBe(false);
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("AGENT_META", () => {
47
+ describe("ROTOR_META", () => {
48
48
  test("every built-in has metadata", () => {
49
- for (const a of BUILTIN_AGENTS) {
50
- expect(AGENT_META[a]).toBeDefined();
51
- expect(AGENT_META[a]!.emoji.length).toBeGreaterThan(0);
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(AGENT_META.worker!.writesFiles).toBe(true);
56
- expect(AGENT_META.oracle!.writesFiles).toBe(false);
55
+ expect(ROTOR_META.worker!.writesFiles).toBe(true);
56
+ expect(ROTOR_META.oracle!.writesFiles).toBe(false);
57
57
  });
58
58
  });
59
59
 
60
- describe("getAgentMeta", () => {
60
+ describe("getRotorMeta", () => {
61
61
  test("returns fallback for unknown", () => {
62
- const m = getAgentMeta("zzz");
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("parseAgentName", () => {
77
+ describe("parseRotorName", () => {
78
78
  test("trims and validates", () => {
79
- expect(parseAgentName(" oracle ")).toBe("oracle");
80
- expect(parseAgentName("with space")).toBeNull();
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(DEFAULT_AGENT)).toBeNull();
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("formatAgentSwitchNotify", () => {
94
+ describe("formatRotorSwitchNotify", () => {
95
95
  test("multi-line: old → new + capability", () => {
96
- const out = formatAgentSwitchNotify("worker", "oracle");
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("groupedAvailableAgents", () => {
120
+ describe("groupedAvailableRotors", () => {
121
121
  test("includes built-in group", () => {
122
- const groups = groupedAvailableAgents("/nonexistent");
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/agents/ scan picks up our fixture
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", "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 = groupedAvailableAgents();
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", "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 = groupedAvailableAgents(projectDir);
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 });