pi-better-harness 0.12.3 → 0.13.1

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
@@ -60,6 +60,27 @@ Sandbox state is human-only: `/sandbox`, `/sandbox on`, `/sandbox off`,
60
60
  commands with no tool equivalent. `/sandbox off` and `/sandbox default off`
61
61
  need interactive confirmation. Full policy: [pi-better-sandbox](https://github.com/1aboveio/pi-better-harness/tree/main/packages/pi-better-sandbox#readme).
62
62
 
63
+ ## Minimal Tool Output
64
+
65
+ The bundled harness also provides `/tool-output minimal`, `/tool-output normal`,
66
+ and `/tool-output` (toggle). Minimal mode hides every collapsed result body,
67
+ including built-in, extension, and MCP text/images, without changing execution,
68
+ sandboxing, or agent-facing payloads. Tool headers and error coloring remain;
69
+ Ctrl+O reveals results, and fullscreen Pi versions with clickable tool rows
70
+ also support clicking headers.
71
+
72
+ Normal mode is the default. The preference is saved in the current session,
73
+ including resume/reload. This is a version-sensitive internal TUI adapter,
74
+ tested with Pi 0.82.1 and 0.99.1; incompatible APIs produce a warning and leave
75
+ ordinary output enabled. Print/RPC output and exported transcripts are unchanged.
76
+
77
+ The standalone-package installer does not install this bundled extension.
78
+ Load the bundled harness or run it directly from a checkout:
79
+
80
+ ```sh
81
+ pi -e ./packages/pi-better-harness/extensions/minimal-output/index.ts
82
+ ```
83
+
63
84
  ## When To Use
64
85
 
65
86
  Use the installer when you want every core extension with standalone package identities. Install an individual package instead when you only need the sandbox, subagents, shell task supervision, synchronous SSH, goal tracking, or plans.
@@ -0,0 +1,149 @@
1
+ import { readFileSync, realpathSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ interface ToolComponent {
7
+ expanded: boolean;
8
+ showImages: boolean;
9
+ resultRendererComponent?: unknown;
10
+ ui: { requestRender(): void };
11
+ invalidate(): void;
12
+ setExpanded(expanded: boolean): void;
13
+ }
14
+
15
+ type Method = (this: ToolComponent, ...args: unknown[]) => unknown;
16
+ interface ToolPrototype {
17
+ updateDisplay: Method;
18
+ getResultRenderer: Method;
19
+ getTextOutput: Method;
20
+ setExpanded: Method;
21
+ invalidate: Method;
22
+ [HOOK]?: HookState;
23
+ }
24
+ interface HookState {
25
+ enabled: boolean;
26
+ owners: number;
27
+ redraw(collapse?: boolean): void;
28
+ restore(): void;
29
+ }
30
+ export interface MinimalOutputHook {
31
+ setEnabled(enabled: boolean): void;
32
+ dispose(): void;
33
+ }
34
+
35
+ const HOOK = Symbol.for("pi-better-harness.minimal-output-hook");
36
+ const emptyResult = () => ({ render: () => [] as string[], invalidate() {} });
37
+
38
+ /** Resolve the running host's SDK, not a second SDK bundled beside the extension. */
39
+ export async function loadToolPrototype(): Promise<ToolPrototype> {
40
+ let require = createRequire(import.meta.url);
41
+ if (process.argv[1]) {
42
+ try {
43
+ const hostRequire = createRequire(realpathSync(process.argv[1]));
44
+ sdkEntry(hostRequire);
45
+ require = hostRequire;
46
+ } catch { /* SDK embedding or a test runner: use the extension's SDK. */ }
47
+ }
48
+ const entry = sdkEntry(require);
49
+ const path = join(dirname(entry), "modes/interactive/components/tool-execution.js");
50
+ const module = await import(pathToFileURL(path).href);
51
+ return module.ToolExecutionComponent.prototype;
52
+ }
53
+
54
+ // Pi's ESM-only export cannot be resolved with require.resolve on older SDKs.
55
+ function sdkEntry(require: ReturnType<typeof createRequire>): string {
56
+ for (const directory of require.resolve.paths("@earendil-works/pi-coding-agent") ?? []) {
57
+ const root = join(directory, "@earendil-works/pi-coding-agent");
58
+ try {
59
+ const metadata = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
60
+ const entry = metadata.exports?.["."]?.import ?? metadata.main;
61
+ if (metadata.name === "@earendil-works/pi-coding-agent" && typeof entry === "string") return join(root, entry);
62
+ } catch { /* Continue through Node's module search paths. */ }
63
+ }
64
+ throw new Error("Could not locate the running Pi SDK. Normal output remains enabled.");
65
+ }
66
+
67
+ /** Internal TUI adapter: no tools are replaced and no result content is changed. */
68
+ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutputHook {
69
+ for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "invalidate"] as const) {
70
+ if (typeof prototype[name] !== "function") {
71
+ throw new Error(`Pi's tool display API is incompatible: missing ${name}. Normal output remains enabled.`);
72
+ }
73
+ }
74
+ let state = prototype[HOOK];
75
+ if (!state) {
76
+ const originals = {
77
+ updateDisplay: prototype.updateDisplay,
78
+ getResultRenderer: prototype.getResultRenderer,
79
+ getTextOutput: prototype.getTextOutput,
80
+ };
81
+ const seen = new WeakSet<ToolComponent>();
82
+ const components = new Set<WeakRef<ToolComponent>>();
83
+ state = {
84
+ enabled: false,
85
+ owners: 0,
86
+ redraw(collapse = false) {
87
+ for (const reference of components) {
88
+ const component = reference.deref();
89
+ if (!component) { components.delete(reference); continue; }
90
+ if (collapse) component.setExpanded(false);
91
+ else component.invalidate();
92
+ component.ui.requestRender();
93
+ }
94
+ },
95
+ restore() {
96
+ for (const name of Object.keys(originals) as Array<keyof typeof originals>) {
97
+ // Do not remove another extension's subsequently installed wrapper.
98
+ if (prototype[name] === wrappers[name]) prototype[name] = originals[name];
99
+ }
100
+ delete prototype[HOOK];
101
+ components.clear();
102
+ },
103
+ };
104
+ const current = state;
105
+ const wrappers = {
106
+ updateDisplay(this: ToolComponent, ...args: unknown[]) {
107
+ if (!seen.has(this)) { seen.add(this); components.add(new WeakRef(this)); }
108
+ if (!current.enabled || this.expanded) return originals.updateDisplay.apply(this, args);
109
+ const showImages = this.showImages;
110
+ const lastRenderer = this.resultRendererComponent;
111
+ this.showImages = false;
112
+ try {
113
+ return originals.updateDisplay.apply(this, args);
114
+ } finally {
115
+ this.showImages = showImages;
116
+ // Expanded mode must get the original renderer's previous component, not our empty one.
117
+ this.resultRendererComponent = lastRenderer;
118
+ }
119
+ },
120
+ getResultRenderer(this: ToolComponent, ...args: unknown[]) {
121
+ return current.enabled && !this.expanded ? emptyResult : originals.getResultRenderer.apply(this, args);
122
+ },
123
+ getTextOutput(this: ToolComponent, ...args: unknown[]) {
124
+ return current.enabled && !this.expanded ? "" : originals.getTextOutput.apply(this, args);
125
+ },
126
+ };
127
+ Object.assign(prototype, wrappers);
128
+ prototype[HOOK] = state;
129
+ }
130
+ const current = state;
131
+ current.owners += 1;
132
+ let disposed = false;
133
+ return {
134
+ setEnabled(enabled) {
135
+ if (disposed) return;
136
+ current.enabled = enabled;
137
+ current.redraw(enabled);
138
+ },
139
+ dispose() {
140
+ if (disposed) return;
141
+ disposed = true;
142
+ current.owners -= 1;
143
+ if (current.owners > 0) return;
144
+ current.enabled = false;
145
+ current.redraw();
146
+ current.restore();
147
+ },
148
+ };
149
+ }
@@ -0,0 +1,85 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { installMinimalOutputHook, loadToolPrototype, type MinimalOutputHook } from "./hook.ts";
3
+
4
+ const ENTRY = "pi-better-harness-tool-output";
5
+
6
+ export default function minimalOutputExtension(pi: ExtensionAPI): void {
7
+ let hook: MinimalOutputHook | undefined;
8
+ let enabled = false;
9
+
10
+ function status(ctx: ExtensionContext): void {
11
+ ctx.ui.setStatus(ENTRY, enabled ? "tools: minimal" : undefined);
12
+ }
13
+
14
+ function collapseTools(ctx: ExtensionContext): void {
15
+ // setToolsExpanded is a no-op when already false; force a redraw of restored transcript rows.
16
+ if (!ctx.ui.getToolsExpanded()) ctx.ui.setToolsExpanded(true);
17
+ ctx.ui.setToolsExpanded(false);
18
+ }
19
+
20
+ async function ensureHook(ctx: ExtensionContext): Promise<boolean> {
21
+ if (ctx.mode !== "tui") {
22
+ ctx.ui.notify("Minimal tool output is available only in Pi's interactive TUI.", "warning");
23
+ return false;
24
+ }
25
+ if (hook) return true;
26
+ try {
27
+ hook = installMinimalOutputHook(await loadToolPrototype());
28
+ return true;
29
+ } catch (error) {
30
+ ctx.ui.notify(`Minimal tool output is unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
31
+ return false;
32
+ }
33
+ }
34
+
35
+ pi.on("session_start", async (_event, ctx) => {
36
+ if (ctx.mode !== "tui") return;
37
+ enabled = false;
38
+ for (const entry of ctx.sessionManager.getBranch()) {
39
+ if (entry.type === "custom" && entry.customType === ENTRY) {
40
+ const data = entry.data as { enabled?: unknown } | undefined;
41
+ if (typeof data?.enabled === "boolean") enabled = data.enabled;
42
+ }
43
+ }
44
+ // Observe transcript components from startup, so the toggle also redraws existing rows.
45
+ if (await ensureHook(ctx)) {
46
+ if (enabled) collapseTools(ctx);
47
+ hook!.setEnabled(enabled);
48
+ } else enabled = false;
49
+ status(ctx);
50
+ });
51
+
52
+ pi.on("session_shutdown", () => {
53
+ hook?.dispose();
54
+ hook = undefined;
55
+ enabled = false;
56
+ });
57
+
58
+ pi.registerCommand("tool-output", {
59
+ description: "Tool output: minimal | normal (no argument toggles). Display only; agent results are unchanged.",
60
+ getArgumentCompletions: (argumentPrefix) => {
61
+ const prefix = argumentPrefix.trimStart().toLowerCase();
62
+ const matches = [
63
+ { value: "minimal", label: "minimal", description: "Hide collapsed tool result bodies" },
64
+ { value: "normal", label: "normal", description: "Restore ordinary tool output" },
65
+ ].filter((option) => option.value.startsWith(prefix));
66
+ return matches.length > 0 ? matches : null;
67
+ },
68
+ handler: async (args, ctx) => {
69
+ const mode = args.trim().toLowerCase();
70
+ if (mode && mode !== "minimal" && mode !== "normal") {
71
+ ctx.ui.notify("Usage: /tool-output [minimal|normal]", "warning");
72
+ return;
73
+ }
74
+ if (!await ensureHook(ctx)) return;
75
+ enabled = mode ? mode === "minimal" : !enabled;
76
+ if (enabled) collapseTools(ctx);
77
+ hook!.setEnabled(enabled);
78
+ pi.appendEntry(ENTRY, { version: 1, enabled });
79
+ status(ctx);
80
+ ctx.ui.notify(enabled
81
+ ? "Minimal tool output on. Click a tool in fullscreen mode or use Ctrl+O to reveal results."
82
+ : "Normal tool output restored.", "info");
83
+ },
84
+ });
85
+ }
@@ -28,11 +28,10 @@ Network access - On
28
28
  Guarded (follows the file rules)
29
29
  [x] apply_patch harness adapter
30
30
  Trusted (runs outside the file rules)
31
- [x] web_fetch @juicesharp/rpiv-web-tools · needs Network On
32
- [x] web_search @juicesharp/rpiv-web-tools · needs Network On
33
- [ ] <other installed tool> <its package>
31
+ > [x] @juicesharp/rpiv-web-tools 2/2
32
+ > [ ] <other installed package> 0/1
34
33
 
35
- ↑↓ Select row ←→ Select column Space Change
34
+ ↑↓ Select row ←→ Select column / expand / collapse Enter Fold Space Change
36
35
  Save as defaults
37
36
  ```
38
37
 
@@ -44,10 +43,22 @@ Save as defaults
44
43
  full before any file changes.
45
44
  - **Trusted** tools are the other tools your Pi has loaded, listed with their
46
45
  package. A ticked one is loaded into the subagent and runs in its Pi process,
47
- **outside the file rules**. Ticking one needs a second Space. It is admitted
46
+ **outside the file rules**. One Space toggles it immediately. It is admitted
48
47
  only from the package you ticked. Known network tool names are refused while
49
48
  Network access is Off: `web_fetch`, `web_search`, `firecrawl_scrape`, `firecrawl_extract`, `mcp`, `mcpScript`, `remote_bash`, and any `mcp__*` name. That is a name list, not a network sandbox.
50
49
 
50
+ Trusted tools use two levels: a package/provider group, then its individual tools.
51
+ Non-MCP tools are grouped by exact owning package; `mcp__<provider>__...` tools
52
+ are grouped by provider and owning package together. Groups start collapsed.
53
+ `>` means collapsed and `v` means expanded. Their checkbox shows `[x]` when
54
+ all children are selected, `[-]` when some are selected, and `[ ]` when none
55
+ are; the count is selected/total. Right expands a group. Left collapses a group,
56
+ or moves from a child to its parent and collapses it. Enter folds the selected
57
+ group. Space on a group selects all children when none or only some are selected,
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
60
+ entries are saved, never a package/provider wildcard or the open/closed state.
61
+
51
62
  Defaults: `apply_patch`, `web_fetch`, and `web_search` on; everything else off.
52
63
  See [ADR 0009](../../docs/adr/0009-guarded-and-trusted-subagent-tools.md).
53
64
 
@@ -109,9 +120,11 @@ repository's `.git/hooks` or `.git/config` (`core.hooksPath`, `core.fsmonitor`,
109
120
  `~/.gitconfig`, `~/.config/git`, and `~/.git-templates` themselves stay protected. Add your own paths to the deny list with `/sandbox deny add <path>`. Under
110
121
  Write, those paths are also protected from removal and renaming.
111
122
 
112
- A looser value (a higher level, a capability switched on, or a sandbox switched
113
- off) takes effect only on a second Space, and saving looser defaults needs a
114
- second Enter. Each prompt lists what would loosen. No model
123
+ Space applies every change immediately, including higher permission levels,
124
+ capabilities switched on, and a sandbox switched off. Saving looser defaults
125
+ still needs a second Enter, with a prompt listing what would loosen. The focused
126
+ row has a full-width background and bold text; the selected Main/Subagents cell
127
+ also uses inverse styling. No model
115
128
  tool can change these settings. Other rows toggle Off/On. Detail cells under an
116
129
  Off sandbox display a dimmed `-` and cannot be changed; their values return when
117
130
  the sandbox is enabled again.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.7.3",
3
+ "version": "0.8.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",
@@ -2,7 +2,7 @@ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
3
3
 
4
4
  import { isNetworkTool, packageLabel, type DiscoveredTool } from "./shared-task-tools.ts";
5
- import { defaultSandboxPermissions, describeLoosening, type CredentialAccess, type FileAccess, type SandboxPermissionProfile as PermissionProfile, type SandboxPermissionSettings as PermissionSettings } from "./permissions.ts";
5
+ import { defaultSandboxPermissions, type CredentialAccess, type FileAccess, type SandboxPermissionProfile as PermissionProfile, type SandboxPermissionSettings as PermissionSettings } from "./permissions.ts";
6
6
  export type { PermissionProfile, PermissionSettings };
7
7
 
8
8
  export interface PermissionPageHandlers {
@@ -15,9 +15,12 @@ export interface PermissionPageHandlers {
15
15
  discoverTools?(): readonly Pick<DiscoveredTool, "name" | "package">[];
16
16
  }
17
17
 
18
+ type TrustedItem = { kind: "trusted"; name: string; package: string; loaded: boolean; group: string };
19
+ type ToolGroup = { kind: "group"; id: string; label: string; tools: TrustedItem[] };
18
20
  type ToolItem =
19
21
  | { kind: "guarded"; name: "apply_patch" }
20
- | { kind: "trusted"; name: string; package: string; loaded: boolean };
22
+ | TrustedItem
23
+ | ToolGroup;
21
24
 
22
25
  const GUARDED_HINT = "apply_patch goes through the guarded file operations: Project files and Outside project apply to it.";
23
26
  const TRUSTED_HINT = "Trusted tools run in the subagent's Pi process, outside the file rules. Tick only tools you trust.";
@@ -66,7 +69,7 @@ function cell(text: string, width: number): string {
66
69
  return cut + " ".repeat(Math.max(0, width - visibleWidth(cut)));
67
70
  }
68
71
 
69
- /** A flat, keyboard-driven view; handlers own the effective policy and persistence. */
72
+ /** Keyboard-driven profiles and a foldable package/provider tool tree. */
70
73
  export function createPermissionsPage(
71
74
  theme: Theme,
72
75
  handlers: PermissionPageHandlers,
@@ -91,24 +94,41 @@ export function createPermissionsPage(
91
94
  }
92
95
  const isTicked = (item: { name: string; package: string }) =>
93
96
  !!settings?.subagentTools.trusted.some((tool) => tool.name === item.name && tool.package === item.package);
94
- /** Guarded first, then discovered and ticked trusted tools, by name. */
95
- function toolItems(): ToolItem[] {
96
- const trusted = new Map<string, ToolItem & { kind: "trusted" }>();
97
- for (const tool of discovered) trusted.set(`${tool.name}\0${tool.package}`, { kind: "trusted", name: tool.name, package: tool.package, loaded: true });
98
- for (const tool of settings?.subagentTools.trusted ?? []) {
99
- const key = `${tool.name}\0${tool.package}`;
100
- if (!trusted.has(key)) trusted.set(key, { kind: "trusted", name: tool.name, package: tool.package, loaded: false });
97
+ const expanded = new Set<string>();
98
+ function toolGroups(): ToolGroup[] {
99
+ const trusted = new Map<string, TrustedItem>();
100
+ const add = (tool: { name: string; package: string }, loaded: boolean) => {
101
+ const provider = /^mcp__(.+?)__/.exec(tool.name)?.[1];
102
+ const group = JSON.stringify([tool.package, provider ?? ""]);
103
+ const key = JSON.stringify([tool.name, tool.package]);
104
+ if (!trusted.has(key)) trusted.set(key, { ...tool, kind: "trusted", loaded, group });
105
+ };
106
+ for (const tool of discovered) add(tool, true);
107
+ for (const tool of settings?.subagentTools.trusted ?? []) add(tool, false);
108
+ const groups = new Map<string, ToolGroup>();
109
+ for (const tool of trusted.values()) {
110
+ if (!groups.has(tool.group)) {
111
+ const provider = JSON.parse(tool.group)[1] as string;
112
+ groups.set(tool.group, { kind: "group", id: tool.group,
113
+ label: provider ? `${provider} (${packageLabel(tool.package)})` : packageLabel(tool.package), tools: [] });
114
+ }
115
+ groups.get(tool.group)!.tools.push(tool);
101
116
  }
102
- return [{ kind: "guarded", name: "apply_patch" },
103
- ...[...trusted.values()].sort((a, b) => a.name.localeCompare(b.name) || a.package.localeCompare(b.package))];
117
+ for (const group of groups.values()) group.tools.sort((a, b) => a.name.localeCompare(b.name));
118
+ return [...groups.values()].sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
119
+ }
120
+ function toolItems(): ToolItem[] {
121
+ return [{ kind: "guarded", name: "apply_patch" }, ...toolGroups().flatMap((group): ToolItem[] =>
122
+ [group, ...(expanded.has(group.id) ? group.tools : [])])];
104
123
  }
124
+ const itemKey = (item: ToolItem) => item.kind === "group" ? item.id
125
+ : item.kind === "guarded" ? "apply_patch" : JSON.stringify([item.name, item.package]);
105
126
  let items = toolItems();
106
127
  const saveRow = () => rows.length + items.length;
107
128
  let row = 0;
108
129
  let column = 0;
109
130
  let busy = false;
110
131
  let pendingConfirmation: string | undefined;
111
- let pendingChange: string | undefined;
112
132
 
113
133
  function report(error: unknown): void {
114
134
  message = errorText(error);
@@ -122,7 +142,14 @@ export function createPermissionsPage(
122
142
  if (row >= rows.length) {
123
143
  const item = items[row - rows.length]!;
124
144
  if (item.kind === "guarded") next.subagentTools.applyPatch = !next.subagentTools.applyPatch;
125
- else if (isTicked(item)) {
145
+ else if (item.kind === "group") {
146
+ const all = item.tools.every(isTicked);
147
+ for (const tool of item.tools) {
148
+ if (all) next.subagentTools.trusted = next.subagentTools.trusted.filter((entry) =>
149
+ !(entry.name === tool.name && entry.package === tool.package));
150
+ else if (!isTicked(tool)) next.subagentTools.trusted.push({ name: tool.name, package: tool.package });
151
+ }
152
+ } else if (isTicked(item)) {
126
153
  next.subagentTools.trusted = next.subagentTools.trusted.filter((tool) => !(tool.name === item.name && tool.package === item.package));
127
154
  } else next.subagentTools.trusted.push({ name: item.name, package: item.package });
128
155
  return apply(next);
@@ -144,22 +171,18 @@ export function createPermissionsPage(
144
171
 
145
172
  async function apply(next: PermissionSettings): Promise<void> {
146
173
  if (!settings) return;
147
- // A looser value is applied only on a second Space, like a looser Save.
148
- const loosened = describeLoosening(settings, next);
149
- const nextKey = JSON.stringify(next);
150
- if (loosened.length && pendingChange !== nextKey) {
151
- pendingChange = nextKey;
152
- message = `Looser (${loosened.join("; ")}). Press Space again to apply.`;
153
- isError = false;
154
- requestRender();
155
- return;
156
- }
157
- pendingChange = undefined;
158
174
  busy = true;
159
175
  try {
160
176
  await handlers.change(snapshot(next));
177
+ const selected = row >= rows.length && row < saveRow() ? items[row - rows.length] : undefined;
178
+ const wasSave = row === saveRow();
179
+ const successors = selected ? items.slice(row - rows.length + 1).map(itemKey) : [];
161
180
  settings = next;
162
181
  items = toolItems();
182
+ const index = selected ? items.findIndex((item) => itemKey(item) === itemKey(selected)) : -1;
183
+ const successor = successors.map((key) => items.findIndex((item) => itemKey(item) === key)).find((at) => at >= 0);
184
+ row = wasSave ? saveRow() : index >= 0 ? rows.length + index
185
+ : successor !== undefined ? rows.length + successor : Math.min(row, saveRow());
163
186
  message = "";
164
187
  isError = false;
165
188
  requestRender();
@@ -201,11 +224,21 @@ export function createPermissionsPage(
201
224
  }
202
225
  }
203
226
 
227
+ function fold(open?: boolean): void {
228
+ const item = row >= rows.length && row < saveRow() ? items[row - rows.length] : undefined;
229
+ const id = item?.kind === "group" ? item.id : item?.kind === "trusted" && open === false ? item.group : undefined;
230
+ if (!id) return;
231
+ if (open ?? !expanded.has(id)) expanded.add(id);
232
+ else expanded.delete(id);
233
+ items = toolItems();
234
+ row = rows.length + items.findIndex((entry) => entry.kind === "group" && entry.id === id);
235
+ requestRender();
236
+ }
237
+
204
238
  return {
205
239
  invalidate() {},
206
240
  handleInput(data: string) {
207
241
  if (!matchesKey(data, Key.enter)) pendingConfirmation = undefined;
208
- if (!matchesKey(data, Key.space)) pendingChange = undefined;
209
242
  if (matchesKey(data, Key.escape)) {
210
243
  close();
211
244
  } else if (matchesKey(data, Key.up)) {
@@ -215,15 +248,19 @@ export function createPermissionsPage(
215
248
  row = Math.min(saveRow(), row + 1);
216
249
  requestRender();
217
250
  } else if (matchesKey(data, Key.left)) {
218
- column = 0;
251
+ if (row >= rows.length && row < saveRow()) fold(false);
252
+ else column = 0;
219
253
  requestRender();
220
254
  } else if (matchesKey(data, Key.right)) {
221
- column = 1;
255
+ if (row >= rows.length && row < saveRow()) fold(true);
256
+ else column = 1;
222
257
  requestRender();
223
258
  } else if (matchesKey(data, Key.space)) {
224
259
  void change();
225
260
  } else if (matchesKey(data, Key.enter) && row === saveRow()) {
226
261
  void save();
262
+ } else if (matchesKey(data, Key.enter)) {
263
+ fold();
227
264
  }
228
265
  },
229
266
  render(width: number): string[] {
@@ -235,14 +272,16 @@ export function createPermissionsPage(
235
272
  const available = Math.max(0, w - prefix - labelWidth - gap);
236
273
  const mainWidth = Math.ceil(available / 2);
237
274
  const subWidth = available - mainWidth;
275
+ const highlight = (text: string, selected: boolean): string => selected
276
+ ? theme.bg("selectedBg", theme.bold(cell(text, w))) : text;
238
277
  const line = (label: string, main: string, sub: string, selected: boolean, dimMain = false, dimSub = false) => {
239
278
  const marker = prefix ? (selected ? "> " : " ") : "";
240
279
  const labelPart = cell(label, labelWidth);
241
- const mainPart = cell(main, mainWidth);
242
- const subPart = cell(sub, subWidth);
243
- return theme.fg(selected ? "accent" : "text", marker + labelPart) + " ".repeat(gap) +
244
- theme.fg(dimMain ? "dim" : selected && column === 0 ? "accent" : "text", mainPart) +
245
- theme.fg(dimSub ? "dim" : selected && column === 1 ? "accent" : "text", subPart);
280
+ const mainPart = theme.fg(dimMain ? "dim" : "text", cell(main, mainWidth));
281
+ const subPart = theme.fg(dimSub ? "dim" : "text", cell(sub, subWidth));
282
+ return highlight(theme.fg(selected ? "accent" : "text", marker + labelPart) + " ".repeat(gap) +
283
+ (selected && column === 0 ? theme.inverse(mainPart) : mainPart) +
284
+ (selected && column === 1 ? theme.inverse(subPart) : subPart), selected);
246
285
  };
247
286
  const output = [line("Sandbox permissions", "Main", "Subagents", false), ""];
248
287
  for (let i = 0; i < rows.length; i++) {
@@ -261,16 +300,25 @@ export function createPermissionsPage(
261
300
  }
262
301
  // Subagents · Tools: which extension tools a confined subagent may use.
263
302
  output.push("", theme.fg("text", truncateToWidth(`${prefix ? " " : ""}Subagents · Tools`, w, "")));
264
- const nameWidth = Math.min(24, Math.max(...items.map((item) => visibleWidth(item.name))), Math.max(0, w - prefix - 8));
303
+ const toolNames = items.flatMap((item) => item.kind === "group" ? [] : [visibleWidth(item.name)]);
304
+ const nameWidth = Math.min(24, Math.max(...toolNames), Math.max(0, w - prefix - 10));
265
305
  items.forEach((item, index) => {
266
306
  if (index === 0) output.push(theme.fg("dim", truncateToWidth(`${prefix ? " " : ""} Guarded (follows the file rules)`, w, "")));
267
307
  if (index === 1) output.push(theme.fg("dim", truncateToWidth(`${prefix ? " " : ""} Trusted (runs outside the file rules)`, w, "")));
268
308
  const selected = row === rows.length + index;
269
- const ticked = item.kind === "guarded" ? !!settings?.subagentTools.applyPatch : isTicked(item);
270
- const detail = item.kind === "guarded" ? "harness adapter" : [packageLabel(item.package),
271
- ...(isNetworkTool(item.name) ? ["needs Network On"] : []), ...(item.loaded ? [] : ["not loaded"])].join(" · ");
272
- const text = `${prefix ? (selected ? "> " : " ") : ""} [${ticked ? "x" : " "}] ${cell(item.name, nameWidth)} ${detail}`;
273
- output.push(theme.fg(selected ? "accent" : settings ? "text" : "dim", truncateToWidth(text, w, "")));
309
+ let text: string;
310
+ if (item.kind === "group") {
311
+ const count = item.tools.filter(isTicked).length;
312
+ const tick = count === item.tools.length ? "x" : count ? "-" : " ";
313
+ text = ` ${expanded.has(item.id) ? "v" : ">"} [${tick}] ${item.label} ${count}/${item.tools.length}`;
314
+ } else {
315
+ const ticked = item.kind === "guarded" ? !!settings?.subagentTools.applyPatch : isTicked(item);
316
+ const detail = item.kind === "guarded" ? "harness adapter" : [
317
+ ...(isNetworkTool(item.name) ? ["needs Network On"] : []), ...(item.loaded ? [] : ["not loaded"])].join(" · ");
318
+ text = `${item.kind === "guarded" ? " " : " "}[${ticked ? "x" : " "}] ${cell(item.name, nameWidth)} ${detail}`;
319
+ }
320
+ output.push(highlight(theme.fg(selected ? "accent" : settings ? "text" : "dim",
321
+ truncateToWidth(`${prefix ? (selected ? "> " : " ") : ""}${text}`, w, "")), selected));
274
322
  });
275
323
  if (items.length === 1) {
276
324
  output.push(theme.fg("dim", truncateToWidth(`${prefix ? " " : ""} Trusted (runs outside the file rules)`, w, "")),
@@ -282,13 +330,18 @@ export function createPermissionsPage(
282
330
  const contextual = tool ? (tool.kind === "guarded" ? GUARDED_HINT : TRUSTED_HINT)
283
331
  : selected && settings ? cellHint(selected, settings[columns[column]!]) : undefined;
284
332
  for (const hint of [
285
- "↑↓ Select row · ←→ Select column · Space Change · Enter Save · Esc Back",
333
+ "↑↓ Select · ←→ Column/fold · Space Toggle tool/group · Enter Fold/Save · Esc Back",
286
334
  "Changes apply to new launches. Background tasks follow their launcher.",
287
335
  "Stored credentials: known files only; excludes OS vaults and environment tokens.",
288
336
  "Trusted tools run outside the file rules.",
289
337
  ...(contextual ? [contextual] : []),
290
338
  ]) output.push(theme.fg("dim", truncateToWidth(hint, w, "")));
291
- if (message) output.push(theme.fg(isError ? "error" : "muted", truncateToWidth(message.replace(/[\r\n]+/g, " "), w, "")));
339
+ if (message) {
340
+ const clean = message.replace(/[\r\n]+/g, " ");
341
+ const confirmation = /^(.*) (Press Enter again to save\.)$/.exec(clean);
342
+ const messages = confirmation && visibleWidth(clean) > w ? [confirmation[1]!, confirmation[2]!] : [clean];
343
+ for (const text of messages) output.push(theme.fg(isError ? "error" : "muted", truncateToWidth(text, w, "")));
344
+ }
292
345
  return output;
293
346
  },
294
347
  };
@@ -12,6 +12,7 @@ Use `pi-better-subagents` when you want Pi to launch independent agent work with
12
12
 
13
13
  ## Core Features
14
14
  - Non-blocking subagent launches, with an optional role and named-agent catalog (`docs/agent-catalog.md`, `docs/agent-catalog-lifecycle.md`).
15
+ - Typed foreground `subagent_spawn_batch` launch receipts for native Pi codemode (Pi 0.99.1+), preserving direct tool calls, per-run callbacks, and batch capacity/catalog guarantees. See [codemode batch launches](docs/usage.md#codemode-batch-launches).
15
16
  - Default OS write sandboxing on macOS and Linux.
16
17
  - Explicit tool allowlists for child sessions.
17
18
  - Durable logs, result retrieval, and [failure observations](docs/failure-observations.md) independent of lifecycle status. A live child handles its own tool errors; the parent is woken only for actionable incidents, and children can classify handled failures with `failure_disposition`.
@@ -44,14 +45,14 @@ nontrivial role-owned task, while the foreground coordinates, integrates, and
44
45
  verifies. See [usage notes](docs/usage.md#delegation-mode).
45
46
 
46
47
  `agents_catalog` shows each role's and named agent's default model and effort,
47
- such as `role developer "Developer" … default openai/gpt-6-sol@high`. Roles are
48
+ such as `role developer "Developer" … default openai/gpt-6.1-sol@high`. Roles are
48
49
  listed and passed by short name (`role: "developer"`); the stored id
49
50
  `role.developer` also works, and named agents keep their full id
50
51
  (`agent: "agent.payments"`). To launch on that default, omit
51
52
  `model` and `thinking` on a role or agent spawn; name one only for a stated
52
53
  reason. When a launch's model or effort differs from the default, its launch
53
54
  line says so, for example
54
- `model openai/gpt-6-astra@high (role developer default openai/gpt-6-sol@high)`.
55
+ `model openai/gpt-6-astra@high (role developer default openai/gpt-6.1-sol@high)`.
55
56
 
56
57
  ## When To Use
57
58