pi-better-subagents 0.11.0 → 0.13.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
@@ -36,10 +36,14 @@ Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or ne
36
36
  ## Delegation Modes
37
37
 
38
38
  `config.json` sets `delegationMode` to `manual`, `adaptive` (default), or
39
- `coordinator`. `/subagents` displays the active mode, and
40
- `/subagents mode manual|adaptive|coordinator` changes it for the current
41
- session without changing config. `/subagents save` writes the current mode as
42
- the next session's default. The navigator's `main` row opens the same control:
39
+ `coordinator`. `/subagents settings` (or `/subagents`) opens a settings page
40
+ for delegation mode and the concurrent-subagent cap (default 4). Edits apply to
41
+ this session and persist across reloads; Ctrl+S saves both as defaults, and
42
+ Reset to defaults clears
43
+ session overrides. `/subagents mode manual|adaptive|coordinator` and
44
+ `/subagents cap <number>` are command equivalents; `/subagents save` persists
45
+ both. Lowering the cap blocks new single and batch launches without stopping
46
+ running work. The navigator's `main` row retains its mode-only controls:
43
47
  `m` cycles the session mode, `ctrl+s` saves that default, and `x`
44
48
  does not stop it. Manual delegates only on explicit user or workflow request, even with a
45
49
  plan. Adaptive delegates substantial independent work when useful. Coordinator
package/agent-commands.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  type CodexDocument,
48
48
  } from "./codex-import.ts";
49
49
  import { resolveRoleAssignment } from "./role-assignment.ts";
50
+ import { saveAgentSessionDefaults, withAgentSessionSettings, type AgentSessionOverrides, type AgentSessionSettings } from "./agent-session-settings.ts";
50
51
 
51
52
  const MODEL_PATTERN = /^[a-z0-9][a-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._+-]*$/;
52
53
  const TIER_PATTERN = /^[a-z][a-z0-9-]{0,32}$/;
@@ -68,6 +69,9 @@ export interface AgentCommandDeps {
68
69
  now?: () => string;
69
70
  /** Resolution unit injects availability, actual model/effort, and launchability. */
70
71
  enrich?: LaunchEnricher;
72
+ sessionSettings?: AgentSessionSettings;
73
+ /** Available foreground registry models, as provider/id. */
74
+ editableModels?: () => readonly string[];
71
75
  /**
72
76
  * Live slash-command context, before list/inspect/create/reload/import.
73
77
  * Lifecycle refreshes the foreground model and settings here. Direct
@@ -112,6 +116,7 @@ interface CatalogLocation {
112
116
  projectTrusted: boolean;
113
117
  projectConfigDirName?: string;
114
118
  bundledRoot?: string;
119
+ sessionSettings?: AgentSessionOverrides;
115
120
  }
116
121
 
117
122
  interface ParsedArgs {
@@ -149,6 +154,7 @@ export function registerAgentCommands(pi: Pick<ExtensionAPI, "registerCommand">,
149
154
 
150
155
  export async function executeAgentsCommand(args: string, host: AgentCommandHost, deps: AgentCommandDeps = {}): Promise<AgentCommandResult> {
151
156
  const parsed = parseCommandArgs(args);
157
+ if (parsed.positionals.length === 0 && parsed.flags.size === 0 && host.mode === "tui" && host.hasUI && host.ui.custom) parsed.positionals.push("list");
152
158
  if (parsed.unknown.length > 0 || parsed.missing.length > 0) {
153
159
  const detail = [
154
160
  ...parsed.unknown.map((flag) => `Unknown flag --${flag}.`),
@@ -161,8 +167,8 @@ export async function executeAgentsCommand(args: string, host: AgentCommandHost,
161
167
  }
162
168
  const command = parsed.positionals[0]!;
163
169
  const loc = location(host, deps);
164
- if (command === "list") return finishInspection(host, await runList(parsed, loc, deps));
165
- if (command === "show" || command === "inspect") return finishInspection(host, await runShow(command, parsed, host, loc, deps));
170
+ if (command === "list") return finishInspection(host, await runList(parsed, loc, deps), deps);
171
+ if (command === "show" || command === "inspect") return finishInspection(host, await runShow(command, parsed, host, loc, deps), deps);
166
172
  if (command === "reload") return finish(host, await runReload(parsed, loc, deps));
167
173
  if (command === "create") return finish(host, await runCreate(parsed, host, loc, deps));
168
174
  if (command === "import-codex") return finish(host, await runImport(parsed, host, loc, deps));
@@ -215,7 +221,7 @@ async function runList(parsed: ParsedArgs, loc: CatalogLocation, deps: AgentComm
215
221
 
216
222
  async function runReload(parsed: ParsedArgs, loc: CatalogLocation, deps: AgentCommandDeps): Promise<AgentCommandResult> {
217
223
  if (parsed.positionals.length > 1) return result("error", "reload", false, "reload does not take extra arguments.");
218
- const listed = presentCatalog(refreshCatalog(loc), deps.enrich);
224
+ const listed = presentCatalog(withAgentSessionSettings(refreshCatalog(loc), loc.sessionSettings), deps.enrich);
219
225
  return {
220
226
  ...result("ok", "reload", false, `Reloaded revision ${listed.revision} for inspection. The next launch reads the files again on its own; this command is not a prerequisite.\n${listed.text}`),
221
227
  diagnostics: listed.diagnostics,
@@ -662,11 +668,12 @@ function location(host: AgentCommandHost, deps: AgentCommandDeps): CatalogLocati
662
668
  projectTrusted: host.isProjectTrusted(),
663
669
  projectConfigDirName: deps.projectConfigDirName,
664
670
  bundledRoot: deps.bundledRoot,
671
+ sessionSettings: deps.sessionSettings?.snapshot(),
665
672
  };
666
673
  }
667
674
 
668
675
  function readCatalog(loc: CatalogLocation): CatalogSnapshot {
669
- return loadCatalog(loc);
676
+ return withAgentSessionSettings(loadCatalog(loc), loc.sessionSettings);
670
677
  }
671
678
 
672
679
  function untrusted(command: string): AgentCommandResult {
@@ -707,16 +714,37 @@ function finish(host: AgentCommandHost, commandResult: AgentCommandResult): Agen
707
714
  return commandResult;
708
715
  }
709
716
 
710
- async function finishInspection(host: AgentCommandHost, commandResult: AgentCommandResult): Promise<AgentCommandResult> {
717
+ async function finishInspection(host: AgentCommandHost, commandResult: AgentCommandResult, deps: AgentCommandDeps): Promise<AgentCommandResult> {
711
718
  if (commandResult.ok && host.mode === "tui" && host.hasUI && host.ui.custom) {
712
719
  const catalog = commandResult.data && typeof commandResult.data === "object" && "entries" in commandResult.data
713
720
  ? commandResult.data as ReturnType<typeof presentCatalog> : undefined;
714
721
  const view = catalog ? undefined : commandResult.data as OperationView;
715
- const entries = catalog ?? { entries: [view], diagnostics: commandResult.diagnostics, revision: "", text: "" };
722
+ const entries = catalog ?? { entries: [view!], diagnostics: commandResult.diagnostics, revision: "", text: "" };
716
723
  try {
717
724
  host.ui.setWidget?.("agents-catalog", undefined);
718
725
  await host.ui.custom<void>((tui, theme, _keys, done) =>
719
- createCatalogComponent(entries, theme, () => tui.requestRender(), () => done(), view?.id), { overlay: true });
726
+ createCatalogComponent(entries, theme, () => tui.requestRender(), () => done(), view?.id, deps.sessionSettings ? {
727
+ get: () => presentCatalog(readCatalog(location(host, deps)), deps.enrich),
728
+ models: () => deps.editableModels?.() ?? [],
729
+ settings: () => deps.sessionSettings!.snapshot(),
730
+ change: (id, key, value) => deps.sessionSettings!.change(id, key, value),
731
+ save(id) {
732
+ const loc = location(host, deps);
733
+ const saved = saveAgentSessionDefaults(loadCatalog(loc), id, deps.sessionSettings!.snapshot(), loc);
734
+ if (saved.ok) {
735
+ commandResult.wrote = true;
736
+ commandResult.path = saved.path;
737
+ commandResult.scope = saved.scope;
738
+ }
739
+ return { ok: saved.ok, message: saved.ok ? `Defaults saved (${saved.scope}): ${id}.` : saved.diagnostics.map((item) => item.message).join(" ") };
740
+ },
741
+ } : undefined), { overlay: true });
742
+ if (deps.sessionSettings) {
743
+ const current = readCatalog(location(host, deps));
744
+ const refreshed = view ? presentCatalogEntry(current, view.id, deps.enrich) : presentCatalog(current, deps.enrich);
745
+ commandResult.data = refreshed;
746
+ commandResult.message = refreshed.text;
747
+ }
720
748
  return commandResult;
721
749
  } catch {
722
750
  // A non-interactive host may expose custom() without supporting overlays.
@@ -0,0 +1,89 @@
1
+ /** Branch-local catalog preferences. Null removes a definition's own value. */
2
+ import { EFFORT_LEVELS, type CatalogDefinition, type PortablePreferences, type ThinkingLevel } from "./catalog-schema.ts";
3
+ import { contentDigest, saveDefinition, type CatalogSnapshot, type LoadCatalogInput, type SaveResult } from "./catalog-store.ts";
4
+
5
+ export const AGENT_SESSION_SETTINGS_ENTRY = "pi-better-subagents-agent-settings";
6
+ export type AgentSettingKey = "model" | "effort";
7
+ export type AgentSessionOverrides = Readonly<Record<string, Readonly<{ model?: string | null; effort?: ThinkingLevel | null }>>>;
8
+ export interface AgentSessionContext {
9
+ sessionManager?: { getBranch?(): readonly unknown[] };
10
+ }
11
+ export interface AgentSessionSettings {
12
+ restore(ctx: AgentSessionContext): void;
13
+ snapshot(): AgentSessionOverrides;
14
+ change(id: string, key: AgentSettingKey, value: string | null): void;
15
+ clear(): void;
16
+ }
17
+
18
+ function valid(id: unknown, key: unknown, value: unknown): boolean {
19
+ return typeof id === "string" && /^(role|agent)\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(id) &&
20
+ (key === "model" || key === "effort") && (value === null || typeof value === "string" &&
21
+ (key === "model" ? /^[a-z0-9][a-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(value)
22
+ : (EFFORT_LEVELS as readonly string[]).includes(value)));
23
+ }
24
+
25
+ /** The host must restore from the current context before constructing catalog hosts. */
26
+ export function createAgentSessionSettings(pi: { appendEntry(customType: string, data: unknown): void }): AgentSessionSettings {
27
+ let overrides: AgentSessionOverrides = Object.freeze({});
28
+ const apply = (id: string, key: AgentSettingKey, value: string | null) => {
29
+ overrides = Object.freeze({ ...overrides, [id]: Object.freeze({ ...overrides[id], [key]: value }) });
30
+ };
31
+ return {
32
+ snapshot: () => overrides,
33
+ clear() { overrides = Object.freeze({}); },
34
+ restore(ctx) {
35
+ overrides = Object.freeze({});
36
+ const branch = ctx.sessionManager?.getBranch?.();
37
+ if (!Array.isArray(branch)) return;
38
+ for (const entry of branch) {
39
+ if (!entry || entry.type !== "custom" || entry.customType !== AGENT_SESSION_SETTINGS_ENTRY) continue;
40
+ const data = entry.data;
41
+ if (data?.version !== 1 || !valid(data.id, data.key, data.value)) continue;
42
+ apply(data.id, data.key, data.value);
43
+ }
44
+ },
45
+ change(id, key, value) {
46
+ if (!valid(id, key, value)) throw new Error("Choose a catalog id and a valid model or effort. Nothing changed.");
47
+ // Append first: a persistence failure must not become an ephemeral edit.
48
+ pi.appendEntry(AGENT_SESSION_SETTINGS_ENTRY, { version: 1, id, key, value });
49
+ apply(id, key, value);
50
+ },
51
+ };
52
+ }
53
+
54
+ /** Copy only preferences; never repair or bypass a catalog validation failure. */
55
+ export function sessionPreferences(definition: CatalogDefinition, settings: AgentSessionOverrides = {}): PortablePreferences {
56
+ const own = { ...(definition.kind === "role" ? definition.defaults : definition.overrides) };
57
+ const patch = settings[definition.id];
58
+ for (const key of ["model", "effort"] as const) {
59
+ if (!patch || !Object.hasOwn(patch, key)) continue;
60
+ if (patch[key] === null) delete own[key];
61
+ else if (patch[key] !== undefined) Object.assign(own, { [key]: patch[key] });
62
+ }
63
+ return own;
64
+ }
65
+
66
+ /** A launch batch retains this immutable overlay, even if the UI changes later. */
67
+ export function withAgentSessionSettings(snapshot: CatalogSnapshot, settings?: AgentSessionOverrides): CatalogSnapshot {
68
+ if (!settings || Object.keys(settings).length === 0) return snapshot;
69
+ const ordered = Object.fromEntries(Object.keys(settings).sort().map((id) => [id, Object.freeze({
70
+ ...(Object.hasOwn(settings[id]!, "model") ? { model: settings[id]!.model } : {}),
71
+ ...(Object.hasOwn(settings[id]!, "effort") ? { effort: settings[id]!.effort } : {}),
72
+ })]));
73
+ const digest = contentDigest(JSON.stringify([snapshot.digest, ordered]));
74
+ return Object.freeze({ ...snapshot, digest, revision: digest, sessionSettings: Object.freeze(ordered) });
75
+ }
76
+
77
+ /** Save selected own preferences only. Never flatten inherited role settings. */
78
+ export function saveAgentSessionDefaults(snapshot: CatalogSnapshot, id: string, settings: AgentSessionOverrides, location: LoadCatalogInput): SaveResult {
79
+ const entry = snapshot.roles.get(id) ?? snapshot.agents.get(id);
80
+ if (!entry?.definition || !entry.structurallyValid || entry.duplicate) {
81
+ return { ok: false, diagnostics: [{ severity: "error", code: "agents-save", message: `Cannot save ${id}: repair the catalog definition first. Session edits are unchanged.`, blocking: true, structural: false }] };
82
+ }
83
+ const definition = structuredClone(entry.definition);
84
+ const preferences = sessionPreferences(definition, settings);
85
+ if (definition.kind === "role") definition.defaults = preferences;
86
+ else definition.overrides = preferences;
87
+ // The store validates and writes atomically, including trust and symlink checks.
88
+ return saveDefinition({ ...location, definition, scope: entry.scope === "project" ? "project" : "user", replace: entry.scope !== "bundled" });
89
+ }
@@ -6,6 +6,7 @@
6
6
  import { presentCatalog, presentCatalogEntry, type LaunchEnricher } from "./agent-inspection.ts";
7
7
  import { canonicalCatalogId } from "./catalog-schema.ts";
8
8
  import { defaultUserRoot, loadCatalog } from "./catalog-store.ts";
9
+ import { withAgentSessionSettings, type AgentSessionOverrides, type AgentSessionSettings } from "./agent-session-settings.ts";
9
10
 
10
11
  type TypeModule = {
11
12
  Object: (value: unknown) => unknown;
@@ -19,6 +20,7 @@ export interface DiscoveryHost {
19
20
  userRoot: string;
20
21
  projectConfigDirName?: string;
21
22
  bundledRoot?: string;
23
+ sessionSettings?: AgentSessionOverrides;
22
24
  }
23
25
 
24
26
  export interface DiscoveryDeps {
@@ -26,6 +28,7 @@ export interface DiscoveryDeps {
26
28
  bundledRoot?: string;
27
29
  projectConfigDirName?: string;
28
30
  enrich?: LaunchEnricher;
31
+ sessionSettings?: AgentSessionSettings;
29
32
  resolveHost?: (ctx: { cwd: string; isProjectTrusted(): boolean }) => DiscoveryHost;
30
33
  }
31
34
 
@@ -67,15 +70,16 @@ export function agentsCatalogTool(Type: TypeModule, deps: DiscoveryDeps = {}) {
67
70
  return toolError(`agents_catalog action must be list or inspect, got ${JSON.stringify(action ?? "")}. It does not create, import, reload, or launch. Nothing was written.`);
68
71
  }
69
72
  const host = resolveHost(ctx, deps);
73
+ const snapshot = withAgentSessionSettings(loadCatalog(host), host.sessionSettings ?? deps.sessionSettings?.snapshot());
70
74
  if (action === "list") {
71
- const listed = presentCatalog(loadCatalog(host), deps.enrich);
75
+ const listed = presentCatalog(snapshot, deps.enrich);
72
76
  return {
73
77
  content: [{ type: "text" as const, text: listed.text }],
74
78
  details: { action, wrote: false, revision: listed.revision, entries: listed.entries, diagnostics: listed.diagnostics },
75
79
  };
76
80
  }
77
81
  if (!params.id?.trim()) return toolError("inspect needs a role name or agent id. Call agents_catalog with action list to see them. Nothing was written.");
78
- const view = presentCatalogEntry(loadCatalog(host), canonicalCatalogId(params.id), deps.enrich);
82
+ const view = presentCatalogEntry(snapshot, canonicalCatalogId(params.id), deps.enrich);
79
83
  return {
80
84
  content: [{ type: "text" as const, text: view.text }],
81
85
  details: { action, wrote: false, view },
@@ -18,6 +18,7 @@ import {
18
18
  type ThinkingLevel,
19
19
  } from "./catalog-schema.ts";
20
20
  import type { CatalogEntry, CatalogScope, CatalogSnapshot } from "./catalog-store.ts";
21
+ import { sessionPreferences } from "./agent-session-settings.ts";
21
22
 
22
23
  export interface FieldValue<T> {
23
24
  value: T | null;
@@ -223,6 +224,7 @@ function resolveRole(snapshot: CatalogSnapshot, roleId: string): Resolution {
223
224
  return blockedEntry(snapshot, entry, entry.diagnostics);
224
225
  }
225
226
  const definition = entry.definition;
227
+ const preferences = sessionPreferences(definition, snapshot.sessionSettings);
226
228
  const launchable = entry.schemaLaunchable;
227
229
  const diagnostics = launchable
228
230
  ? [...entry.diagnostics]
@@ -245,9 +247,9 @@ function resolveRole(snapshot: CatalogSnapshot, roleId: string): Resolution {
245
247
  description: definition.description,
246
248
  instructionMode: "role",
247
249
  instructions: definition.body,
248
- model: fieldFromRole(definition.defaults.model),
249
- effort: fieldFromRole(definition.defaults.effort),
250
- tier: fieldFromRole(definition.defaults.tier),
250
+ model: fieldFromRole(preferences.model),
251
+ effort: fieldFromRole(preferences.effort),
252
+ tier: fieldFromRole(preferences.tier),
251
253
  launchable,
252
254
  diagnostics,
253
255
  source: originOf(entry),
@@ -332,6 +334,8 @@ function baseEffective(
332
334
  } | undefined,
333
335
  ): EffectiveDefinition {
334
336
  const mode = agent.instructionMode;
337
+ const overrides = sessionPreferences(agent, snapshot.sessionSettings);
338
+ const defaults = inheritance ? sessionPreferences(inheritance.role, snapshot.sessionSettings) : {};
335
339
  const instructions = inheritance
336
340
  ? combineInstructions(inheritance.role.body, agent.body, mode)
337
341
  : agent.body;
@@ -343,9 +347,9 @@ function baseEffective(
343
347
  roleId: agent.roleId,
344
348
  instructionMode: mode,
345
349
  instructions,
346
- model: inheritance ? inheritField(agent.overrides, inheritance.role.defaults, "model") : absentField(),
347
- effort: inheritance ? inheritField(agent.overrides, inheritance.role.defaults, "effort") : absentField(),
348
- tier: inheritance ? inheritField(agent.overrides, inheritance.role.defaults, "tier") : absentField(),
350
+ model: inheritance ? inheritField(overrides, defaults, "model") : absentField(),
351
+ effort: inheritance ? inheritField(overrides, defaults, "effort") : absentField(),
352
+ tier: inheritance ? inheritField(overrides, defaults, "tier") : absentField(),
349
353
  launchable: inheritance?.launchable ?? false,
350
354
  diagnostics,
351
355
  source: originOf(entry),
@@ -23,6 +23,7 @@ import { allocateCatalogLabel } from "./catalog-identity.ts";
23
23
  import { resolveRoleAssignment, type RoleAssignment } from "./role-assignment.ts";
24
24
  import { configureTierPolicy, DEFAULT_TIER_POLICY, type TierPolicy, type TierSpec } from "./tier-policy.ts";
25
25
  import { canonicalAgentId, canonicalRoleId, shortRoleName, type Diagnostic, type ThinkingLevel } from "./catalog-schema.ts";
26
+ import { withAgentSessionSettings, type AgentSessionOverrides } from "./agent-session-settings.ts";
26
27
 
27
28
  export interface CatalogHost {
28
29
  cwd: string;
@@ -38,6 +39,9 @@ export interface CatalogHost {
38
39
  select?: (title: string, options: string[]) => Promise<string | undefined>;
39
40
  /** Passed through only when the caller already has a registry root. Omitted in production. */
40
41
  registryDir?: string;
42
+ sessionSettings?: AgentSessionOverrides;
43
+ /** Live UI edits; read once when admitting a launch snapshot. */
44
+ getSessionSettings?: () => AgentSessionOverrides;
41
45
  }
42
46
 
43
47
  export interface CatalogJobFields {
@@ -142,17 +146,19 @@ export function noteCatalogHost(next: CatalogHost): void {
142
146
  foregroundModel: "foregroundModel" in next ? next.foregroundModel : previous?.foregroundModel,
143
147
  configuredDefaultModel: "configuredDefaultModel" in next ? next.configuredDefaultModel : previous?.configuredDefaultModel,
144
148
  tiers: "tiers" in next ? next.tiers : previous?.tiers,
149
+ sessionSettings: next.sessionSettings,
150
+ getSessionSettings: next.getSessionSettings,
145
151
  };
146
152
  }
147
153
 
148
154
  export function loadLaunchSnapshot(host: CatalogHost): CatalogSnapshot {
149
- return loadCatalog({
155
+ return withAgentSessionSettings(loadCatalog({
150
156
  cwd: host.cwd,
151
157
  projectTrusted: host.projectTrusted,
152
158
  userRoot: host.userRoot ?? defaultUserRoot(),
153
159
  bundledRoot: host.bundledRoot,
154
160
  projectConfigDirName: host.projectConfigDirName,
155
- });
161
+ }), host.getSessionSettings?.() ?? host.sessionSettings);
156
162
  }
157
163
 
158
164
  export function createLaunchEnricher(): LaunchEnricher {
package/catalog-store.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  import { homedir } from "node:os";
29
29
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
+ import type { AgentSessionOverrides } from "./agent-session-settings.ts";
31
32
  import {
32
33
  CATALOG_SCHEMA_VERSION,
33
34
  DiagnosticCodes,
@@ -108,6 +109,8 @@ export interface CatalogSnapshot {
108
109
  /** Same-scope duplicates. They occupy the id without selecting a filesystem-order winner. */
109
110
  blocked: readonly CatalogEntry[];
110
111
  diagnostics: readonly Diagnostic[];
112
+ /** Immutable, branch-local preferences; never written by loadCatalog. */
113
+ sessionSettings?: AgentSessionOverrides;
111
114
  }
112
115
 
113
116
  export interface SaveResult {
package/catalog-ui.ts CHANGED
@@ -1,7 +1,17 @@
1
- import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1
+ import { Input, SelectList, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import type { OperationField, OperationList, OperationView } from "./agent-inspection.ts";
3
+ import { EFFORT_LEVELS } from "./catalog-schema.ts";
4
+ import type { AgentSessionOverrides, AgentSettingKey } from "./agent-session-settings.ts";
3
5
 
4
- type Theme = { fg(color: string, text: string): string };
6
+ type Theme = { fg(color: string, text: string): string; bg?(color: string, text: string): string; bold?(text: string): string; inverse?(text: string): string };
7
+
8
+ export interface CatalogEditor {
9
+ get(): OperationList;
10
+ models(): readonly string[];
11
+ settings(): AgentSessionOverrides;
12
+ change(id: string, key: AgentSettingKey, value: string | null): void;
13
+ save(id: string): { ok: boolean; message: string };
14
+ }
5
15
 
6
16
  function stateLabel(entry: OperationView): string {
7
17
  if (entry.launchable === false) return "blocked";
@@ -84,11 +94,65 @@ export function createCatalogComponent(
84
94
  requestRender: () => void,
85
95
  close: () => void,
86
96
  initialId?: string,
97
+ editor?: CatalogEditor,
87
98
  ) {
88
99
  let selectedId = initialId ?? catalog.entries[0]?.id;
89
- let mode: "list" | "detail" | "prompt" = initialId ? "detail" : "list";
100
+ let mode: "list" | "detail" | "prompt" | "settings" = initialId ? "detail" : "list";
90
101
  let query = "";
91
102
  let scroll = 0;
103
+ let settingRow = 0;
104
+ let picker: SelectList | undefined;
105
+ let filterPicker: ((filter: string) => void) | undefined;
106
+ let pickerKey: AgentSettingKey | undefined;
107
+ const search = new Input();
108
+ let focused = false;
109
+ let message = "";
110
+ let error = false;
111
+ const highlight = (text: string, width: number) => theme.bg?.("selectedBg", theme.bold?.(cell(text, width)) ?? cell(text, width)) ?? theme.fg("accent", text);
112
+ function report(text: string, failed = false) { message = text; error = failed; requestRender(); }
113
+ function refresh() { if (editor) catalog = editor.get(); }
114
+ function save() {
115
+ if (!editor || !selected()) return;
116
+ try {
117
+ const result = editor.save(selected()!.id);
118
+ refresh();
119
+ report(result.message, !result.ok);
120
+ } catch (cause) { report(String(cause instanceof Error ? cause.message : cause), true); }
121
+ }
122
+ function edit() {
123
+ const current = selected();
124
+ if (!current || !editor) return;
125
+ if (!current.definitionValid) { report("Repair the catalog definition before editing.", true); return; }
126
+ const key: AgentSettingKey = settingRow === 0 ? "model" : "effort";
127
+ const values = key === "model" ? [...new Set([...(editor.models()), ...(current.fields?.model.value ? [current.fields.model.value] : [])])].sort() : [...EFFORT_LEVELS];
128
+ const items = [{ value: "", label: "Inherit" }, ...values.map((value) => ({ value, label: value }))];
129
+ pickerKey = key;
130
+ search.setValue("");
131
+ search.focused = focused && key === "model";
132
+ const pickerTheme: ConstructorParameters<typeof SelectList>[2] = {
133
+ selectedPrefix: (text) => theme.fg("accent", text),
134
+ selectedText: (text) => theme.inverse?.(text) ?? theme.fg("accent", text),
135
+ description: (text) => theme.fg("dim", text),
136
+ scrollInfo: (text) => theme.fg("dim", text),
137
+ noMatch: (text) => theme.fg("warning", text),
138
+ };
139
+ const onSelect = (item: { value: string }) => {
140
+ try {
141
+ editor.change(current.id, key, item.value || null);
142
+ refresh();
143
+ picker = undefined;
144
+ search.focused = false;
145
+ report("Session settings updated.");
146
+ } catch (cause) { report(cause instanceof Error ? cause.message : String(cause), true); }
147
+ };
148
+ filterPicker = (filter) => {
149
+ picker = new SelectList(items.filter((item) => item.label.toLowerCase().includes(filter.toLowerCase())), 12, pickerTheme);
150
+ picker.onCancel = () => { picker = undefined; search.focused = false; };
151
+ picker.onSelect = onSelect;
152
+ };
153
+ filterPicker("");
154
+ picker!.setSelectedIndex(Math.max(0, items.findIndex((item) => item.value === current.fields?.[key].value)));
155
+ }
92
156
  const filtered = () => catalog.entries.filter((entry) =>
93
157
  [entry.id, entry.identity.name, entry.identity.description, entry.role?.id]
94
158
  .some((value) => value?.toLowerCase().includes(query.toLowerCase())));
@@ -100,6 +164,8 @@ export function createCatalogComponent(
100
164
  let lastDetailWidth = 80;
101
165
 
102
166
  return {
167
+ get focused() { return focused; },
168
+ set focused(value: boolean) { focused = value; search.focused = value && !!picker && pickerKey === "model"; },
103
169
  render(width: number): string[] {
104
170
  const safeWidth = Math.max(1, width);
105
171
  lastDetailWidth = safeWidth;
@@ -109,9 +175,32 @@ export function createCatalogComponent(
109
175
  const detailLines = current ? detailRows(current, safeWidth) : [];
110
176
  const selectedIndex = rows.findIndex((entry) => entry.id === selectedId);
111
177
  const start = Math.max(0, Math.min(selectedIndex - 6, rows.length - 14));
112
- const lines = mode !== "list" && current ? [
178
+ if (picker) return [
179
+ theme.fg("accent", `Agents / ${pickerKey}`), "",
180
+ ...(pickerKey === "model" ? search.render(safeWidth) : []),
181
+ ...picker.render(safeWidth),
182
+ ...(message ? [theme.fg(error ? "error" : "muted", message)] : []),
183
+ ].map(line);
184
+ const lines = mode === "settings" && current ? [
185
+ theme.fg("accent", `Agents / ${current.identity.name || current.id}`), "",
186
+ ...["Model", "Effort"].flatMap((label, index) => {
187
+ const key = index === 0 ? "model" : "effort";
188
+ const field = current.fields?.[key];
189
+ const own = editor?.settings()[current.id];
190
+ const ownSession = Object.hasOwn(own ?? {}, key);
191
+ const roleSession = field?.inherited && Object.hasOwn(editor?.settings()[current.role?.id ?? ""] ?? {}, key);
192
+ const origin = ownSession && own?.[key] === null ? "inherit / session" : ownSession ? "session" : roleSession ? "inherited / session" : field?.inherited ? "inherited" : "catalog";
193
+ const value = `${field?.value ?? "(unset)"} ${origin}`;
194
+ const text = `${settingRow === index ? "> " : " "}${label.padEnd(18)} ${value}`;
195
+ if (safeWidth < 42) {
196
+ const title = `${settingRow === index ? "> " : " "}${label}`;
197
+ return [settingRow === index ? highlight(title, safeWidth) : title, ...wrapLine(value, Math.max(1, safeWidth - 2)).map((line) => ` ${line}`)];
198
+ }
199
+ return [settingRow === index ? highlight(text, safeWidth) : text];
200
+ }),
201
+ ] : mode !== "list" && current ? [
113
202
  theme.fg("accent", mode === "prompt" ? "Agents / prompt" : "Agents / inspect"),
114
- theme.fg("dim", mode === "prompt" ? "← details · ↑↓ scroll · Esc close" : "p prompt · ← list · ↑↓ scroll · Esc close"),
203
+ theme.fg("dim", mode === "prompt" ? "← details · ↑↓ scroll · Esc close" : `${editor ? "e settings · " : ""}p prompt · ← list · ↑↓ scroll · Esc close`),
115
204
  "",
116
205
  ...detailLines.slice(scroll, scroll + 22).map((text, i) =>
117
206
  i === 0 && scroll === 0 ? theme.fg("accent", text) : text),
@@ -127,25 +216,37 @@ export function createCatalogComponent(
127
216
  const active = selectedId === entry.id;
128
217
  const marker = active ? "›" : " ";
129
218
  const text = `${marker} ${catalogColumns(entry, safeWidth)}`;
130
- return active ? theme.fg("accent", text) : text;
219
+ return active ? highlight(text, safeWidth) : text;
131
220
  }),
132
221
  ...(rows.length === 0 ? [" No matching definitions"] : []),
133
222
  ...(rows.length > 14 ? [theme.fg("dim", ` ${start + 1}-${Math.min(start + 14, rows.length)} of ${rows.length}`)] : []),
134
223
  ...catalog.diagnostics.slice(0, 2).map((diagnostic) => theme.fg("warning", `! ${diagnostic.message}`)),
135
224
  ...(catalog.diagnostics.length > 2 ? [theme.fg("warning", `! ${catalog.diagnostics.length - 2} more catalog diagnostics`)] : []),
136
225
  ];
226
+ if (editor && mode !== "prompt") lines.push("", theme.fg("dim", mode === "settings" ? "Up/Down Select · Enter Change · ctrl+s Save defaults · Esc Back" : "ctrl+s Save selected defaults"));
227
+ if (message) lines.push("", theme.fg(error ? "error" : "muted", message));
137
228
  return lines.map(line);
138
229
  },
139
230
  handleInput(data: string): void {
140
231
  const rows = filtered();
232
+ if (picker) {
233
+ if (pickerKey === "model" && !(["up", "down", "enter", "escape"] as const).some((key) => matchesKey(data, key))) {
234
+ search.handleInput(data);
235
+ filterPicker?.(search.getValue());
236
+ } else picker.handleInput(data);
237
+ requestRender();
238
+ return;
239
+ }
240
+ if (editor && matchesKey(data, "ctrl+s")) { save(); return; }
141
241
  if (matchesKey(data, "escape") || matchesKey(data, "left")) {
142
- if (mode === "prompt") { mode = "detail"; scroll = 0; }
242
+ if (mode === "prompt" || mode === "settings") { mode = "detail"; scroll = 0; }
143
243
  else if (mode === "detail") { mode = "list"; scroll = 0; }
144
244
  else if (query) { query = ""; selectedId = catalog.entries[0]?.id; }
145
245
  else { close(); return; }
146
246
  } else if (matchesKey(data, "up") || matchesKey(data, "down")) {
147
247
  const delta = matchesKey(data, "down") ? 1 : -1;
148
- if (mode !== "list" && selected()) scroll = Math.max(0, Math.min(detailRows(selected()!, lastDetailWidth).length - 1, scroll + delta));
248
+ if (mode === "settings") settingRow = Math.max(0, Math.min(1, settingRow + delta));
249
+ else if (mode !== "list" && selected()) scroll = Math.max(0, Math.min(detailRows(selected()!, lastDetailWidth).length - 1, scroll + delta));
149
250
  else if (rows.length) selectedId = rows[Math.max(0, Math.min(rows.length - 1, rows.findIndex((entry) => entry.id === selectedId) + delta))]?.id;
150
251
  } else if (matchesKey(data, "enter") && mode === "list" && selected()) {
151
252
  mode = "detail";
@@ -153,6 +254,11 @@ export function createCatalogComponent(
153
254
  } else if ((data === "p" || data === "P") && mode === "detail" && selected()) {
154
255
  mode = "prompt";
155
256
  scroll = 0;
257
+ } else if (editor && (data === "e" || data === "E") && mode === "detail" && selected()) {
258
+ mode = "settings";
259
+ settingRow = 0;
260
+ } else if (mode === "settings" && (matchesKey(data, "enter") || data === " ")) {
261
+ try { edit(); } catch (cause) { report(cause instanceof Error ? cause.message : String(cause), true); }
156
262
  } else if (mode === "list" && matchesKey(data, "backspace")) {
157
263
  query = query.slice(0, -1);
158
264
  selectedId = filtered()[0]?.id;
@@ -162,6 +268,6 @@ export function createCatalogComponent(
162
268
  }
163
269
  requestRender();
164
270
  },
165
- invalidate() {},
271
+ invalidate() { picker?.invalidate(); search.invalidate(); },
166
272
  };
167
273
  }
package/config.ts CHANGED
@@ -21,7 +21,7 @@ export interface SubagentConfig extends TimingSettings {
21
21
  delegationMode?: DelegationMode | null;
22
22
  defaultModel?: string | null;
23
23
  defaultTools?: string | null;
24
- /** Max subagents allowed to run at once. */
24
+ /** Default concurrency cap; /subagents cap overrides it for the current session. */
25
25
  maxConcurrent?: number | null;
26
26
  /**
27
27
  * Tool name → extension package(s) that provide it. Drives which extension
@@ -74,6 +74,14 @@ export interface SubagentConfig extends TimingSettings {
74
74
  /** Concurrency cap when config.json sets none. */
75
75
  export const DEFAULT_MAX_CONCURRENT = 4;
76
76
 
77
+ export function isConcurrencyCap(value: unknown): value is number {
78
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
79
+ }
80
+
81
+ export function normalizeConcurrencyCap(value: unknown): number {
82
+ return isConcurrencyCap(value) ? value : DEFAULT_MAX_CONCURRENT;
83
+ }
84
+
77
85
  /** Built-in default tool set when config.json sets nothing. */
78
86
  export const SAFE_DEFAULT_TOOLS = "read, bash, edit, write, web_search, web_fetch";
79
87
  /** Safe default for a hermetic (clean) child where extension tools don't exist. */
@@ -123,6 +131,25 @@ export function writeDelegationMode(mode: DelegationMode, path = configPath()):
123
131
  if (path === configPath()) cached = current;
124
132
  }
125
133
 
134
+ /** Save human-facing defaults together, preserving unrelated configuration. */
135
+ export function writeSubagentSettings(settings: { delegationMode: DelegationMode; maxConcurrent: number }, path = configPath()): void {
136
+ if (!isDelegationMode(settings.delegationMode)) throw new Error("Invalid delegation mode.");
137
+ if (!isConcurrencyCap(settings.maxConcurrent)) throw new Error("Concurrent subagents must be a positive whole number.");
138
+ let current: Record<string, unknown> = {};
139
+ try {
140
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
141
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Config must be a JSON object.");
142
+ current = parsed as Record<string, unknown>;
143
+ } catch (error) {
144
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
145
+ }
146
+ Object.assign(current, settings);
147
+ const pending = `${path}.${process.pid}.tmp`;
148
+ writeFileSync(pending, `${JSON.stringify(current, null, 2)}\n`);
149
+ renameSync(pending, path);
150
+ if (path === configPath()) cached = current as SubagentConfig;
151
+ }
152
+
126
153
  /** Normalize a comma/space tool list to pi's bare comma form: "a, b" → "a,b". */
127
154
  export function normalizeTools(list: string): string {
128
155
  return list.split(",").map((t) => t.trim()).filter(Boolean).join(",");
package/delegation.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export type DelegationMode = "manual" | "adaptive" | "coordinator";
2
2
 
3
+ export const DELEGATION_MODES = ["manual", "adaptive", "coordinator"] as const;
4
+
3
5
  export const DELEGATION_MODE_REQUEST = "pi-better-subagents:delegation-mode-request";
4
6
 
5
7
  export function isDelegationMode(value: unknown): value is DelegationMode {
@@ -39,3 +39,26 @@ Navigator rows keep the display name, model, and effort. Details include the run
39
39
  ## Inspection
40
40
 
41
41
  The extension registers `/agents` and `agents_catalog` through `createAgentOperations`. The launch enricher calls `assessCatalog` / `assessSelection` with the current model registry, project trust, and user catalog root. It reports the existing spawn controls and `grantedByCatalog: false`. If the registry or snapshot is not the one just loaded, launchability stays unknown instead of being reported as launchable.
42
+
43
+ ## Session Settings
44
+
45
+ `createAgentSessionSettings` restores model and effort overrides from the active
46
+ Pi session branch. The extension restores before catalog-dependent work and on
47
+ `session_start` and `session_tree`; switch and shutdown clear transient state
48
+ without appending reset entries. Edits survive reload, resume, and fork, while
49
+ new sessions start from catalog defaults.
50
+
51
+ The catalog host supplies a live `getSessionSettings` getter so inspection
52
+ reflects edits made while the overlay is open. `loadLaunchSnapshot` reads that
53
+ getter once and freezes the overlay for each batch. Later edits cannot alter
54
+ already-admitted jobs. Model choices come from the current Pi model registry.
55
+
56
+ Ctrl+S saves only the selected definition's own preferences, never flattened
57
+ inherited settings. Bundled roles are copied to user scope; existing user and
58
+ project definitions are saved in their own scope with the store's trust,
59
+ validation, and atomic-write checks. Plain `loadCatalog` remains disk-only so
60
+ saving selects the current winning definition rather than a stale overlay.
61
+
62
+ Headless callers can inject a session controller or immutable
63
+ `CatalogHost.sessionSettings`. Hosts without these hooks retain inspection-only
64
+ UI and the prior launch behavior.