@bacnh85/pi-subagent 0.16.0 → 0.17.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,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0 (2026-08-31)
4
+
5
+ ### Added
6
+
7
+ - **Add/remove model roles from the `/subagent` panel** — new `+ Add role` /
8
+ `− Remove role` action rows (two-prompt flow: name → chain). Custom roles are
9
+ deletable; built-in roles (`fast`/`coder`/`smart`) reset to their bundled
10
+ default. New names validate against `[A-Za-z0-9._-]{1,64}` with
11
+ case-insensitive collision checks.
12
+ - **Per-agent default display** — each override row now shows the agent's
13
+ default (no-override) chain, e.g. `scout (default: @fast → zai-coding-cn/
14
+ glm-5-turbo, …)`, so blank = inherit is meaningful. Labels track live role
15
+ edits and freshly added roles appear in `@role` completions immediately.
16
+ - Panel save is now guarded by a working-copy content diff (action-only
17
+ sessions — add/remove without row edits — previously left `editedKeys` empty
18
+ and silently skipped persistence).
19
+
20
+ ## 0.16.1 (2026-08-29)
21
+
22
+ ### Added
23
+
24
+ - `/subagent` argument completion: keywords (`list|all|agents|roles|reload|
25
+ refresh|history`), discovered agent names, and `@role` refs.
26
+ - Roles editor rows now offer inline model suggestions (Tab to pick, Enter
27
+ keeps typed text) when run against @bacnh85/pi-config-panel >= 0.1.1; the
28
+ package stays compilable and fully functional on 0.1.0 (suggestions simply
29
+ absent), so no dependency floor bump is required.
30
+
3
31
  ## 0.16.0 (2026-08-23)
4
32
 
5
33
  ### Features
package/README.md CHANGED
@@ -34,9 +34,11 @@ in `~/.pi/agent/settings.json` under `subagent.roles`:
34
34
 
35
35
  `/subagent` opens the interactive role editor (TUI panel via the shared
36
36
  `@bacnh85/pi-config-panel` kernel; prints the effective mapping headless).
37
- `/subagent list` lists agents, `/subagent <name>` shows an agent's resolved
38
- chain, and `/subagent @role` (or `/subagent fast`) shows a role's chain and
39
- the agents using it.
37
+ The panel supports `+ Add role` / `− Remove role` (custom roles are deletable,
38
+ built-ins reset to their bundled default) and shows each agent's default
39
+ (no-override) chain on its row. `/subagent list` lists agents,
40
+ `/subagent <name>` shows an agent's resolved chain, and `/subagent @role`
41
+ (or `/subagent fast`) shows a role's chain and the agents using it.
40
42
 
41
43
  ## Live progress widget
42
44
 
@@ -329,6 +329,20 @@ export default function (pi: ExtensionAPI) {
329
329
  });
330
330
  pi.registerCommand("subagent", {
331
331
  description: "Configure model roles (/subagent), list agents (/subagent list), agent details (/subagent <name>), role detail (/subagent @role), reload definitions (/subagent reload), history (/subagent history)",
332
+ getArgumentCompletions: (prefix) => {
333
+ const ctx = currentCtx;
334
+ const keywords = ["list", "all", "agents", "roles", "reload", "refresh", "history"];
335
+ const vocab = [...keywords];
336
+ if (ctx) {
337
+ const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
338
+ vocab.push(...discovery.agents.map((a) => a.name));
339
+ try { vocab.push(...Object.keys(readSubagentRoles(ctx).roles).map((r) => `@${r}`)); } catch { /* roles optional */ }
340
+ }
341
+ const q = prefix.trim().toLowerCase();
342
+ const items = vocab.filter((v) => v.toLowerCase().startsWith(q))
343
+ .map((v) => ({ value: v, label: v, description: keywords.includes(v) ? "subagent command" : "agent / role" }));
344
+ return items.length > 0 ? items : null;
345
+ },
332
346
  handler: async (args, ctx) => {
333
347
  const cmd = args.trim().toLowerCase();
334
348
  const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
@@ -364,7 +378,7 @@ export default function (pi: ExtensionAPI) {
364
378
 
365
379
  const openRolesEditor = async (): Promise<void> => {
366
380
  // Role mapping editor: panel in TUI, plain text otherwise.
367
- const [{ openConfigPanel }, { buildRows, buildRolesPanelCfg, cfgToPatch, preserveUnknownAgentModels, writeSubagentSection }] = await Promise.all([
381
+ const [{ openConfigPanel }, { buildRows, buildRolesPanelCfg, cfgToPatch, makeAddRoleAction, makeRemoveRoleAction, preserveUnknownAgentModels, writeSubagentSection }] = await Promise.all([
368
382
  import("@bacnh85/pi-config-panel"),
369
383
  import("./roles-panel.ts"),
370
384
  ]);
@@ -387,13 +401,31 @@ export default function (pi: ExtensionAPI) {
387
401
  }
388
402
  const current = readSubagentRolesGlobal();
389
403
  const working = buildRolesPanelCfg(discovery.agents, current);
404
+ // Actions (add/remove role) set model.dirty but not editedKeys — guard
405
+ // the save on a working-copy diff instead (pi-a2a pattern).
406
+ const before = JSON.stringify([working.roles, working.agentModels]);
407
+ const notify = (message: string, kind?: "info" | "warning" | "error") => ctx.ui.notify(message, kind ?? "info");
408
+ const panelOptions = {
409
+ models: () => {
410
+ try { return ctx.modelRegistry.getAvailable().map((m) => `${m.provider}/${m.id}`); }
411
+ catch { return []; }
412
+ },
413
+ // Working copy: tracks live edits + freshly added (unsaved) roles.
414
+ roles: () => Object.keys(working.roles),
415
+ effectiveRoles: working.roles,
416
+ };
417
+ const actions = {
418
+ addRole: makeAddRoleAction(working, { notify }),
419
+ removeRole: makeRemoveRoleAction(working, { notify }),
420
+ };
390
421
  await openConfigPanel({
391
422
  ctx,
392
423
  cfg: working,
393
- build: (cfg) => buildRows(cfg, discovery.agents),
424
+ actions,
425
+ build: (cfg, panelActions) => buildRows(cfg, discovery.agents, panelOptions, panelActions),
394
426
  title: "Subagent model roles",
395
- onSave: (saved, editedKeys) => {
396
- if (!(saved && editedKeys && editedKeys.size > 0)) return;
427
+ onSave: (saved) => {
428
+ if (!saved || JSON.stringify([working.roles, working.agentModels]) === before) return;
397
429
  const patch = cfgToPatch(working);
398
430
  patch.agentModels = preserveUnknownAgentModels(
399
431
  patch.agentModels,
@@ -13,9 +13,19 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "
13
13
  import { homedir } from "node:os";
14
14
  import { dirname, join } from "node:path";
15
15
  import { row } from "@bacnh85/pi-config-panel";
16
- import type { PanelGroup } from "@bacnh85/pi-config-panel";
16
+ import type { PanelAction, PanelGroup, PanelRow } from "@bacnh85/pi-config-panel";
17
+
18
+ // ponytail: local structural type — the kernel only reads value/label/
19
+ // description, so this stays compatible with the published 0.1.0 range while
20
+ // completion support ships in 0.1.1 (no static import of new kernel symbols).
21
+ interface CompletionItem {
22
+ value: string;
23
+ label?: string;
24
+ description?: string;
25
+ }
17
26
  import type { AgentConfig } from "./agents.ts";
18
- import { DEFAULT_ROLES, readSubagentRoles, type RolesConfig } from "./roles.ts";
27
+ import { getModelCandidates } from "./agents.ts";
28
+ import { DEFAULT_ROLES, readSubagentRoles, resolveAgentModelChain, type RoleMap, type RolesConfig } from "./roles.ts";
19
29
 
20
30
  // ---------------------------------------------------------------------------
21
31
  // Settings persistence (global only — repo .pi/settings.json is read-only)
@@ -83,6 +93,17 @@ export interface RolesPanelCfg {
83
93
  agentModels: Record<string, string>;
84
94
  }
85
95
 
96
+ /** Completion sources for the panel's model rows (lazy — resolved per keypress). */
97
+ export interface RolesPanelOptions {
98
+ /** Available model refs (`provider/id`), sorted; may be empty before registry sync. */
99
+ models: () => string[];
100
+ /** Known role names (defaults + configured), offered as `@role` on agent rows. */
101
+ roles: () => string[];
102
+ /** Effective role chains used to render each agent's default (falls back to
103
+ * DEFAULT_ROLES). Pass the working copy's roles so labels track live edits. */
104
+ effectiveRoles?: RoleMap;
105
+ }
106
+
86
107
  /** Seed a working config from current effective settings + bundled agents. */
87
108
  export function buildRolesPanelCfg(agents: AgentConfig[], current: RolesConfig): RolesPanelCfg {
88
109
  const roleNames = new Set([...Object.keys(DEFAULT_ROLES), ...Object.keys(current.roles)]);
@@ -98,24 +119,62 @@ export function buildRolesPanelCfg(agents: AgentConfig[], current: RolesConfig):
98
119
  return cfg;
99
120
  }
100
121
 
101
- /** Build panel groups. Role rows first, then one override row per agent. */
102
- export function buildRows(cfg: RolesPanelCfg, agents: AgentConfig[]): PanelGroup[] {
122
+ /** Human-readable default (no-override) chain for an agent's panel row label. */
123
+ export function defaultChainLabel(
124
+ agent: Pick<AgentConfig, "name" | "model" | "models">,
125
+ roles: RoleMap,
126
+ ): string {
127
+ const raw = getModelCandidates(agent);
128
+ const alias = raw.filter((m) => m.startsWith("@")).join(", ");
129
+ const { candidates } = resolveAgentModelChain(agent, { roles, agentModels: {} });
130
+ const chain = candidates.length > 0 ? candidates.join(", ") : "parent fallback";
131
+ // alias is @-prefixed, candidates are expanded model ids — never equal.
132
+ return alias ? `default: ${alias} → ${chain}` : `default: ${chain}`;
133
+ }
134
+
135
+ /** Build panel groups. Role rows first, then one override row per agent.
136
+ * `options` adds inline model/@role completions when provided (optional so
137
+ * existing unit tests and non-TUI callers stay unchanged). `actions` appends
138
+ * the + Add role / − Remove role action rows when provided. */
139
+ export function buildRows(
140
+ cfg: RolesPanelCfg,
141
+ agents: AgentConfig[],
142
+ options?: RolesPanelOptions,
143
+ actions?: Record<string, PanelAction>,
144
+ ): PanelGroup[] {
103
145
  const defaultChain = (name: string) => Array.isArray(DEFAULT_ROLES[name]) ? (DEFAULT_ROLES[name] as string[]).join(", ") : String(DEFAULT_ROLES[name] ?? "");
146
+ const modelItems = (): CompletionItem[] =>
147
+ (options?.models() ?? []).sort().map((ref) => ({ value: ref }));
148
+ const roleItems = (): CompletionItem[] =>
149
+ (options?.roles() ?? []).map((name) => ({ value: `@${name}`, description: "role chain" }));
150
+ // ponytail: opts spread keeps this compilable against kernel 0.1.0 (whose
151
+ // row() opts type lacks `completions`); the runtime contract is additive and
152
+ // 0.1.1+ consumes the field. Drop the cast when the dep floor moves to 0.1.1.
153
+ const withCompletions = (completions: () => CompletionItem[]) =>
154
+ ({ completions }) as unknown as { mask?: boolean };
104
155
  const roleRows = Object.keys(cfg.roles).sort().map((name) => {
105
156
  // Label shows the default chain so blank is meaningful.
106
157
  return row(`role.${name}`, `@${name} (default: ${defaultChain(name) || "none"})`, "string", cfg.roles[name], (v) => {
107
158
  cfg.roles[name] = String(v ?? "").trim();
108
- });
159
+ }, withCompletions(modelItems));
109
160
  });
161
+ const effective = options?.effectiveRoles ?? DEFAULT_ROLES;
110
162
  const agentRows = agents.map((agent) =>
111
- row(`agent.${agent.name}`, agent.name, "string", cfg.agentModels[agent.name] ?? "", (v) => {
163
+ row(`agent.${agent.name}`, `${agent.name} (${defaultChainLabel(agent, effective)})`, "string", cfg.agentModels[agent.name] ?? "", (v) => {
112
164
  const value = String(v ?? "").trim();
113
165
  if (value) cfg.agentModels[agent.name] = value;
114
166
  else delete cfg.agentModels[agent.name];
115
- }),
167
+ }, withCompletions(() => [...modelItems(), ...roleItems()])),
116
168
  );
169
+ const actionRows: PanelRow[] = [];
170
+ if (actions?.addRole) {
171
+ actionRows.push({ key: "action.addRole", label: "+ Add role", kind: "action", value: undefined, set: (p) => actions.addRole!.run(p as never) });
172
+ }
173
+ if (actions?.removeRole) {
174
+ actionRows.push({ key: "action.removeRole", label: "− Remove role", kind: "action", value: undefined, set: (p) => actions.removeRole!.run(p as never) });
175
+ }
117
176
  return [
118
- { key: "roles", label: "Model roles (chain, blank = default)", rows: roleRows },
177
+ { key: "roles", label: "Model roles (chain, blank = default)", rows: [...roleRows, ...actionRows] },
119
178
  { key: "agents", label: "Per-agent overrides (blank = inherit)", rows: agentRows },
120
179
  ];
121
180
  }
@@ -137,6 +196,88 @@ export function cfgToPatch(cfg: RolesPanelCfg): { roles: RolesConfig["roles"]; a
137
196
  return { roles, agentModels };
138
197
  }
139
198
 
199
+ /** Validate a new role name; returns an error message or null when OK. */
200
+ export function validateNewRoleName(name: string, known: readonly string[]): string | null {
201
+ const trimmed = name.trim();
202
+ if (!trimmed) return "Role name is empty.";
203
+ if (!/^[A-Za-z0-9._-]{1,64}$/.test(trimmed)) {
204
+ return `Invalid role name "${trimmed}" — use letters, digits, dot, dash, underscore (max 64).`;
205
+ }
206
+ const clash = known.find((k) => k.toLowerCase() === trimmed.toLowerCase());
207
+ if (clash) return `Role @${clash} already exists.`;
208
+ return null;
209
+ }
210
+
211
+ /** Apply a role removal to the working config. Built-in roles (DEFAULT_ROLES)
212
+ * can only be reset to their bundled chain (blank); custom roles are deleted
213
+ * (row disappears on rebuild, key dropped by cfgToPatch). Unknown → null. */
214
+ export function removeRoleFromCfg(cfg: RolesPanelCfg, name: string): "reset" | "deleted" | null {
215
+ const key = Object.keys(cfg.roles).find((k) => k.toLowerCase() === name.trim().toLowerCase());
216
+ if (!key) return null;
217
+ if (DEFAULT_ROLES[key] !== undefined) {
218
+ cfg.roles[key] = "";
219
+ return "reset";
220
+ }
221
+ delete cfg.roles[key];
222
+ return "deleted";
223
+ }
224
+
225
+ export interface RoleActionOpts {
226
+ /** User feedback (defaults to no-op so tests stay quiet). */
227
+ notify?: (message: string, kind?: "info" | "warning" | "error") => void;
228
+ }
229
+
230
+ /** "+ Add role" panel action: prompt name → validate → prompt chain → mutate
231
+ * the working config (kernel rebuilds rows + marks dirty after the action). */
232
+ export function makeAddRoleAction(cfg: RolesPanelCfg, opts: RoleActionOpts = {}): PanelAction {
233
+ return {
234
+ label: "Add role",
235
+ run: (prompt) => new Promise<void>((resolve) => {
236
+ prompt("Role name (e.g. writer)", (name) => {
237
+ const trimmed = (name ?? "").trim();
238
+ if (!trimmed) return resolve();
239
+ const err = validateNewRoleName(trimmed, Object.keys(cfg.roles));
240
+ if (err) {
241
+ opts.notify?.(err, "warning");
242
+ return resolve();
243
+ }
244
+ prompt("Model chain (comma-separated; @role or * allowed)", (chain) => {
245
+ const value = (chain ?? "").trim();
246
+ if (!value) {
247
+ opts.notify?.("Chain required — role not added.", "warning");
248
+ return resolve();
249
+ }
250
+ cfg.roles[trimmed] = value;
251
+ resolve();
252
+ });
253
+ });
254
+ }),
255
+ };
256
+ }
257
+
258
+ /** "− Remove role" panel action: prompt pick from known roles, then reset
259
+ * (built-in) or delete (custom) via removeRoleFromCfg. */
260
+ export function makeRemoveRoleAction(cfg: RolesPanelCfg, opts: RoleActionOpts = {}): PanelAction {
261
+ return {
262
+ label: "Remove role",
263
+ run: (prompt) => new Promise<void>((resolve) => {
264
+ const names = Object.keys(cfg.roles).sort();
265
+ if (names.length === 0) {
266
+ opts.notify?.("No roles to remove.", "warning");
267
+ return resolve();
268
+ }
269
+ prompt(`Remove role (${names.join(", ")})`, (pick) => {
270
+ if (!pick) return resolve();
271
+ const result = removeRoleFromCfg(cfg, pick);
272
+ if (result === "reset") opts.notify?.(`@${pick.trim()} reset to bundled default`, "info");
273
+ else if (result === "deleted") opts.notify?.(`@${pick.trim()} removed`, "info");
274
+ else opts.notify?.(`No role named "${pick.trim()}".`, "warning");
275
+ resolve();
276
+ });
277
+ }),
278
+ };
279
+ }
280
+
140
281
  /** Keep overrides for agents NOT shown in the panel (e.g. overrides for
141
282
  * project-local agents saved globally from another project) so a panel save
142
283
  * doesn't wipe them. Discovered-agent entries always follow the panel. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "In-process subagents for Pi with isolated SDK sessions, parallel and chained delegation, and inspectable threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -62,9 +62,9 @@
62
62
  "check": "npm run typecheck && npm test"
63
63
  },
64
64
  "peerDependencies": {
65
- "@earendil-works/pi-coding-agent": ">=0.80.0 <0.85.0",
66
- "@earendil-works/pi-ai": ">=0.80.0 <0.85.0",
67
65
  "@earendil-works/pi-agent-core": ">=0.80.0 <0.85.0",
66
+ "@earendil-works/pi-ai": ">=0.80.0 <0.85.0",
67
+ "@earendil-works/pi-coding-agent": ">=0.80.0 <0.85.0",
68
68
  "@earendil-works/pi-tui": ">=0.80.0 <0.85.0",
69
69
  "typebox": ">=1.3.0 <2.0.0"
70
70
  },
@@ -74,14 +74,14 @@
74
74
  "devDependencies": {
75
75
  "@earendil-works/pi-agent-core": "^0.84.0",
76
76
  "@earendil-works/pi-ai": "^0.84.0",
77
- "@earendil-works/pi-coding-agent": "^0.84.0",
78
- "@earendil-works/pi-tui": "^0.84.0",
77
+ "@earendil-works/pi-coding-agent": "^0.84.3",
78
+ "@earendil-works/pi-tui": "^0.84.3",
79
79
  "@types/mocha": "^10.0.10",
80
80
  "@types/node": "^20.19.43",
81
81
  "mocha": "^11.8.0",
82
82
  "tsx": "^4.22.4",
83
- "typescript": "^5.9.3",
84
- "typebox": "^1.3.1"
83
+ "typebox": "^1.3.1",
84
+ "typescript": "^5.9.3"
85
85
  },
86
86
  "overrides": {
87
87
  "serialize-javascript@>=5.0.0 <7.0.5": "^7.0.5",