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.
- package/README.md +21 -0
- package/extensions/minimal-output/hook.ts +149 -0
- package/extensions/minimal-output/index.ts +85 -0
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +22 -20
- package/node_modules/pi-better-plan/package.json +1 -1
- package/node_modules/pi-better-plan/src/index.ts +17 -1
- package/node_modules/pi-better-sandbox/README.md +29 -17
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/permissions-page.ts +96 -50
- package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +22 -20
- package/node_modules/pi-better-subagents/README.md +3 -3
- package/node_modules/pi-better-subagents/catalog-runtime.ts +3 -3
- package/node_modules/pi-better-subagents/catalog-schema.ts +3 -3
- package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +1 -1
- package/node_modules/pi-better-subagents/docs/agent-catalog.md +4 -4
- package/node_modules/pi-better-subagents/docs/agent-model-resolution.md +1 -1
- package/node_modules/pi-better-subagents/index.ts +15 -2
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/roles/role.developer.md +1 -1
- package/node_modules/pi-better-subagents/roles/role.product-manager.md +1 -1
- package/node_modules/pi-better-subagents/roles/role.researcher.md +1 -1
- package/node_modules/pi-better-subagents/shared-sandbox-core.ts +22 -20
- package/node_modules/pi-better-subagents/tier-policy.ts +2 -2
- package/node_modules/pi-better-subagents/timing.ts +9 -8
- 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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
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.
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
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
|
-
[...
|
|
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 = [...
|
|
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)
|
|
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
|
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 -
|
|
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]
|
|
32
|
-
[
|
|
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**.
|
|
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
|
-
|
|
88
|
+
retains fixed protections that are not configurable per tool:
|
|
78
89
|
|
|
79
|
-
- **Credentials**
|
|
80
|
-
|
|
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
|
|
84
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
second Enter
|
|
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.
|
|
156
|
-
|
|
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
|