@dougbots/pi-agents 0.1.0 → 0.2.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
@@ -49,6 +49,36 @@ 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
+
52
82
  ## Commands
53
83
 
54
84
  | Command | Description |
@@ -57,18 +87,35 @@ Profile fields:
57
87
  | `/agent none` | Deactivate agent, restore all tools |
58
88
  | `/agent` | List available agents |
59
89
  | `/agents` | Same as `/agent` |
90
+ | `/agent-profile <name>` | Select a session-scoped runtime agent profile |
91
+ | `/agent-profile none` | Clear the session runtime agent profile |
92
+ | `/agent-profile` | Show the active and available runtime agent profiles |
60
93
 
61
94
  ## Boot with agent
62
95
 
63
96
  ```bash
64
97
  PI_AGENT=reviewer pi
65
98
  PI_AGENT=jockey pi -c
99
+ PI_AGENT=explore PI_AGENT_PROFILE=cloud pi
66
100
  ```
67
101
 
68
102
  ## Avenor integration
69
103
 
70
104
  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
105
 
106
+ Pass `model` as well to override only the profile's default model while retaining its prompt, tools, and permissions:
107
+
108
+ ```text
109
+ avenor_spawn(
110
+ agent: "reviewer",
111
+ model: "anthropic/claude-opus-4",
112
+ backend: "pi",
113
+ prompt: "Review the outage recovery changes",
114
+ )
115
+ ```
116
+
117
+ Explicit `--model` selection takes precedence over the profile default. Avenor forwards its `model` option to the pi subprocess as `--model`, so no agent config changes are needed for a one-off override.
118
+
72
119
  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
120
 
74
121
  ## Profile vs. agent
@@ -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
+ }
@@ -1,8 +1,15 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
- import { Type } from "typebox";
4
3
  import { readFileSync, existsSync } from "node:fs";
5
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";
12
+ import { findCliModelOverride, parseModelSpecifier } from "./model-selection.ts";
6
13
 
7
14
  /* ------------------------------------------------------------------ */
8
15
  /* Types */
@@ -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?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
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 — merges global (~/.pi/agent/agents.json) */
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,53 +136,112 @@ function checkBashPermission(
99
136
 
100
137
  export default async function (pi: ExtensionAPI) {
101
138
  const agents = loadAgentsConfig();
139
+ const { profiles: agentProfiles, errors: agentProfileConfigErrors } = loadAgentProfilesConfig();
140
+ // Built-in flags are not exposed through pi.getFlag(). Avenor forwards its
141
+ // model option to pi as --model, so argv is the extension-level signal that
142
+ // the caller's model should take precedence over the profile default.
143
+ const cliModelOverride = findCliModelOverride(process.argv.slice(2));
102
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
+ /** Apply a named agent-profile, optionally saving the choice in this session. */
165
+ async function selectAgentProfile(
166
+ name: string | undefined,
167
+ ctx: ExtensionContext,
168
+ persist: boolean,
169
+ ): Promise<boolean> {
170
+ if (name && !agentProfiles[name]) {
171
+ ctx.ui.notify(`Agent profile "${name}" not found in agent-profiles.json`, "error");
172
+ return false;
173
+ }
174
+
175
+ const previousName = activeAgentProfileName;
176
+ activeAgentProfileName = name;
177
+ setAgentProfileStatus(ctx);
178
+
179
+ // The active role must receive a new effective model/thinking level now;
180
+ // future roles resolve the overlay when /agent is invoked. Do not persist
181
+ // a selection that cannot be applied to the current active role.
182
+ if (activeAgent && !(await applyAgent(activeAgent.name, ctx))) {
183
+ activeAgentProfileName = previousName;
184
+ setAgentProfileStatus(ctx);
185
+ return false;
186
+ }
187
+
188
+ if (persist) pi.appendEntry("pi-agents:profile", { name: name ?? null });
189
+ return true;
190
+ }
103
191
 
104
192
  /* ---- Apply agent profile to the current session ---- */
105
193
  async function applyAgent(name: string, ctx: ExtensionContext): Promise<boolean> {
106
- const profile = agents[name];
194
+ const profile = getEffectiveAgentProfile(name);
107
195
  if (!profile) {
108
196
  ctx.ui.notify(`Agent "${name}" not found in agents.json`, "error");
109
197
  return false;
110
198
  }
111
199
 
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);
200
+ // Explicit CLI selection wins over the profile default. This preserves
201
+ // `avenor_spawn(agent: ..., model: ..., backend: "pi")` overrides because
202
+ // Avenor forwards model to the pi subprocess as --model.
203
+ if (!cliModelOverride) {
204
+ const modelSpecifier = parseModelSpecifier(profile.model);
205
+ if (!modelSpecifier) {
206
+ ctx.ui.notify(`Invalid model "${profile.model}"; expected provider/model-id`, "error");
207
+ return false;
208
+ }
120
209
 
121
- // Thinking level
122
- if (profile.thinkingLevel) {
123
- pi.setThinkingLevel(profile.thinkingLevel);
210
+ const model = ctx.modelRegistry.find(modelSpecifier.provider, modelSpecifier.modelId);
211
+ if (!model) {
212
+ ctx.ui.notify(`Model "${profile.model}" not found in registry`, "error");
213
+ return false;
214
+ }
215
+ await pi.setModel(model);
124
216
  }
125
217
 
218
+ if (profile.thinkingLevel) pi.setThinkingLevel(profile.thinkingLevel);
219
+
126
220
  // Tool restrictions — best-effort against currently registered tools.
127
221
  // tool_call handler enforces restrictions regardless of registration timing.
128
222
  try {
129
223
  const allTools = pi.getAllTools();
130
224
  let allowed: typeof allTools;
131
225
  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)
226
+ allowed = allTools.filter((tool) => profile.tools!.includes(tool.name));
135
227
  } else {
136
228
  allowed = [...allTools];
137
229
  }
138
230
  if (profile.excludeTools) {
139
- allowed = allowed.filter((t) => !profile.excludeTools!.includes(t.name));
231
+ allowed = allowed.filter((tool) => !profile.excludeTools!.includes(tool.name));
140
232
  }
141
- pi.setActiveTools(allowed.map((t) => t.name));
233
+ pi.setActiveTools(allowed.map((tool) => tool.name));
142
234
  } catch {
143
- // setActiveTools may fail if called too early; tool_call gate is the fallback
235
+ // setActiveTools may fail if called too early; tool_call gate is the fallback.
144
236
  }
145
237
 
146
238
  activeAgent = { name, profile };
147
239
  ctx.ui.setStatus("agent", `agent:${name}`);
148
- ctx.ui.notify(`Agent "${name}" active${profile.description ? ": " + profile.description : ""}`, "info");
240
+ const selected = activeAgentProfileName ? ` [${activeAgentProfileName}]` : "";
241
+ ctx.ui.notify(
242
+ `Agent "${name}" active${selected}${profile.description ? ": " + profile.description : ""}`,
243
+ "info",
244
+ );
149
245
  return true;
150
246
  }
151
247
 
@@ -153,44 +249,55 @@ export default async function (pi: ExtensionAPI) {
153
249
  function deactivateAgent(ctx: ExtensionContext) {
154
250
  activeAgent = null;
155
251
  try {
156
- pi.setActiveTools(pi.getAllTools().map((t) => t.name));
252
+ pi.setActiveTools(pi.getAllTools().map((tool) => tool.name));
157
253
  } catch { /* no-op if tools not fully registered */ }
158
254
  ctx.ui.setStatus("agent", "");
159
255
  ctx.ui.notify("Agent deactivated — all tools restored", "info");
160
256
  }
161
257
 
162
- /* ---- session_start: apply PI_AGENT env var ---- */
258
+ /* ---- session_start: restore profile, then apply PI_AGENT ---- */
163
259
  pi.on("session_start", async (_event, ctx) => {
164
- const envAgent = process.env.PI_AGENT;
165
- if (envAgent && agents[envAgent]) {
166
- await applyAgent(envAgent, ctx);
260
+ if (agentProfileConfigErrors.length) {
261
+ ctx.ui.notify(
262
+ `Ignored malformed agent-profile configuration:\n${agentProfileConfigErrors.join("\n")}`,
263
+ "warning",
264
+ );
167
265
  }
266
+
267
+ // The environment is an explicit launch-time override. A persisted command
268
+ // selection is restored otherwise, including a saved `none` selection.
269
+ const environmentProfile = process.env.PI_AGENT_PROFILE ?? process.env.PROFILE;
270
+ const persistedProfile = findPersistedProfileName(ctx.sessionManager.getEntries());
271
+ const selectedProfile = environmentProfile ?? persistedProfile;
272
+ if (selectedProfile) await selectAgentProfile(selectedProfile, ctx, false);
273
+ else setAgentProfileStatus(ctx);
274
+
275
+ const envAgent = process.env.PI_AGENT;
276
+ if (envAgent && agents[envAgent]) await applyAgent(envAgent, ctx);
168
277
  });
169
278
 
170
279
  /* ---- before_agent_start: inject agent system prompt ---- */
171
280
  pi.on("before_agent_start", async (event, _ctx) => {
172
281
  if (!activeAgent) return undefined;
173
282
 
174
- // Re-apply tool restrictions each turn (catches late-registered tools)
283
+ // Re-apply tool restrictions each turn (catches late-registered tools).
175
284
  try {
176
285
  const profile = activeAgent.profile;
177
286
  const allTools = pi.getAllTools();
178
287
  let allowed: typeof allTools;
179
288
  if (profile.tools) {
180
- allowed = allTools.filter((t) => profile.tools.includes(t.name));
289
+ allowed = allTools.filter((tool) => profile.tools.includes(tool.name));
181
290
  } else {
182
291
  allowed = [...allTools];
183
292
  }
184
293
  if (profile.excludeTools) {
185
- allowed = allowed.filter((t) => !profile.excludeTools.includes(t.name));
294
+ allowed = allowed.filter((tool) => !profile.excludeTools.includes(tool.name));
186
295
  }
187
- pi.setActiveTools(allowed.map((t) => t.name));
296
+ pi.setActiveTools(allowed.map((tool) => tool.name));
188
297
  } catch { /* best effort */ }
189
298
 
190
299
  const agentPrompt = resolveSystemPrompt(activeAgent.profile.systemPrompt);
191
- return {
192
- systemPrompt: agentPrompt + "\n\n" + event.systemPrompt,
193
- };
300
+ return { systemPrompt: agentPrompt + "\n\n" + event.systemPrompt };
194
301
  });
195
302
 
196
303
  /* ---- tool_call: enforce tool allowlist/denylist + bash permissions ---- */
@@ -198,22 +305,18 @@ export default async function (pi: ExtensionAPI) {
198
305
  if (!activeAgent) return;
199
306
  const profile = activeAgent.profile;
200
307
 
201
- // Tool allowlist gate
202
308
  if (profile.tools && !profile.tools.includes(event.toolName)) {
203
309
  return { block: true, reason: `Tool "${event.toolName}" not allowed for agent "${activeAgent.name}"` };
204
310
  }
205
311
 
206
- // Tool denylist gate
207
312
  if (profile.excludeTools && profile.excludeTools.includes(event.toolName)) {
208
313
  ctx.ui.notify(`Blocked tool: ${event.toolName}`, "warning");
209
314
  return { block: true, reason: `Tool "${event.toolName}" excluded for agent "${activeAgent.name}"` };
210
315
  }
211
316
 
212
- // Bash permission gate
213
317
  if (event.toolName === "bash" && profile.permissions?.bash) {
214
318
  const command = (event.input as { command?: string })?.command ?? "";
215
- const verdict = checkBashPermission(profile.permissions.bash, command);
216
- if (verdict === "deny") {
319
+ if (checkBashPermission(profile.permissions.bash, command) === "deny") {
217
320
  ctx.ui.notify(`Blocked: ${command.slice(0, 80)}`, "warning");
218
321
  return { block: true, reason: `Denied by ${activeAgent.name} bash permissions` };
219
322
  }
@@ -224,12 +327,12 @@ export default async function (pi: ExtensionAPI) {
224
327
  pi.registerCommand("agent", {
225
328
  description: "Switch to an agent profile (or 'none' to deactivate)",
226
329
  getArgumentCompletions: (prefix) => {
227
- const matches = Object.keys(agents).filter((n) => n.startsWith(prefix ?? ""));
330
+ const matches = Object.keys(agents).filter((name) => name.startsWith(prefix ?? ""));
228
331
  return matches.length
229
- ? matches.map((n) => ({
230
- value: n,
231
- label: n,
232
- description: agents[n].description ?? "",
332
+ ? matches.map((name) => ({
333
+ value: name,
334
+ label: name,
335
+ description: agents[name].description ?? "",
233
336
  }))
234
337
  : null;
235
338
  },
@@ -238,7 +341,7 @@ export default async function (pi: ExtensionAPI) {
238
341
  if (!name) {
239
342
  const current = activeAgent ? ` (active: ${activeAgent.name})` : "";
240
343
  const list = Object.entries(agents)
241
- .map(([n, p]) => ` ${n}${p.description ? ": " + p.description : ""}`)
344
+ .map(([agentName, profile]) => ` ${agentName}${profile.description ? ": " + profile.description : ""}`)
242
345
  .join("\n");
243
346
  ctx.ui.notify(`Agent profiles${current}:\n${list}`, "info");
244
347
  return;
@@ -257,9 +360,39 @@ export default async function (pi: ExtensionAPI) {
257
360
  handler: async (_args, ctx) => {
258
361
  const current = activeAgent ? ` [active: ${activeAgent.name}]` : "";
259
362
  const list = Object.entries(agents)
260
- .map(([n, p]) => ` ${n}${p.description ? ": " + p.description : ""}`)
363
+ .map(([name, profile]) => ` ${name}${profile.description ? ": " + profile.description : ""}`)
261
364
  .join("\n");
262
365
  ctx.ui.notify(`Agent profiles${current}:\n${list}`, "info");
263
366
  },
264
367
  });
368
+
369
+ /* ---- /agent-profile <name> — select a session-scoped runtime overlay ---- */
370
+ pi.registerCommand("agent-profile", {
371
+ description: "Select a session-scoped agent runtime profile (or 'none' to clear)",
372
+ getArgumentCompletions: (prefix) => {
373
+ const names = ["none", ...Object.keys(agentProfiles)];
374
+ const matches = names.filter((name) => name.startsWith(prefix ?? ""));
375
+ return matches.length
376
+ ? matches.map((name) => ({
377
+ value: name,
378
+ label: name,
379
+ description: name === "none"
380
+ ? "Clear the session agent profile"
381
+ : agentProfiles[name]?.description ?? "",
382
+ }))
383
+ : null;
384
+ },
385
+ handler: async (args, ctx) => {
386
+ const name = args?.trim();
387
+ if (!name) {
388
+ const current = activeAgentProfileName ?? "none";
389
+ const list = Object.entries(agentProfiles)
390
+ .map(([profileName, profile]) => ` ${profileName}${profile.description ? ": " + profile.description : ""}`)
391
+ .join("\n");
392
+ ctx.ui.notify(`Agent runtime profiles (active: ${current})${list ? `:\n${list}` : ""}`, "info");
393
+ return;
394
+ }
395
+ await selectAgentProfile(name === "none" ? undefined : name, ctx, true);
396
+ },
397
+ });
265
398
  }
@@ -0,0 +1,29 @@
1
+ export interface ModelSpecifier {
2
+ provider: string;
3
+ modelId: string;
4
+ }
5
+
6
+ /**
7
+ * Return the model explicitly supplied to pi, if any.
8
+ *
9
+ * Pi consumes --model itself, so extension flags cannot expose this value via
10
+ * pi.getFlag(). Inspecting argv lets agent profiles respect normal CLI
11
+ * precedence, including models forwarded by Avenor.
12
+ */
13
+ export function findCliModelOverride(argv: readonly string[]): string | undefined {
14
+ for (let index = 0; index < argv.length - 1; index += 1) {
15
+ if (argv[index] === "--model") return argv[index + 1];
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ /** Split provider/model-id at the first slash so model IDs may contain slashes. */
21
+ export function parseModelSpecifier(specifier: string): ModelSpecifier | undefined {
22
+ const separator = specifier.indexOf("/");
23
+ if (separator <= 0 || separator === specifier.length - 1) return undefined;
24
+
25
+ return {
26
+ provider: specifier.slice(0, separator),
27
+ modelId: specifier.slice(separator + 1),
28
+ };
29
+ }
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@dougbots/pi-agents",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Agent profiles for pi — preconfigured model, system prompt, tool restrictions, and permission gates",
5
+ "scripts": {
6
+ "test": "node --experimental-strip-types --test tests/*.test.mts"
7
+ },
5
8
  "keywords": ["pi-package", "pi", "pi-extension", "agents"],
6
9
  "license": "MIT",
7
10
  "repository": {