pi-better-harness 0.12.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
  };
@@ -44,14 +44,14 @@ nontrivial role-owned task, while the foreground coordinates, integrates, and
44
44
  verifies. See [usage notes](docs/usage.md#delegation-mode).
45
45
 
46
46
  `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
47
+ such as `role developer "Developer" … default openai/gpt-6.1-sol@high`. Roles are
48
48
  listed and passed by short name (`role: "developer"`); the stored id
49
49
  `role.developer` also works, and named agents keep their full id
50
50
  (`agent: "agent.payments"`). To launch on that default, omit
51
51
  `model` and `thinking` on a role or agent spawn; name one only for a stated
52
52
  reason. When a launch's model or effort differs from the default, its launch
53
53
  line says so, for example
54
- `model openai/gpt-6-astra@high (role developer default openai/gpt-6-sol@high)`.
54
+ `model openai/gpt-6-astra@high (role developer default openai/gpt-6.1-sol@high)`.
55
55
 
56
56
  ## When To Use
57
57
 
@@ -256,9 +256,9 @@ export async function clarifyCatalogRequest(
256
256
  * Launch-line note for a catalog run whose effective model or effort differs
257
257
  * from the role or agent default. The wording names the cause, so a fallback
258
258
  * or a capped effort is not mistaken for a caller override:
259
- * - override: `model openai/gpt-6-astra@high (role developer default openai/gpt-6-sol@high)`
260
- * - fallback: `model xai/grok-4.7@high (role developer default openai/gpt-6-sol@high unavailable; foreground fallback)`
261
- * - capped effort: `model openai/gpt-6-sol@medium (role developer default openai/gpt-6-sol@high; effort capped at medium by the model)`
259
+ * - override: `model openai/gpt-6-astra@high (role developer default openai/gpt-6.1-sol@high)`
260
+ * - fallback: `model xai/grok-4.7@high (role developer default openai/gpt-6.1-sol@high unavailable; foreground fallback)`
261
+ * - capped effort: `model openai/gpt-6.1-sol@medium (role developer default openai/gpt-6.1-sol@high; effort capped at medium by the model)`
262
262
  * Undefined for a non-catalog run, a definition with no default, or a launch
263
263
  * that matches the default. Only the fields the definition sets are compared.
264
264
  */
@@ -72,10 +72,10 @@ export const PARSER_LIMITS = {
72
72
  * runtime already uses. Tiers are catalog policy, not benchmark rankings.
73
73
  */
74
74
  export const APPROVED_ROLE_DEFAULTS = [
75
- { id: "role.researcher", name: "Researcher", model: "openai/gpt-6-sol", effort: "medium", tier: "balanced" },
75
+ { id: "role.researcher", name: "Researcher", model: "openai/gpt-6.1-sol", effort: "medium", tier: "balanced" },
76
76
  { id: "role.explorer", name: "Explorer", model: "openai/gpt-6-luna", effort: "medium", tier: "efficient" },
77
- { id: "role.product-manager", name: "Product Manager", model: "openai/gpt-6-sol", effort: "medium", tier: "balanced" },
78
- { id: "role.developer", name: "Developer", model: "openai/gpt-6-sol", effort: "high", tier: "balanced" },
77
+ { id: "role.product-manager", name: "Product Manager", model: "openai/gpt-6.1-sol", effort: "medium", tier: "balanced" },
78
+ { id: "role.developer", name: "Developer", model: "openai/gpt-6.1-sol", effort: "high", tier: "balanced" },
79
79
  { id: "role.reviewer", name: "Reviewer", model: "openai/gpt-6-astra", effort: "medium", tier: "frontier" },
80
80
  { id: "role.architect", name: "Architect", model: "openai/gpt-6-astra", effort: "high", tier: "frontier" },
81
81
  ] as const;
@@ -52,7 +52,7 @@ Codex applies the agent file's model and `model_reasoning_effort` ahead of the c
52
52
 
53
53
  `agents_catalog` accepts `list` and `inspect` only. It uses the same inspection view as `/agents show`. It has no write path. Launch remains `subagent_spawn` or the batch tool, with at most one `agent` or `role` selector. Those selectors are lifecycle's wiring, not this tool.
54
54
 
55
- A list line names a role by its short name (`role developer "Developer" …`), the form the `role` field takes; the inspect header, `identity.id`, and the structured `id` keep the stored id `role.developer`. Named agents show their full `agent.<slug>` id in both. Each list line and the inspect header end with the definition's default model and effort when one is set, such as `default openai/gpt-6-sol@high`: a role's own default, or a named agent's override or inherited role default. The structured view carries the same value as `defaults: { model, effort, label }`, or `null` when neither is set. A spawn that omits `model` and `thinking` uses it. When a catalog launch's model or effort differs from it, the launch line (and each batch job line) adds a note such as `model openai/gpt-6-astra@high (role developer default openai/gpt-6-sol@high)`; a named agent's note reads `agent default …`. Only the fields the definition sets are compared. The note names the cause: a fallback from an unavailable default reads `(role developer default openai/gpt-6-sol@high unavailable; foreground fallback)` (or `same-tier` / `configured-default`), and an effort the model cannot run adds `effort capped at <level> by the model`.
55
+ A list line names a role by its short name (`role developer "Developer" …`), the form the `role` field takes; the inspect header, `identity.id`, and the structured `id` keep the stored id `role.developer`. Named agents show their full `agent.<slug>` id in both. Each list line and the inspect header end with the definition's default model and effort when one is set, such as `default openai/gpt-6.1-sol@high`: a role's own default, or a named agent's override or inherited role default. The structured view carries the same value as `defaults: { model, effort, label }`, or `null` when neither is set. A spawn that omits `model` and `thinking` uses it. When a catalog launch's model or effort differs from it, the launch line (and each batch job line) adds a note such as `model openai/gpt-6-astra@high (role developer default openai/gpt-6.1-sol@high)`; a named agent's note reads `agent default …`. Only the fields the definition sets are compared. The note names the cause: a fallback from an unavailable default reads `(role developer default openai/gpt-6.1-sol@high unavailable; foreground fallback)` (or `same-tier` / `configured-default`), and an effort the model cannot run adds `effort capped at <level> by the model`.
56
56
 
57
57
  ## Lifecycle attachment
58
58
 
@@ -16,7 +16,7 @@ id: role.developer
16
16
  name: Developer
17
17
  description: Implements a requested change in the existing system.
18
18
  defaults:
19
- model: openai/gpt-6-sol
19
+ model: openai/gpt-6.1-sol
20
20
  effort: high
21
21
  tier: balanced
22
22
  ---
@@ -100,10 +100,10 @@ Model availability, same-tier fallback, and nearest-effort adjustment are not de
100
100
 
101
101
  | Role | Stored id | Name | Model | Effort | Tier |
102
102
  | --- | --- | --- | --- | --- | --- |
103
- | `researcher` | `role.researcher` | Researcher | `openai/gpt-6-sol` | medium | balanced |
103
+ | `researcher` | `role.researcher` | Researcher | `openai/gpt-6.1-sol` | medium | balanced |
104
104
  | `explorer` | `role.explorer` | Explorer | `openai/gpt-6-luna` | medium | efficient |
105
- | `product-manager` | `role.product-manager` | Product Manager | `openai/gpt-6-sol` | medium | balanced |
106
- | `developer` | `role.developer` | Developer | `openai/gpt-6-sol` | high | balanced |
105
+ | `product-manager` | `role.product-manager` | Product Manager | `openai/gpt-6.1-sol` | medium | balanced |
106
+ | `developer` | `role.developer` | Developer | `openai/gpt-6.1-sol` | high | balanced |
107
107
  | `reviewer` | `role.reviewer` | Reviewer | `openai/gpt-6-astra` | medium | frontier |
108
108
  | `architect` | `role.architect` | Architect | `openai/gpt-6-astra` | high | frontier |
109
109
 
@@ -10,7 +10,7 @@ Normative rules are R3, R4, R8, and the resolver half of R10. Prose parsing and
10
10
  - It does not grant tools, sandbox modes, extensions, or presets. Inspection copies the catalog `capabilities` object (`grantedByCatalog: false`).
11
11
  - It does not call a provider. Availability is one `getAvailable()` snapshot from the foreground registry. `find()` only explains that a known model is outside that set.
12
12
  - It does not retry, and it does not substitute after a child has started. `automaticRetry` and `substitutionAfterStart` are false. `startupFailure.action` is `surface-error-no-retry-no-substitution`. A later provider initialization failure is a lifecycle error.
13
- - It does not invent tier candidates. Built-in tiers name the approved models and have empty candidate lists. `openai/gpt-6-sol`, `openai/gpt-6-luna`, and `openai/gpt-6-astra` are not substitutes.
13
+ - It does not invent tier candidates. Built-in tiers name the approved models and have empty candidate lists. `openai/gpt-6.1-sol`, `openai/gpt-6-luna`, and `openai/gpt-6-astra` are not substitutes.
14
14
 
15
15
  ## Precedence
16
16
 
@@ -5,7 +5,7 @@ id: role.developer
5
5
  name: Developer
6
6
  description: Owns implementation and focused tests; excludes architecture policy and independent review.
7
7
  defaults:
8
- model: openai/gpt-6-sol
8
+ model: openai/gpt-6.1-sol
9
9
  effort: high
10
10
  tier: balanced
11
11
  ---
@@ -5,7 +5,7 @@ id: role.product-manager
5
5
  name: Product Manager
6
6
  description: Owns user-visible requirements and acceptance criteria; excludes technical design and implementation.
7
7
  defaults:
8
- model: openai/gpt-6-sol
8
+ model: openai/gpt-6.1-sol
9
9
  effort: medium
10
10
  tier: balanced
11
11
  ---
@@ -5,7 +5,7 @@ id: role.researcher
5
5
  name: Researcher
6
6
  description: Owns external-source evidence and factual uncertainty; excludes repo mapping and changes.
7
7
  defaults:
8
- model: openai/gpt-6-sol
8
+ model: openai/gpt-6.1-sol
9
9
  effort: medium
10
10
  tier: balanced
11
11
  ---
@@ -49,13 +49,13 @@ export interface NormalizedTierPolicy {
49
49
  }
50
50
 
51
51
  /**
52
- * Approved model membership only. `openai/gpt-6-sol`, `openai/gpt-6-luna`, and
52
+ * Approved model membership only. `openai/gpt-6.1-sol`, `openai/gpt-6-luna`, and
53
53
  * `openai/gpt-6-astra` are not fallbacks for each other.
54
54
  */
55
55
  export const DEFAULT_TIER_POLICY = {
56
56
  tiers: {
57
57
  efficient: { members: ["openai/gpt-6-luna"], candidates: [] },
58
- balanced: { members: ["openai/gpt-6-sol"], candidates: [] },
58
+ balanced: { members: ["openai/gpt-6.1-sol"], candidates: [] },
59
59
  frontier: { members: ["openai/gpt-6-astra"], candidates: [] },
60
60
  },
61
61
  } as const satisfies TierPolicy;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.12.3",
3
+ "version": "0.13.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",
@@ -23,7 +23,8 @@
23
23
  "extensions/background-tasks/index.ts",
24
24
  "extensions/ssh/index.ts",
25
25
  "extensions/goal/index.ts",
26
- "extensions/plan/index.ts"
26
+ "extensions/plan/index.ts",
27
+ "extensions/minimal-output/index.ts"
27
28
  ],
28
29
  "image": "https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-harness.png"
29
30
  },
@@ -47,13 +48,14 @@
47
48
  ],
48
49
  "scripts": {
49
50
  "prepack": "node ../../scripts/stage-harness-dependencies.mjs",
51
+ "typecheck": "tsc --noEmit --allowImportingTsExtensions --module NodeNext --moduleResolution NodeNext --target ES2022 --strict --skipLibCheck --types node extensions/minimal-output/index.ts extensions/minimal-output/hook.ts",
50
52
  "test": "node --test test/*.test.mjs"
51
53
  },
52
54
  "dependencies": {
53
55
  "pi-better-background-tasks": "0.6.2",
54
56
  "pi-better-goal": "0.5.0",
55
57
  "pi-better-plan": "0.5.1",
56
- "pi-better-sandbox": "0.7.3",
58
+ "pi-better-sandbox": "0.8.0",
57
59
  "pi-better-ssh": "0.1.1",
58
60
  "pi-better-subagents": "0.9.3",
59
61
  "smol-toml": "1.9.0",