@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 +82 -0
- package/extensions/agents/index.ts +265 -0
- package/package.json +15 -0
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
|
+
}
|