@pi-kaush/pi-agent-mode 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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - Add `profile` frontmatter: activate a named model profile from the shared machine-local `~/.pi/agent/profiles.yaml` (`@pi-kaush/pi-model-profiles`), walking candidates in order and applying the first available model and thinking level.
6
+ - Reject agents declaring both `profile` and `model`; precedence is explicit: profile first, then model, then the session default.
7
+ - Load `@pi-kaush/pi-model-profiles` lazily so a missing library degrades only profile support; `/agent` remains fully functional without it.
8
+
3
9
  ## 0.1.0
4
10
 
5
11
  - Add the `/agent` command for activating a configured Pi agent as a persistent mode in the current session.
package/README.md CHANGED
@@ -5,14 +5,13 @@ the session to the agent's model, thinking level, and tool set, appends the agen
5
5
  prompt to Pi's base instructions, and restores the pre-activation baseline when cleared.
6
6
  It does not spawn children and provides no delegated subagents.
7
7
 
8
- ## Install
8
+ ### Install
9
9
 
10
- ```sh
10
+ ```fish
11
11
  pi install npm:@pi-kaush/pi-agent-mode
12
12
  ```
13
13
 
14
- Restart Pi or run `/reload`. To pin a specific release, append its version, such as
15
- `@0.1.0`.
14
+ Restart Pi or run `/reload`.
16
15
 
17
16
  ## Usage
18
17
 
@@ -37,11 +36,27 @@ description: ... # required
37
36
  emoji: 🔍 # optional, shown in the footer status
38
37
  tools: read, grep # optional allowlist; unavailable tools are reported and omitted
39
38
  model: provider/model:high # optional; thinking suffix may be :off/:low/:medium/:high/:max
39
+ profile: coder # optional; named model candidates from ~/.pi/agent/profiles.yaml
40
40
  confirmProjectAgents: false # project agents default to requiring confirmation
41
41
  ---
42
42
  Prompt body appended to Pi's base instructions while the agent is active.
43
43
  ```
44
44
 
45
+ ## Model selection precedence
46
+
47
+ Agent frontmatter may declare a `model` (exact model, optional thinking
48
+ suffix) or a `profile` — a named entry in the shared machine-local
49
+ `~/.pi/agent/profiles.yaml` maintained by
50
+ [@pi-kaush/pi-model-profiles](../pi-model-profiles). An agent must not declare
51
+ both. Profile candidates are walked in order at activation and the first
52
+ model that exists and is authenticated wins; once the session is running,
53
+ models never switch mid-request. Agents without either keep the current
54
+ session model.
55
+
56
+ `pi-agent-mode` loads `@pi-kaush/pi-model-profiles` lazily: if the library is
57
+ missing, only agents that declare `profile:` fail (with an install hint) and
58
+ every other `/agent` capability is unencumbered.
59
+
45
60
  ## Behavior
46
61
 
47
62
  - The first activation captures the current model, thinking level, and active
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-agent-mode",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Activate a configured Pi agent as a persistent mode in the current session.",
5
5
  "license": "MIT",
6
6
  "author": "Kaushik Gopal",
@@ -33,12 +33,19 @@
33
33
  "./src/index.ts"
34
34
  ]
35
35
  },
36
+ "dependencies": {
37
+ "@pi-kaush/pi-model-profiles": "^0.1.0"
38
+ },
36
39
  "peerDependencies": {
37
- "@earendil-works/pi-coding-agent": ">=0.80.6"
40
+ "@earendil-works/pi-coding-agent": ">=0.80.6",
41
+ "@earendil-works/pi-tui": ">=0.80.6"
38
42
  },
39
43
  "peerDependenciesMeta": {
40
44
  "@earendil-works/pi-coding-agent": {
41
45
  "optional": true
46
+ },
47
+ "@earendil-works/pi-tui": {
48
+ "optional": true
42
49
  }
43
50
  },
44
51
  "engines": {
@@ -23,6 +23,7 @@ export interface AgentConfig {
23
23
  emoji?: string;
24
24
  tools?: string[];
25
25
  model?: string;
26
+ profile?: string;
26
27
  confirmProjectAgents?: boolean;
27
28
  systemPrompt: string;
28
29
  source: "user" | "project";
@@ -35,6 +36,7 @@ interface AgentFrontmatter extends Record<string, unknown> {
35
36
  emoji?: string;
36
37
  tools?: string;
37
38
  model?: string;
39
+ profile?: string;
38
40
  confirmProjectAgents?: boolean;
39
41
  }
40
42
 
@@ -82,31 +84,51 @@ function loadAgentsFromDir(
82
84
  continue;
83
85
  }
84
86
 
85
- const { frontmatter, body } = parseFrontmatter<AgentFrontmatter>(content);
86
-
87
- if (!frontmatter.name || !frontmatter.description) {
87
+ try {
88
+ const { frontmatter, body } = parseFrontmatter<AgentFrontmatter>(content);
89
+ const name =
90
+ typeof frontmatter.name === "string" ? frontmatter.name : undefined;
91
+ const description =
92
+ typeof frontmatter.description === "string"
93
+ ? frontmatter.description
94
+ : undefined;
95
+ if (!name || !description) continue;
96
+
97
+ const tools =
98
+ typeof frontmatter.tools === "string"
99
+ ? frontmatter.tools
100
+ .split(",")
101
+ .map((tool) => tool.trim())
102
+ .filter(Boolean)
103
+ : undefined;
104
+ const emoji =
105
+ typeof frontmatter.emoji === "string"
106
+ ? frontmatter.emoji.trim()
107
+ : undefined;
108
+ const model =
109
+ typeof frontmatter.model === "string" ? frontmatter.model : undefined;
110
+ const profile =
111
+ typeof frontmatter.profile === "string"
112
+ ? frontmatter.profile.trim()
113
+ : undefined;
114
+
115
+ agents.push({
116
+ name,
117
+ description,
118
+ ...(emoji ? { emoji } : {}),
119
+ ...(tools && tools.length > 0 ? { tools } : {}),
120
+ ...(model ? { model } : {}),
121
+ ...(profile ? { profile } : {}),
122
+ ...(typeof frontmatter.confirmProjectAgents === "boolean"
123
+ ? { confirmProjectAgents: frontmatter.confirmProjectAgents }
124
+ : {}),
125
+ systemPrompt: body,
126
+ source,
127
+ filePath,
128
+ });
129
+ } catch {
88
130
  continue;
89
131
  }
90
-
91
- const tools = frontmatter.tools
92
- ?.split(",")
93
- .map((t: string) => t.trim())
94
- .filter(Boolean);
95
-
96
- const emoji = frontmatter.emoji?.trim();
97
- agents.push({
98
- name: frontmatter.name,
99
- description: frontmatter.description,
100
- ...(emoji ? { emoji } : {}),
101
- ...(tools && tools.length > 0 ? { tools } : {}),
102
- ...(frontmatter.model ? { model: frontmatter.model } : {}),
103
- ...(typeof frontmatter.confirmProjectAgents === "boolean"
104
- ? { confirmProjectAgents: frontmatter.confirmProjectAgents }
105
- : {}),
106
- systemPrompt: body,
107
- source,
108
- filePath,
109
- });
110
132
  }
111
133
 
112
134
  return agents;
@@ -148,14 +170,8 @@ export function discoverAgents(
148
170
 
149
171
  const agentMap = new Map<string, AgentConfig>();
150
172
 
151
- if (scope === "both") {
152
- for (const agent of userAgents) agentMap.set(agent.name, agent);
153
- for (const agent of projectAgents) agentMap.set(agent.name, agent);
154
- } else if (scope === "user") {
155
- for (const agent of userAgents) agentMap.set(agent.name, agent);
156
- } else {
157
- for (const agent of projectAgents) agentMap.set(agent.name, agent);
158
- }
173
+ for (const agent of userAgents) agentMap.set(agent.name, agent);
174
+ for (const agent of projectAgents) agentMap.set(agent.name, agent);
159
175
 
160
176
  return { agents: Array.from(agentMap.values()), projectAgentsDir };
161
177
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * TUI picker for `/agent`: SelectList with per-item descriptions, theme
3
+ * colors, agent emoji kept in the label, and a ● marker on the active agent.
4
+ * Non-TUI modes fall back to plain ctx.ui.select in the command handler.
5
+ */
6
+
7
+ import {
8
+ DynamicBorder,
9
+ type ExtensionCommandContext,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ Container,
13
+ SelectList,
14
+ Text,
15
+ type SelectItem,
16
+ } from "@earendil-works/pi-tui";
17
+ import { formatAgentDisplayName, type AgentConfig } from "./agent-discovery.ts";
18
+
19
+ export const NONE_VALUE = "none";
20
+
21
+ export function buildAgentPickerItems(
22
+ agents: AgentConfig[],
23
+ activeName: string | undefined,
24
+ ): SelectItem[] {
25
+ const items: SelectItem[] = agents.map((agent) => {
26
+ const marker = agent.name === activeName ? "● " : "";
27
+ const origin = agent.source === "project" ? "Project agent. " : "";
28
+ return {
29
+ value: agent.name,
30
+ label: `${marker}${formatAgentDisplayName(agent)}`,
31
+ description: `${origin}${agent.description}`,
32
+ };
33
+ });
34
+ if (activeName) {
35
+ items.push({
36
+ value: NONE_VALUE,
37
+ label: "None",
38
+ description: "Restore the default model, thinking level, and tools",
39
+ });
40
+ }
41
+ return items;
42
+ }
43
+
44
+ /** Resolves with the picked agent name, NONE_VALUE, or null when cancelled. */
45
+ export async function showAgentPicker(
46
+ ctx: ExtensionCommandContext,
47
+ items: SelectItem[],
48
+ ): Promise<string | null> {
49
+ return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
50
+ const container = new Container();
51
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
52
+ container.addChild(
53
+ new Text(theme.fg("accent", theme.bold("Activate agent")), 1, 0),
54
+ );
55
+
56
+ const list = new SelectList(items, Math.min(items.length, 10), {
57
+ selectedPrefix: (t: string) => theme.fg("accent", t),
58
+ selectedText: (t: string) => theme.fg("accent", t),
59
+ description: (t: string) => theme.fg("muted", t),
60
+ scrollInfo: (t: string) => theme.fg("dim", t),
61
+ noMatch: (t: string) => theme.fg("warning", t),
62
+ });
63
+ list.onSelect = (item: SelectItem) => done(item.value);
64
+ list.onCancel = () => done(null);
65
+ container.addChild(list);
66
+
67
+ container.addChild(
68
+ new Text(
69
+ theme.fg("dim", "↑↓ navigate • enter select • esc cancel ● active"),
70
+ 1,
71
+ 0,
72
+ ),
73
+ );
74
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
75
+
76
+ return {
77
+ render: (width: number) => container.render(width),
78
+ invalidate: () => container.invalidate(),
79
+ handleInput: (data: string) => {
80
+ list.handleInput(data);
81
+ tui.requestRender();
82
+ },
83
+ };
84
+ });
85
+ }
package/src/index.ts CHANGED
@@ -14,6 +14,8 @@ import type {
14
14
  ExtensionAPI,
15
15
  ExtensionContext,
16
16
  } from "@earendil-works/pi-coding-agent";
17
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
18
+ import type { ModelProfilesConfig } from "@pi-kaush/pi-model-profiles";
17
19
  import {
18
20
  discoverAgents,
19
21
  formatAgentDisplayName,
@@ -21,18 +23,19 @@ import {
21
23
  type AgentConfig,
22
24
  } from "./agent-discovery.ts";
23
25
  import { parseAgentModelSpec, type ThinkingLevel } from "./model-spec.ts";
26
+ import {
27
+ buildAgentPickerItems,
28
+ NONE_VALUE,
29
+ showAgentPicker,
30
+ } from "./agent-picker.ts";
24
31
 
25
32
  interface ModelReference {
26
33
  provider: string;
27
34
  id: string;
28
35
  }
29
36
 
30
- export interface AgentModelSpec {
31
- model: string;
32
- thinkingLevel?: ThinkingLevel;
33
- }
34
-
35
- export { parseAgentModelSpec };
37
+ export { isModuleUnavailable, parseAgentModelSpec };
38
+ export type { AgentModelSpec } from "./model-spec.ts";
36
39
 
37
40
  // Durable session state is namespaced so it cannot collide with other
38
41
  // packages. Legacy Aikado sessions wrote `active-agent-state`; restore reads
@@ -62,6 +65,51 @@ interface ActiveAgentState {
62
65
  baseline?: AgentBaseline;
63
66
  }
64
67
 
68
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
69
+ if (typeof value !== "object" || value === null) return false;
70
+ const prototype = Object.getPrototypeOf(value);
71
+ return prototype === Object.prototype || prototype === null;
72
+ }
73
+
74
+ function isActiveAgentState(value: unknown): value is ActiveAgentState {
75
+ if (!isPlainObject(value) || typeof value.active !== "boolean") return false;
76
+ if (
77
+ value.active &&
78
+ (typeof value.name !== "string" || value.name.length === 0)
79
+ )
80
+ return false;
81
+
82
+ if (value.baseline !== undefined) {
83
+ if (!isPlainObject(value.baseline)) return false;
84
+ if (typeof value.baseline.thinkingLevel !== "string") return false;
85
+ if (
86
+ !Array.isArray(value.baseline.tools) ||
87
+ !value.baseline.tools.every((tool) => typeof tool === "string")
88
+ )
89
+ return false;
90
+
91
+ if (value.baseline.model !== undefined) {
92
+ if (
93
+ !isPlainObject(value.baseline.model) ||
94
+ typeof value.baseline.model.provider !== "string" ||
95
+ typeof value.baseline.model.id !== "string"
96
+ )
97
+ return false;
98
+ }
99
+ }
100
+
101
+ return true;
102
+ }
103
+
104
+ function isModuleUnavailable(error: unknown): boolean {
105
+ const code = (error as { code?: string } | undefined)?.code;
106
+ return (
107
+ code === "MODULE_NOT_FOUND" ||
108
+ code === "ERR_MODULE_NOT_FOUND" ||
109
+ (error instanceof Error && error.message.includes("Cannot find module"))
110
+ );
111
+ }
112
+
65
113
  export function registerAgentMode(pi: ExtensionAPI) {
66
114
  let activeAgent: ActiveAgent | undefined;
67
115
 
@@ -163,6 +211,67 @@ export function registerAgentMode(pi: ExtensionAPI) {
163
211
  pi.setActiveTools(valid);
164
212
  }
165
213
 
214
+ /**
215
+ * Walk the agent's profile candidates in order and switch to the first
216
+ * model that exists and is authenticated. Fallback happens only during
217
+ * activation; running sessions never switch models mid-request.
218
+ */
219
+ async function applyProfileModels(
220
+ agent: AgentConfig,
221
+ ctx: ExtensionContext,
222
+ ): Promise<{ thinkingLevel?: ThinkingLevel } | false> {
223
+ let profiles: ModelProfilesConfig;
224
+ try {
225
+ // Runtime-only import: a missing @pi-kaush/pi-model-profiles degrades
226
+ // just profile support instead of failing the whole extension load.
227
+ const profilesModule = await import("@pi-kaush/pi-model-profiles");
228
+ profiles = profilesModule.loadModelProfiles(
229
+ profilesModule.resolveProfilesPath(getAgentDir()),
230
+ );
231
+ } catch (error) {
232
+ if (isModuleUnavailable(error)) {
233
+ ctx.ui.notify(
234
+ `Agent ${formatAgentDisplayName(agent)}: profile support unavailable; install it with pi install npm:@pi-kaush/pi-model-profiles.`,
235
+ "warning",
236
+ );
237
+ } else {
238
+ ctx.ui.notify(
239
+ `Agent ${formatAgentDisplayName(agent)}: ${error instanceof Error ? error.message : error}`,
240
+ "error",
241
+ );
242
+ }
243
+ return false;
244
+ }
245
+
246
+ const profileName = agent.profile!.trim();
247
+ const profile = profiles.profiles[profileName];
248
+ if (!profile) {
249
+ ctx.ui.notify(
250
+ `Agent ${formatAgentDisplayName(agent)}: unknown profile "${profileName}". Available: ${Object.keys(profiles.profiles).join(", ")}.`,
251
+ "error",
252
+ );
253
+ return false;
254
+ }
255
+
256
+ for (const candidate of profile.candidates) {
257
+ const model = findModel(candidate.model, ctx);
258
+ if (!model) continue;
259
+ if (await pi.setModel(model)) {
260
+ return {
261
+ ...(candidate.thinkingLevel !== undefined
262
+ ? { thinkingLevel: candidate.thinkingLevel }
263
+ : {}),
264
+ };
265
+ }
266
+ }
267
+
268
+ ctx.ui.notify(
269
+ `Agent ${formatAgentDisplayName(agent)}: profile "${profileName}" has no available candidate models. Configured: ${profile.candidates.map((candidate) => candidate.model).join(", ")}.`,
270
+ "error",
271
+ );
272
+ return false;
273
+ }
274
+
166
275
  async function activateAgent(
167
276
  agent: AgentConfig,
168
277
  ctx: ExtensionContext,
@@ -174,7 +283,18 @@ export function registerAgentMode(pi: ExtensionAPI) {
174
283
  thinkingLevel: baseline.thinkingLevel ?? pi.getThinkingLevel(),
175
284
  };
176
285
  let thinkingLevel = baseline.thinkingLevel;
177
- if (agent.model) {
286
+ if (agent.profile && agent.model) {
287
+ ctx.ui.notify(
288
+ `Agent ${formatAgentDisplayName(agent)}: declare either "profile" or "model" in frontmatter, not both.`,
289
+ "error",
290
+ );
291
+ return false;
292
+ }
293
+ if (agent.profile) {
294
+ const applied = await applyProfileModels(agent, ctx);
295
+ if (!applied) return false;
296
+ thinkingLevel = applied.thinkingLevel ?? baseline.thinkingLevel;
297
+ } else if (agent.model) {
178
298
  const modelSpec = parseAgentModelSpec(agent.model);
179
299
  const model = findModel(modelSpec.model, ctx);
180
300
  if (!model) {
@@ -192,8 +312,12 @@ export function registerAgentMode(pi: ExtensionAPI) {
192
312
  return false;
193
313
  }
194
314
  thinkingLevel = modelSpec.thinkingLevel ?? baseline.thinkingLevel;
195
- } else {
196
- await restoreModel(baseline.model, ctx);
315
+ } else if (!(await restoreModel(baseline.model, ctx))) {
316
+ ctx.ui.notify(
317
+ `Agent ${formatAgentDisplayName(agent)}: could not restore the baseline model; activation aborted.`,
318
+ "error",
319
+ );
320
+ return false;
197
321
  }
198
322
 
199
323
  pi.setThinkingLevel(thinkingLevel);
@@ -210,15 +334,17 @@ export function registerAgentMode(pi: ExtensionAPI) {
210
334
  return;
211
335
  }
212
336
 
213
- await restoreModel(activeAgent.baseline.model, ctx);
337
+ const modelRestored = await restoreModel(activeAgent.baseline.model, ctx);
214
338
  pi.setThinkingLevel(activeAgent.baseline.thinkingLevel);
215
339
  pi.setActiveTools(activeAgent.baseline.tools);
216
340
  activeAgent = undefined;
217
341
  updateAgentStatus(ctx);
218
342
  persistActiveAgent();
219
343
  ctx.ui.notify(
220
- "Active agent cleared; previous model and tools restored.",
221
- "info",
344
+ modelRestored
345
+ ? "Active agent cleared; previous model and tools restored."
346
+ : "Active agent cleared, but the previous model could not be restored.",
347
+ modelRestored ? "info" : "warning",
222
348
  );
223
349
  }
224
350
 
@@ -272,13 +398,52 @@ export function registerAgentMode(pi: ExtensionAPI) {
272
398
  );
273
399
  return;
274
400
  }
275
- const choices = agents.map(
276
- (candidate) =>
277
- `${formatAgentDisplayName(candidate)} — ${candidate.description}`,
278
- );
279
- const choice = await ctx.ui.select("Activate agent:", choices);
280
- if (!choice) return;
281
- agent = agents[choices.indexOf(choice)];
401
+ let picked: string | null;
402
+ if (ctx.mode === "tui") {
403
+ picked = await showAgentPicker(
404
+ ctx,
405
+ buildAgentPickerItems(agents, activeAgent?.agent.name),
406
+ );
407
+ } else {
408
+ const items = buildAgentPickerItems(agents, activeAgent?.agent.name);
409
+ const baseLabels = items.map(
410
+ (item) => `${item.label} — ${item.description}`,
411
+ );
412
+ const labelCounts = new Map<string, number>();
413
+ for (const label of baseLabels) {
414
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
415
+ }
416
+ const labels = baseLabels.map((label, index) =>
417
+ labelCounts.get(label)! > 1
418
+ ? `${label} [${items[index]!.value}]`
419
+ : label,
420
+ );
421
+ const choice = await ctx.ui.select("Activate agent:", labels);
422
+ if (choice === undefined) {
423
+ picked = null;
424
+ } else {
425
+ const firstMatch = labels.indexOf(choice);
426
+ if (
427
+ firstMatch !== -1 &&
428
+ labels.lastIndexOf(choice) !== firstMatch
429
+ ) {
430
+ ctx.ui.notify(
431
+ "That selection matches more than one agent; rename one of the duplicates.",
432
+ "warning",
433
+ );
434
+ picked = null;
435
+ } else {
436
+ picked =
437
+ firstMatch === -1 ? null : (items[firstMatch]?.value ?? null);
438
+ }
439
+ }
440
+ }
441
+ if (!picked) return;
442
+ if (picked === NONE_VALUE) {
443
+ await clearActiveAgent(ctx);
444
+ return;
445
+ }
446
+ agent = agents.find((candidate) => candidate.name === picked);
282
447
  }
283
448
 
284
449
  if (!agent || !(await confirmProjectAgent(agent, ctx))) return;
@@ -302,7 +467,8 @@ export function registerAgentMode(pi: ExtensionAPI) {
302
467
  .filter(
303
468
  (entry): entry is CustomEntry<ActiveAgentState> =>
304
469
  entry.type === "custom" &&
305
- ACTIVE_AGENT_STATE_TYPES.includes(entry.customType),
470
+ ACTIVE_AGENT_STATE_TYPES.includes(entry.customType) &&
471
+ isActiveAgentState(entry.data),
306
472
  )
307
473
  .pop();
308
474
  const state = stateEntry?.data;