pi-better-harness 0.18.0 → 0.19.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/README.md +2 -0
  2. package/node_modules/pi-better-background-tasks/package.json +1 -1
  3. package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +2 -0
  4. package/node_modules/pi-better-goal/README.md +2 -2
  5. package/node_modules/pi-better-goal/package.json +1 -1
  6. package/node_modules/pi-better-goal/src/index.ts +77 -14
  7. package/node_modules/pi-better-goal/src/preferences.ts +7 -4
  8. package/node_modules/pi-better-goal/src/settings-page.ts +1 -0
  9. package/node_modules/pi-better-sandbox/README.md +39 -9
  10. package/node_modules/pi-better-sandbox/index.ts +1 -0
  11. package/node_modules/pi-better-sandbox/package.json +1 -1
  12. package/node_modules/pi-better-sandbox/permissions-page.ts +4 -4
  13. package/node_modules/pi-better-sandbox/permissions.ts +10 -4
  14. package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +2 -0
  15. package/node_modules/pi-better-sandbox/shared-task-process-list.ts +203 -0
  16. package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +8 -1
  17. package/node_modules/pi-better-sandbox/shared-task-tools.ts +1 -1
  18. package/node_modules/pi-better-subagents/docs/usage.md +15 -4
  19. package/node_modules/pi-better-subagents/index.ts +6 -1
  20. package/node_modules/pi-better-subagents/package.json +1 -1
  21. package/node_modules/pi-better-subagents/permission-policy.ts +4 -1
  22. package/node_modules/pi-better-subagents/shared-sandbox-core.ts +2 -0
  23. package/node_modules/pi-better-subagents/shared-task-process-list.ts +203 -0
  24. package/node_modules/pi-better-subagents/shared-task-sandbox.ts +8 -1
  25. package/node_modules/pi-better-subagents/shared-task-tools.ts +1 -1
  26. package/node_modules/pi-better-subagents/subagent-tools.ts +5 -0
  27. package/node_modules/pi-better-subagents/task-guard.ts +3 -1
  28. package/node_modules/pi-better-subagents/task-policy.ts +4 -1
  29. package/package.json +5 -5
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # pi-better-harness
2
2
 
3
+ <img src="https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/brand/logo.png" alt="Pi Better Harness logo" width="128" height="128" />
4
+
3
5
  `pi-better-harness` is a Pi meta package that installs the core Pi Better Harness extensions: an opt-in foreground write sandbox, delegated subagents, durable background tasks, synchronous SSH commands, goal tracking, and structured plans.
4
6
 
5
7
  ## Quick Answer
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -60,6 +60,8 @@ export type SandboxPermissions = {
60
60
  storedCredentials: "off" | "read" | "read-write";
61
61
  commands: boolean;
62
62
  network: boolean;
63
+ /** Fixed process_list adapter only; omitted legacy profiles keep it Off. */
64
+ processAccess?: "off" | "read";
63
65
  };
64
66
 
65
67
  /** Whether a file access level allows creating and overwriting files. */
@@ -13,8 +13,8 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
13
13
  ## Core Features
14
14
 
15
15
  - `/goal` runtime for starting, pausing, resuming, completing, and clearing the current objective.
16
- - `/goal settings` opens an interactive settings page in the TUI. Select a row with Up/Down and toggle with Space/Enter; changes save immediately, and Escape closes the page. Both controls default to `on`. Automatic continuation controls idle and background-drain wakes; conversational resume controls the agent's Escape-pause resume tool. Goal kickoff, `/goal resume`, and `alt+g` remain available with either control off. For scripts or non-interactive sessions, use `/goal settings auto-continue on|off` and `/goal settings conversational-resume on|off`; bare `/goal settings` prints the saved values outside the TUI.
17
- - `escape` pauses the active goal, and it stays paused while you talk to the agent. With conversational resume enabled, say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. The status line shows the available resume path, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
16
+ - `/goal settings` opens an interactive settings page in the TUI. Select a row with Up/Down and toggle with Space/Enter; changes save immediately, and Escape closes the page. All three controls default to `on`. Automatic continuation controls idle and background-drain wakes; conversational resume controls the agent's Escape-pause resume tool; **Pause on Esc** controls whether editor Escape pauses the active goal. Goal kickoff, `/goal resume`, and `alt+g` remain available with these controls off. For scripts or non-interactive sessions, use `/goal settings auto-continue on|off`, `/goal settings conversational-resume on|off`, and `/goal settings pause-on-escape on|off`; bare `/goal settings` prints the saved values outside the TUI.
17
+ - `escape` pauses the active goal by default, and it stays paused while you talk to the agent. With **Pause on Esc** off, observed editor Escape leaves the goal active while preserving native streaming interruption and menu/dialog cancellation. Automatic continuation remains independent. Hosts without terminal/focus inspection and unknown custom editors retain generic abort-to-pause behavior because they cannot identify Escape reliably. With conversational resume enabled, say "go" (the agent then calls `goal_resume`), or use `/goal resume` or `alt+g`. `/goal pause` resumes only through `/goal resume` or `alt+g`. The status line shows the available resume path, and paused goals are never poked. On a macOS terminal without Option-as-Meta, `alt+g` types `©`; use `/goal resume` there.
18
18
  - Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
19
19
  - A compact goal widget that does not replace Pi's footer.
20
20
  - Background activity tracking for subagents and other registered providers.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,5 +1,6 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { CustomEditor, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
+ import { isKeyRelease, matchesKey } from "@earendil-works/pi-tui";
3
4
  import { readFileSync } from "node:fs";
4
5
  import { Type } from "typebox";
5
6
  import { commandAvailable, commandInvocation, resolveGoalCommand } from "./command-binding.js";
@@ -108,10 +109,10 @@ const GOAL_ACTIONS: readonly AutocompleteItem[] = [
108
109
  { value: "resume", label: "resume", description: "Resume the paused goal" },
109
110
  { value: "clear", label: "clear", description: "Remove the current goal" },
110
111
  { value: "complete", label: "complete", description: "Mark the current goal complete" },
111
- { value: "settings", label: "settings", description: "Inspect or persist goal continuation and conversational resume controls" },
112
+ { value: "settings", label: "settings", description: "Inspect or persist goal continuation, conversational resume, and Escape pause controls" },
112
113
  ];
113
114
 
114
- const GOAL_SETTINGS = ["auto-continue", "conversational-resume"] as const;
115
+ const GOAL_SETTINGS = ["auto-continue", "conversational-resume", "pause-on-escape"] as const;
115
116
 
116
117
  export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
117
118
  const prefix = argumentPrefix.trimStart().toLowerCase();
@@ -227,7 +228,7 @@ function formatGoal(
227
228
  goal: GoalSnapshot | null,
228
229
  continuation: ReturnType<typeof currentContinuationState> = null,
229
230
  stall = observeGoalStall(goal, continuation),
230
- preferences: GoalPreferences = { autoContinue: true, conversationalResume: true },
231
+ preferences: GoalPreferences = { autoContinue: true, conversationalResume: true, pauseOnEscape: true },
231
232
  ): string {
232
233
  const settings = formatPreferences(preferences);
233
234
  if (!goal) {
@@ -260,6 +261,7 @@ function formatPreferences(preferences: GoalPreferences): string {
260
261
  return [
261
262
  `Automatic continuation setting: ${preferences.autoContinue ? "on" : "off"}${WAKE_DISABLED ? " (disabled by environment)" : ""}`,
262
263
  `Conversational resume setting: ${preferences.conversationalResume ? "on" : "off"}`,
264
+ `Pause on Esc setting: ${preferences.pauseOnEscape ? "on" : "off"}`,
263
265
  ].join("\n");
264
266
  }
265
267
 
@@ -291,6 +293,8 @@ export default function (pi: ExtensionAPI): void {
291
293
  let currentCtx: ExtensionContext | undefined;
292
294
  let executionGeneration = 0;
293
295
  let agentGeneration = -1;
296
+ let turnGeneration = 0;
297
+ let escapeAbortSuppression: { session: number; turn: number; execution: number } | undefined;
294
298
  let sessionGeneration = 0;
295
299
  let wakeGeneration = 0;
296
300
  let snapshotSequence = 0;
@@ -306,8 +310,11 @@ export default function (pi: ExtensionAPI): void {
306
310
  let idleContinuationTimer: ReturnType<typeof setTimeout> | undefined;
307
311
  let idleContinuationSignature = "";
308
312
  let refreshGoalWidget: ((force?: boolean) => void) | undefined;
313
+ let terminalInputUnsubscribe: (() => void) | undefined;
314
+ let terminalInputIsEditor: (() => boolean) | undefined;
315
+ let uiPromptDepth = 0;
309
316
  let lastAgentEvidence: ContinuationEvidence | null = null;
310
- let preferences: GoalPreferences = { autoContinue: true, conversationalResume: true };
317
+ let preferences: GoalPreferences = { autoContinue: true, conversationalResume: true, pauseOnEscape: true };
311
318
  /** Background items active when each pending blocking question started, keyed by tool call id. */
312
319
  const pendingQuestions = new Map<string, Map<string, BackgroundWorkItem>>();
313
320
 
@@ -416,6 +423,11 @@ export default function (pi: ExtensionAPI): void {
416
423
  : "Goal paused. Use /goal resume or alt+g to resume it.");
417
424
  };
418
425
 
426
+ const escapeAbortIsSuppressed = (): boolean =>
427
+ escapeAbortSuppression?.session === sessionGeneration &&
428
+ escapeAbortSuppression.turn === turnGeneration &&
429
+ escapeAbortSuppression.execution === executionGeneration;
430
+
419
431
  /** Why a paused goal cannot become active again, or null when it can. */
420
432
  const resumeBlocker = (goal: GoalSnapshot): string | null => {
421
433
  if (!goal.command && skillCommandName(goal.objective)) {
@@ -723,6 +735,16 @@ export default function (pi: ExtensionAPI): void {
723
735
  ctx.ui.setWidget(
724
736
  EXTENSION_NAME,
725
737
  (tui, theme) => {
738
+ const inputIsEditor = (): boolean => {
739
+ const focus = tui as typeof tui & { getFocusedComponent?(): Partial<CustomEditor> | null };
740
+ if (typeof focus.getFocusedComponent !== "function" || tui.hasOverlay()) return false;
741
+ // Duck typing survives Pi's bundled/jiti module boundaries. Unknown
742
+ // custom editors and dialog components keep ownership of Escape.
743
+ const editor = focus.getFocusedComponent();
744
+ return typeof editor?.onEscape === "function" &&
745
+ typeof editor.isShowingAutocomplete === "function" && !editor.isShowingAutocomplete();
746
+ };
747
+ terminalInputIsEditor = inputIsEditor;
726
748
  const requestRender = (force = false): void => {
727
749
  try {
728
750
  tui.requestRender(force);
@@ -746,6 +768,7 @@ export default function (pi: ExtensionAPI): void {
746
768
  return {
747
769
  dispose() {
748
770
  scheduler.dispose();
771
+ if (terminalInputIsEditor === inputIsEditor) terminalInputIsEditor = undefined;
749
772
  if (refreshGoalWidget === refresh) {
750
773
  refreshGoalWidget = undefined;
751
774
  }
@@ -818,12 +841,14 @@ export default function (pi: ExtensionAPI): void {
818
841
  const [, setting, mode, ...extra] = trimmed.split(/\s+/);
819
842
  if (setting !== undefined && (!GOAL_SETTINGS.includes(setting as typeof GOAL_SETTINGS[number]) ||
820
843
  (mode !== "on" && mode !== "off") || extra.length > 0)) {
821
- notifyGoal(ctx, "Usage: /goal settings [auto-continue|conversational-resume on|off]", "warning");
844
+ notifyGoal(ctx, "Usage: /goal settings [auto-continue|conversational-resume|pause-on-escape on|off]", "warning");
822
845
  return;
823
846
  }
824
847
  if (setting !== undefined) {
825
848
  try {
826
- await changePreference(setting === "auto-continue" ? "autoContinue" : "conversationalResume", mode === "on");
849
+ const key = setting === "auto-continue" ? "autoContinue"
850
+ : setting === "conversational-resume" ? "conversationalResume" : "pauseOnEscape";
851
+ await changePreference(key, mode === "on");
827
852
  } catch (error) {
828
853
  notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
829
854
  return;
@@ -1058,8 +1083,13 @@ export default function (pi: ExtensionAPI): void {
1058
1083
  });
1059
1084
 
1060
1085
  pi.on("session_start", async (_event, ctx) => {
1086
+ terminalInputUnsubscribe?.();
1087
+ terminalInputUnsubscribe = undefined;
1088
+ terminalInputIsEditor = undefined;
1089
+ uiPromptDepth = 0;
1061
1090
  executionGeneration += 1;
1062
1091
  sessionGeneration += 1;
1092
+ escapeAbortSuppression = undefined;
1063
1093
  clearIdleContinuation();
1064
1094
  continuationQueuedFor = null;
1065
1095
  backgroundDrainTracker = null;
@@ -1081,6 +1111,23 @@ export default function (pi: ExtensionAPI): void {
1081
1111
  pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
1082
1112
  syncResumeTool(getGoal(ctx));
1083
1113
  installGoalWidget(ctx);
1114
+ if (ctx.mode === "tui" && ctx.hasUI && typeof ctx.ui.onTerminalInput === "function") {
1115
+ const session = sessionGeneration;
1116
+ terminalInputUnsubscribe = ctx.ui.onTerminalInput((data) => {
1117
+ if (session === sessionGeneration && uiPromptDepth === 0 &&
1118
+ !isKeyRelease(data) && matchesKey(data, "escape") && terminalInputIsEditor?.()) {
1119
+ if (preferences.pauseOnEscape) {
1120
+ pauseGoalOnInterrupt(ctx);
1121
+ } else if (foregroundRunning) {
1122
+ // The original key reaches Pi next and may abort this run. Preserve
1123
+ // this keypress's choice through both abort notifications only.
1124
+ escapeAbortSuppression = { session, turn: turnGeneration, execution: executionGeneration };
1125
+ }
1126
+ }
1127
+ // Observe only: Pi must still interrupt streams or handle editor Escape.
1128
+ return undefined;
1129
+ });
1130
+ }
1084
1131
  const snapshot = await publishSnapshot(ctx);
1085
1132
  if (!snapshot) return;
1086
1133
  syncPollingState();
@@ -1089,6 +1136,9 @@ export default function (pi: ExtensionAPI): void {
1089
1136
  }
1090
1137
  });
1091
1138
 
1139
+ pi.on("ui_prompt_start", () => { uiPromptDepth += 1; });
1140
+ pi.on("ui_prompt_end", () => { uiPromptDepth = Math.max(0, uiPromptDepth - 1); });
1141
+
1092
1142
  pi.on("input", async (event, ctx) => {
1093
1143
  if (event.source === "extension") {
1094
1144
  return;
@@ -1170,6 +1220,8 @@ export default function (pi: ExtensionAPI): void {
1170
1220
 
1171
1221
  pi.on("agent_start", async (_event, ctx) => {
1172
1222
  executionGeneration += 1;
1223
+ turnGeneration += 1;
1224
+ escapeAbortSuppression = undefined;
1173
1225
  agentGeneration = executionGeneration;
1174
1226
  currentCtx = ctx;
1175
1227
  foregroundRunning = true;
@@ -1178,10 +1230,16 @@ export default function (pi: ExtensionAPI): void {
1178
1230
  lastAgentEvidence = null;
1179
1231
  const turnSignal = ctx.signal;
1180
1232
  if (turnSignal) {
1233
+ const session = sessionGeneration;
1234
+ const turn = turnGeneration;
1235
+ const onAbort = (): void => {
1236
+ if (session !== sessionGeneration || turn !== turnGeneration) return;
1237
+ if (!escapeAbortIsSuppressed()) pauseGoalOnInterrupt(ctx);
1238
+ };
1181
1239
  if (turnSignal.aborted) {
1182
- pauseGoalOnInterrupt(ctx);
1240
+ onAbort();
1183
1241
  } else {
1184
- turnSignal.addEventListener("abort", () => pauseGoalOnInterrupt(ctx), { once: true });
1242
+ turnSignal.addEventListener("abort", onAbort, { once: true });
1185
1243
  }
1186
1244
  }
1187
1245
  await publishSnapshot(ctx);
@@ -1189,13 +1247,12 @@ export default function (pi: ExtensionAPI): void {
1189
1247
 
1190
1248
  pi.on("agent_end", async (event, ctx) => {
1191
1249
  lastAgentEvidence = agentGeneration === executionGeneration ? continuationEvidence(event.messages) : null;
1192
- // `escape` is a pi-reserved built-in shortcut (`app.interrupt`), so extensions
1193
- // cannot register it. Observe the interrupt instead: when a running turn is
1194
- // aborted (escape / ctrl+c while streaming), pause the active goal so it does
1195
- // not auto-continue after the user stopped the agent.
1196
- if (wasTurnAborted(event.messages)) {
1250
+ // Only an observed editor Escape with pause disabled exempts this abort.
1251
+ // Unknown interrupt sources retain the safety fallback on every host.
1252
+ if (wasTurnAborted(event.messages) && !escapeAbortIsSuppressed()) {
1197
1253
  pauseGoalOnInterrupt(ctx);
1198
1254
  }
1255
+ escapeAbortSuppression = undefined;
1199
1256
  });
1200
1257
 
1201
1258
  pi.on("tool_execution_start", async (event, ctx) => {
@@ -1228,6 +1285,7 @@ export default function (pi: ExtensionAPI): void {
1228
1285
 
1229
1286
  pi.on("agent_settled", async (_event, ctx) => {
1230
1287
  executionGeneration += 1;
1288
+ escapeAbortSuppression = undefined;
1231
1289
  currentCtx = ctx;
1232
1290
  foregroundRunning = false;
1233
1291
  const snapshot = await publishSnapshot(ctx);
@@ -1274,6 +1332,11 @@ export default function (pi: ExtensionAPI): void {
1274
1332
  pi.on("session_shutdown", async (_event, ctx) => {
1275
1333
  executionGeneration += 1;
1276
1334
  sessionGeneration += 1;
1335
+ escapeAbortSuppression = undefined;
1336
+ terminalInputUnsubscribe?.();
1337
+ terminalInputUnsubscribe = undefined;
1338
+ terminalInputIsEditor = undefined;
1339
+ uiPromptDepth = 0;
1277
1340
  stopPolling();
1278
1341
  foregroundRunning = false;
1279
1342
  pendingQuestions.clear();
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
5
5
  export interface GoalPreferences {
6
6
  autoContinue: boolean;
7
7
  conversationalResume: boolean;
8
+ pauseOnEscape: boolean;
8
9
  }
9
10
 
10
11
  export interface GoalPreferenceSeams {
@@ -22,7 +23,7 @@ export function readGoalPreferences(seams: GoalPreferenceSeams = {}): GoalPrefer
22
23
  raw = readFileSync(path, "utf8");
23
24
  } catch (error) {
24
25
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
25
- return { autoContinue: true, conversationalResume: true };
26
+ return { autoContinue: true, conversationalResume: true, pauseOnEscape: true };
26
27
  }
27
28
  throw new Error(`Goal preferences at ${path} could not be read: ${messageOf(error)}`);
28
29
  }
@@ -35,16 +36,18 @@ export function readGoalPreferences(seams: GoalPreferenceSeams = {}): GoalPrefer
35
36
  const value = parsed as Partial<GoalPreferences> & { version?: unknown } | null;
36
37
  if (value?.version !== 1 ||
37
38
  (value.autoContinue !== undefined && typeof value.autoContinue !== "boolean") ||
38
- (value.conversationalResume !== undefined && typeof value.conversationalResume !== "boolean")) {
39
- throw new Error(`Goal preferences at ${path} must contain version 1 and boolean autoContinue/conversationalResume settings.`);
39
+ (value.conversationalResume !== undefined && typeof value.conversationalResume !== "boolean") ||
40
+ (value.pauseOnEscape !== undefined && typeof value.pauseOnEscape !== "boolean")) {
41
+ throw new Error(`Goal preferences at ${path} must contain version 1 and boolean autoContinue/conversationalResume/pauseOnEscape settings.`);
40
42
  }
41
43
  return {
42
44
  autoContinue: value.autoContinue ?? true,
43
45
  conversationalResume: value.conversationalResume ?? true,
46
+ pauseOnEscape: value.pauseOnEscape ?? true,
44
47
  };
45
48
  }
46
49
 
47
- /** Persist one control without replacing the other session's saved choice. */
50
+ /** Persist one control without replacing another session's saved choices. */
48
51
  export function writeGoalPreference(
49
52
  key: keyof GoalPreferences,
50
53
  enabled: boolean,
@@ -5,6 +5,7 @@ import type { GoalPreferences } from "./preferences.js";
5
5
  const rows: { key: keyof GoalPreferences; label: string }[] = [
6
6
  { key: "autoContinue", label: "Automatic continuation" },
7
7
  { key: "conversationalResume", label: "Conversational resume" },
8
+ { key: "pauseOnEscape", label: "Pause on Esc" },
8
9
  ];
9
10
 
10
11
  export function createGoalSettingsPage(
@@ -23,9 +23,10 @@ Sandbox Off On
23
23
 
24
24
  Project files - Write & delete
25
25
  Outside project - Write
26
- Stored credentials - Read
26
+ Stored credentials - Write & delete
27
27
  Run commands & applications - On
28
28
  Network access - On
29
+ Process access - Off
29
30
 
30
31
  Subagents · Tools
31
32
  Guarded (follows the file rules)
@@ -70,9 +71,14 @@ Project files and Outside project cycle through four levels:
70
71
  | ----- | ------- |
71
72
  | Off | No access. |
72
73
  | Read | Read only. |
73
- | Write | Read, create, and overwrite in place. Nothing can be removed or renamed away. |
74
+ | Write | Read, create, and overwrite in place; removal is restricted, with the exceptions below. |
74
75
  | Write & delete | Full access, including removal. This was called "Read / write"; saved settings keep it. |
75
76
 
77
+ Stored credentials uses the same labels but offers only **Off**, **Read**, and
78
+ **Write & delete**. Subagents default to Write & delete; Main retains Read.
79
+ Explicit saved defaults and session profiles are preserved, including Read and
80
+ Off. Changing the built-in default does not upgrade existing profiles.
81
+
76
82
  Whatever these rows say, removal is always allowed in **temp**, **hidden
77
83
  directories and files directly under your home** (`~/.cache`, `~/.gradle`,
78
84
  `~/.npm`, …), and **git worktree folders** (`.worktrees/`, `*-worktrees/`). The
@@ -89,12 +95,16 @@ select the row.
89
95
  home directory and temp, so tool caches just work with no per-tool setup. It
90
96
  retains fixed protections that are not configurable per tool:
91
97
 
92
- - **Credentials** follow the independent Stored credentials row. The default
93
- `Read` setting exposes the files listed below plus `~/Library/Keychains`,
98
+ - **Credentials** follow the independent Stored credentials row. The Subagents
99
+ `Write & delete` default permits reading, modifying, and removing the files
100
+ listed below plus `~/Library/Keychains`,
94
101
  `~/.claude/.credentials.json`, `~/.claude.json`, `~/.gnupg`,
95
102
  `~/.codex/auth.json`, `~/.cargo/credentials(.toml)`, `~/.pgpass`, and
96
- `~/.config/rclone` to confined subagents, while denying writes. Set the row to
97
- Off to hide them or Read / write when a CLI must refresh credential state.
103
+ `~/.config/rclone`, subject to stricter protected-path rules. Set the row to
104
+ Read to deny credential writes or Off to hide them. Writable credentials let
105
+ CLIs such as `gcloud` update database permissions and refresh token state even
106
+ for read-only cloud queries. This also lets tasks alter or delete credentials;
107
+ it is not a read-only authentication capability.
98
108
  - **Code that runs later** cannot be written, removed, or renamed: shell startup
99
109
  files (`.bashrc`, `.zshrc`, `.profile`, and the rest), `~/.config/fish`,
100
110
  `~/.gitconfig`, `~/.config/git`, `~/.config/systemd/user`,
@@ -108,7 +118,7 @@ retains fixed protections that are not configurable per tool:
108
118
  control directories, and task-runtime provenance) cannot be written, removed,
109
119
  or renamed.
110
120
 
111
- Every entry is protected under the literal path under home, every symlink hop
121
+ Every protected entry is guarded under the literal path under home, every symlink hop
112
122
  on the way to its target, and the target itself. So a dotfiles manager's links
113
123
  (stow, chezmoi), including chains of links, can't be swapped or retargeted. A
114
124
  link whose target is missing protects that target too: it can't be created.
@@ -166,10 +176,30 @@ could likely delete these snapshots. Restore from one with Time Machine or
166
176
  AWS, GitHub CLI, Google Cloud CLI, Azure, Kubernetes, Docker, npm, netrc, Git
167
177
  credentials, and Pi's file-based auth. These rules override ordinary file access.
168
178
  OS vault services such as Keychain and Secret Service, and tokens inherited in
169
- environment variables, are excluded. Read / write may be needed by a CLI that
179
+ environment variables, are excluded. Write & delete may be needed by a CLI that
170
180
  refreshes a token or updates its credential database. This row applies
171
181
  independently of Project files and Outside project.
172
182
 
183
+ **Process access** controls the fixed `process_list` tool: **Off** (default) or
184
+ **Read**. Read returns current-user process IDs and names only, with optional
185
+ literal name filtering and a bounded result limit. It does not expose command
186
+ arguments, environment variables, debugger attachment, signals, termination,
187
+ or emulator/ADB operations. Legacy saved profiles stay Off until a human
188
+ enables Read; subagents snapshot the setting at launch.
189
+
190
+ The inventory helper uses a fixed system `pgrep` command under the existing
191
+ kernel policy, without a shell or model-controlled arguments. It can work with
192
+ Run commands & applications Off because it is a fixed read-only adapter, not
193
+ general command execution. No sandbox restrictions are relaxed. The control
194
+ governs this adapter only: it does not make arbitrary shell process inspection
195
+ impossible when commands are enabled. Windows has no supported inventory backend.
196
+ Linux still requires a supported permission profile with visible process metadata;
197
+ hidden-root profiles without `/proc` fail rather than gaining a new host mount.
198
+
199
+ On macOS, `/bin/ps` is setuid and can be refused at execution by Seatbelt even
200
+ when process information reads are allowed. Enabling Process access provides
201
+ `process_list`; it does not authorize raw `/bin/ps` or an unconfined workaround.
202
+
173
203
  File and shell operations use the kernel: macOS uses Seatbelt (`sandbox-exec`)
174
204
  and Linux uses Bubblewrap (`bwrap`). `read`, `write`, and `edit` keep Pi's normal
175
205
  tool behavior and mutation queues, while a fixed worker performs filesystem
@@ -187,7 +217,7 @@ commands & applications Off. The fixed file worker remains available according
187
217
  to the file permissions even when task commands are Off.
188
218
 
189
219
  The task executor provides private scratch through `TMPDIR`, `TMP`, and `TEMP`.
190
- Outside Read and Read/write also retain explicit runtime write exceptions for
220
+ Outside Read and Write & delete also retain explicit runtime write exceptions for
191
221
  `/tmp` (canonical `/private/tmp` on macOS), the current user's macOS temporary
192
222
  directory, and that user's Security.framework MDS cache. MDS access lets CLI
193
223
  Keychain retrieval initialize and refresh its cache; it does not restrict the
@@ -53,6 +53,7 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
53
53
  let shellPath: string | undefined;
54
54
  const ownEntry = fileURLToPath(import.meta.url);
55
55
  const boundary = installTaskTools(pi, { controller, cwd: process.cwd(), shellPath: () => shellPath,
56
+ processAccess: () => controller.permissionSettings()?.main.processAccess ?? "off",
56
57
  trustedSources: [ownEntry,
57
58
  join(dirname(ownEntry), "../../extensions/sandbox/index.ts"),
58
59
  join(dirname(ownEntry), "../pi-better-harness/extensions/sandbox/index.ts")],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.9.1",
3
+ "version": "0.10.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",
@@ -34,11 +34,11 @@ const rows = [
34
34
  { label: "Stored credentials", key: "storedCredentials" },
35
35
  { label: "Run commands & applications", key: "commands" },
36
36
  { label: "Network access", key: "network" },
37
+ { label: "Process access", key: "processAccess" },
37
38
  ] as const;
38
39
  const fileValues: readonly FileAccess[] = ["off", "read", "write", "read-write"];
39
40
  const credentialValues: readonly CredentialAccess[] = ["off", "read", "read-write"];
40
41
  const FILE_LABELS: Record<FileAccess, string> = { off: "Off", read: "Read", write: "Write", "read-write": "Write & delete" };
41
- const CREDENTIAL_LABELS: Record<CredentialAccess, string> = { off: "Off", read: "Read", "read-write": "Read / write" };
42
42
 
43
43
  /** The context line for the highlighted cell, or undefined. */
44
44
  function cellHint(key: string, profile: PermissionProfile | undefined): string | undefined {
@@ -50,6 +50,7 @@ function cellHint(key: string, profile: PermissionProfile | undefined): string |
50
50
  : "Always deletable: temp, hidden ~/.directories and worktree folders (.worktrees/, *-worktrees/).";
51
51
  }
52
52
  if (key === "storedCredentials") return "Known credential files follow this row independently of Outside project.";
53
+ if (key === "processAccess") return "process_list only: current-user PIDs and names. No process control; shell access is unchanged.";
53
54
  return undefined;
54
55
  }
55
56
  const columns = ["main", "subagents"] as const;
@@ -158,6 +159,8 @@ export function createPermissionsPage(
158
159
  if (key !== "enabled" && !settings[profile].enabled) return;
159
160
  if (key === "enabled" || key === "commands" || key === "network") {
160
161
  next[profile][key] = !next[profile][key];
162
+ } else if (key === "processAccess") {
163
+ next[profile][key] = next[profile][key] === "off" ? "read" : "off";
161
164
  } else if (key === "storedCredentials") {
162
165
  const current = next[profile][key];
163
166
  next[profile][key] = credentialValues[(credentialValues.indexOf(current) + 1) % credentialValues.length]!;
@@ -276,9 +279,6 @@ export function createPermissionsPage(
276
279
  const entry = rows[i]!;
277
280
  const value = (profile: PermissionProfile): string => {
278
281
  if (entry.key !== "enabled" && !profile.enabled) return "-";
279
- if (entry.key === "storedCredentials") {
280
- return CREDENTIAL_LABELS[profile.storedCredentials];
281
- }
282
282
  const current = profile[entry.key];
283
283
  return typeof current === "boolean" ? (current ? "On" : "Off") : FILE_LABELS[current];
284
284
  };
@@ -9,7 +9,7 @@ export type { SubagentToolSettings } from "./shared-task-tools.ts";
9
9
  */
10
10
  export type FileAccess = "off" | "read" | "write" | "read-write";
11
11
  /** Stored credentials keeps its three levels. */
12
- export type CredentialAccess = "off" | "read" | "read-write";
12
+ export type CredentialAccess = Exclude<FileAccess, "write">;
13
13
  export interface SandboxPermissionProfile {
14
14
  enabled: boolean;
15
15
  projectFiles: FileAccess;
@@ -17,6 +17,7 @@ export interface SandboxPermissionProfile {
17
17
  storedCredentials: CredentialAccess;
18
18
  commands: boolean;
19
19
  network: boolean;
20
+ processAccess: "off" | "read";
20
21
  }
21
22
  export interface SandboxPermissionSettings {
22
23
  main: SandboxPermissionProfile;
@@ -27,7 +28,8 @@ export interface SandboxPermissionSettings {
27
28
 
28
29
  /**
29
30
  * Return fresh profiles so changing one column never changes the other.
30
- * Subagents default to Outside project = Write; Main keeps Read.
31
+ * Subagents default to Outside project = Write and credentials = Write & delete.
32
+ * Main keeps Read for both rows. Explicit saved profiles retain their values.
31
33
  */
32
34
  export function defaultSandboxPermissions(): SandboxPermissionSettings {
33
35
  const profile = (): SandboxPermissionProfile => ({
@@ -37,8 +39,9 @@ export function defaultSandboxPermissions(): SandboxPermissionSettings {
37
39
  storedCredentials: "read",
38
40
  commands: true,
39
41
  network: true,
42
+ processAccess: "off",
40
43
  });
41
- return { main: profile(), subagents: { ...profile(), enabled: true, outsideProject: "write" }, subagentTools: defaultSubagentTools() };
44
+ return { main: profile(), subagents: { ...profile(), enabled: true, outsideProject: "write", storedCredentials: "read-write" }, subagentTools: defaultSubagentTools() };
42
45
  }
43
46
 
44
47
  function isProfile(value: unknown): value is SandboxPermissionProfile {
@@ -48,7 +51,8 @@ function isProfile(value: unknown): value is SandboxPermissionProfile {
48
51
  const access = (v: unknown): boolean => credential(v) || v === "write";
49
52
  return typeof p.enabled === "boolean" && typeof p.commands === "boolean" &&
50
53
  typeof p.network === "boolean" && access(p.projectFiles) &&
51
- access(p.outsideProject) && credential(p.storedCredentials);
54
+ access(p.outsideProject) && credential(p.storedCredentials) &&
55
+ (p.processAccess === undefined || p.processAccess === "off" || p.processAccess === "read");
52
56
  }
53
57
 
54
58
  const FILE_RANK: Record<FileAccess, number> = { off: 0, read: 1, write: 2, "read-write": 3 };
@@ -71,6 +75,7 @@ export function describeLoosening(previous: SandboxPermissionSettings, next: San
71
75
  for (const key of ["commands", "network"] as const) {
72
76
  if (b[key] && !a[key]) changes.push(`${label}: ${key} on`);
73
77
  }
78
+ if (a.processAccess !== "read" && b.processAccess === "read") changes.push(`${label}: processAccess off → read`);
74
79
  }
75
80
  // A trusted tool runs outside the file rules: ticking one loosens the profile.
76
81
  for (const tool of next.subagentTools.trusted) {
@@ -91,6 +96,7 @@ export function parseSandboxPermissions(value: unknown): SandboxPermissionSettin
91
96
  const copy = (p: SandboxPermissionProfile): SandboxPermissionProfile => ({
92
97
  enabled: p.enabled, projectFiles: p.projectFiles, outsideProject: p.outsideProject,
93
98
  storedCredentials: p.storedCredentials, commands: p.commands, network: p.network,
99
+ processAccess: p.processAccess ?? "off",
94
100
  });
95
101
  // Files written before the Tools section existed get the default tool set.
96
102
  return { main: copy(settings.main), subagents: copy(settings.subagents), subagentTools: parseSubagentTools(settings.subagentTools) };
@@ -60,6 +60,8 @@ export type SandboxPermissions = {
60
60
  storedCredentials: "off" | "read" | "read-write";
61
61
  commands: boolean;
62
62
  network: boolean;
63
+ /** Fixed process_list adapter only; omitted legacy profiles keep it Off. */
64
+ processAccess?: "off" | "read";
63
65
  };
64
66
 
65
67
  /** Whether a file access level allows creating and overwriting files. */