pi-better-harness 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.
@@ -11,7 +11,8 @@ pi install npm:pi-better-sandbox
11
11
  Either way you keep starting Pi the way you always have — `pi`. There is
12
12
  no launcher or wrapper command. Main starts inactive; Subagents start confined.
13
13
  Open `/sandbox` to change either column, or use `/sandbox on` for the current
14
- Main session. **Save as defaults** persists both profiles.
14
+ Main session. Edits apply immediately and are saved to the current session branch.
15
+ **Ctrl+S** saves both profiles as defaults for new sessions.
15
16
 
16
17
  ```text
17
18
  Sandbox permissions Main Subagents
@@ -31,8 +32,7 @@ Network access - On
31
32
  > [x] @juicesharp/rpiv-web-tools 2/2
32
33
  > [ ] <other installed package> 0/1
33
34
 
34
- ↑↓ Select row ←→ Select column / expand / collapse Enter Fold Space Change
35
- Save as defaults
35
+ ↑↓ Select · ←→ Column/fold · Space Toggle · ctrl+s Save default · Enter Fold · Esc Back
36
36
  ```
37
37
 
38
38
  **Subagents · Tools** picks the extension tools a confined subagent may use:
@@ -56,7 +56,7 @@ are; the count is selected/total. Right expands a group. Left collapses a group,
56
56
  or moves from a child to its parent and collapses it. Enter folds the selected
57
57
  group. Space on a group selects all children when none or only some are selected,
58
58
  or deselects them all when all are selected. Every toggle applies on one Space;
59
- saving looser defaults still needs a second Enter. Only individual tool
59
+ Ctrl+S saves defaults and reports anything loosened. Only individual tool
60
60
  entries are saved, never a package/provider wildcard or the open/closed state.
61
61
 
62
62
  Defaults: `apply_patch`, `web_fetch`, and `web_search` on; everything else off.
@@ -123,7 +123,7 @@ Write, those paths are also protected from removal and renaming.
123
123
  Space applies every change immediately, including higher permission levels,
124
124
  capabilities switched on, and a sandbox switched off. `ctrl+s` saves the
125
125
  defaults from any row on the first press, and the note names anything that
126
- loosened. Enter on the Save row still saves. The focused
126
+ loosened. Enter only folds tool groups; it never saves defaults. The focused
127
127
  row has a full-width background and bold text; the selected Main/Subagents cell
128
128
  also uses inverse styling. No model
129
129
  tool can change these settings. Other rows toggle Off/On. Detail cells under an
@@ -319,7 +319,7 @@ in your rule set but is held out in that project, with a message saying so.
319
319
  Nothing is migrated on disk. A saved `pi-better-sandbox-permissions.json` keeps
320
320
  its values: a saved `read-write` is now shown as Write & delete, and a saved
321
321
  Subagents Outside project = Read stays Read. Only a fresh configuration, or
322
- **Save as defaults** after you choose Write, uses the new Subagents default. To
322
+ **Ctrl+S** after you choose Write, uses the new Subagents default. To
323
323
  adopt it, open `/sandbox`, set Subagents → Outside project to Write, and save.
324
324
 
325
325
  `~/.pi/agent/sandbox.json` (`filesystem.allowWrite`, `allowRead`,
@@ -328,12 +328,19 @@ package never reads it, and it has no effect on the harness.
328
328
 
329
329
  ## Lifecycle
330
330
 
331
- The foreground sandbox is inactive by default. Session overrides do not survive
332
- startup, new session, resume, fork, or reload. Save as defaults writes both
331
+ The foreground sandbox is inactive by default. Permission edits, including
332
+ Sandbox switches and `/sandbox on|off`, auto-persist as Pi custom entries on the
333
+ current session branch, not as global defaults. Resume, fork, reload and session
334
+ tree navigation restore the latest policy on the active branch. A branch with
335
+ no permission entry and a new session inherit saved global settings. Invalid
336
+ persisted session policy fails closed rather than falling back to defaults.
337
+ Ctrl+S writes both
333
338
  profiles to `~/.pi/agent/extensions/pi-better-sandbox-permissions.json` (or the
334
339
  corresponding `$PI_CODING_AGENT_DIR`). Existing activation preferences in
335
340
  `pi-better-sandbox-preferences.json` migrate when no profile file exists.
336
341
  `/sandbox default on|off` remains available and updates Main's saved switch.
342
+ Failed default saves leave the current session's edits intact. Write-deny rules
343
+ retain their separate global persistence through `/sandbox deny` and `/sandbox rules`.
337
344
 
338
345
  Toggles apply to operations launched after the change. A command already running
339
346
  keeps the policy it launched with.
@@ -55,8 +55,8 @@ const DISABLE_TITLE = "Disable the foreground write sandbox?";
55
55
 
56
56
  const DISABLE_MESSAGE = [
57
57
  "The built-in bash, write, and edit tools and user-entered ! / !! commands",
58
- "will run with normal host write access for the rest of this session. The",
59
- "next session applies your persisted foreground sandbox default.",
58
+ "will run with normal host write access on this session branch, including",
59
+ "after resume or reload. New sessions apply your saved sandbox default.",
60
60
  ].join("\n");
61
61
 
62
62
  const NO_UI_REJECTION =
@@ -80,6 +80,8 @@ export type SandboxCommandDeps = {
80
80
  onStateChange: (status: ForegroundSandboxStatus) => void;
81
81
  /** Persist a default and apply it to the current session. */
82
82
  setDefault: (enabled: boolean) => ForegroundSandboxStatus;
83
+ /** Persist the session change before mutating enforcement. */
84
+ setSessionEnabled?: (enabled: boolean) => ForegroundSandboxStatus;
83
85
  openPermissions?: (ctx: ExtensionCommandContext) => Promise<void>;
84
86
  };
85
87
 
@@ -89,6 +91,7 @@ export function createSandboxCommandHandler({
89
91
  denyRules,
90
92
  onStateChange,
91
93
  setDefault,
94
+ setSessionEnabled,
92
95
  openPermissions,
93
96
  }: SandboxCommandDeps) {
94
97
  return async function handleSandboxCommand(
@@ -112,7 +115,12 @@ export function createSandboxCommandHandler({
112
115
  }
113
116
 
114
117
  if (subcommand === "on") {
115
- const status = controller.enable();
118
+ let status: ForegroundSandboxStatus;
119
+ try { status = setSessionEnabled ? setSessionEnabled(true) : controller.enable(); }
120
+ catch (error) {
121
+ ctx.ui.notify(`Sandbox state unchanged: ${error instanceof Error ? error.message : String(error)}`, "error");
122
+ return;
123
+ }
116
124
  onStateChange(status);
117
125
  ctx.ui.notify(
118
126
  status.state === "enabled"
@@ -140,7 +148,12 @@ export function createSandboxCommandHandler({
140
148
  ctx.ui.notify("Foreground sandbox left on.", "info");
141
149
  return;
142
150
  }
143
- const status = controller.disable();
151
+ let status: ForegroundSandboxStatus;
152
+ try { status = setSessionEnabled ? setSessionEnabled(false) : controller.disable(); }
153
+ catch (error) {
154
+ ctx.ui.notify(`Sandbox state unchanged: ${error instanceof Error ? error.message : String(error)}`, "error");
155
+ return;
156
+ }
144
157
  onStateChange(status);
145
158
  ctx.ui.notify(
146
159
  "Foreground sandbox OFF for this session. Shell commands can now write anywhere.",
@@ -38,6 +38,7 @@ import { writeSandboxDefault } from "./preferences.ts";
38
38
  import { readPermissionSettings, writePermissionSettings } from "./permission-settings.ts";
39
39
  import { defaultSandboxPermissions, describeLoosening, type SandboxPermissionSettings } from "./permissions.ts";
40
40
  import { openPermissionsPage } from "./permissions-page.ts";
41
+ import { appendSessionPermissions, readSessionPermissions } from "./session-permissions.ts";
41
42
 
42
43
  import { footerTone, formatFooterStatus } from "./status.ts";
43
44
  import { ForegroundSandboxController, type ForegroundSandboxStatus } from "./state.ts";
@@ -98,9 +99,7 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
98
99
  // `/sandbox deny ...` and the `/sandbox rules` page.
99
100
  const denyRules = new DenyRuleManager({ controller, onStateChange: announce });
100
101
 
101
- pi.on("session_start", (_event, ctx: ExtensionContext) => {
102
- shellPath = resolveShellPath(ctx.cwd);
103
- boundary.register(ctx.cwd);
102
+ const restorePermissions = (ctx: ExtensionContext): void => {
104
103
  paintFooter = (status) => {
105
104
  ctx.ui.setStatus(
106
105
  FOOTER_KEY,
@@ -108,11 +107,14 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
108
107
  );
109
108
  };
110
109
 
111
- // Re-read saved profiles on each session. Malformed policy blocks protected
112
- // operations rather than silently widening a restricted session.
110
+ // Session policy takes precedence over global defaults. Never skip an
111
+ // invalid latest entry to recover an older, potentially broader policy.
113
112
  let settings = defaultSandboxPermissions();
113
+ let hasSessionPolicy = false;
114
114
  try {
115
- settings = readPermissionSettings();
115
+ const sessionPermissions = readSessionPermissions(ctx);
116
+ hasSessionPolicy = sessionPermissions !== undefined;
117
+ settings = sessionPermissions ?? readPermissionSettings();
116
118
  } catch (error) {
117
119
  controller.beginSession(ctx.cwd, true);
118
120
  const message = `Sandbox permissions could not be loaded: ${error instanceof Error ? error.message : String(error)}`;
@@ -130,7 +132,7 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
130
132
  .map((tool) => tool.sourceInfo?.path).filter((path): path is string => typeof path === "string" && isAbsolute(path))
131
133
  .map(runtimeCodeRoot));
132
134
  controller.setPermissionSettings(settings);
133
- controller.applyDefault(settings.main.enabled);
135
+ if (!hasSessionPolicy) controller.applyDefault(settings.main.enabled);
134
136
 
135
137
  // Then the rules are re-read and re-resolved, because the same global
136
138
  // template set means different absolute paths in a different project.
@@ -154,7 +156,14 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
154
156
  status.state === "disabled" ? "info" : "warning",
155
157
  );
156
158
  }
159
+ };
160
+
161
+ pi.on("session_start", (_event, ctx) => {
162
+ shellPath = resolveShellPath(ctx.cwd);
163
+ boundary.register(ctx.cwd);
164
+ restorePermissions(ctx);
157
165
  });
166
+ pi.on("session_tree", (_event, ctx) => restorePermissions(ctx));
158
167
 
159
168
  pi.on("session_shutdown", () => {
160
169
  controller.dispose();
@@ -167,11 +176,18 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
167
176
  controller,
168
177
  denyRules,
169
178
  onStateChange: announce,
179
+ setSessionEnabled: (enabled) => {
180
+ const settings = controller.permissionSettings() ?? defaultSandboxPermissions();
181
+ settings.main.enabled = enabled;
182
+ appendSessionPermissions(pi, settings);
183
+ return enabled ? controller.enable() : controller.disable();
184
+ },
170
185
  setDefault: (enabled) => {
171
186
  const settings = controller.permissionSettings() ?? defaultSandboxPermissions();
172
187
  settings.main.enabled = enabled;
173
188
  writePermissionSettings(settings);
174
189
  writeSandboxDefault(enabled ? "on" : "off");
190
+ appendSessionPermissions(pi, settings);
175
191
  return controller.setPermissionSettings(settings);
176
192
  },
177
193
  openPermissions: async (ctx) => {
@@ -179,9 +195,12 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
179
195
  getConfig: () => controller.permissionSettings() ?? defaultSandboxPermissions(),
180
196
  // Trusted-tool candidates: what this Pi has actually registered, by owning package.
181
197
  discoverTools: () => discoverTrustedTools(pi.getAllTools?.() ?? []),
182
- change: (settings) => announce(controller.setPermissionSettings(settings)),
198
+ change: (settings) => {
199
+ appendSessionPermissions(pi, settings);
200
+ announce(controller.setPermissionSettings(settings));
201
+ },
183
202
  save: (settings) => writePermissionSettings(settings),
184
- // Persisting a looser default needs a second, explicit Enter.
203
+ // Report every capability loosened by saving these defaults.
185
204
  loosening: (settings) => {
186
205
  let previous: SandboxPermissionSettings;
187
206
  try { previous = readPermissionSettings(); } catch { previous = defaultSandboxPermissions(); }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Pi extension with independent Main and Subagents permission profiles, file guards, and OS sandbox enforcement.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -124,7 +124,7 @@ export function createPermissionsPage(
124
124
  const itemKey = (item: ToolItem) => item.kind === "group" ? item.id
125
125
  : item.kind === "guarded" ? "apply_patch" : JSON.stringify([item.name, item.package]);
126
126
  let items = toolItems();
127
- const saveRow = () => rows.length + items.length;
127
+ const lastRow = () => rows.length + items.length - 1;
128
128
  let row = 0;
129
129
  let column = 0;
130
130
  let busy = false;
@@ -136,7 +136,7 @@ export function createPermissionsPage(
136
136
  }
137
137
 
138
138
  async function change(): Promise<void> {
139
- if (!settings || busy || row === saveRow()) return;
139
+ if (!settings || busy) return;
140
140
  const next = snapshot(settings);
141
141
  if (row >= rows.length) {
142
142
  const item = items[row - rows.length]!;
@@ -173,15 +173,14 @@ export function createPermissionsPage(
173
173
  busy = true;
174
174
  try {
175
175
  await handlers.change(snapshot(next));
176
- const selected = row >= rows.length && row < saveRow() ? items[row - rows.length] : undefined;
177
- const wasSave = row === saveRow();
176
+ const selected = row >= rows.length ? items[row - rows.length] : undefined;
178
177
  const successors = selected ? items.slice(row - rows.length + 1).map(itemKey) : [];
179
178
  settings = next;
180
179
  items = toolItems();
181
180
  const index = selected ? items.findIndex((item) => itemKey(item) === itemKey(selected)) : -1;
182
181
  const successor = successors.map((key) => items.findIndex((item) => itemKey(item) === key)).find((at) => at >= 0);
183
- row = wasSave ? saveRow() : index >= 0 ? rows.length + index
184
- : successor !== undefined ? rows.length + successor : Math.min(row, saveRow());
182
+ row = index >= 0 ? rows.length + index
183
+ : successor !== undefined ? rows.length + successor : Math.min(row, lastRow());
185
184
  message = "";
186
185
  isError = false;
187
186
  requestRender();
@@ -215,7 +214,7 @@ export function createPermissionsPage(
215
214
  }
216
215
 
217
216
  function fold(open?: boolean): void {
218
- const item = row >= rows.length && row < saveRow() ? items[row - rows.length] : undefined;
217
+ const item = row >= rows.length ? items[row - rows.length] : undefined;
219
218
  const id = item?.kind === "group" ? item.id : item?.kind === "trusted" && open === false ? item.group : undefined;
220
219
  if (!id) return;
221
220
  if (open ?? !expanded.has(id)) expanded.add(id);
@@ -234,22 +233,20 @@ export function createPermissionsPage(
234
233
  row = Math.max(0, row - 1);
235
234
  requestRender();
236
235
  } else if (matchesKey(data, Key.down)) {
237
- row = Math.min(saveRow(), row + 1);
236
+ row = Math.min(lastRow(), row + 1);
238
237
  requestRender();
239
238
  } else if (matchesKey(data, Key.left)) {
240
- if (row >= rows.length && row < saveRow()) fold(false);
239
+ if (row >= rows.length) fold(false);
241
240
  else column = 0;
242
241
  requestRender();
243
242
  } else if (matchesKey(data, Key.right)) {
244
- if (row >= rows.length && row < saveRow()) fold(true);
243
+ if (row >= rows.length) fold(true);
245
244
  else column = 1;
246
245
  requestRender();
247
246
  } else if (matchesKey(data, Key.space)) {
248
247
  void change();
249
248
  } else if (matchesKey(data, "ctrl+s")) {
250
249
  void save();
251
- } else if (matchesKey(data, Key.enter) && row === saveRow()) {
252
- void save();
253
250
  } else if (matchesKey(data, Key.enter)) {
254
251
  fold();
255
252
  }
@@ -315,13 +312,13 @@ export function createPermissionsPage(
315
312
  output.push(theme.fg("dim", truncateToWidth(`${prefix ? " " : ""} Trusted (runs outside the file rules)`, w, "")),
316
313
  theme.fg("dim", truncateToWidth(`${prefix ? " " : ""} No other extension tools are installed.`, w, "")));
317
314
  }
318
- output.push("", line("Save as defaults", "", "", row === saveRow()));
315
+ output.push("");
319
316
  const selected = row < rows.length ? rows[row]!.key : undefined;
320
- const tool = row >= rows.length && row < saveRow() ? items[row - rows.length] : undefined;
317
+ const tool = row >= rows.length ? items[row - rows.length] : undefined;
321
318
  const contextual = tool ? (tool.kind === "guarded" ? GUARDED_HINT : TRUSTED_HINT)
322
319
  : selected && settings ? cellHint(selected, settings[columns[column]!]) : undefined;
323
320
  for (const hint of [
324
- "↑↓ Select · ←→ Column/fold · Space Toggle · ctrl+s Save · Enter Fold · Esc Back",
321
+ "↑↓ Select · ←→ Column/fold · Space Toggle · ctrl+s Save default · Enter Fold · Esc Back",
325
322
  "Changes apply to new launches. Background tasks follow their launcher.",
326
323
  "Stored credentials: known files only; excludes OS vaults and environment tokens.",
327
324
  "Trusted tools run outside the file rules.",
@@ -0,0 +1,24 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { parseSandboxPermissions, type SandboxPermissionSettings } from "./permissions.ts";
3
+
4
+ export const SESSION_PERMISSION_ENTRY = "pi-better-sandbox-permissions";
5
+
6
+ /** Only the active branch contributes policy; the newest entry must validate. */
7
+ export function readSessionPermissions(ctx: ExtensionContext): SandboxPermissionSettings | undefined {
8
+ const entry = [...ctx.sessionManager.getBranch()].reverse().find((entry) =>
9
+ entry.type === "custom" && entry.customType === SESSION_PERMISSION_ENTRY);
10
+ if (!entry || entry.type !== "custom") return undefined;
11
+ const data = entry.data as { version?: unknown; permissions?: unknown } | undefined;
12
+ if (!data || data.version !== 1) throw new Error("Unsupported sandbox session permissions version.");
13
+ // Session snapshots have always included Tools. Unlike old global files,
14
+ // an incomplete snapshot must not gain the default trusted-tool grants.
15
+ if (!data.permissions || typeof data.permissions !== "object" ||
16
+ (data.permissions as { subagentTools?: unknown }).subagentTools === undefined) {
17
+ throw new Error("Sandbox session permissions require explicit subagent tool settings.");
18
+ }
19
+ return parseSandboxPermissions(data.permissions);
20
+ }
21
+
22
+ export function appendSessionPermissions(pi: ExtensionAPI, settings: SandboxPermissionSettings): void {
23
+ pi.appendEntry(SESSION_PERMISSION_ENTRY, { version: 1, permissions: parseSandboxPermissions(settings) });
24
+ }
@@ -6,9 +6,8 @@
6
6
  * the *effective* status derived from live runtime evidence (which backend this
7
7
  * platform actually resolves, not what the package intended).
8
8
  *
9
- * Session overrides are deliberately in-memory only. Every session start —
10
- * startup, new, resume, fork, reload — calls `beginSession` with the persisted
11
- * default and clears the previous override.
9
+ * The extension persists permission snapshots in Pi custom entries and restores
10
+ * the active branch after `beginSession` resets this in-memory controller.
12
11
  */
13
12
 
14
13
  import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
@@ -132,7 +131,7 @@ export class ForegroundSandboxController {
132
131
 
133
132
  /**
134
133
  * Capture the canonical launch directory and apply the persisted default.
135
- * A session override never survives a new, resumed, forked, or reloaded session.
134
+ * The extension then restores any permissions on the active session branch.
136
135
  */
137
136
  beginSession(cwd: string, defaultEnabled = false): ForegroundSandboxStatus {
138
137
  const projectRoot = canonicalizePath(cwd, this.#seams);
@@ -156,7 +155,7 @@ export class ForegroundSandboxController {
156
155
  return this.status();
157
156
  }
158
157
 
159
- /** Turn protection off for this session only. Never persisted. */
158
+ /** Turn protection off for this session; persistence belongs to the extension. */
160
159
  disable(): ForegroundSandboxStatus {
161
160
  this.#sessionOverride = false;
162
161
  this.#policyProblem = undefined;
@@ -38,7 +38,8 @@ Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or ne
38
38
  `config.json` sets `delegationMode` to `manual`, `adaptive` (default), or
39
39
  `coordinator`. `/subagents settings` (or `/subagents`) opens a settings page
40
40
  for delegation mode and the concurrent-subagent cap (default 4). Edits apply to
41
- this session; Save as defaults persists both, and Reset to defaults clears
41
+ this session and persist across reloads; Ctrl+S saves both as defaults, and
42
+ Reset to defaults clears
42
43
  session overrides. `/subagents mode manual|adaptive|coordinator` and
43
44
  `/subagents cap <number>` are command equivalents; `/subagents save` persists
44
45
  both. Lowering the cap blocks new single and batch launches without stopping
@@ -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 {
@@ -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 {
@@ -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
  }
@@ -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.
@@ -6,7 +6,17 @@ Commands, the read-only discovery tool, and the Codex importer for `pi-agent/v1`
6
6
 
7
7
  `/agents` is one Pi command. Dialogs use `ctx.ui.select`, `confirm`, `input`, and `editor`. `ctx.hasUI` is false in print and JSON mode. When a choice or confirmation is required there, the command returns `clarification-needed` and writes nothing.
8
8
 
9
- In an interactive TUI, `/agents list` and `/agents show` open a focused catalog view instead of leaving the listing in a persistent widget. Rows align name, effective model, source, and launch state; narrow terminals retain name and model. The model is the resolved actual model when available, otherwise the requested or inherited preference, or `not resolved`. Type to filter by id, name, description, or base role; use Up/Down and Enter to inspect. The detail view puts launchability and its reason first, then source, inherited/overridden settings, and diagnostics. Press `p` in detail to read the complete effective instructions (role plus agent in add mode, agent text in replace mode); this is not the task-specific launch prompt. Up/Down scrolls detail or prompt; Left or Escape returns to detail, then list, clears the filter, then closes. `/agents show <id>` opens that definition directly. Print/JSON callers still receive the complete text and structured result.
9
+ In an interactive TUI, `/agents`, `/agents list`, and `/agents show` open a focused catalog view instead of leaving the listing in a persistent widget. Rows align name, effective model, source, and launch state; narrow terminals retain name and model. The model is the resolved actual model when available, otherwise the requested or inherited preference, or `not resolved`. Type to filter by id, name, description, or base role; use Up/Down and Enter to inspect. The detail view puts launchability and its reason first, then source, inherited/overridden settings, and diagnostics. Press `p` in detail to read the complete effective instructions (role plus agent in add mode, agent text in replace mode); this is not the task-specific launch prompt. Up/Down scrolls detail or prompt; Left or Escape returns to detail, then list, clears the filter, then closes. `/agents show <id>` opens that definition directly. Print/JSON callers still receive the complete text and structured result; bare `/agents` still returns help outside the custom TUI.
10
+
11
+ ### Model and effort edits
12
+
13
+ When lifecycle supplies `sessionSettings`, press `e` in detail to edit Model or Effort. Up/Down selects the setting and Enter opens its choices. Model choices come from the foreground registry, plus the current preference if unavailable; type to filter model names. Effort choices use the existing catalog effort levels. Escape cancels an unselected choice. Only these two fields are editable; instructions, base role, tier, and execution controls are unchanged.
14
+
15
+ A selected value immediately appends a custom entry to the current session branch. It does not write a catalog file. Reloading the extension or session restores the active branch's edits. Branches share their common ancestor settings but subsequent edits do not cross to siblings. Role edits flow into named agents that inherit that field. Choosing `Inherit` removes the selected definition's own value, including a saved agent override, instead of storing an explicit null preference. For a role with no parent, that removes its default. Later role/file edits remain live.
16
+
17
+ Ctrl+S in list, detail, or settings (or Enter on Save as defaults) explicitly saves the selected definition's own model and effort changes. It never copies inherited role fields into an agent. Existing personal/project definitions stay in their winning scope; a bundled definition becomes a personal whole-definition shadow copy, leaving the package file untouched. Project trust, duplicate/invalid-definition checks, validation, and atomic catalog writes still apply. Both successful and failed saves leave branch edits intact. A failed save reports the error without changing the catalog defaults.
18
+
19
+ New launches and `agents_catalog` discovery use the same session overlay. Explicit per-run model/effort choices still win, a batch retains one immutable snapshot, and active/completed runs do not change. Role effort remains an inherited default with the existing nearest-supported adjustment; named-agent effort remains explicit and blocks when unsupported. The lifecycle wiring required to enable this surface is in [agent-catalog-lifecycle.md](./agent-catalog-lifecycle.md#session-settings-integration).
10
20
 
11
21
  | Invocation | Effect |
12
22
  | --- | --- |
@@ -18,8 +18,10 @@ Set `delegationMode` in this package's `config.json` to `manual`, `adaptive`, or
18
18
  `coordinator` (default `adaptive`). This controls foreground delegation guidance,
19
19
  not child permissions or catalog definitions. `/subagents settings` (or simply
20
20
  `/subagents`) opens the terminal settings page with mode and concurrent-subagent
21
- cap controls, saved defaults, Save as defaults, and Reset to defaults. Changes
22
- apply immediately to this session; Escape closes the page without undoing them.
21
+ cap controls and Reset to defaults. The page uses the same selected-row and
22
+ active-cell styling as `/sandbox`, with one editable value column.
23
+ Edits automatically persist for this session, including across reloads; Escape
24
+ closes the page without undoing them.
23
25
  Enter edits the cap as a positive whole number; Escape cancels an unfinished edit.
24
26
  Ctrl+S saves both settings as defaults. Sandbox permissions remain in `/sandbox`.
25
27
 
@@ -146,6 +146,7 @@ import {
146
146
  import { enforceRegistrySizeCapOnce, runDailyCleanupOnce } from "./cleanup.ts";
147
147
  import { assertThinkingLevel, parseModelThinking, type ThinkingLevel } from "./thinking.ts";
148
148
  import { createAgentOperations } from "./agent-operations.ts";
149
+ import { createAgentSessionSettings } from "./agent-session-settings.ts";
149
150
  import {
150
151
  clarifyCatalogRequest,
151
152
  createLaunchEnricher,
@@ -1498,6 +1499,8 @@ function catalogRoleSchema(purpose: string) {
1498
1499
  }
1499
1500
 
1500
1501
  export default function (pi: ExtensionAPI) {
1502
+ const agentSessionSettings = createAgentSessionSettings(pi);
1503
+ let catalogModelRegistry: ExtensionContext["modelRegistry"] | undefined;
1501
1504
  let delegationOverride: DelegationMode | undefined;
1502
1505
  let capOverride: number | undefined;
1503
1506
  const activeConcurrencyCap = (): number => capOverride ?? normalizeConcurrencyCap(loadConfig().maxConcurrent);
@@ -1588,8 +1591,12 @@ export default function (pi: ExtensionAPI) {
1588
1591
  }
1589
1592
 
1590
1593
  function catalogHostFrom(ctx: ExtensionContext): CatalogHost {
1594
+ if (typeof ctx.sessionManager?.getBranch === "function") agentSessionSettings.restore(ctx);
1595
+ catalogModelRegistry = ctx.modelRegistry;
1591
1596
  const cfg = loadConfig();
1592
1597
  return {
1598
+ sessionSettings: agentSessionSettings.snapshot(),
1599
+ getSessionSettings: () => agentSessionSettings.snapshot(),
1593
1600
  cwd: ctx.cwd,
1594
1601
  projectTrusted: typeof ctx.isProjectTrusted === "function" ? ctx.isProjectTrusted() : false,
1595
1602
  userRoot: defaultUserRoot(),
@@ -2245,6 +2252,8 @@ export default function (pi: ExtensionAPI) {
2245
2252
 
2246
2253
  const agentOperations = createAgentOperations({
2247
2254
  projectConfigDirName,
2255
+ sessionSettings: agentSessionSettings,
2256
+ editableModels: () => catalogModelRegistry?.getAvailable().map((model) => `${model.provider}/${model.id}`) ?? [],
2248
2257
  propagateCommandContext(commandCtx) {
2249
2258
  noteCatalogHost(catalogHostFrom(commandCtx));
2250
2259
  },
@@ -2255,6 +2264,7 @@ export default function (pi: ExtensionAPI) {
2255
2264
  hasUI: full.hasUI === true,
2256
2265
  model: full.model,
2257
2266
  modelRegistry: full.modelRegistry,
2267
+ sessionManager: full.sessionManager,
2258
2268
  ui: full.ui ?? { select: async () => undefined },
2259
2269
  isProjectTrusted: () => full.isProjectTrusted(),
2260
2270
  } as ExtensionContext));
@@ -2263,6 +2273,7 @@ export default function (pi: ExtensionAPI) {
2263
2273
  projectTrusted: full.isProjectTrusted(),
2264
2274
  userRoot: defaultUserRoot(),
2265
2275
  projectConfigDirName,
2276
+ sessionSettings: agentSessionSettings.snapshot(),
2266
2277
  };
2267
2278
  },
2268
2279
  enrich: createLaunchEnricher(),
@@ -2367,6 +2378,8 @@ export default function (pi: ExtensionAPI) {
2367
2378
  });
2368
2379
 
2369
2380
  pi.on("session_tree", async (_event, ctx) => {
2381
+ agentSessionSettings.restore(ctx);
2382
+ try { noteCatalogHost(catalogHostFrom(ctx)); } catch { /* catalog inspection stays undecided */ }
2370
2383
  restoreDelegationMode(ctx);
2371
2384
  });
2372
2385
 
@@ -2374,6 +2387,7 @@ export default function (pi: ExtensionAPI) {
2374
2387
  // alive, resume the ticking widget. Deferred out of the factory per pi's
2375
2388
  // "no background resources at load" rule.
2376
2389
  pi.on("session_start", async (_event, ctx) => {
2390
+ agentSessionSettings.restore(ctx);
2377
2391
  restoreDelegationMode(ctx);
2378
2392
  uiCtx = ctx;
2379
2393
  try { noteCatalogHost(catalogHostFrom(ctx)); } catch { /* catalog inspection stays undecided */ }
@@ -2413,6 +2427,8 @@ export default function (pi: ExtensionAPI) {
2413
2427
  });
2414
2428
 
2415
2429
  pi.on("session_before_switch", () => {
2430
+ agentSessionSettings.clear();
2431
+ catalogModelRegistry = undefined;
2416
2432
  activeCallbackOrigin = undefined;
2417
2433
  cancelCallbackBatch(pi);
2418
2434
  mainAgentStartedAt = undefined;
@@ -2426,6 +2442,8 @@ export default function (pi: ExtensionAPI) {
2426
2442
  // running/orphaned children before dropping the session origin, so they
2427
2443
  // cannot become live process groups with no coordinator.
2428
2444
  stopCurrentSessionSubagents(ctx);
2445
+ agentSessionSettings.clear();
2446
+ catalogModelRegistry = undefined;
2429
2447
  activeCallbackOrigin = undefined;
2430
2448
  if (typeof unsubscribeDelegationRequest === "function") unsubscribeDelegationRequest();
2431
2449
  cancelCallbackBatch(pi);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,12 @@ export interface SubagentSettingsHandlers {
20
20
  reset(): void;
21
21
  }
22
22
 
23
- const labels = ["Delegation mode", "Concurrent subagents", "Save as defaults", "Reset to defaults"];
23
+ const labels = ["Delegation mode", "Concurrent subagents", "Reset to defaults"];
24
+
25
+ function cell(text: string, width: number): string {
26
+ const clipped = Tui.truncateToWidth(text, width, "");
27
+ return clipped + " ".repeat(Math.max(0, width - Tui.visibleWidth(clipped)));
28
+ }
24
29
 
25
30
  export function createSubagentSettingsPage(
26
31
  theme: Theme,
@@ -52,9 +57,6 @@ export function createSubagentSettingsPage(
52
57
  input.handleInput("\x05");
53
58
  input.focused = focused;
54
59
  report("");
55
- } else if (row === 2) {
56
- const result = handlers.save();
57
- report(result.message, !result.ok);
58
60
  } else {
59
61
  handlers.reset();
60
62
  report("Session settings reset to saved defaults.");
@@ -98,31 +100,41 @@ export function createSubagentSettingsPage(
98
100
  else if (Tui.matchesKey(data, Tui.Key.down)) row = Math.min(labels.length - 1, row + 1);
99
101
  else if (Tui.matchesKey(data, Tui.Key.enter) || data === " ") action();
100
102
  else if (Tui.matchesKey(data, "ctrl+s")) {
101
- const selected = row;
102
- row = 2;
103
- action();
104
- row = selected;
103
+ const result = handlers.save();
104
+ report(result.message, !result.ok);
105
105
  }
106
106
  requestRender();
107
107
  },
108
108
  render(width: number) {
109
109
  const settings = handlers.get();
110
- const values = [
111
- `${settings.mode} (${settings.modeSource})`,
112
- `${settings.maxConcurrent} (${settings.capSource})`,
113
- "",
114
- "",
115
- ];
116
- const lines = [theme.fg("accent", theme.bold("Subagent settings")), ""];
110
+ const w = Math.max(1, Math.floor(width));
111
+ const compact = w < 40;
112
+ const labelWidth = 26;
113
+ const valueWidth = Math.max(1, w - labelWidth - 1);
114
+ const current = [settings.mode, String(settings.maxConcurrent)];
115
+ const highlight = (text: string, selected: boolean): string => selected
116
+ ? theme.bg("selectedBg", theme.bold(cell(text, w))) : text;
117
+ const marker = (selected: boolean) => selected ? "> " : " ";
118
+ const lines: string[] = [theme.fg("accent", theme.bold(" Subagent settings")), ""];
117
119
  labels.forEach((label, index) => {
118
- const text = `${index === row ? ">" : " "} ${label}${values[index] ? `: ${values[index]}` : ""}`;
119
- lines.push(index === row ? theme.fg("accent", theme.bold(text)) : text);
120
+ const selected = index === row;
121
+ if (index === 2) lines.push("");
122
+ const title = theme.fg(selected ? "accent" : "text", marker(selected) + label);
123
+ if (index >= 2) {
124
+ lines.push(highlight(title, selected));
125
+ } else if (compact) {
126
+ lines.push(highlight(title, selected));
127
+ const active = editing && index === 1 ? input.render(Math.max(1, w - 2))[0]! : current[index]!;
128
+ lines.push(" " + (selected && !editing ? theme.inverse(active) : active));
129
+ } else {
130
+ const active = editing && index === 1 ? input.render(valueWidth)[0]! : cell(current[index]!, valueWidth);
131
+ lines.push(highlight(cell(title, labelWidth) + " " +
132
+ (selected && !editing ? theme.inverse(active) : theme.fg("text", active)), selected));
133
+ }
120
134
  });
121
- if (editing) lines.push("", ...input.render(Math.max(1, width)));
122
- lines.push("", theme.fg("muted", `Saved defaults: ${settings.defaultMode}, cap ${settings.defaultCap}`));
123
- if (message) lines.push(theme.fg(error ? "error" : "success", message));
124
- lines.push("", theme.fg("dim", editing ? "Enter apply Esc cancel" : "Up/Down select Enter change Ctrl+S save Esc close"));
125
- return lines.map((line) => Tui.truncateToWidth(line, Math.max(1, width), ""));
135
+ if (message) lines.push("", theme.fg(error ? "error" : "muted", message));
136
+ lines.push("", theme.fg("dim", editing ? "Enter Apply · Esc Cancel" : "Up/Down Select · Space/Enter Change · ctrl+s Save default · Esc Back"));
137
+ return lines.map((line) => Tui.truncateToWidth(line, w, ""));
126
138
  },
127
139
  };
128
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -55,9 +55,9 @@
55
55
  "pi-better-background-tasks": "0.6.3",
56
56
  "pi-better-goal": "0.6.1",
57
57
  "pi-better-plan": "0.5.2",
58
- "pi-better-sandbox": "0.8.2",
58
+ "pi-better-sandbox": "0.9.0",
59
59
  "pi-better-ssh": "0.1.1",
60
- "pi-better-subagents": "0.12.0",
60
+ "pi-better-subagents": "0.13.0",
61
61
  "smol-toml": "1.9.0",
62
62
  "yaml": "^2.9.1"
63
63
  },