pi-better-harness 0.15.3 → 0.17.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/node_modules/pi-better-goal/README.md +1 -1
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +2 -2
- package/node_modules/pi-better-sandbox/README.md +15 -8
- package/node_modules/pi-better-sandbox/commands.ts +17 -4
- package/node_modules/pi-better-sandbox/index.ts +28 -9
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/permissions-page.ts +12 -15
- package/node_modules/pi-better-sandbox/session-permissions.ts +24 -0
- package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +43 -32
- package/node_modules/pi-better-sandbox/state.ts +4 -5
- package/node_modules/pi-better-subagents/README.md +8 -4
- package/node_modules/pi-better-subagents/agent-commands.ts +35 -7
- package/node_modules/pi-better-subagents/agent-session-settings.ts +89 -0
- package/node_modules/pi-better-subagents/agents-catalog-tool.ts +6 -2
- package/node_modules/pi-better-subagents/catalog-resolver.ts +10 -6
- package/node_modules/pi-better-subagents/catalog-runtime.ts +8 -2
- package/node_modules/pi-better-subagents/catalog-store.ts +3 -0
- package/node_modules/pi-better-subagents/catalog-ui.ts +115 -9
- package/node_modules/pi-better-subagents/config.ts +28 -1
- package/node_modules/pi-better-subagents/delegation.ts +2 -0
- package/node_modules/pi-better-subagents/docs/agent-catalog-lifecycle.md +23 -0
- package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +11 -1
- package/node_modules/pi-better-subagents/docs/usage.md +35 -11
- package/node_modules/pi-better-subagents/index.ts +98 -22
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/settings-page.ts +140 -0
- package/node_modules/pi-better-subagents/shared-task-sandbox.ts +43 -32
- package/package.json +4 -4
|
@@ -17,7 +17,7 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
|
|
|
17
17
|
- Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
|
|
18
18
|
- A compact goal widget that does not replace Pi's footer.
|
|
19
19
|
- Background activity tracking for subagents and other registered providers.
|
|
20
|
-
- A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (
|
|
20
|
+
- A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (60s through 600s by default), followed by a hold recoverable with `/goal resume`. Delays saturate at Node's timer limit, and observed background drains reset progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
|
|
21
21
|
- An observable-progress stall state for active goals.
|
|
22
22
|
|
|
23
23
|
## Skill-Owned Workflows
|
|
@@ -50,7 +50,7 @@ import {
|
|
|
50
50
|
} from "./types.js";
|
|
51
51
|
|
|
52
52
|
const POLL_INTERVAL_MS = 2_000;
|
|
53
|
-
const DEFAULT_IDLE_CONTINUATION_DELAY_MS =
|
|
53
|
+
const DEFAULT_IDLE_CONTINUATION_DELAY_MS = 60_000;
|
|
54
54
|
const DEFAULT_MAX_NO_PROGRESS_RETRIES = 10;
|
|
55
55
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
56
56
|
const WAKE_DISABLED =
|
|
@@ -124,7 +124,7 @@ function parseDurationEnv(raw: string | undefined, fallback: number): number {
|
|
|
124
124
|
|
|
125
125
|
/**
|
|
126
126
|
* Linear backoff for no-progress retries: each identical outcome waits one more
|
|
127
|
-
* grace period than the last (
|
|
127
|
+
* grace period than the last (60s, 120s, 180s, ... by default).
|
|
128
128
|
*/
|
|
129
129
|
export function idleContinuationDelay(noProgressRetries: number, baseMs = IDLE_CONTINUATION_DELAY_MS): number {
|
|
130
130
|
return Math.min(MAX_TIMER_DELAY_MS, baseMs * (1 + Math.max(0, noProgressRetries)));
|
|
@@ -11,7 +11,8 @@ pi install npm:pi-better-sandbox
|
|
|
11
11
|
Either way you keep starting Pi the way you always have — `pi`. There is
|
|
12
12
|
no launcher or wrapper command. Main starts inactive; Subagents start confined.
|
|
13
13
|
Open `/sandbox` to change either column, or use `/sandbox on` for the current
|
|
14
|
-
Main session.
|
|
14
|
+
Main session. Edits apply immediately and are saved to the current session branch.
|
|
15
|
+
**Ctrl+S** saves both profiles as defaults for new sessions.
|
|
15
16
|
|
|
16
17
|
```text
|
|
17
18
|
Sandbox permissions Main Subagents
|
|
@@ -31,8 +32,7 @@ Network access - On
|
|
|
31
32
|
> [x] @juicesharp/rpiv-web-tools 2/2
|
|
32
33
|
> [ ] <other installed package> 0/1
|
|
33
34
|
|
|
34
|
-
↑↓ Select
|
|
35
|
-
Save as defaults
|
|
35
|
+
↑↓ Select · ←→ Column/fold · Space Toggle · ctrl+s Save default · Enter Fold · Esc Back
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
**Subagents · Tools** picks the extension tools a confined subagent may use:
|
|
@@ -56,7 +56,7 @@ are; the count is selected/total. Right expands a group. Left collapses a group,
|
|
|
56
56
|
or moves from a child to its parent and collapses it. Enter folds the selected
|
|
57
57
|
group. Space on a group selects all children when none or only some are selected,
|
|
58
58
|
or deselects them all when all are selected. Every toggle applies on one Space;
|
|
59
|
-
|
|
59
|
+
Ctrl+S saves defaults and reports anything loosened. Only individual tool
|
|
60
60
|
entries are saved, never a package/provider wildcard or the open/closed state.
|
|
61
61
|
|
|
62
62
|
Defaults: `apply_patch`, `web_fetch`, and `web_search` on; everything else off.
|
|
@@ -123,7 +123,7 @@ Write, those paths are also protected from removal and renaming.
|
|
|
123
123
|
Space applies every change immediately, including higher permission levels,
|
|
124
124
|
capabilities switched on, and a sandbox switched off. `ctrl+s` saves the
|
|
125
125
|
defaults from any row on the first press, and the note names anything that
|
|
126
|
-
loosened. Enter
|
|
126
|
+
loosened. Enter only folds tool groups; it never saves defaults. The focused
|
|
127
127
|
row has a full-width background and bold text; the selected Main/Subagents cell
|
|
128
128
|
also uses inverse styling. No model
|
|
129
129
|
tool can change these settings. Other rows toggle Off/On. Detail cells under an
|
|
@@ -319,7 +319,7 @@ in your rule set but is held out in that project, with a message saying so.
|
|
|
319
319
|
Nothing is migrated on disk. A saved `pi-better-sandbox-permissions.json` keeps
|
|
320
320
|
its values: a saved `read-write` is now shown as Write & delete, and a saved
|
|
321
321
|
Subagents Outside project = Read stays Read. Only a fresh configuration, or
|
|
322
|
-
**
|
|
322
|
+
**Ctrl+S** after you choose Write, uses the new Subagents default. To
|
|
323
323
|
adopt it, open `/sandbox`, set Subagents → Outside project to Write, and save.
|
|
324
324
|
|
|
325
325
|
`~/.pi/agent/sandbox.json` (`filesystem.allowWrite`, `allowRead`,
|
|
@@ -328,12 +328,19 @@ package never reads it, and it has no effect on the harness.
|
|
|
328
328
|
|
|
329
329
|
## Lifecycle
|
|
330
330
|
|
|
331
|
-
The foreground sandbox is inactive by default.
|
|
332
|
-
|
|
331
|
+
The foreground sandbox is inactive by default. Permission edits, including
|
|
332
|
+
Sandbox switches and `/sandbox on|off`, auto-persist as Pi custom entries on the
|
|
333
|
+
current session branch, not as global defaults. Resume, fork, reload and session
|
|
334
|
+
tree navigation restore the latest policy on the active branch. A branch with
|
|
335
|
+
no permission entry and a new session inherit saved global settings. Invalid
|
|
336
|
+
persisted session policy fails closed rather than falling back to defaults.
|
|
337
|
+
Ctrl+S writes both
|
|
333
338
|
profiles to `~/.pi/agent/extensions/pi-better-sandbox-permissions.json` (or the
|
|
334
339
|
corresponding `$PI_CODING_AGENT_DIR`). Existing activation preferences in
|
|
335
340
|
`pi-better-sandbox-preferences.json` migrate when no profile file exists.
|
|
336
341
|
`/sandbox default on|off` remains available and updates Main's saved switch.
|
|
342
|
+
Failed default saves leave the current session's edits intact. Write-deny rules
|
|
343
|
+
retain their separate global persistence through `/sandbox deny` and `/sandbox rules`.
|
|
337
344
|
|
|
338
345
|
Toggles apply to operations launched after the change. A command already running
|
|
339
346
|
keeps the policy it launched with.
|
|
@@ -55,8 +55,8 @@ const DISABLE_TITLE = "Disable the foreground write sandbox?";
|
|
|
55
55
|
|
|
56
56
|
const DISABLE_MESSAGE = [
|
|
57
57
|
"The built-in bash, write, and edit tools and user-entered ! / !! commands",
|
|
58
|
-
"will run with normal host write access
|
|
59
|
-
"
|
|
58
|
+
"will run with normal host write access on this session branch, including",
|
|
59
|
+
"after resume or reload. New sessions apply your saved sandbox default.",
|
|
60
60
|
].join("\n");
|
|
61
61
|
|
|
62
62
|
const NO_UI_REJECTION =
|
|
@@ -80,6 +80,8 @@ export type SandboxCommandDeps = {
|
|
|
80
80
|
onStateChange: (status: ForegroundSandboxStatus) => void;
|
|
81
81
|
/** Persist a default and apply it to the current session. */
|
|
82
82
|
setDefault: (enabled: boolean) => ForegroundSandboxStatus;
|
|
83
|
+
/** Persist the session change before mutating enforcement. */
|
|
84
|
+
setSessionEnabled?: (enabled: boolean) => ForegroundSandboxStatus;
|
|
83
85
|
openPermissions?: (ctx: ExtensionCommandContext) => Promise<void>;
|
|
84
86
|
};
|
|
85
87
|
|
|
@@ -89,6 +91,7 @@ export function createSandboxCommandHandler({
|
|
|
89
91
|
denyRules,
|
|
90
92
|
onStateChange,
|
|
91
93
|
setDefault,
|
|
94
|
+
setSessionEnabled,
|
|
92
95
|
openPermissions,
|
|
93
96
|
}: SandboxCommandDeps) {
|
|
94
97
|
return async function handleSandboxCommand(
|
|
@@ -112,7 +115,12 @@ export function createSandboxCommandHandler({
|
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
if (subcommand === "on") {
|
|
115
|
-
|
|
118
|
+
let status: ForegroundSandboxStatus;
|
|
119
|
+
try { status = setSessionEnabled ? setSessionEnabled(true) : controller.enable(); }
|
|
120
|
+
catch (error) {
|
|
121
|
+
ctx.ui.notify(`Sandbox state unchanged: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
116
124
|
onStateChange(status);
|
|
117
125
|
ctx.ui.notify(
|
|
118
126
|
status.state === "enabled"
|
|
@@ -140,7 +148,12 @@ export function createSandboxCommandHandler({
|
|
|
140
148
|
ctx.ui.notify("Foreground sandbox left on.", "info");
|
|
141
149
|
return;
|
|
142
150
|
}
|
|
143
|
-
|
|
151
|
+
let status: ForegroundSandboxStatus;
|
|
152
|
+
try { status = setSessionEnabled ? setSessionEnabled(false) : controller.disable(); }
|
|
153
|
+
catch (error) {
|
|
154
|
+
ctx.ui.notify(`Sandbox state unchanged: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
144
157
|
onStateChange(status);
|
|
145
158
|
ctx.ui.notify(
|
|
146
159
|
"Foreground sandbox OFF for this session. Shell commands can now write anywhere.",
|
|
@@ -38,6 +38,7 @@ import { writeSandboxDefault } from "./preferences.ts";
|
|
|
38
38
|
import { readPermissionSettings, writePermissionSettings } from "./permission-settings.ts";
|
|
39
39
|
import { defaultSandboxPermissions, describeLoosening, type SandboxPermissionSettings } from "./permissions.ts";
|
|
40
40
|
import { openPermissionsPage } from "./permissions-page.ts";
|
|
41
|
+
import { appendSessionPermissions, readSessionPermissions } from "./session-permissions.ts";
|
|
41
42
|
|
|
42
43
|
import { footerTone, formatFooterStatus } from "./status.ts";
|
|
43
44
|
import { ForegroundSandboxController, type ForegroundSandboxStatus } from "./state.ts";
|
|
@@ -98,9 +99,7 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
98
99
|
// `/sandbox deny ...` and the `/sandbox rules` page.
|
|
99
100
|
const denyRules = new DenyRuleManager({ controller, onStateChange: announce });
|
|
100
101
|
|
|
101
|
-
|
|
102
|
-
shellPath = resolveShellPath(ctx.cwd);
|
|
103
|
-
boundary.register(ctx.cwd);
|
|
102
|
+
const restorePermissions = (ctx: ExtensionContext): void => {
|
|
104
103
|
paintFooter = (status) => {
|
|
105
104
|
ctx.ui.setStatus(
|
|
106
105
|
FOOTER_KEY,
|
|
@@ -108,11 +107,14 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
108
107
|
);
|
|
109
108
|
};
|
|
110
109
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
110
|
+
// Session policy takes precedence over global defaults. Never skip an
|
|
111
|
+
// invalid latest entry to recover an older, potentially broader policy.
|
|
113
112
|
let settings = defaultSandboxPermissions();
|
|
113
|
+
let hasSessionPolicy = false;
|
|
114
114
|
try {
|
|
115
|
-
|
|
115
|
+
const sessionPermissions = readSessionPermissions(ctx);
|
|
116
|
+
hasSessionPolicy = sessionPermissions !== undefined;
|
|
117
|
+
settings = sessionPermissions ?? readPermissionSettings();
|
|
116
118
|
} catch (error) {
|
|
117
119
|
controller.beginSession(ctx.cwd, true);
|
|
118
120
|
const message = `Sandbox permissions could not be loaded: ${error instanceof Error ? error.message : String(error)}`;
|
|
@@ -130,7 +132,7 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
130
132
|
.map((tool) => tool.sourceInfo?.path).filter((path): path is string => typeof path === "string" && isAbsolute(path))
|
|
131
133
|
.map(runtimeCodeRoot));
|
|
132
134
|
controller.setPermissionSettings(settings);
|
|
133
|
-
controller.applyDefault(settings.main.enabled);
|
|
135
|
+
if (!hasSessionPolicy) controller.applyDefault(settings.main.enabled);
|
|
134
136
|
|
|
135
137
|
// Then the rules are re-read and re-resolved, because the same global
|
|
136
138
|
// template set means different absolute paths in a different project.
|
|
@@ -154,7 +156,14 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
154
156
|
status.state === "disabled" ? "info" : "warning",
|
|
155
157
|
);
|
|
156
158
|
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
pi.on("session_start", (_event, ctx) => {
|
|
162
|
+
shellPath = resolveShellPath(ctx.cwd);
|
|
163
|
+
boundary.register(ctx.cwd);
|
|
164
|
+
restorePermissions(ctx);
|
|
157
165
|
});
|
|
166
|
+
pi.on("session_tree", (_event, ctx) => restorePermissions(ctx));
|
|
158
167
|
|
|
159
168
|
pi.on("session_shutdown", () => {
|
|
160
169
|
controller.dispose();
|
|
@@ -167,11 +176,18 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
167
176
|
controller,
|
|
168
177
|
denyRules,
|
|
169
178
|
onStateChange: announce,
|
|
179
|
+
setSessionEnabled: (enabled) => {
|
|
180
|
+
const settings = controller.permissionSettings() ?? defaultSandboxPermissions();
|
|
181
|
+
settings.main.enabled = enabled;
|
|
182
|
+
appendSessionPermissions(pi, settings);
|
|
183
|
+
return enabled ? controller.enable() : controller.disable();
|
|
184
|
+
},
|
|
170
185
|
setDefault: (enabled) => {
|
|
171
186
|
const settings = controller.permissionSettings() ?? defaultSandboxPermissions();
|
|
172
187
|
settings.main.enabled = enabled;
|
|
173
188
|
writePermissionSettings(settings);
|
|
174
189
|
writeSandboxDefault(enabled ? "on" : "off");
|
|
190
|
+
appendSessionPermissions(pi, settings);
|
|
175
191
|
return controller.setPermissionSettings(settings);
|
|
176
192
|
},
|
|
177
193
|
openPermissions: async (ctx) => {
|
|
@@ -179,9 +195,12 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
|
|
|
179
195
|
getConfig: () => controller.permissionSettings() ?? defaultSandboxPermissions(),
|
|
180
196
|
// Trusted-tool candidates: what this Pi has actually registered, by owning package.
|
|
181
197
|
discoverTools: () => discoverTrustedTools(pi.getAllTools?.() ?? []),
|
|
182
|
-
change: (settings) =>
|
|
198
|
+
change: (settings) => {
|
|
199
|
+
appendSessionPermissions(pi, settings);
|
|
200
|
+
announce(controller.setPermissionSettings(settings));
|
|
201
|
+
},
|
|
183
202
|
save: (settings) => writePermissionSettings(settings),
|
|
184
|
-
//
|
|
203
|
+
// Report every capability loosened by saving these defaults.
|
|
185
204
|
loosening: (settings) => {
|
|
186
205
|
let previous: SandboxPermissionSettings;
|
|
187
206
|
try { previous = readPermissionSettings(); } catch { previous = defaultSandboxPermissions(); }
|
|
@@ -124,7 +124,7 @@ export function createPermissionsPage(
|
|
|
124
124
|
const itemKey = (item: ToolItem) => item.kind === "group" ? item.id
|
|
125
125
|
: item.kind === "guarded" ? "apply_patch" : JSON.stringify([item.name, item.package]);
|
|
126
126
|
let items = toolItems();
|
|
127
|
-
const
|
|
127
|
+
const lastRow = () => rows.length + items.length - 1;
|
|
128
128
|
let row = 0;
|
|
129
129
|
let column = 0;
|
|
130
130
|
let busy = false;
|
|
@@ -136,7 +136,7 @@ export function createPermissionsPage(
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
async function change(): Promise<void> {
|
|
139
|
-
if (!settings || busy
|
|
139
|
+
if (!settings || busy) return;
|
|
140
140
|
const next = snapshot(settings);
|
|
141
141
|
if (row >= rows.length) {
|
|
142
142
|
const item = items[row - rows.length]!;
|
|
@@ -173,15 +173,14 @@ export function createPermissionsPage(
|
|
|
173
173
|
busy = true;
|
|
174
174
|
try {
|
|
175
175
|
await handlers.change(snapshot(next));
|
|
176
|
-
const selected = row >= rows.length
|
|
177
|
-
const wasSave = row === saveRow();
|
|
176
|
+
const selected = row >= rows.length ? items[row - rows.length] : undefined;
|
|
178
177
|
const successors = selected ? items.slice(row - rows.length + 1).map(itemKey) : [];
|
|
179
178
|
settings = next;
|
|
180
179
|
items = toolItems();
|
|
181
180
|
const index = selected ? items.findIndex((item) => itemKey(item) === itemKey(selected)) : -1;
|
|
182
181
|
const successor = successors.map((key) => items.findIndex((item) => itemKey(item) === key)).find((at) => at >= 0);
|
|
183
|
-
row =
|
|
184
|
-
: successor !== undefined ? rows.length + successor : Math.min(row,
|
|
182
|
+
row = index >= 0 ? rows.length + index
|
|
183
|
+
: successor !== undefined ? rows.length + successor : Math.min(row, lastRow());
|
|
185
184
|
message = "";
|
|
186
185
|
isError = false;
|
|
187
186
|
requestRender();
|
|
@@ -215,7 +214,7 @@ export function createPermissionsPage(
|
|
|
215
214
|
}
|
|
216
215
|
|
|
217
216
|
function fold(open?: boolean): void {
|
|
218
|
-
const item = row >= rows.length
|
|
217
|
+
const item = row >= rows.length ? items[row - rows.length] : undefined;
|
|
219
218
|
const id = item?.kind === "group" ? item.id : item?.kind === "trusted" && open === false ? item.group : undefined;
|
|
220
219
|
if (!id) return;
|
|
221
220
|
if (open ?? !expanded.has(id)) expanded.add(id);
|
|
@@ -234,22 +233,20 @@ export function createPermissionsPage(
|
|
|
234
233
|
row = Math.max(0, row - 1);
|
|
235
234
|
requestRender();
|
|
236
235
|
} else if (matchesKey(data, Key.down)) {
|
|
237
|
-
row = Math.min(
|
|
236
|
+
row = Math.min(lastRow(), row + 1);
|
|
238
237
|
requestRender();
|
|
239
238
|
} else if (matchesKey(data, Key.left)) {
|
|
240
|
-
if (row >= rows.length
|
|
239
|
+
if (row >= rows.length) fold(false);
|
|
241
240
|
else column = 0;
|
|
242
241
|
requestRender();
|
|
243
242
|
} else if (matchesKey(data, Key.right)) {
|
|
244
|
-
if (row >= rows.length
|
|
243
|
+
if (row >= rows.length) fold(true);
|
|
245
244
|
else column = 1;
|
|
246
245
|
requestRender();
|
|
247
246
|
} else if (matchesKey(data, Key.space)) {
|
|
248
247
|
void change();
|
|
249
248
|
} else if (matchesKey(data, "ctrl+s")) {
|
|
250
249
|
void save();
|
|
251
|
-
} else if (matchesKey(data, Key.enter) && row === saveRow()) {
|
|
252
|
-
void save();
|
|
253
250
|
} else if (matchesKey(data, Key.enter)) {
|
|
254
251
|
fold();
|
|
255
252
|
}
|
|
@@ -315,13 +312,13 @@ export function createPermissionsPage(
|
|
|
315
312
|
output.push(theme.fg("dim", truncateToWidth(`${prefix ? " " : ""} Trusted (runs outside the file rules)`, w, "")),
|
|
316
313
|
theme.fg("dim", truncateToWidth(`${prefix ? " " : ""} No other extension tools are installed.`, w, "")));
|
|
317
314
|
}
|
|
318
|
-
output.push(""
|
|
315
|
+
output.push("");
|
|
319
316
|
const selected = row < rows.length ? rows[row]!.key : undefined;
|
|
320
|
-
const tool = row >= rows.length
|
|
317
|
+
const tool = row >= rows.length ? items[row - rows.length] : undefined;
|
|
321
318
|
const contextual = tool ? (tool.kind === "guarded" ? GUARDED_HINT : TRUSTED_HINT)
|
|
322
319
|
: selected && settings ? cellHint(selected, settings[columns[column]!]) : undefined;
|
|
323
320
|
for (const hint of [
|
|
324
|
-
"↑↓ Select · ←→ Column/fold · Space Toggle · ctrl+s Save · Enter Fold · Esc Back",
|
|
321
|
+
"↑↓ Select · ←→ Column/fold · Space Toggle · ctrl+s Save default · Enter Fold · Esc Back",
|
|
325
322
|
"Changes apply to new launches. Background tasks follow their launcher.",
|
|
326
323
|
"Stored credentials: known files only; excludes OS vaults and environment tokens.",
|
|
327
324
|
"Trusted tools run outside the file rules.",
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { parseSandboxPermissions, type SandboxPermissionSettings } from "./permissions.ts";
|
|
3
|
+
|
|
4
|
+
export const SESSION_PERMISSION_ENTRY = "pi-better-sandbox-permissions";
|
|
5
|
+
|
|
6
|
+
/** Only the active branch contributes policy; the newest entry must validate. */
|
|
7
|
+
export function readSessionPermissions(ctx: ExtensionContext): SandboxPermissionSettings | undefined {
|
|
8
|
+
const entry = [...ctx.sessionManager.getBranch()].reverse().find((entry) =>
|
|
9
|
+
entry.type === "custom" && entry.customType === SESSION_PERMISSION_ENTRY);
|
|
10
|
+
if (!entry || entry.type !== "custom") return undefined;
|
|
11
|
+
const data = entry.data as { version?: unknown; permissions?: unknown } | undefined;
|
|
12
|
+
if (!data || data.version !== 1) throw new Error("Unsupported sandbox session permissions version.");
|
|
13
|
+
// Session snapshots have always included Tools. Unlike old global files,
|
|
14
|
+
// an incomplete snapshot must not gain the default trusted-tool grants.
|
|
15
|
+
if (!data.permissions || typeof data.permissions !== "object" ||
|
|
16
|
+
(data.permissions as { subagentTools?: unknown }).subagentTools === undefined) {
|
|
17
|
+
throw new Error("Sandbox session permissions require explicit subagent tool settings.");
|
|
18
|
+
}
|
|
19
|
+
return parseSandboxPermissions(data.permissions);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function appendSessionPermissions(pi: ExtensionAPI, settings: SandboxPermissionSettings): void {
|
|
23
|
+
pi.appendEntry(SESSION_PERMISSION_ENTRY, { version: 1, permissions: parseSandboxPermissions(settings) });
|
|
24
|
+
}
|
|
@@ -6,6 +6,8 @@ const { createBashToolDefinition, createReadToolDefinition, createWriteToolDefin
|
|
|
6
6
|
createEditToolDefinition, createLocalBashOperations, getShellConfig } = PiCodingAgent;
|
|
7
7
|
import { pathToFileURL } from "node:url";
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import { unlink } from "node:fs/promises";
|
|
9
11
|
import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, readlinkSync, writeFileSync } from "node:fs";
|
|
10
12
|
import { homedir, tmpdir } from "node:os";
|
|
11
13
|
import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
@@ -117,42 +119,51 @@ export function createTaskBashOperations(
|
|
|
117
119
|
const scratch = plan.policy.runtimeWrite?.[0];
|
|
118
120
|
const taskEnv = { ...process.env, ...options.env,
|
|
119
121
|
...(scratch ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : {}) };
|
|
120
|
-
const wrapped = maybeBuildSandboxCommand({
|
|
121
|
-
policy: plan.policy, profilePath: plan.profilePath,
|
|
122
|
-
execPath: "/usr/bin/env", execArgs: ["-i", "--", ...Object.entries(taskEnv)
|
|
123
|
-
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
|
124
|
-
.map(([key, value]) => `${key}=${value}`), shell.shell, ...shell.args, command],
|
|
125
|
-
}, { sandboxEnabled: true, explicitSandbox: true });
|
|
126
|
-
if (!wrapped) throw new Error("Sandbox: no task execution backend is available.");
|
|
127
|
-
for (const line of wrapped.notices ?? []) options.onData(Buffer.from(`${line}\n`));
|
|
128
122
|
if (options.signal?.aborted) throw new Error("aborted");
|
|
129
123
|
if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout * 1000 > 2147483647)) {
|
|
130
124
|
throw new Error("Invalid timeout: must be a positive, supported number of seconds");
|
|
131
125
|
}
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
126
|
+
// A sibling launch must never rewrite a profile another process is reading.
|
|
127
|
+
const profilePath = `${plan.profilePath}.${randomUUID()}.bash.sb`;
|
|
128
|
+
try {
|
|
129
|
+
const wrapped = maybeBuildSandboxCommand({
|
|
130
|
+
policy: plan.policy, profilePath,
|
|
131
|
+
execPath: "/usr/bin/env", execArgs: ["-i", "--", ...Object.entries(taskEnv)
|
|
132
|
+
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
|
133
|
+
.map(([key, value]) => `${key}=${value}`), shell.shell, ...shell.args, command],
|
|
134
|
+
}, { sandboxEnabled: true, explicitSandbox: true });
|
|
135
|
+
if (!wrapped) throw new Error("Sandbox: no task execution backend is available.");
|
|
136
|
+
for (const line of wrapped.notices ?? []) options.onData(Buffer.from(`${line}\n`));
|
|
137
|
+
if (options.signal?.aborted) throw new Error("aborted");
|
|
138
|
+
// No shell or caller-controlled loader environment runs before the boundary.
|
|
139
|
+
return await new Promise<{ exitCode: number | null }>((resolve, reject) => {
|
|
140
|
+
const child = spawn(wrapped.file, wrapped.fileArgs, { cwd, detached: true,
|
|
141
|
+
env: { PATH: "/usr/bin:/bin", HOME: plan.policy.home }, stdio: ["ignore", "pipe", "pipe"] });
|
|
142
|
+
let timedOut = false;
|
|
143
|
+
const kill = () => { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } } };
|
|
144
|
+
const timer = options.timeout === undefined ? undefined : setTimeout(() => { timedOut = true; kill(); }, options.timeout * 1000);
|
|
145
|
+
if (child.pid) trackDetachedChildPid(child.pid);
|
|
146
|
+
const cleanup = () => {
|
|
147
|
+
if (child.pid) untrackDetachedChildPid(child.pid);
|
|
148
|
+
if (timer) clearTimeout(timer);
|
|
149
|
+
options.signal?.removeEventListener("abort", kill);
|
|
150
|
+
};
|
|
151
|
+
child.stdout.on("data", options.onData);
|
|
152
|
+
child.stderr.on("data", options.onData);
|
|
153
|
+
options.signal?.addEventListener("abort", kill, { once: true });
|
|
154
|
+
if (options.signal?.aborted) kill();
|
|
155
|
+
void waitForChildProcess(child).then((exitCode: number | null) => {
|
|
156
|
+
cleanup();
|
|
157
|
+
if (options.signal?.aborted) reject(new Error("aborted"));
|
|
158
|
+
else if (timedOut) reject(new Error(`timeout:${options.timeout}`));
|
|
159
|
+
else resolve({ exitCode });
|
|
160
|
+
}, (error: unknown) => { cleanup(); reject(error); });
|
|
161
|
+
});
|
|
162
|
+
} finally {
|
|
163
|
+
await unlink(profilePath).catch((error: NodeJS.ErrnoException) => {
|
|
164
|
+
if (error.code !== "ENOENT") throw error;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
156
167
|
},
|
|
157
168
|
};
|
|
158
169
|
}
|
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
* the *effective* status derived from live runtime evidence (which backend this
|
|
7
7
|
* platform actually resolves, not what the package intended).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* default and clears the previous override.
|
|
9
|
+
* The extension persists permission snapshots in Pi custom entries and restores
|
|
10
|
+
* the active branch after `beginSession` resets this in-memory controller.
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
|
|
@@ -132,7 +131,7 @@ export class ForegroundSandboxController {
|
|
|
132
131
|
|
|
133
132
|
/**
|
|
134
133
|
* Capture the canonical launch directory and apply the persisted default.
|
|
135
|
-
*
|
|
134
|
+
* The extension then restores any permissions on the active session branch.
|
|
136
135
|
*/
|
|
137
136
|
beginSession(cwd: string, defaultEnabled = false): ForegroundSandboxStatus {
|
|
138
137
|
const projectRoot = canonicalizePath(cwd, this.#seams);
|
|
@@ -156,7 +155,7 @@ export class ForegroundSandboxController {
|
|
|
156
155
|
return this.status();
|
|
157
156
|
}
|
|
158
157
|
|
|
159
|
-
/** Turn protection off for this session
|
|
158
|
+
/** Turn protection off for this session; persistence belongs to the extension. */
|
|
160
159
|
disable(): ForegroundSandboxStatus {
|
|
161
160
|
this.#sessionOverride = false;
|
|
162
161
|
this.#policyProblem = undefined;
|
|
@@ -36,10 +36,14 @@ Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or ne
|
|
|
36
36
|
## Delegation Modes
|
|
37
37
|
|
|
38
38
|
`config.json` sets `delegationMode` to `manual`, `adaptive` (default), or
|
|
39
|
-
`coordinator`. `/subagents`
|
|
40
|
-
|
|
41
|
-
session
|
|
42
|
-
|
|
39
|
+
`coordinator`. `/subagents settings` (or `/subagents`) opens a settings page
|
|
40
|
+
for delegation mode and the concurrent-subagent cap (default 4). Edits apply to
|
|
41
|
+
this session and persist across reloads; Ctrl+S saves both as defaults, and
|
|
42
|
+
Reset to defaults clears
|
|
43
|
+
session overrides. `/subagents mode manual|adaptive|coordinator` and
|
|
44
|
+
`/subagents cap <number>` are command equivalents; `/subagents save` persists
|
|
45
|
+
both. Lowering the cap blocks new single and batch launches without stopping
|
|
46
|
+
running work. The navigator's `main` row retains its mode-only controls:
|
|
43
47
|
`m` cycles the session mode, `ctrl+s` saves that default, and `x`
|
|
44
48
|
does not stop it. Manual delegates only on explicit user or workflow request, even with a
|
|
45
49
|
plan. Adaptive delegates substantial independent work when useful. Coordinator
|