@dougbots/pi-agents 0.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 ADDED
@@ -0,0 +1,82 @@
1
+ # @dougbots/pi-agents
2
+
3
+ Agent profiles for pi — preconfigured model, system prompt, tool restrictions, and permission gates.
4
+
5
+ ## What it does
6
+
7
+ Defines named agent profiles (mirroring opencode's `agent` concept) that can be:
8
+ - Activated on the current session: `/agent jockey`
9
+ - Booted at startup: `PI_AGENT=reviewer pi`
10
+ - Spawned by [avenor](https://github.com/sdougbrown/avenor) as pi subprocesses: `avenor_spawn(agent: "reviewer", backend: "pi")`
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ # As a pi package (recommended)
16
+ pi install git:github.com/sdougbrown/pi-agents
17
+
18
+ # Local development
19
+ pi install /path/to/pi-agents
20
+ ```
21
+
22
+ ## Config
23
+
24
+ `~/.pi/agent/agents.json` (global) and `.pi/agents.json` (project, overrides global):
25
+
26
+ ```json
27
+ {
28
+ "reviewer": {
29
+ "description": "Code reviewer — no edits",
30
+ "model": "provider/model-id",
31
+ "systemPrompt": "inline text or file:/path/to/prompt.md",
32
+ "thinkingLevel": "high",
33
+ "excludeTools": ["write", "edit"],
34
+ "permissions": {
35
+ "bash": {
36
+ "allow": ["git *", "npm test *"],
37
+ "deny": ["git push*", "rm -rf*"]
38
+ }
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ Profile fields:
45
+ - `model` — `"provider/model-id"` (required)
46
+ - `systemPrompt` — inline string or `file:/absolute/path` (required)
47
+ - `thinkingLevel` — `off` | `low` | `medium` | `high` | `xhigh`
48
+ - `tools` — allowlist of tool names (if set, only these are callable)
49
+ - `excludeTools` — denylist of tool names (removed from available set)
50
+ - `permissions.bash` — `{ allow?: string[], deny?: string[] }` with glob patterns
51
+
52
+ ## Commands
53
+
54
+ | Command | Description |
55
+ |---------|-------------|
56
+ | `/agent <name>` | Switch current session to agent profile |
57
+ | `/agent none` | Deactivate agent, restore all tools |
58
+ | `/agent` | List available agents |
59
+ | `/agents` | Same as `/agent` |
60
+
61
+ ## Boot with agent
62
+
63
+ ```bash
64
+ PI_AGENT=reviewer pi
65
+ PI_AGENT=jockey pi -c
66
+ ```
67
+
68
+ ## Avenor integration
69
+
70
+ When `avenor_spawn(agent: "reviewer", backend: "pi")` is called, avenor spawns `pi --mode rpc` with `PI_AGENT=reviewer`. The agents extension applies the full profile (model, systemPrompt, tools, permissions) automatically.
71
+
72
+ Requires [avenor](https://github.com/sdougbrown/avenor) with the pi backend (v0.3.3+). Model resolution falls back to `~/.pi/agent/agents.json` when the agent is not found in opencode config.
73
+
74
+ ## Profile vs. agent
75
+
76
+ `pi-profiles` (by Carter McAlister) is a session config overlay — it swaps settings/extensions/skills and reloads the current session. Agents are discrete "personalities" with their own model, prompt, tool restrictions, and permission gates, designed to be spawned as subprocesses by avenor.
77
+
78
+ ## Dependencies
79
+
80
+ - **pi** — the extension runtime
81
+ - **avenor** — for subprocess spawning with `backend: "pi"` (optional, for sub-agent workflows)
82
+ - best used with `@dougbots/avenor-pi` to provide the tools to spawn those processes
@@ -0,0 +1,265 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+ import { readFileSync, existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ /* ------------------------------------------------------------------ */
8
+ /* Types */
9
+ /* ------------------------------------------------------------------ */
10
+
11
+ interface BashPermissions {
12
+ allow?: string[];
13
+ deny?: string[];
14
+ }
15
+
16
+ interface AgentProfile {
17
+ description?: string;
18
+ model: string; // "provider/model-id"
19
+ systemPrompt: string; // inline text or "file:/absolute/path/to/prompt.md"
20
+ thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
21
+ tools?: string[]; // allowlist — if set, only these tools are callable
22
+ excludeTools?: string[]; // denylist — removed from available tools
23
+ permissions?: {
24
+ bash?: BashPermissions;
25
+ };
26
+ }
27
+
28
+ interface AgentsConfig {
29
+ [name: string]: AgentProfile;
30
+ }
31
+
32
+ /* ------------------------------------------------------------------ */
33
+ /* Config loading — merges global (~/.pi/agent/agents.json) */
34
+ /* with project (.pi/agents.json), project wins. */
35
+ /* ------------------------------------------------------------------ */
36
+
37
+ function loadAgentsConfig(): AgentsConfig {
38
+ const agentDir = getAgentDir();
39
+ const globalPath = join(agentDir, "agents.json");
40
+ const projectPath = join(process.cwd(), ".pi", "agents.json");
41
+
42
+ const configs: AgentsConfig[] = [];
43
+ for (const [label, path] of [
44
+ ["global", globalPath],
45
+ ["project", projectPath],
46
+ ] as const) {
47
+ if (!existsSync(path)) continue;
48
+ try {
49
+ configs.push(JSON.parse(readFileSync(path, "utf8")));
50
+ } catch (err) {
51
+ console.error(`[agents] Failed to parse ${label} agents.json (${path}):`, err);
52
+ }
53
+ }
54
+ // Later entries override earlier — project wins over global
55
+ return configs.reduce<AgentsConfig>((merged, cfg) => ({ ...merged, ...cfg }), {});
56
+ }
57
+
58
+ function resolveSystemPrompt(raw: string): string {
59
+ if (raw.startsWith("file:")) {
60
+ const path = raw.slice(5);
61
+ if (!existsSync(path)) {
62
+ throw new Error(`Agent system prompt file not found: ${path}`);
63
+ }
64
+ return readFileSync(path, "utf8");
65
+ }
66
+ return raw;
67
+ }
68
+
69
+ /* ------------------------------------------------------------------ */
70
+ /* Bash permission matching (simple glob: * = anything) */
71
+ /* ------------------------------------------------------------------ */
72
+
73
+ function matchPattern(pattern: string, command: string): boolean {
74
+ const regex = new RegExp(
75
+ "^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
76
+ );
77
+ return regex.test(command);
78
+ }
79
+
80
+ function checkBashPermission(
81
+ perms: BashPermissions | undefined,
82
+ command: string,
83
+ ): "allow" | "deny" {
84
+ if (!perms) return "allow";
85
+ // Deny wins over allow
86
+ for (const pattern of perms.deny ?? []) {
87
+ if (matchPattern(pattern, command)) return "deny";
88
+ }
89
+ for (const pattern of perms.allow ?? []) {
90
+ if (matchPattern(pattern, command)) return "allow";
91
+ }
92
+ // If allow list exists but no match, deny. If only deny list, allow.
93
+ return perms.allow ? "deny" : "allow";
94
+ }
95
+
96
+ /* ------------------------------------------------------------------ */
97
+ /* Extension */
98
+ /* ------------------------------------------------------------------ */
99
+
100
+ export default async function (pi: ExtensionAPI) {
101
+ const agents = loadAgentsConfig();
102
+ let activeAgent: { name: string; profile: AgentProfile } | null = null;
103
+
104
+ /* ---- Apply agent profile to the current session ---- */
105
+ async function applyAgent(name: string, ctx: ExtensionContext): Promise<boolean> {
106
+ const profile = agents[name];
107
+ if (!profile) {
108
+ ctx.ui.notify(`Agent "${name}" not found in agents.json`, "error");
109
+ return false;
110
+ }
111
+
112
+ // Resolve model
113
+ const [provider, modelId] = profile.model.split("/");
114
+ const model = ctx.modelRegistry.find(provider, modelId);
115
+ if (!model) {
116
+ ctx.ui.notify(`Model "${profile.model}" not found in registry`, "error");
117
+ return false;
118
+ }
119
+ await pi.setModel(model);
120
+
121
+ // Thinking level
122
+ if (profile.thinkingLevel) {
123
+ pi.setThinkingLevel(profile.thinkingLevel);
124
+ }
125
+
126
+ // Tool restrictions — best-effort against currently registered tools.
127
+ // tool_call handler enforces restrictions regardless of registration timing.
128
+ try {
129
+ const allTools = pi.getAllTools();
130
+ let allowed: typeof allTools;
131
+ if (profile.tools) {
132
+ // Allowlist: only these tool names
133
+ allowed = allTools.filter((t) => profile.tools!.includes(t.name));
134
+ // Also include any future tools whose name matches (checked at call time)
135
+ } else {
136
+ allowed = [...allTools];
137
+ }
138
+ if (profile.excludeTools) {
139
+ allowed = allowed.filter((t) => !profile.excludeTools!.includes(t.name));
140
+ }
141
+ pi.setActiveTools(allowed.map((t) => t.name));
142
+ } catch {
143
+ // setActiveTools may fail if called too early; tool_call gate is the fallback
144
+ }
145
+
146
+ activeAgent = { name, profile };
147
+ ctx.ui.setStatus("agent", `agent:${name}`);
148
+ ctx.ui.notify(`Agent "${name}" active${profile.description ? ": " + profile.description : ""}`, "info");
149
+ return true;
150
+ }
151
+
152
+ /* ---- Deactivate agent ---- */
153
+ function deactivateAgent(ctx: ExtensionContext) {
154
+ activeAgent = null;
155
+ try {
156
+ pi.setActiveTools(pi.getAllTools().map((t) => t.name));
157
+ } catch { /* no-op if tools not fully registered */ }
158
+ ctx.ui.setStatus("agent", "");
159
+ ctx.ui.notify("Agent deactivated — all tools restored", "info");
160
+ }
161
+
162
+ /* ---- session_start: apply PI_AGENT env var ---- */
163
+ pi.on("session_start", async (_event, ctx) => {
164
+ const envAgent = process.env.PI_AGENT;
165
+ if (envAgent && agents[envAgent]) {
166
+ await applyAgent(envAgent, ctx);
167
+ }
168
+ });
169
+
170
+ /* ---- before_agent_start: inject agent system prompt ---- */
171
+ pi.on("before_agent_start", async (event, _ctx) => {
172
+ if (!activeAgent) return undefined;
173
+
174
+ // Re-apply tool restrictions each turn (catches late-registered tools)
175
+ try {
176
+ const profile = activeAgent.profile;
177
+ const allTools = pi.getAllTools();
178
+ let allowed: typeof allTools;
179
+ if (profile.tools) {
180
+ allowed = allTools.filter((t) => profile.tools.includes(t.name));
181
+ } else {
182
+ allowed = [...allTools];
183
+ }
184
+ if (profile.excludeTools) {
185
+ allowed = allowed.filter((t) => !profile.excludeTools.includes(t.name));
186
+ }
187
+ pi.setActiveTools(allowed.map((t) => t.name));
188
+ } catch { /* best effort */ }
189
+
190
+ const agentPrompt = resolveSystemPrompt(activeAgent.profile.systemPrompt);
191
+ return {
192
+ systemPrompt: agentPrompt + "\n\n" + event.systemPrompt,
193
+ };
194
+ });
195
+
196
+ /* ---- tool_call: enforce tool allowlist/denylist + bash permissions ---- */
197
+ pi.on("tool_call", async (event, ctx) => {
198
+ if (!activeAgent) return;
199
+ const profile = activeAgent.profile;
200
+
201
+ // Tool allowlist gate
202
+ if (profile.tools && !profile.tools.includes(event.toolName)) {
203
+ return { block: true, reason: `Tool "${event.toolName}" not allowed for agent "${activeAgent.name}"` };
204
+ }
205
+
206
+ // Tool denylist gate
207
+ if (profile.excludeTools && profile.excludeTools.includes(event.toolName)) {
208
+ ctx.ui.notify(`Blocked tool: ${event.toolName}`, "warning");
209
+ return { block: true, reason: `Tool "${event.toolName}" excluded for agent "${activeAgent.name}"` };
210
+ }
211
+
212
+ // Bash permission gate
213
+ if (event.toolName === "bash" && profile.permissions?.bash) {
214
+ const command = (event.input as { command?: string })?.command ?? "";
215
+ const verdict = checkBashPermission(profile.permissions.bash, command);
216
+ if (verdict === "deny") {
217
+ ctx.ui.notify(`Blocked: ${command.slice(0, 80)}`, "warning");
218
+ return { block: true, reason: `Denied by ${activeAgent.name} bash permissions` };
219
+ }
220
+ }
221
+ });
222
+
223
+ /* ---- /agent <name> — switch to an agent profile ---- */
224
+ pi.registerCommand("agent", {
225
+ description: "Switch to an agent profile (or 'none' to deactivate)",
226
+ getArgumentCompletions: (prefix) => {
227
+ const matches = Object.keys(agents).filter((n) => n.startsWith(prefix ?? ""));
228
+ return matches.length
229
+ ? matches.map((n) => ({
230
+ value: n,
231
+ label: n,
232
+ description: agents[n].description ?? "",
233
+ }))
234
+ : null;
235
+ },
236
+ handler: async (args, ctx) => {
237
+ const name = args?.trim();
238
+ if (!name) {
239
+ const current = activeAgent ? ` (active: ${activeAgent.name})` : "";
240
+ const list = Object.entries(agents)
241
+ .map(([n, p]) => ` ${n}${p.description ? ": " + p.description : ""}`)
242
+ .join("\n");
243
+ ctx.ui.notify(`Agent profiles${current}:\n${list}`, "info");
244
+ return;
245
+ }
246
+ if (name === "none") {
247
+ deactivateAgent(ctx);
248
+ return;
249
+ }
250
+ await applyAgent(name, ctx);
251
+ },
252
+ });
253
+
254
+ /* ---- /agents — list profiles ---- */
255
+ pi.registerCommand("agents", {
256
+ description: "List available agent profiles",
257
+ handler: async (_args, ctx) => {
258
+ const current = activeAgent ? ` [active: ${activeAgent.name}]` : "";
259
+ const list = Object.entries(agents)
260
+ .map(([n, p]) => ` ${n}${p.description ? ": " + p.description : ""}`)
261
+ .join("\n");
262
+ ctx.ui.notify(`Agent profiles${current}:\n${list}`, "info");
263
+ },
264
+ });
265
+ }
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "@dougbots/pi-agents",
3
+ "version": "0.1.0",
4
+ "description": "Agent profiles for pi — preconfigured model, system prompt, tool restrictions, and permission gates",
5
+ "keywords": ["pi-package", "pi", "pi-extension", "agents"],
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/sdougbrown/pi-agents.git"
10
+ },
11
+ "files": ["extensions", "README.md"],
12
+ "pi": {
13
+ "extensions": ["./extensions"]
14
+ }
15
+ }