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.
Files changed (29) hide show
  1. package/node_modules/pi-better-goal/README.md +1 -1
  2. package/node_modules/pi-better-goal/package.json +1 -1
  3. package/node_modules/pi-better-goal/src/index.ts +2 -2
  4. package/node_modules/pi-better-sandbox/README.md +15 -8
  5. package/node_modules/pi-better-sandbox/commands.ts +17 -4
  6. package/node_modules/pi-better-sandbox/index.ts +28 -9
  7. package/node_modules/pi-better-sandbox/package.json +1 -1
  8. package/node_modules/pi-better-sandbox/permissions-page.ts +12 -15
  9. package/node_modules/pi-better-sandbox/session-permissions.ts +24 -0
  10. package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +43 -32
  11. package/node_modules/pi-better-sandbox/state.ts +4 -5
  12. package/node_modules/pi-better-subagents/README.md +8 -4
  13. package/node_modules/pi-better-subagents/agent-commands.ts +35 -7
  14. package/node_modules/pi-better-subagents/agent-session-settings.ts +89 -0
  15. package/node_modules/pi-better-subagents/agents-catalog-tool.ts +6 -2
  16. package/node_modules/pi-better-subagents/catalog-resolver.ts +10 -6
  17. package/node_modules/pi-better-subagents/catalog-runtime.ts +8 -2
  18. package/node_modules/pi-better-subagents/catalog-store.ts +3 -0
  19. package/node_modules/pi-better-subagents/catalog-ui.ts +115 -9
  20. package/node_modules/pi-better-subagents/config.ts +28 -1
  21. package/node_modules/pi-better-subagents/delegation.ts +2 -0
  22. package/node_modules/pi-better-subagents/docs/agent-catalog-lifecycle.md +23 -0
  23. package/node_modules/pi-better-subagents/docs/agent-catalog-operations.md +11 -1
  24. package/node_modules/pi-better-subagents/docs/usage.md +35 -11
  25. package/node_modules/pi-better-subagents/index.ts +98 -22
  26. package/node_modules/pi-better-subagents/package.json +1 -1
  27. package/node_modules/pi-better-subagents/settings-page.ts +140 -0
  28. package/node_modules/pi-better-subagents/shared-task-sandbox.ts +43 -32
  29. 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 (30s through 300s 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.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 = 30_000;
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 (30s, 60s, 90s, ... by default).
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. **Save as defaults** persists both profiles.
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 row ←→ Select column / expand / collapse Enter Fold Space Change
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
- saving looser defaults still needs a second Enter. Only individual tool
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 on the Save row still saves. The focused
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
- **Save as defaults** after you choose Write, uses the new Subagents default. To
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. Session overrides do not survive
332
- startup, new session, resume, fork, or reload. Save as defaults writes both
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 for the rest of this session. The",
59
- "next session applies your persisted foreground sandbox default.",
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
- const status = controller.enable();
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
- const status = controller.disable();
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
- pi.on("session_start", (_event, ctx: ExtensionContext) => {
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
- // Re-read saved profiles on each session. Malformed policy blocks protected
112
- // operations rather than silently widening a restricted session.
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
- settings = readPermissionSettings();
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) => announce(controller.setPermissionSettings(settings)),
198
+ change: (settings) => {
199
+ appendSessionPermissions(pi, settings);
200
+ announce(controller.setPermissionSettings(settings));
201
+ },
183
202
  save: (settings) => writePermissionSettings(settings),
184
- // Persisting a looser default needs a second, explicit Enter.
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(); }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Pi extension with independent Main and Subagents permission profiles, file guards, and OS sandbox enforcement.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 saveRow = () => rows.length + items.length;
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 || row === saveRow()) return;
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 && row < saveRow() ? items[row - rows.length] : undefined;
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 = wasSave ? saveRow() : index >= 0 ? rows.length + index
184
- : successor !== undefined ? rows.length + successor : Math.min(row, saveRow());
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 && row < saveRow() ? items[row - rows.length] : undefined;
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(saveRow(), row + 1);
236
+ row = Math.min(lastRow(), row + 1);
238
237
  requestRender();
239
238
  } else if (matchesKey(data, Key.left)) {
240
- if (row >= rows.length && row < saveRow()) fold(false);
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 && row < saveRow()) fold(true);
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("", line("Save as defaults", "", "", row === saveRow()));
315
+ output.push("");
319
316
  const selected = row < rows.length ? rows[row]!.key : undefined;
320
- const tool = row >= rows.length && row < saveRow() ? items[row - rows.length] : undefined;
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
- // No shell or caller-controlled loader environment runs before the boundary.
133
- return new Promise((resolve, reject) => {
134
- const child = spawn(wrapped.file, wrapped.fileArgs, { cwd, detached: true,
135
- env: { PATH: "/usr/bin:/bin", HOME: plan.policy.home }, stdio: ["ignore", "pipe", "pipe"] });
136
- let timedOut = false;
137
- const kill = () => { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } } };
138
- const timer = options.timeout === undefined ? undefined : setTimeout(() => { timedOut = true; kill(); }, options.timeout * 1000);
139
- if (child.pid) trackDetachedChildPid(child.pid);
140
- const cleanup = () => {
141
- if (child.pid) untrackDetachedChildPid(child.pid);
142
- if (timer) clearTimeout(timer);
143
- options.signal?.removeEventListener("abort", kill);
144
- };
145
- child.stdout.on("data", options.onData);
146
- child.stderr.on("data", options.onData);
147
- options.signal?.addEventListener("abort", kill, { once: true });
148
- if (options.signal?.aborted) kill();
149
- void waitForChildProcess(child).then((exitCode: number | null) => {
150
- cleanup();
151
- if (options.signal?.aborted) reject(new Error("aborted"));
152
- else if (timedOut) reject(new Error(`timeout:${options.timeout}`));
153
- else resolve({ exitCode });
154
- }, (error: unknown) => { cleanup(); reject(error); });
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
- * Session overrides are deliberately in-memory only. Every session start —
10
- * startup, new, resume, fork, reload — calls `beginSession` with the persisted
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
- * A session override never survives a new, resumed, forked, or reloaded session.
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 only. Never persisted. */
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` displays the active mode, and
40
- `/subagents mode manual|adaptive|coordinator` changes it for the current
41
- session without changing config. `/subagents save` writes the current mode as
42
- the next session's default. The navigator's `main` row opens the same control:
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