pi-better-harness 0.12.2 → 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.
Files changed (26) hide show
  1. package/README.md +21 -0
  2. package/extensions/minimal-output/hook.ts +149 -0
  3. package/extensions/minimal-output/index.ts +85 -0
  4. package/node_modules/pi-better-background-tasks/package.json +1 -1
  5. package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +22 -20
  6. package/node_modules/pi-better-plan/package.json +1 -1
  7. package/node_modules/pi-better-plan/src/index.ts +17 -1
  8. package/node_modules/pi-better-sandbox/README.md +29 -17
  9. package/node_modules/pi-better-sandbox/package.json +1 -1
  10. package/node_modules/pi-better-sandbox/permissions-page.ts +96 -50
  11. package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +22 -20
  12. package/node_modules/pi-better-subagents/README.md +3 -3
  13. package/node_modules/pi-better-subagents/catalog-runtime.ts +3 -3
  14. package/node_modules/pi-better-subagents/catalog-schema.ts +3 -3
  15. package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +1 -1
  16. package/node_modules/pi-better-subagents/docs/agent-catalog.md +4 -4
  17. package/node_modules/pi-better-subagents/docs/agent-model-resolution.md +1 -1
  18. package/node_modules/pi-better-subagents/index.ts +15 -2
  19. package/node_modules/pi-better-subagents/package.json +1 -1
  20. package/node_modules/pi-better-subagents/roles/role.developer.md +1 -1
  21. package/node_modules/pi-better-subagents/roles/role.product-manager.md +1 -1
  22. package/node_modules/pi-better-subagents/roles/role.researcher.md +1 -1
  23. package/node_modules/pi-better-subagents/shared-sandbox-core.ts +22 -20
  24. package/node_modules/pi-better-subagents/tier-policy.ts +2 -2
  25. package/node_modules/pi-better-subagents/timing.ts +9 -8
  26. package/package.json +8 -6
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
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -228,10 +228,7 @@ const CREDENTIAL_LOCATIONS = [
228
228
  ".docker/config.json", ".npmrc", ".netrc", ".git-credentials", ".pi/agent/auth.json",
229
229
  ] as const;
230
230
 
231
- /**
232
- * Credential stores added by the broad-write profile. They are never readable
233
- * or writable there, whatever the Stored credentials row says.
234
- */
231
+ /** Credential stores added to the Stored credentials row by the broad-write profile. */
235
232
  const BROAD_CREDENTIAL_LOCATIONS = [
236
233
  "Library/Keychains", ".claude/.credentials.json", ".claude.json", ".gnupg",
237
234
  ".codex/auth.json", ".cargo/credentials.toml", ".cargo/credentials", ".pgpass", ".config/rclone",
@@ -386,10 +383,7 @@ function compile(
386
383
  return {
387
384
  writableRoot, denyWrite, home: policy.home,
388
385
  ...(policy.permissions && {
389
- // The broad-write profile's credential deny list is not optional.
390
- permissions: broadMode
391
- ? { ...policy.permissions, storedCredentials: "off" as const }
392
- : { ...policy.permissions },
386
+ permissions: { ...policy.permissions },
393
387
  credentialPaths: broadMode ? broadCredentialPaths(policy.home, seams) : credentialFilePaths(policy.home, seams),
394
388
  compatibilityWrite,
395
389
  runtimeWrite,
@@ -598,7 +592,7 @@ export function evaluateReadAccess(
598
592
  const permissions = policy.permissions;
599
593
  if (!permissions) return { allowed: true, path };
600
594
  if (policy.broad) {
601
- const denied = isCredential(path, policy) ||
595
+ const denied = (isCredential(path, policy) && permissions.storedCredentials === "off") ||
602
596
  (permissions.projectFiles === "off" && contains(policy.writableRoot, path));
603
597
  return denied ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
604
598
  }
@@ -652,7 +646,8 @@ function evaluateBroadWrite(
652
646
  for (const denied of [...policy.denyWrite, ...broad.codePaths]) {
653
647
  if (contains(denied, path)) return { allowed: false, path, reason: "write-denied", deniedBy: denied };
654
648
  }
655
- if (isCredential(path, policy)) return { allowed: false, path, reason: "permission-denied" };
649
+ if (isCredential(path, policy)) return canWrite(policy.permissions!.storedCredentials)
650
+ ? { allowed: true, path } : { allowed: false, path, reason: "permission-denied" };
656
651
  if (policy.runtimeWrite?.some((root) => contains(root, path))) return { allowed: true, path };
657
652
  if (contains(policy.writableRoot, path)) {
658
653
  return canWrite(policy.permissions!.projectFiles)
@@ -808,7 +803,8 @@ export function resetRecoverySnapshotClock(): void {
808
803
  }
809
804
 
810
805
  function broadProtectedPaths(policy: CompiledSandboxWritePolicy, broad: CompiledBroadWrite): string[] {
811
- return [...policy.denyWrite, ...(policy.credentialPaths ?? []), ...broad.codePaths,
806
+ return [...policy.denyWrite,
807
+ ...(policy.permissions?.storedCredentials === "read-write" ? [] : policy.credentialPaths ?? []), ...broad.codePaths,
812
808
  ...(!canWrite(policy.permissions?.projectFiles) ? [policy.writableRoot] : [])];
813
809
  }
814
810
 
@@ -927,7 +923,7 @@ function buildBroadProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSea
927
923
  `(allow file-write-unlink (regex #"^${home}/(.+/)?[.]worktrees/."))`,
928
924
  `(allow file-write-unlink (regex #"^${home}/(.+/)?[^/]+-worktrees/."))`,
929
925
  );
930
- // The fixed deny list overrides every grant above. A rule naming
926
+ // Write-denied controls and credentials override every broad grant above. A rule naming
931
927
  // `file-write-unlink` outranks a `file-write*` wildcard whatever their
932
928
  // order (verified on the real kernel), so each protected path also gets an
933
929
  // explicit unlink denial; otherwise the removal grants above would reopen it.
@@ -936,8 +932,8 @@ function buildBroadProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSea
936
932
  if (!canWrite(permissions.projectFiles)) denyWrite(project);
937
933
  for (const path of broad.codePaths) denyWrite(path);
938
934
  for (const path of policy.credentialPaths ?? []) {
939
- rules.push(`(deny file-read* (subpath ${sbpl(path)}))`);
940
- denyWrite(path);
935
+ if (permissions.storedCredentials === "off") rules.push(`(deny file-read* (subpath ${sbpl(path)}))`);
936
+ if (permissions.storedCredentials !== "read-write") denyWrite(path);
941
937
  }
942
938
  for (const path of policy.denyWrite) denyWrite(path);
943
939
  // Renaming an ancestor would move a protected subtree to an unprotected path,
@@ -1350,9 +1346,11 @@ function maskSources(profilePath: string): { directory: string; file: string } {
1350
1346
  * the root is read-only; temp, the workspace, hidden home entries and worktree
1351
1347
  * folders are writable (removal included); ordinary top-level home folders and
1352
1348
  * home itself stay read-only, so sibling repositories cannot even be edited in
1353
- * place there. Credentials are masked by empty mode-000 mounts; code that runs
1354
- * later and caller-denied paths are read-only binds. Every writable bind and
1355
- * protected leaf is a mount point, which Linux refuses to rename. A protected
1349
+ * place there. Off credentials are masked by empty mode-000 mounts, Read
1350
+ * credentials are read-only binds, and Read / write credentials follow the
1351
+ * surrounding broad-write rules. Code that runs later and caller-denied paths
1352
+ * are read-only binds. Every writable bind and protected leaf is a mount point,
1353
+ * which Linux refuses to rename. A protected
1356
1354
  * symlink inside a writable directory (a stow link in `~/.config`) makes that
1357
1355
  * directory read-only with each existing entry bound writable again, so only
1358
1356
  * new top-level entries there are refused.
@@ -1373,14 +1371,15 @@ function buildLinuxBroadCommand(
1373
1371
  throw new Error("Linux bubblewrap cannot separate removal from writing for Project files; use Write & delete or Read.");
1374
1372
  }
1375
1373
  const credentials = policy.credentialPaths ?? [];
1374
+ const protectedCredentials = permissions.storedCredentials === "read-write" ? [] : credentials;
1376
1375
  const writeDenied = [...broad.codePaths, ...policy.denyWrite];
1377
1376
  if (permissions.projectFiles === "read-write" &&
1378
- [...credentials, ...writeDenied].some((path) => path !== project && contains(path, project))) {
1377
+ [...protectedCredentials, ...writeDenied].some((path) => path !== project && contains(path, project))) {
1379
1378
  throw new Error("Linux bubblewrap cannot make a project writable inside a protected directory.");
1380
1379
  }
1381
1380
  const list = seams.listDirectory ?? systemListDirectory;
1382
1381
  const exists = (path: string) => (seams.pathExists ?? existsSync)(path);
1383
- const protectedPaths = [...credentials, ...writeDenied];
1382
+ const protectedPaths = [...protectedCredentials, ...writeDenied];
1384
1383
  const insideProtected = (path: string) => protectedPaths.some((guard) => contains(guard, path));
1385
1384
 
1386
1385
  const writable = new Set<string>();
@@ -1498,7 +1497,10 @@ function buildLinuxBroadCommand(
1498
1497
  readOnly.add(path);
1499
1498
  }
1500
1499
  };
1501
- for (const path of credentials) guard(path, true);
1500
+ for (const path of credentials) {
1501
+ if (permissions.storedCredentials === "off") guard(path, true);
1502
+ else if (permissions.storedCredentials === "read") guard(path, false);
1503
+ }
1502
1504
  for (const path of writeDenied) guard(path, false);
1503
1505
  if (permissions.projectFiles === "read") readOnly.add(project);
1504
1506
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import { Text } from "@earendil-works/pi-tui";
3
+ import { Text, type AutocompleteItem } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
 
6
6
  import {
@@ -78,6 +78,21 @@ const LEGACY_PLAN_NAV_STATUS_KEY = "pi-better-plan-nav";
78
78
  const DELEGATION_MODE_REQUEST = "pi-better-subagents:delegation-mode-request";
79
79
  type DelegationMode = "manual" | "adaptive" | "coordinator";
80
80
 
81
+ const PLAN_ACTIONS: readonly AutocompleteItem[] = [
82
+ { value: "clear", label: "clear", description: "Remove the current plan" },
83
+ { value: "hide", label: "hide", description: "Hide the plan widget" },
84
+ { value: "show", label: "show", description: "Restore automatic plan display" },
85
+ { value: "pin auto", label: "pin auto", description: "Use automatic plan pinning" },
86
+ { value: "pin on", label: "pin on", description: "Keep the plan pinned" },
87
+ { value: "pin off", label: "pin off", description: "Keep the plan unpinned" },
88
+ ];
89
+
90
+ export function planArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
91
+ const prefix = argumentPrefix.trimStart().toLowerCase();
92
+ const matches = PLAN_ACTIONS.filter((action) => action.value.startsWith(prefix));
93
+ return matches.length > 0 ? [...matches] : null;
94
+ }
95
+
81
96
  function activeDelegationMode(pi: ExtensionAPI): DelegationMode {
82
97
  const request: { mode?: string } = {};
83
98
  pi.events.emit(DELEGATION_MODE_REQUEST, request);
@@ -396,6 +411,7 @@ export default function planExtension(pi: ExtensionAPI): void {
396
411
 
397
412
  pi.registerCommand("plan", {
398
413
  description: "Inspect, show, hide, clear, or configure the current plan",
414
+ getArgumentCompletions: planArgumentCompletions,
399
415
  handler: async (args, ctx) => {
400
416
  const input = args.trim().toLowerCase();
401
417
  if (!input) return showFullPlan(ctx);
@@ -20,7 +20,7 @@ Sandbox Off On
20
20
 
21
21
  Project files - Write & delete
22
22
  Outside project - Write
23
- Stored credentials - Off (fixed)
23
+ Stored credentials - Read
24
24
  Run commands & applications - On
25
25
  Network access - On
26
26
 
@@ -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
 
@@ -74,15 +85,14 @@ select the row.
74
85
 
75
86
  **Outside project = Write** (the Subagents default) lets tasks write across your
76
87
  home directory and temp, so tool caches just work with no per-tool setup. It
77
- keeps one fixed deny list that is not configurable per tool:
88
+ retains fixed protections that are not configurable per tool:
78
89
 
79
- - **Credentials** are neither readable nor writable, whatever the Stored
80
- credentials row says: the files listed below plus `~/Library/Keychains`,
90
+ - **Credentials** follow the independent Stored credentials row. The default
91
+ `Read` setting exposes the files listed below plus `~/Library/Keychains`,
81
92
  `~/.claude/.credentials.json`, `~/.claude.json`, `~/.gnupg`,
82
93
  `~/.codex/auth.json`, `~/.cargo/credentials(.toml)`, `~/.pgpass`, and
83
- `~/.config/rclone`. The table shows
84
- "Off (fixed)". Tasks that need git over SSH, `gh`, or cloud CLIs need Outside
85
- project = Read or Write & delete.
94
+ `~/.config/rclone` to confined subagents, while denying writes. Set the row to
95
+ Off to hide them or Read / write when a CLI must refresh credential state.
86
96
  - **Code that runs later** cannot be written, removed, or renamed: shell startup
87
97
  files (`.bashrc`, `.zshrc`, `.profile`, and the rest), `~/.config/fish`,
88
98
  `~/.gitconfig`, `~/.config/git`, `~/.config/systemd/user`,
@@ -110,9 +120,11 @@ repository's `.git/hooks` or `.git/config` (`core.hooksPath`, `core.fsmonitor`,
110
120
  `~/.gitconfig`, `~/.config/git`, and `~/.git-templates` themselves stay protected. Add your own paths to the deny list with `/sandbox deny add <path>`. Under
111
121
  Write, those paths are also protected from removal and renaming.
112
122
 
113
- A looser value (a higher level, a capability switched on, or a sandbox switched
114
- off) takes effect only on a second Space, and saving looser defaults needs a
115
- 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
116
128
  tool can change these settings. Other rows toggle Off/On. Detail cells under an
117
129
  Off sandbox display a dimmed `-` and cannot be changed; their values return when
118
130
  the sandbox is enabled again.
@@ -152,8 +164,8 @@ AWS, GitHub CLI, Google Cloud CLI, Azure, Kubernetes, Docker, npm, netrc, Git
152
164
  credentials, and Pi's file-based auth. These rules override ordinary file access.
153
165
  OS vault services such as Keychain and Secret Service, and tokens inherited in
154
166
  environment variables, are excluded. Read / write may be needed by a CLI that
155
- refreshes a token or updates its credential database. Under Outside project =
156
- Write, credentials are always Off (see above).
167
+ refreshes a token or updates its credential database. This row applies
168
+ independently of Project files and Outside project.
157
169
 
158
170
  File and shell operations use the kernel: macOS uses Seatbelt (`sandbox-exec`)
159
171
  and Linux uses Bubblewrap (`bwrap`). `read`, `write`, and `edit` keep Pi's normal
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.7.2",
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",