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.
- package/README.md +2 -0
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +2 -0
- package/node_modules/pi-better-goal/README.md +2 -2
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +77 -14
- package/node_modules/pi-better-goal/src/preferences.ts +7 -4
- package/node_modules/pi-better-goal/src/settings-page.ts +1 -0
- package/node_modules/pi-better-sandbox/README.md +39 -9
- package/node_modules/pi-better-sandbox/index.ts +1 -0
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/permissions-page.ts +4 -4
- package/node_modules/pi-better-sandbox/permissions.ts +10 -4
- package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +2 -0
- package/node_modules/pi-better-sandbox/shared-task-process-list.ts +203 -0
- package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +8 -1
- package/node_modules/pi-better-sandbox/shared-task-tools.ts +1 -1
- package/node_modules/pi-better-subagents/docs/usage.md +15 -4
- package/node_modules/pi-better-subagents/index.ts +6 -1
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/permission-policy.ts +4 -1
- package/node_modules/pi-better-subagents/shared-sandbox-core.ts +2 -0
- package/node_modules/pi-better-subagents/shared-task-process-list.ts +203 -0
- package/node_modules/pi-better-subagents/shared-task-sandbox.ts +8 -1
- package/node_modules/pi-better-subagents/shared-task-tools.ts +1 -1
- package/node_modules/pi-better-subagents/subagent-tools.ts +5 -0
- package/node_modules/pi-better-subagents/task-guard.ts +3 -1
- package/node_modules/pi-better-subagents/task-policy.ts +4 -1
- 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
|
|
@@ -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.
|
|
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,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
|
|
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
|
-
|
|
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
|
-
|
|
1240
|
+
onAbort();
|
|
1183
1241
|
} else {
|
|
1184
|
-
turnSignal.addEventListener("abort",
|
|
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
|
-
//
|
|
1193
|
-
//
|
|
1194
|
-
|
|
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
|
-
|
|
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
|
|
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 -
|
|
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
|
|
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
|
|
93
|
-
`
|
|
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
|
|
97
|
-
|
|
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
|
|
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.
|
|
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
|
|
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")],
|
|
@@ -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 =
|
|
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
|
|
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. */
|