@bacnh85/pi-subagent 0.16.1 → 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,22 @@
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
+
3
20
  ## 0.16.1 (2026-08-29)
4
21
 
5
22
  ### Added
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
 
@@ -378,7 +378,7 @@ export default function (pi: ExtensionAPI) {
378
378
 
379
379
  const openRolesEditor = async (): Promise<void> => {
380
380
  // Role mapping editor: panel in TUI, plain text otherwise.
381
- const [{ openConfigPanel }, { buildRows, buildRolesPanelCfg, cfgToPatch, preserveUnknownAgentModels, writeSubagentSection }] = await Promise.all([
381
+ const [{ openConfigPanel }, { buildRows, buildRolesPanelCfg, cfgToPatch, makeAddRoleAction, makeRemoveRoleAction, preserveUnknownAgentModels, writeSubagentSection }] = await Promise.all([
382
382
  import("@bacnh85/pi-config-panel"),
383
383
  import("./roles-panel.ts"),
384
384
  ]);
@@ -401,20 +401,31 @@ export default function (pi: ExtensionAPI) {
401
401
  }
402
402
  const current = readSubagentRolesGlobal();
403
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");
404
408
  const panelOptions = {
405
409
  models: () => {
406
410
  try { return ctx.modelRegistry.getAvailable().map((m) => `${m.provider}/${m.id}`); }
407
411
  catch { return []; }
408
412
  },
409
- roles: () => [...Object.keys(DEFAULT_ROLES), ...Object.keys(readSubagentRolesGlobal().roles)],
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 }),
410
420
  };
411
421
  await openConfigPanel({
412
422
  ctx,
413
423
  cfg: working,
414
- build: (cfg) => buildRows(cfg, discovery.agents, panelOptions),
424
+ actions,
425
+ build: (cfg, panelActions) => buildRows(cfg, discovery.agents, panelOptions, panelActions),
415
426
  title: "Subagent model roles",
416
- onSave: (saved, editedKeys) => {
417
- if (!(saved && editedKeys && editedKeys.size > 0)) return;
427
+ onSave: (saved) => {
428
+ if (!saved || JSON.stringify([working.roles, working.agentModels]) === before) return;
418
429
  const patch = cfgToPatch(working);
419
430
  patch.agentModels = preserveUnknownAgentModels(
420
431
  patch.agentModels,
@@ -13,7 +13,7 @@ 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
17
 
18
18
  // ponytail: local structural type — the kernel only reads value/label/
19
19
  // description, so this stays compatible with the published 0.1.0 range while
@@ -24,7 +24,8 @@ interface CompletionItem {
24
24
  description?: string;
25
25
  }
26
26
  import type { AgentConfig } from "./agents.ts";
27
- 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";
28
29
 
29
30
  // ---------------------------------------------------------------------------
30
31
  // Settings persistence (global only — repo .pi/settings.json is read-only)
@@ -98,6 +99,9 @@ export interface RolesPanelOptions {
98
99
  models: () => string[];
99
100
  /** Known role names (defaults + configured), offered as `@role` on agent rows. */
100
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;
101
105
  }
102
106
 
103
107
  /** Seed a working config from current effective settings + bundled agents. */
@@ -115,10 +119,29 @@ export function buildRolesPanelCfg(agents: AgentConfig[], current: RolesConfig):
115
119
  return cfg;
116
120
  }
117
121
 
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
+
118
135
  /** Build panel groups. Role rows first, then one override row per agent.
119
136
  * `options` adds inline model/@role completions when provided (optional so
120
- * existing unit tests and non-TUI callers stay unchanged). */
121
- export function buildRows(cfg: RolesPanelCfg, agents: AgentConfig[], options?: RolesPanelOptions): PanelGroup[] {
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[] {
122
145
  const defaultChain = (name: string) => Array.isArray(DEFAULT_ROLES[name]) ? (DEFAULT_ROLES[name] as string[]).join(", ") : String(DEFAULT_ROLES[name] ?? "");
123
146
  const modelItems = (): CompletionItem[] =>
124
147
  (options?.models() ?? []).sort().map((ref) => ({ value: ref }));
@@ -135,15 +158,23 @@ export function buildRows(cfg: RolesPanelCfg, agents: AgentConfig[], options?: R
135
158
  cfg.roles[name] = String(v ?? "").trim();
136
159
  }, withCompletions(modelItems));
137
160
  });
161
+ const effective = options?.effectiveRoles ?? DEFAULT_ROLES;
138
162
  const agentRows = agents.map((agent) =>
139
- 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) => {
140
164
  const value = String(v ?? "").trim();
141
165
  if (value) cfg.agentModels[agent.name] = value;
142
166
  else delete cfg.agentModels[agent.name];
143
167
  }, withCompletions(() => [...modelItems(), ...roleItems()])),
144
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
+ }
145
176
  return [
146
- { key: "roles", label: "Model roles (chain, blank = default)", rows: roleRows },
177
+ { key: "roles", label: "Model roles (chain, blank = default)", rows: [...roleRows, ...actionRows] },
147
178
  { key: "agents", label: "Per-agent overrides (blank = inherit)", rows: agentRows },
148
179
  ];
149
180
  }
@@ -165,6 +196,88 @@ export function cfgToPatch(cfg: RolesPanelCfg): { roles: RolesConfig["roles"]; a
165
196
  return { roles, agentModels };
166
197
  }
167
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
+
168
281
  /** Keep overrides for agents NOT shown in the panel (e.g. overrides for
169
282
  * project-local agents saved globally from another project) so a panel save
170
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.1",
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",