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