pi-better-harness 0.12.3 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/extensions/minimal-output/hook.ts +149 -0
- package/extensions/minimal-output/index.ts +85 -0
- package/node_modules/pi-better-sandbox/README.md +21 -8
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/permissions-page.ts +94 -41
- package/node_modules/pi-better-subagents/README.md +3 -2
- package/node_modules/pi-better-subagents/batch.mjs +58 -0
- 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/docs/usage.md +695 -0
- package/node_modules/pi-better-subagents/index.ts +29 -12
- package/node_modules/pi-better-subagents/package.json +2 -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/tier-policy.ts +2 -2
- package/package.json +6 -4
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]
|
|
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
|
|
|
@@ -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
|
-
|
|
113
|
-
|
|
114
|
-
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
|
|
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.
|
|
@@ -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,
|
|
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
|
-
|
|
|
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
|
-
/**
|
|
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
|
-
|
|
95
|
-
function
|
|
96
|
-
const trusted = new Map<string,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
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)
|
|
339
|
+
if (message) {
|
|
340
|
+
const clean = message.replace(/[\r\n]+/g, " ");
|
|
341
|
+
const confirmation = /^(.*) (Press Enter again to save\.)$/.exec(clean);
|
|
342
|
+
const messages = confirmation && visibleWidth(clean) > w ? [confirmation[1]!, confirmation[2]!] : [clean];
|
|
343
|
+
for (const text of messages) output.push(theme.fg(isError ? "error" : "muted", truncateToWidth(text, w, "")));
|
|
344
|
+
}
|
|
292
345
|
return output;
|
|
293
346
|
},
|
|
294
347
|
};
|
|
@@ -12,6 +12,7 @@ Use `pi-better-subagents` when you want Pi to launch independent agent work with
|
|
|
12
12
|
|
|
13
13
|
## Core Features
|
|
14
14
|
- Non-blocking subagent launches, with an optional role and named-agent catalog (`docs/agent-catalog.md`, `docs/agent-catalog-lifecycle.md`).
|
|
15
|
+
- Typed foreground `subagent_spawn_batch` launch receipts for native Pi codemode (Pi 0.99.1+), preserving direct tool calls, per-run callbacks, and batch capacity/catalog guarantees. See [codemode batch launches](docs/usage.md#codemode-batch-launches).
|
|
15
16
|
- Default OS write sandboxing on macOS and Linux.
|
|
16
17
|
- Explicit tool allowlists for child sessions.
|
|
17
18
|
- Durable logs, result retrieval, and [failure observations](docs/failure-observations.md) independent of lifecycle status. A live child handles its own tool errors; the parent is woken only for actionable incidents, and children can classify handled failures with `failure_disposition`.
|
|
@@ -44,14 +45,14 @@ nontrivial role-owned task, while the foreground coordinates, integrates, and
|
|
|
44
45
|
verifies. See [usage notes](docs/usage.md#delegation-mode).
|
|
45
46
|
|
|
46
47
|
`agents_catalog` shows each role's and named agent's default model and effort,
|
|
47
|
-
such as `role developer "Developer" … default openai/gpt-6-sol@high`. Roles are
|
|
48
|
+
such as `role developer "Developer" … default openai/gpt-6.1-sol@high`. Roles are
|
|
48
49
|
listed and passed by short name (`role: "developer"`); the stored id
|
|
49
50
|
`role.developer` also works, and named agents keep their full id
|
|
50
51
|
(`agent: "agent.payments"`). To launch on that default, omit
|
|
51
52
|
`model` and `thinking` on a role or agent spawn; name one only for a stated
|
|
52
53
|
reason. When a launch's model or effort differs from the default, its launch
|
|
53
54
|
line says so, for example
|
|
54
|
-
`model openai/gpt-6-astra@high (role developer default openai/gpt-6-sol@high)`.
|
|
55
|
+
`model openai/gpt-6-astra@high (role developer default openai/gpt-6.1-sol@high)`.
|
|
55
56
|
|
|
56
57
|
## When To Use
|
|
57
58
|
|