@dougbots/pi-agents 0.1.1 → 0.2.1
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 +40 -0
- package/extensions/agents/agent-profile.ts +107 -0
- package/extensions/agents/index.ts +177 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,6 +49,42 @@ Profile fields:
|
|
|
49
49
|
- `excludeTools` — denylist of tool names (removed from available set)
|
|
50
50
|
- `permissions.bash` — `{ allow?: string[], deny?: string[] }` with glob patterns
|
|
51
51
|
|
|
52
|
+
### Runtime agent profiles
|
|
53
|
+
|
|
54
|
+
A runtime agent profile is a session-scoped overlay for role profiles. It can
|
|
55
|
+
change execution settings without changing the role's system prompt, tools, or
|
|
56
|
+
permission boundary. Define global profiles in `~/.pi/agent/agent-profiles.json`
|
|
57
|
+
and project overrides in `.pi/agent-profiles.json`:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"cloud": {
|
|
62
|
+
"description": "Temporary cloud fallbacks",
|
|
63
|
+
"agents": {
|
|
64
|
+
"explore": { "model": "sparky/deepseek-flash" },
|
|
65
|
+
"mule": { "model": "sparky/deepseek-flash" },
|
|
66
|
+
"reviewer": { "model": "sparky/deepseek-flash" }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Only `model` and `thinkingLevel` may be overridden. Project profile entries
|
|
73
|
+
merge over global entries by profile and agent name. Malformed entries are
|
|
74
|
+
ignored and reported as a Pi warning. A selected profile is persisted in the Pi
|
|
75
|
+
session and restored by `/resume`.
|
|
76
|
+
|
|
77
|
+
`PI_AGENT_PROFILE=<name>` is an explicit launch-time override; `PROFILE=<name>`
|
|
78
|
+
is accepted as a convenience alias. Both take precedence over a saved session
|
|
79
|
+
selection. Explicit `--model` still takes precedence over an agent-profile's
|
|
80
|
+
model.
|
|
81
|
+
|
|
82
|
+
While a session has a profile active, the extension reflects it into
|
|
83
|
+
`process.env.PI_AGENT_PROFILE` (cleared on `/agent-profile none`). Child
|
|
84
|
+
processes inherit it, so tooling like `agent-model.sh <role> pi` resolves the
|
|
85
|
+
same model the session runs, and fresh dispatches from the session stay on the
|
|
86
|
+
active profile.
|
|
87
|
+
|
|
52
88
|
## Commands
|
|
53
89
|
|
|
54
90
|
| Command | Description |
|
|
@@ -57,12 +93,16 @@ Profile fields:
|
|
|
57
93
|
| `/agent none` | Deactivate agent, restore all tools |
|
|
58
94
|
| `/agent` | List available agents |
|
|
59
95
|
| `/agents` | Same as `/agent` |
|
|
96
|
+
| `/agent-profile <name>` | Select a session-scoped runtime agent profile |
|
|
97
|
+
| `/agent-profile none` | Clear the session runtime agent profile |
|
|
98
|
+
| `/agent-profile` | Show the active and available runtime agent profiles |
|
|
60
99
|
|
|
61
100
|
## Boot with agent
|
|
62
101
|
|
|
63
102
|
```bash
|
|
64
103
|
PI_AGENT=reviewer pi
|
|
65
104
|
PI_AGENT=jockey pi -c
|
|
105
|
+
PI_AGENT=explore PI_AGENT_PROFILE=cloud pi
|
|
66
106
|
```
|
|
67
107
|
|
|
68
108
|
## Avenor integration
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
2
|
+
|
|
3
|
+
/** Runtime-safe fields a named agent-profile may override on a base agent. */
|
|
4
|
+
export interface AgentProfileOverride {
|
|
5
|
+
model?: string;
|
|
6
|
+
thinkingLevel?: ThinkingLevel;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AgentProfileSet {
|
|
10
|
+
description?: string;
|
|
11
|
+
agents?: Record<string, AgentProfileOverride>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AgentProfilesConfig {
|
|
15
|
+
[name: string]: AgentProfileSet;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const thinkingLevels = new Set<ThinkingLevel>([
|
|
19
|
+
"off", "minimal", "low", "medium", "high", "xhigh",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Discard unsupported fields from untrusted JSON config. */
|
|
27
|
+
export function executionOverride(value: unknown): AgentProfileOverride | undefined {
|
|
28
|
+
if (!isRecord(value)) return undefined;
|
|
29
|
+
const override: AgentProfileOverride = {};
|
|
30
|
+
if (typeof value.model === "string") override.model = value.model;
|
|
31
|
+
if (typeof value.thinkingLevel === "string" && thinkingLevels.has(value.thinkingLevel as ThinkingLevel)) {
|
|
32
|
+
override.thinkingLevel = value.thinkingLevel as ThinkingLevel;
|
|
33
|
+
}
|
|
34
|
+
return Object.keys(override).length ? override : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Merge profile config sources: project entries override global entries by
|
|
39
|
+
* profile and agent name. Invalid JSON shapes are ignored rather than making
|
|
40
|
+
* the extension fail to load.
|
|
41
|
+
*/
|
|
42
|
+
export function mergeAgentProfilesConfigs(
|
|
43
|
+
configs: readonly unknown[],
|
|
44
|
+
onInvalid?: (message: string) => void,
|
|
45
|
+
): AgentProfilesConfig {
|
|
46
|
+
const merged: AgentProfilesConfig = {};
|
|
47
|
+
|
|
48
|
+
for (const config of configs) {
|
|
49
|
+
if (!isRecord(config)) {
|
|
50
|
+
onInvalid?.("top-level value must be an object");
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const [name, rawProfile] of Object.entries(config)) {
|
|
55
|
+
if (!isRecord(rawProfile)) {
|
|
56
|
+
onInvalid?.(`profile "${name}" must be an object`);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (rawProfile.agents !== undefined && !isRecord(rawProfile.agents)) {
|
|
60
|
+
onInvalid?.(`profile "${name}" has a non-object agents value`);
|
|
61
|
+
}
|
|
62
|
+
if (rawProfile.description !== undefined && typeof rawProfile.description !== "string") {
|
|
63
|
+
onInvalid?.(`profile "${name}" has a non-string description`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const current = Object.hasOwn(merged, name) ? merged[name] : undefined;
|
|
67
|
+
const rawAgents = isRecord(rawProfile.agents) ? rawProfile.agents : {};
|
|
68
|
+
const profile: AgentProfileSet = {
|
|
69
|
+
...(typeof current?.description === "string" ? { description: current.description } : {}),
|
|
70
|
+
...(typeof rawProfile.description === "string" ? { description: rawProfile.description } : {}),
|
|
71
|
+
agents: {
|
|
72
|
+
...current?.agents,
|
|
73
|
+
...rawAgents as Record<string, AgentProfileOverride>,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// defineProperty avoids the __proto__ setter on normal objects and makes
|
|
78
|
+
// profile-name lookups consistently own-property-only.
|
|
79
|
+
Object.defineProperty(merged, name, {
|
|
80
|
+
value: profile,
|
|
81
|
+
enumerable: true,
|
|
82
|
+
configurable: true,
|
|
83
|
+
writable: true,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return merged;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Return the latest persisted profile selection, including an explicit clear. */
|
|
92
|
+
export function findPersistedProfileName(entries: readonly unknown[]): string | undefined {
|
|
93
|
+
let profileName: string | undefined;
|
|
94
|
+
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
if (!entry || typeof entry !== "object") continue;
|
|
97
|
+
const customEntry = entry as { type?: unknown; customType?: unknown; data?: unknown };
|
|
98
|
+
if (customEntry.type !== "custom" || customEntry.customType !== "pi-agents:profile") continue;
|
|
99
|
+
if (!customEntry.data || typeof customEntry.data !== "object") continue;
|
|
100
|
+
|
|
101
|
+
const name = (customEntry.data as { name?: unknown }).name;
|
|
102
|
+
if (typeof name === "string") profileName = name;
|
|
103
|
+
if (name === null) profileName = undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return profileName;
|
|
107
|
+
}
|
|
@@ -2,6 +2,13 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { readFileSync, existsSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
executionOverride,
|
|
7
|
+
findPersistedProfileName,
|
|
8
|
+
mergeAgentProfilesConfigs,
|
|
9
|
+
type AgentProfilesConfig,
|
|
10
|
+
type ThinkingLevel,
|
|
11
|
+
} from "./agent-profile.ts";
|
|
5
12
|
import { findCliModelOverride, parseModelSpecifier } from "./model-selection.ts";
|
|
6
13
|
|
|
7
14
|
/* ------------------------------------------------------------------ */
|
|
@@ -17,7 +24,7 @@ interface AgentProfile {
|
|
|
17
24
|
description?: string;
|
|
18
25
|
model: string; // "provider/model-id"
|
|
19
26
|
systemPrompt: string; // inline text or "file:/absolute/path/to/prompt.md"
|
|
20
|
-
thinkingLevel?:
|
|
27
|
+
thinkingLevel?: ThinkingLevel;
|
|
21
28
|
tools?: string[]; // allowlist — if set, only these tools are callable
|
|
22
29
|
excludeTools?: string[]; // denylist — removed from available tools
|
|
23
30
|
permissions?: {
|
|
@@ -30,10 +37,10 @@ interface AgentsConfig {
|
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
/* ------------------------------------------------------------------ */
|
|
33
|
-
/* Config loading
|
|
34
|
-
/* with project (.pi/agents.json), project wins. */
|
|
40
|
+
/* Config loading */
|
|
35
41
|
/* ------------------------------------------------------------------ */
|
|
36
42
|
|
|
43
|
+
/** Merge global ~/.pi/agent/agents.json with project .pi/agents.json. */
|
|
37
44
|
function loadAgentsConfig(): AgentsConfig {
|
|
38
45
|
const agentDir = getAgentDir();
|
|
39
46
|
const globalPath = join(agentDir, "agents.json");
|
|
@@ -51,10 +58,40 @@ function loadAgentsConfig(): AgentsConfig {
|
|
|
51
58
|
console.error(`[agents] Failed to parse ${label} agents.json (${path}):`, err);
|
|
52
59
|
}
|
|
53
60
|
}
|
|
54
|
-
// Later entries override earlier — project wins over global
|
|
61
|
+
// Later entries override earlier — project wins over global.
|
|
55
62
|
return configs.reduce<AgentsConfig>((merged, cfg) => ({ ...merged, ...cfg }), {});
|
|
56
63
|
}
|
|
57
64
|
|
|
65
|
+
/**
|
|
66
|
+
* Load named runtime overlays. These can change only execution settings,
|
|
67
|
+
* leaving an agent's prompt, tools, and permission boundary authoritative.
|
|
68
|
+
*/
|
|
69
|
+
function loadAgentProfilesConfig(): { profiles: AgentProfilesConfig; errors: string[] } {
|
|
70
|
+
const agentDir = getAgentDir();
|
|
71
|
+
const globalPath = join(agentDir, "agent-profiles.json");
|
|
72
|
+
const projectPath = join(process.cwd(), ".pi", "agent-profiles.json");
|
|
73
|
+
|
|
74
|
+
let profiles: AgentProfilesConfig = {};
|
|
75
|
+
const errors: string[] = [];
|
|
76
|
+
for (const [label, path] of [
|
|
77
|
+
["global", globalPath],
|
|
78
|
+
["project", projectPath],
|
|
79
|
+
] as const) {
|
|
80
|
+
if (!existsSync(path)) continue;
|
|
81
|
+
try {
|
|
82
|
+
const config = JSON.parse(readFileSync(path, "utf8"));
|
|
83
|
+
profiles = mergeAgentProfilesConfigs([profiles, config], (message) => {
|
|
84
|
+
errors.push(`${label} ${path}: ${message}`);
|
|
85
|
+
});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const message = `Failed to parse ${label} agent-profiles.json (${path}): ${String(err)}`;
|
|
88
|
+
console.error(`[agents] ${message}`);
|
|
89
|
+
errors.push(message);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { profiles, errors };
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
function resolveSystemPrompt(raw: string): string {
|
|
59
96
|
if (raw.startsWith("file:")) {
|
|
60
97
|
const path = raw.slice(5);
|
|
@@ -82,7 +119,7 @@ function checkBashPermission(
|
|
|
82
119
|
command: string,
|
|
83
120
|
): "allow" | "deny" {
|
|
84
121
|
if (!perms) return "allow";
|
|
85
|
-
// Deny wins over allow
|
|
122
|
+
// Deny wins over allow.
|
|
86
123
|
for (const pattern of perms.deny ?? []) {
|
|
87
124
|
if (matchPattern(pattern, command)) return "deny";
|
|
88
125
|
}
|
|
@@ -99,15 +136,78 @@ function checkBashPermission(
|
|
|
99
136
|
|
|
100
137
|
export default async function (pi: ExtensionAPI) {
|
|
101
138
|
const agents = loadAgentsConfig();
|
|
139
|
+
const { profiles: agentProfiles, errors: agentProfileConfigErrors } = loadAgentProfilesConfig();
|
|
102
140
|
// Built-in flags are not exposed through pi.getFlag(). Avenor forwards its
|
|
103
141
|
// model option to pi as --model, so argv is the extension-level signal that
|
|
104
142
|
// the caller's model should take precedence over the profile default.
|
|
105
143
|
const cliModelOverride = findCliModelOverride(process.argv.slice(2));
|
|
106
144
|
let activeAgent: { name: string; profile: AgentProfile } | null = null;
|
|
145
|
+
let activeAgentProfileName: string | undefined;
|
|
146
|
+
|
|
147
|
+
function getEffectiveAgentProfile(name: string): AgentProfile | undefined {
|
|
148
|
+
const base = agents[name];
|
|
149
|
+
if (!base) return undefined;
|
|
150
|
+
|
|
151
|
+
const override = activeAgentProfileName
|
|
152
|
+
? executionOverride(agentProfiles[activeAgentProfileName]?.agents?.[name])
|
|
153
|
+
: undefined;
|
|
154
|
+
return { ...base, ...override };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function setAgentProfileStatus(ctx: ExtensionContext) {
|
|
158
|
+
ctx.ui.setStatus(
|
|
159
|
+
"agent-profile",
|
|
160
|
+
activeAgentProfileName ? `agent-profile:${activeAgentProfileName}` : "",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Surface the session's active agent-profile to child processes.
|
|
166
|
+
*
|
|
167
|
+
* agent-model.sh resolves a role's effective model by reading
|
|
168
|
+
* $PI_AGENT_PROFILE; absent a launch-time override, the interactive
|
|
169
|
+
* /agent-profile selection is otherwise invisible to anything pi spawns.
|
|
170
|
+
* Mirroring it into the env lets the resolver agree with the session and,
|
|
171
|
+
* since children inherit it, keeps fresh dispatches on the same profile.
|
|
172
|
+
*/
|
|
173
|
+
function syncProfileEnv(name: string | undefined) {
|
|
174
|
+
if (name) process.env.PI_AGENT_PROFILE = name;
|
|
175
|
+
else delete process.env.PI_AGENT_PROFILE;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Apply a named agent-profile, optionally saving the choice in this session. */
|
|
179
|
+
async function selectAgentProfile(
|
|
180
|
+
name: string | undefined,
|
|
181
|
+
ctx: ExtensionContext,
|
|
182
|
+
persist: boolean,
|
|
183
|
+
): Promise<boolean> {
|
|
184
|
+
if (name && !agentProfiles[name]) {
|
|
185
|
+
ctx.ui.notify(`Agent profile "${name}" not found in agent-profiles.json`, "error");
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const previousName = activeAgentProfileName;
|
|
190
|
+
activeAgentProfileName = name;
|
|
191
|
+
setAgentProfileStatus(ctx);
|
|
192
|
+
syncProfileEnv(name);
|
|
193
|
+
|
|
194
|
+
// The active role must receive a new effective model/thinking level now;
|
|
195
|
+
// future roles resolve the overlay when /agent is invoked. Do not persist
|
|
196
|
+
// a selection that cannot be applied to the current active role.
|
|
197
|
+
if (activeAgent && !(await applyAgent(activeAgent.name, ctx))) {
|
|
198
|
+
activeAgentProfileName = previousName;
|
|
199
|
+
setAgentProfileStatus(ctx);
|
|
200
|
+
syncProfileEnv(previousName);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (persist) pi.appendEntry("pi-agents:profile", { name: name ?? null });
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
107
207
|
|
|
108
208
|
/* ---- Apply agent profile to the current session ---- */
|
|
109
209
|
async function applyAgent(name: string, ctx: ExtensionContext): Promise<boolean> {
|
|
110
|
-
const profile =
|
|
210
|
+
const profile = getEffectiveAgentProfile(name);
|
|
111
211
|
if (!profile) {
|
|
112
212
|
ctx.ui.notify(`Agent "${name}" not found in agents.json`, "error");
|
|
113
213
|
return false;
|
|
@@ -131,10 +231,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
131
231
|
await pi.setModel(model);
|
|
132
232
|
}
|
|
133
233
|
|
|
134
|
-
|
|
135
|
-
if (profile.thinkingLevel) {
|
|
136
|
-
pi.setThinkingLevel(profile.thinkingLevel);
|
|
137
|
-
}
|
|
234
|
+
if (profile.thinkingLevel) pi.setThinkingLevel(profile.thinkingLevel);
|
|
138
235
|
|
|
139
236
|
// Tool restrictions — best-effort against currently registered tools.
|
|
140
237
|
// tool_call handler enforces restrictions regardless of registration timing.
|
|
@@ -142,23 +239,25 @@ export default async function (pi: ExtensionAPI) {
|
|
|
142
239
|
const allTools = pi.getAllTools();
|
|
143
240
|
let allowed: typeof allTools;
|
|
144
241
|
if (profile.tools) {
|
|
145
|
-
|
|
146
|
-
allowed = allTools.filter((t) => profile.tools!.includes(t.name));
|
|
147
|
-
// Also include any future tools whose name matches (checked at call time)
|
|
242
|
+
allowed = allTools.filter((tool) => profile.tools!.includes(tool.name));
|
|
148
243
|
} else {
|
|
149
244
|
allowed = [...allTools];
|
|
150
245
|
}
|
|
151
246
|
if (profile.excludeTools) {
|
|
152
|
-
allowed = allowed.filter((
|
|
247
|
+
allowed = allowed.filter((tool) => !profile.excludeTools!.includes(tool.name));
|
|
153
248
|
}
|
|
154
|
-
pi.setActiveTools(allowed.map((
|
|
249
|
+
pi.setActiveTools(allowed.map((tool) => tool.name));
|
|
155
250
|
} catch {
|
|
156
|
-
// setActiveTools may fail if called too early; tool_call gate is the fallback
|
|
251
|
+
// setActiveTools may fail if called too early; tool_call gate is the fallback.
|
|
157
252
|
}
|
|
158
253
|
|
|
159
254
|
activeAgent = { name, profile };
|
|
160
255
|
ctx.ui.setStatus("agent", `agent:${name}`);
|
|
161
|
-
|
|
256
|
+
const selected = activeAgentProfileName ? ` [${activeAgentProfileName}]` : "";
|
|
257
|
+
ctx.ui.notify(
|
|
258
|
+
`Agent "${name}" active${selected}${profile.description ? ": " + profile.description : ""}`,
|
|
259
|
+
"info",
|
|
260
|
+
);
|
|
162
261
|
return true;
|
|
163
262
|
}
|
|
164
263
|
|
|
@@ -166,44 +265,55 @@ export default async function (pi: ExtensionAPI) {
|
|
|
166
265
|
function deactivateAgent(ctx: ExtensionContext) {
|
|
167
266
|
activeAgent = null;
|
|
168
267
|
try {
|
|
169
|
-
pi.setActiveTools(pi.getAllTools().map((
|
|
268
|
+
pi.setActiveTools(pi.getAllTools().map((tool) => tool.name));
|
|
170
269
|
} catch { /* no-op if tools not fully registered */ }
|
|
171
270
|
ctx.ui.setStatus("agent", "");
|
|
172
271
|
ctx.ui.notify("Agent deactivated — all tools restored", "info");
|
|
173
272
|
}
|
|
174
273
|
|
|
175
|
-
/* ---- session_start: apply PI_AGENT
|
|
274
|
+
/* ---- session_start: restore profile, then apply PI_AGENT ---- */
|
|
176
275
|
pi.on("session_start", async (_event, ctx) => {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
276
|
+
if (agentProfileConfigErrors.length) {
|
|
277
|
+
ctx.ui.notify(
|
|
278
|
+
`Ignored malformed agent-profile configuration:\n${agentProfileConfigErrors.join("\n")}`,
|
|
279
|
+
"warning",
|
|
280
|
+
);
|
|
180
281
|
}
|
|
282
|
+
|
|
283
|
+
// The environment is an explicit launch-time override. A persisted command
|
|
284
|
+
// selection is restored otherwise, including a saved `none` selection.
|
|
285
|
+
const environmentProfile = process.env.PI_AGENT_PROFILE ?? process.env.PROFILE;
|
|
286
|
+
const persistedProfile = findPersistedProfileName(ctx.sessionManager.getEntries());
|
|
287
|
+
const selectedProfile = environmentProfile ?? persistedProfile;
|
|
288
|
+
if (selectedProfile) await selectAgentProfile(selectedProfile, ctx, false);
|
|
289
|
+
else setAgentProfileStatus(ctx);
|
|
290
|
+
|
|
291
|
+
const envAgent = process.env.PI_AGENT;
|
|
292
|
+
if (envAgent && agents[envAgent]) await applyAgent(envAgent, ctx);
|
|
181
293
|
});
|
|
182
294
|
|
|
183
295
|
/* ---- before_agent_start: inject agent system prompt ---- */
|
|
184
296
|
pi.on("before_agent_start", async (event, _ctx) => {
|
|
185
297
|
if (!activeAgent) return undefined;
|
|
186
298
|
|
|
187
|
-
// Re-apply tool restrictions each turn (catches late-registered tools)
|
|
299
|
+
// Re-apply tool restrictions each turn (catches late-registered tools).
|
|
188
300
|
try {
|
|
189
301
|
const profile = activeAgent.profile;
|
|
190
302
|
const allTools = pi.getAllTools();
|
|
191
303
|
let allowed: typeof allTools;
|
|
192
304
|
if (profile.tools) {
|
|
193
|
-
allowed = allTools.filter((
|
|
305
|
+
allowed = allTools.filter((tool) => profile.tools.includes(tool.name));
|
|
194
306
|
} else {
|
|
195
307
|
allowed = [...allTools];
|
|
196
308
|
}
|
|
197
309
|
if (profile.excludeTools) {
|
|
198
|
-
allowed = allowed.filter((
|
|
310
|
+
allowed = allowed.filter((tool) => !profile.excludeTools.includes(tool.name));
|
|
199
311
|
}
|
|
200
|
-
pi.setActiveTools(allowed.map((
|
|
312
|
+
pi.setActiveTools(allowed.map((tool) => tool.name));
|
|
201
313
|
} catch { /* best effort */ }
|
|
202
314
|
|
|
203
315
|
const agentPrompt = resolveSystemPrompt(activeAgent.profile.systemPrompt);
|
|
204
|
-
return {
|
|
205
|
-
systemPrompt: agentPrompt + "\n\n" + event.systemPrompt,
|
|
206
|
-
};
|
|
316
|
+
return { systemPrompt: agentPrompt + "\n\n" + event.systemPrompt };
|
|
207
317
|
});
|
|
208
318
|
|
|
209
319
|
/* ---- tool_call: enforce tool allowlist/denylist + bash permissions ---- */
|
|
@@ -211,22 +321,18 @@ export default async function (pi: ExtensionAPI) {
|
|
|
211
321
|
if (!activeAgent) return;
|
|
212
322
|
const profile = activeAgent.profile;
|
|
213
323
|
|
|
214
|
-
// Tool allowlist gate
|
|
215
324
|
if (profile.tools && !profile.tools.includes(event.toolName)) {
|
|
216
325
|
return { block: true, reason: `Tool "${event.toolName}" not allowed for agent "${activeAgent.name}"` };
|
|
217
326
|
}
|
|
218
327
|
|
|
219
|
-
// Tool denylist gate
|
|
220
328
|
if (profile.excludeTools && profile.excludeTools.includes(event.toolName)) {
|
|
221
329
|
ctx.ui.notify(`Blocked tool: ${event.toolName}`, "warning");
|
|
222
330
|
return { block: true, reason: `Tool "${event.toolName}" excluded for agent "${activeAgent.name}"` };
|
|
223
331
|
}
|
|
224
332
|
|
|
225
|
-
// Bash permission gate
|
|
226
333
|
if (event.toolName === "bash" && profile.permissions?.bash) {
|
|
227
334
|
const command = (event.input as { command?: string })?.command ?? "";
|
|
228
|
-
|
|
229
|
-
if (verdict === "deny") {
|
|
335
|
+
if (checkBashPermission(profile.permissions.bash, command) === "deny") {
|
|
230
336
|
ctx.ui.notify(`Blocked: ${command.slice(0, 80)}`, "warning");
|
|
231
337
|
return { block: true, reason: `Denied by ${activeAgent.name} bash permissions` };
|
|
232
338
|
}
|
|
@@ -237,12 +343,12 @@ export default async function (pi: ExtensionAPI) {
|
|
|
237
343
|
pi.registerCommand("agent", {
|
|
238
344
|
description: "Switch to an agent profile (or 'none' to deactivate)",
|
|
239
345
|
getArgumentCompletions: (prefix) => {
|
|
240
|
-
const matches = Object.keys(agents).filter((
|
|
346
|
+
const matches = Object.keys(agents).filter((name) => name.startsWith(prefix ?? ""));
|
|
241
347
|
return matches.length
|
|
242
|
-
? matches.map((
|
|
243
|
-
value:
|
|
244
|
-
label:
|
|
245
|
-
description: agents[
|
|
348
|
+
? matches.map((name) => ({
|
|
349
|
+
value: name,
|
|
350
|
+
label: name,
|
|
351
|
+
description: agents[name].description ?? "",
|
|
246
352
|
}))
|
|
247
353
|
: null;
|
|
248
354
|
},
|
|
@@ -251,7 +357,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
251
357
|
if (!name) {
|
|
252
358
|
const current = activeAgent ? ` (active: ${activeAgent.name})` : "";
|
|
253
359
|
const list = Object.entries(agents)
|
|
254
|
-
.map(([
|
|
360
|
+
.map(([agentName, profile]) => ` ${agentName}${profile.description ? ": " + profile.description : ""}`)
|
|
255
361
|
.join("\n");
|
|
256
362
|
ctx.ui.notify(`Agent profiles${current}:\n${list}`, "info");
|
|
257
363
|
return;
|
|
@@ -270,9 +376,39 @@ export default async function (pi: ExtensionAPI) {
|
|
|
270
376
|
handler: async (_args, ctx) => {
|
|
271
377
|
const current = activeAgent ? ` [active: ${activeAgent.name}]` : "";
|
|
272
378
|
const list = Object.entries(agents)
|
|
273
|
-
.map(([
|
|
379
|
+
.map(([name, profile]) => ` ${name}${profile.description ? ": " + profile.description : ""}`)
|
|
274
380
|
.join("\n");
|
|
275
381
|
ctx.ui.notify(`Agent profiles${current}:\n${list}`, "info");
|
|
276
382
|
},
|
|
277
383
|
});
|
|
384
|
+
|
|
385
|
+
/* ---- /agent-profile <name> — select a session-scoped runtime overlay ---- */
|
|
386
|
+
pi.registerCommand("agent-profile", {
|
|
387
|
+
description: "Select a session-scoped agent runtime profile (or 'none' to clear)",
|
|
388
|
+
getArgumentCompletions: (prefix) => {
|
|
389
|
+
const names = ["none", ...Object.keys(agentProfiles)];
|
|
390
|
+
const matches = names.filter((name) => name.startsWith(prefix ?? ""));
|
|
391
|
+
return matches.length
|
|
392
|
+
? matches.map((name) => ({
|
|
393
|
+
value: name,
|
|
394
|
+
label: name,
|
|
395
|
+
description: name === "none"
|
|
396
|
+
? "Clear the session agent profile"
|
|
397
|
+
: agentProfiles[name]?.description ?? "",
|
|
398
|
+
}))
|
|
399
|
+
: null;
|
|
400
|
+
},
|
|
401
|
+
handler: async (args, ctx) => {
|
|
402
|
+
const name = args?.trim();
|
|
403
|
+
if (!name) {
|
|
404
|
+
const current = activeAgentProfileName ?? "none";
|
|
405
|
+
const list = Object.entries(agentProfiles)
|
|
406
|
+
.map(([profileName, profile]) => ` ${profileName}${profile.description ? ": " + profile.description : ""}`)
|
|
407
|
+
.join("\n");
|
|
408
|
+
ctx.ui.notify(`Agent runtime profiles (active: ${current})${list ? `:\n${list}` : ""}`, "info");
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
await selectAgentProfile(name === "none" ? undefined : name, ctx, true);
|
|
412
|
+
},
|
|
413
|
+
});
|
|
278
414
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dougbots/pi-agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Agent profiles for pi — preconfigured model, system prompt, tool restrictions, and permission gates",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --experimental-strip-types --test tests/*.test.mts"
|