pi-soly 0.8.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 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
@@ -34,12 +34,15 @@ const SHIPPED_SKILLS = [
34
34
  ] as const;
35
35
 
36
36
  /** Where pi looks for user agents. Respects HOME/USERPROFILE for
37
- * testability (otherwise we'd always write to the real user home). */
37
+ * testability (otherwise we'd always write to the real user home).
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. */
38
41
  function userAgentsDirs(): string[] {
39
42
  const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
40
43
  return [
41
44
  path.join(home, ".agents"), // vendor-neutral (preferred)
42
- path.join(home, ".pi", "agent", "agents"), // pi's native
45
+ path.join(home, ".pi", "agent", "agents"), // pi native (legacy)
43
46
  ];
44
47
  }
45
48
 
@@ -92,7 +95,7 @@ function copyDirIfMissing(from: string, to: string): "installed" | "skipped" | "
92
95
 
93
96
  /** Install shipped soly agents to `~/.agents/` (vendor-neutral,
94
97
  * preferred). Legacy `~/.pi/agent/agents/` copies are left alone —
95
- * `discoverUserAgents` reads both, so old installs still work. */
98
+ * `discoverUserRotors` reads both, so old installs still work. */
96
99
  export function installSolyAgents(extensionRoot: string): InstallResult {
97
100
  const result: InstallResult = { installed: [], skipped: [], errors: [] };
98
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 `/agent` slash command (footer pill + Ctrl+Tab).
375
- // Soly no longer owns the agent switcher UI.
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
- // Agent switcher (Shift+Tab cycles through available subagents)
83
+ // Rotor switcher (Shift+Tab cycles through available rotors)
84
84
  // ============================================================================
85
85
 
86
86
  // ============================================================================
87
- // Agent switcher: REMOVED. The agent cycler is now owned by the
88
- // separate `pi-switch` extension (footer pill + Ctrl+Tab + /agent slash).
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 /agent slash.
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.
@@ -353,10 +353,14 @@ export default function solyExtension(pi: ExtensionAPI) {
353
353
  // Rules sources (priority order, higher wins on relPath collision).
354
354
  // Project rules always beat global rules. .soly/rules.local/ is
355
355
  // gitignored — for personal overrides on top of the project's rules.
356
+ // .agents/rules/ is the vendor-neutral project-level convention
357
+ // (same role as the old .claude/rules/).
356
358
  ruleSources = [
357
359
  { dir: path.join(ctx.cwd, ".soly", "rules.local"), source: "project-soly", sourceLabel: "local", priority: 5 },
358
360
  { dir: path.join(ctx.cwd, ".soly", "rules"), source: "project-soly", sourceLabel: "soly", priority: 4 },
361
+ { dir: path.join(ctx.cwd, ".agents", "rules"), source: "project-agents", sourceLabel: "agents", priority: 3 },
359
362
  { dir: path.join(os.homedir(), ".soly", "rules"), source: "global-soly", sourceLabel: "soly", priority: 2 },
363
+ { dir: path.join(os.homedir(), ".agents", "rules"), source: "global-agents", sourceLabel: "agents", priority: 1 },
360
364
  ];
361
365
  refreshRules();
362
366
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "0.8.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
- | `/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
 
@@ -45,16 +45,13 @@ The **soly** extension adds project-management workflow to [pi-coding-agent](htt
45
45
  <project-root>/
46
46
  ├── AGENTS.md # vendor-neutral agent context (loaded by pi)
47
47
  ├── agents.md # same as AGENTS.md, lowercase accepted
48
- ├── .agents/ # project-level agent definitions
49
- │ ├── project-reviewer.md
50
- │ └── data-scientist.md
51
- ├── .soly/
48
+ ├── .soly/ # soly state (phases, plans, summaries)
52
49
  │ ├── ROADMAP.md # phase table
53
50
  │ ├── STATE.md # current position + decisions log
54
51
  │ ├── docs/ # 0-point intent docs (human-written, locked)
55
52
  │ │ ├── vision.md
56
53
  │ │ └── architecture.md
57
- │ ├── rules/ # project rules (version-controlled)
54
+ │ ├── rules/ # soly project rules (version-controlled)
58
55
  │ │ ├── code-style.md
59
56
  │ │ └── testing.md
60
57
  │ ├── phases/
@@ -70,8 +67,26 @@ The **soly** extension adds project-management workflow to [pi-coding-agent](htt
70
67
  │ ├── iterations/ # per-execution context bundles (auto)
71
68
  │ ├── HANDOFF.json # pause snapshot
72
69
  │ └── .continue-here.md # pause resume marker
70
+ ├── .agents/ # vendor-neutral agent config (per project)
71
+ │ ├── rules/ # agent rules (loaded with priority 3, after .soly/rules/)
72
+ │ │ ├── code-style.md
73
+ │ │ └── testing.md
74
+ │ ├── skills/ # project-scoped skills (pi auto-discovers)
75
+ │ │ └── my-skill/
76
+ │ │ └── SKILL.md
77
+ │ ├── docs/ # agent-specific docs (intent-style)
78
+ │ │ └── architecture.md
79
+ │ └── agents/ # project-specific agent definitions
80
+ │ ├── project-reviewer.md
81
+ │ └── data-scientist.md
73
82
  ```
74
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:
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
89
+
75
90
  ## Frontmatter conventions
76
91
 
77
92
  ### PLAN.md frontmatter (required)
@@ -183,16 +198,18 @@ The only legal sequence for finishing a plan:
183
198
 
184
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.
185
200
 
186
- ## Cycle agents (4 built-in)
201
+ ## Cycle rotors (4 built-in)
187
202
 
188
- | Agent | Writes | Use for |
203
+ | Rotor | Writes | Use for |
189
204
  |---|---|---|
190
205
  | `worker` | ✅ | Generic implementation, full tools |
191
206
  | `oracle` | ❌ | Decision-consistency, no file edits |
192
207
  | `scout` | ❌ | Codebase recon, read-only |
193
208
  | `reviewer` | ❌ | Adversarial code review |
194
209
 
195
- Switch with `/agent <name>` or `Ctrl+Tab` (cycles through). Footer pill shows current: `· ⚡ worker` / `▶ 🐢 oracle`.
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).
196
213
 
197
214
  ## Subagent: soly-manager (single, mode-switching)
198
215
 
@@ -298,7 +315,13 @@ tools: read, bash
298
315
  You are a data scientist. ...
299
316
  ```
300
317
 
301
- Both `~/.agents/` and `~/.pi/agent/agents/` are read (vendor-neutral preferred). `Ctrl+Tab` to see them in the cycle.
318
+ **Discovered from 4 locations** (priority order):
319
+ 1. `<project>/.agents/` — project vendor-neutral (preferred)
320
+ 2. `<project>/.pi/agent/agents/` — project pi native (legacy)
321
+ 3. `~/.agents/` — user vendor-neutral (preferred)
322
+ 4. `~/.pi/agent/agents/` — user pi native (legacy)
323
+
324
+ `Ctrl+Tab` to see them in the cycle.
302
325
 
303
326
  ### Add a feature to an existing phase
304
327
 
@@ -334,9 +357,9 @@ If `/execute` complains about illegal partial state:
334
357
  - ❌ Edit `.soly/rules/` files you didn't write — those are project invariants
335
358
  - ❌ Skip the SUMMARY — illegal partial state
336
359
  - ❌ Spawn `soly-worker` or `soly-debugger` — use `soly-manager` (mode-switches)
337
- - ❌ Write rules in code comments — use `.soly/rules/*.md` files
360
+ - ❌ Write rules in code comments — use `.soly/rules/*.md` or `.agents/rules/*.md` files
338
361
  - ❌ Edit `.soly/phases/*/PLAN.md` after `status: in_progress` — create a new plan
339
- - ❌ Put intent docs anywhere other than `.soly/docs/`
362
+ - ❌ Put intent docs anywhere other than `.soly/docs/` (or `.agents/docs/` for vendor-neutral)
340
363
 
341
364
  ## When in doubt
342
365
 
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,28 +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
- /** User agent home directories, in priority order. First existing one
69
- * wins for new agent creation; all are read and merged in the cycle.
70
- * Honors $HOME / $USERPROFILE so tests can redirect to a tmp dir. */
71
- export function userAgentDirs(): string[] {
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[] {
72
76
  const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
73
- return [
74
- path.join(home, ".agents"), // vendor-neutral (preferred)
75
- path.join(home, ".pi", "agent", "agents"), // pi's native
76
- ];
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;
77
85
  }
78
86
 
79
- /** Read all user agent names from every registered home dir. Dedupes,
80
- * first-occurrence wins. */
81
- export function discoverUserAgents(): string[] {
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[] {
82
90
  const seen = new Set<string>();
83
91
  const out: string[] = [];
84
- for (const dir of userAgentDirs()) {
92
+ for (const dir of rotorHomeDirs(cwd)) {
85
93
  if (!fs.existsSync(dir)) continue;
86
94
  let entries: string[];
87
95
  try {
@@ -99,7 +107,7 @@ export function discoverUserAgents(): string[] {
99
107
  const nameMatch = fm.match(/^name:\s*(.+)$/m);
100
108
  if (nameMatch) {
101
109
  const n = (nameMatch[1] ?? "").trim();
102
- if (isValidAgentName(n) && !seen.has(n)) {
110
+ if (isValidRotorName(n) && !seen.has(n)) {
103
111
  seen.add(n);
104
112
  out.push(n);
105
113
  }
@@ -111,9 +119,9 @@ export function discoverUserAgents(): string[] {
111
119
  }
112
120
 
113
121
  /** Build the full cycle of available agents. Built-ins first, then
114
- * user-discovered (from all user agent home dirs). Dedupes while
115
- * preserving first-occurrence order. */
116
- export function availableAgents(): string[] {
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[] {
117
125
  const out: string[] = [];
118
126
  const seen = new Set<string>();
119
127
  const push = (n: string) => {
@@ -122,37 +130,37 @@ export function availableAgents(): string[] {
122
130
  out.push(n);
123
131
  }
124
132
  };
125
- for (const a of BUILTIN_AGENTS) push(a);
126
- for (const a of discoverUserAgents()) push(a);
133
+ for (const a of BUILTIN_ROTORS) push(a);
134
+ for (const a of discoverUserRotors(cwd)) push(a);
127
135
  return out;
128
136
  }
129
137
 
130
138
  /** Cycle order. */
131
139
  export function nextAgent(current: string, cycle: readonly string[]): string {
132
- if (cycle.length === 0) return DEFAULT_AGENT;
140
+ if (cycle.length === 0) return DEFAULT_ROTOR;
133
141
  const idx = cycle.indexOf(current);
134
142
  if (idx < 0) return cycle[0]!;
135
143
  return cycle[(idx + 1) % cycle.length]!;
136
144
  }
137
145
 
138
146
  /** Parse a user-supplied agent name. */
139
- export function parseAgentName(raw: string): string | null {
147
+ export function parseRotorName(raw: string): string | null {
140
148
  const n = raw.trim();
141
- if (!isValidAgentName(n)) return null;
149
+ if (!isValidRotorName(n)) return null;
142
150
  return n;
143
151
  }
144
152
 
145
153
  /** Short badge: `<emoji> <name>`. Null for default (silent). */
146
154
  export function formatAgentBadge(agent: string): string | null {
147
- if (agent === DEFAULT_AGENT) return null;
148
- const meta = getAgentMeta(agent);
155
+ if (agent === DEFAULT_ROTOR) return null;
156
+ const meta = getRotorMeta(agent);
149
157
  return `${meta.emoji} ${agent}`;
150
158
  }
151
159
 
152
160
  /** Multi-line switch notify. */
153
- export function formatAgentSwitchNotify(prev: string, next: string): string {
154
- const prevMeta = getAgentMeta(prev);
155
- const nextMeta = getAgentMeta(next);
161
+ export function formatRotorSwitchNotify(prev: string, next: string): string {
162
+ const prevMeta = getRotorMeta(prev);
163
+ const nextMeta = getRotorMeta(next);
156
164
  const lines: string[] = [
157
165
  "pi-switch agent changed",
158
166
  "",
@@ -165,19 +173,19 @@ export function formatAgentSwitchNotify(prev: string, next: string): string {
165
173
  }
166
174
 
167
175
  /** Group agents: built-ins + user-defined. */
168
- export function groupedAvailableAgents(): Array<{ header: string; agents: string[] }> {
169
- const all = availableAgents();
176
+ export function groupedAvailableRotors(cwd?: string): Array<{ header: string; agents: string[] }> {
177
+ const all = availableAgents(cwd);
170
178
  const groups: Array<{ header: string; agents: string[] }> = [];
171
- const builtin = all.filter((a) => BUILTIN_AGENTS.includes(a));
179
+ const builtin = all.filter((a) => BUILTIN_ROTORS.includes(a));
172
180
  if (builtin.length > 0) groups.push({ header: "built-in", agents: builtin });
173
- const user = all.filter((a) => !BUILTIN_AGENTS.includes(a));
181
+ const user = all.filter((a) => !BUILTIN_ROTORS.includes(a));
174
182
  if (user.length > 0) groups.push({ header: "user-defined", agents: user });
175
183
  return groups;
176
184
  }
177
185
 
178
186
  /** Header line shown above chat. Persistent, dim, single line. */
179
187
  export function formatHeaderLine(agent: string): string {
180
- const meta = getAgentMeta(agent);
188
+ const meta = getRotorMeta(agent);
181
189
  const writeTag = meta.writesFiles ? "" : " \u00b7 read-only";
182
190
  return `${meta.emoji} ${agent} \u00b7 ${meta.description}${writeTag} [Ctrl+Tab to cycle]`;
183
191
  }
@@ -204,7 +212,7 @@ export function loadAgent(cwd: string): string | null {
204
212
  const file = agentFilePath(cwd);
205
213
  if (!fs.existsSync(file)) return null;
206
214
  const raw = fs.readFileSync(file, "utf-8").trim();
207
- if (!isValidAgentName(raw)) return null;
215
+ if (!isValidRotorName(raw)) return null;
208
216
  return raw;
209
217
  } catch {
210
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
- cycle = availableAgents();
49
- if (!cycle.includes(currentAgent)) currentAgent = DEFAULT_AGENT;
48
+ cycle = availableAgents(cwd);
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
- if (!availableAgents().includes(target)) {
181
- return ui.notify(`pi-switch: unknown "${target}". available: ${availableAgents().join(", ")}`, "error");
181
+ if (!availableAgents(cwd).includes(target)) {
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}
@@ -314,11 +315,11 @@ you have a specific reason to change it.
314
315
  }
315
316
 
316
317
  function doctorReport(): string {
317
- const cycle = availableAgents();
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,9 +117,9 @@ 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", () => {
@@ -133,7 +133,7 @@ describe("groupedAvailableAgents", () => {
133
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
@@ -141,6 +141,17 @@ describe("groupedAvailableAgents", () => {
141
141
  process.env.USERPROFILE = prevUserProfile ?? home;
142
142
  fs.rmSync(tmp, { recursive: true, force: true });
143
143
  });
144
+
145
+ test("includes project agent when present (cwd scope)", () => {
146
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), "pis-proj-"));
147
+ const agentsDir = path.join(projectDir, ".agents");
148
+ fs.mkdirSync(agentsDir, { recursive: true });
149
+ fs.writeFileSync(path.join(agentsDir, "proj.md"), "---\nname: project-helper\n---\n# body\n");
150
+ const groups = groupedAvailableRotors(projectDir);
151
+ const userGroup = groups.find((g) => g.header === "user-defined");
152
+ expect(userGroup?.agents).toContain("project-helper");
153
+ fs.rmSync(projectDir, { recursive: true, force: true });
154
+ });
144
155
  });
145
156
 
146
157
  // ---------------------------------------------------------------------------