pi-better-harness 0.12.2 → 0.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/shared-sandbox-core.ts +22 -20
- package/node_modules/pi-better-plan/package.json +1 -1
- package/node_modules/pi-better-plan/src/index.ts +17 -1
- package/node_modules/pi-better-sandbox/README.md +8 -9
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/permissions-page.ts +2 -9
- package/node_modules/pi-better-sandbox/shared-sandbox-core.ts +22 -20
- package/node_modules/pi-better-subagents/README.md +1 -1
- package/node_modules/pi-better-subagents/index.ts +15 -2
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/shared-sandbox-core.ts +22 -20
- package/node_modules/pi-better-subagents/timing.ts +9 -8
- package/package.json +5 -5
|
@@ -228,10 +228,7 @@ const CREDENTIAL_LOCATIONS = [
|
|
|
228
228
|
".docker/config.json", ".npmrc", ".netrc", ".git-credentials", ".pi/agent/auth.json",
|
|
229
229
|
] as const;
|
|
230
230
|
|
|
231
|
-
/**
|
|
232
|
-
* Credential stores added by the broad-write profile. They are never readable
|
|
233
|
-
* or writable there, whatever the Stored credentials row says.
|
|
234
|
-
*/
|
|
231
|
+
/** Credential stores added to the Stored credentials row by the broad-write profile. */
|
|
235
232
|
const BROAD_CREDENTIAL_LOCATIONS = [
|
|
236
233
|
"Library/Keychains", ".claude/.credentials.json", ".claude.json", ".gnupg",
|
|
237
234
|
".codex/auth.json", ".cargo/credentials.toml", ".cargo/credentials", ".pgpass", ".config/rclone",
|
|
@@ -386,10 +383,7 @@ function compile(
|
|
|
386
383
|
return {
|
|
387
384
|
writableRoot, denyWrite, home: policy.home,
|
|
388
385
|
...(policy.permissions && {
|
|
389
|
-
|
|
390
|
-
permissions: broadMode
|
|
391
|
-
? { ...policy.permissions, storedCredentials: "off" as const }
|
|
392
|
-
: { ...policy.permissions },
|
|
386
|
+
permissions: { ...policy.permissions },
|
|
393
387
|
credentialPaths: broadMode ? broadCredentialPaths(policy.home, seams) : credentialFilePaths(policy.home, seams),
|
|
394
388
|
compatibilityWrite,
|
|
395
389
|
runtimeWrite,
|
|
@@ -598,7 +592,7 @@ export function evaluateReadAccess(
|
|
|
598
592
|
const permissions = policy.permissions;
|
|
599
593
|
if (!permissions) return { allowed: true, path };
|
|
600
594
|
if (policy.broad) {
|
|
601
|
-
const denied = isCredential(path, policy) ||
|
|
595
|
+
const denied = (isCredential(path, policy) && permissions.storedCredentials === "off") ||
|
|
602
596
|
(permissions.projectFiles === "off" && contains(policy.writableRoot, path));
|
|
603
597
|
return denied ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
|
|
604
598
|
}
|
|
@@ -652,7 +646,8 @@ function evaluateBroadWrite(
|
|
|
652
646
|
for (const denied of [...policy.denyWrite, ...broad.codePaths]) {
|
|
653
647
|
if (contains(denied, path)) return { allowed: false, path, reason: "write-denied", deniedBy: denied };
|
|
654
648
|
}
|
|
655
|
-
if (isCredential(path, policy)) return
|
|
649
|
+
if (isCredential(path, policy)) return canWrite(policy.permissions!.storedCredentials)
|
|
650
|
+
? { allowed: true, path } : { allowed: false, path, reason: "permission-denied" };
|
|
656
651
|
if (policy.runtimeWrite?.some((root) => contains(root, path))) return { allowed: true, path };
|
|
657
652
|
if (contains(policy.writableRoot, path)) {
|
|
658
653
|
return canWrite(policy.permissions!.projectFiles)
|
|
@@ -808,7 +803,8 @@ export function resetRecoverySnapshotClock(): void {
|
|
|
808
803
|
}
|
|
809
804
|
|
|
810
805
|
function broadProtectedPaths(policy: CompiledSandboxWritePolicy, broad: CompiledBroadWrite): string[] {
|
|
811
|
-
return [...policy.denyWrite,
|
|
806
|
+
return [...policy.denyWrite,
|
|
807
|
+
...(policy.permissions?.storedCredentials === "read-write" ? [] : policy.credentialPaths ?? []), ...broad.codePaths,
|
|
812
808
|
...(!canWrite(policy.permissions?.projectFiles) ? [policy.writableRoot] : [])];
|
|
813
809
|
}
|
|
814
810
|
|
|
@@ -927,7 +923,7 @@ function buildBroadProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSea
|
|
|
927
923
|
`(allow file-write-unlink (regex #"^${home}/(.+/)?[.]worktrees/."))`,
|
|
928
924
|
`(allow file-write-unlink (regex #"^${home}/(.+/)?[^/]+-worktrees/."))`,
|
|
929
925
|
);
|
|
930
|
-
//
|
|
926
|
+
// Write-denied controls and credentials override every broad grant above. A rule naming
|
|
931
927
|
// `file-write-unlink` outranks a `file-write*` wildcard whatever their
|
|
932
928
|
// order (verified on the real kernel), so each protected path also gets an
|
|
933
929
|
// explicit unlink denial; otherwise the removal grants above would reopen it.
|
|
@@ -936,8 +932,8 @@ function buildBroadProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSea
|
|
|
936
932
|
if (!canWrite(permissions.projectFiles)) denyWrite(project);
|
|
937
933
|
for (const path of broad.codePaths) denyWrite(path);
|
|
938
934
|
for (const path of policy.credentialPaths ?? []) {
|
|
939
|
-
rules.push(`(deny file-read* (subpath ${sbpl(path)}))`);
|
|
940
|
-
denyWrite(path);
|
|
935
|
+
if (permissions.storedCredentials === "off") rules.push(`(deny file-read* (subpath ${sbpl(path)}))`);
|
|
936
|
+
if (permissions.storedCredentials !== "read-write") denyWrite(path);
|
|
941
937
|
}
|
|
942
938
|
for (const path of policy.denyWrite) denyWrite(path);
|
|
943
939
|
// Renaming an ancestor would move a protected subtree to an unprotected path,
|
|
@@ -1350,9 +1346,11 @@ function maskSources(profilePath: string): { directory: string; file: string } {
|
|
|
1350
1346
|
* the root is read-only; temp, the workspace, hidden home entries and worktree
|
|
1351
1347
|
* folders are writable (removal included); ordinary top-level home folders and
|
|
1352
1348
|
* home itself stay read-only, so sibling repositories cannot even be edited in
|
|
1353
|
-
* place there.
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1349
|
+
* place there. Off credentials are masked by empty mode-000 mounts, Read
|
|
1350
|
+
* credentials are read-only binds, and Read / write credentials follow the
|
|
1351
|
+
* surrounding broad-write rules. Code that runs later and caller-denied paths
|
|
1352
|
+
* are read-only binds. Every writable bind and protected leaf is a mount point,
|
|
1353
|
+
* which Linux refuses to rename. A protected
|
|
1356
1354
|
* symlink inside a writable directory (a stow link in `~/.config`) makes that
|
|
1357
1355
|
* directory read-only with each existing entry bound writable again, so only
|
|
1358
1356
|
* new top-level entries there are refused.
|
|
@@ -1373,14 +1371,15 @@ function buildLinuxBroadCommand(
|
|
|
1373
1371
|
throw new Error("Linux bubblewrap cannot separate removal from writing for Project files; use Write & delete or Read.");
|
|
1374
1372
|
}
|
|
1375
1373
|
const credentials = policy.credentialPaths ?? [];
|
|
1374
|
+
const protectedCredentials = permissions.storedCredentials === "read-write" ? [] : credentials;
|
|
1376
1375
|
const writeDenied = [...broad.codePaths, ...policy.denyWrite];
|
|
1377
1376
|
if (permissions.projectFiles === "read-write" &&
|
|
1378
|
-
[...
|
|
1377
|
+
[...protectedCredentials, ...writeDenied].some((path) => path !== project && contains(path, project))) {
|
|
1379
1378
|
throw new Error("Linux bubblewrap cannot make a project writable inside a protected directory.");
|
|
1380
1379
|
}
|
|
1381
1380
|
const list = seams.listDirectory ?? systemListDirectory;
|
|
1382
1381
|
const exists = (path: string) => (seams.pathExists ?? existsSync)(path);
|
|
1383
|
-
const protectedPaths = [...
|
|
1382
|
+
const protectedPaths = [...protectedCredentials, ...writeDenied];
|
|
1384
1383
|
const insideProtected = (path: string) => protectedPaths.some((guard) => contains(guard, path));
|
|
1385
1384
|
|
|
1386
1385
|
const writable = new Set<string>();
|
|
@@ -1498,7 +1497,10 @@ function buildLinuxBroadCommand(
|
|
|
1498
1497
|
readOnly.add(path);
|
|
1499
1498
|
}
|
|
1500
1499
|
};
|
|
1501
|
-
for (const path of credentials)
|
|
1500
|
+
for (const path of credentials) {
|
|
1501
|
+
if (permissions.storedCredentials === "off") guard(path, true);
|
|
1502
|
+
else if (permissions.storedCredentials === "read") guard(path, false);
|
|
1503
|
+
}
|
|
1502
1504
|
for (const path of writeDenied) guard(path, false);
|
|
1503
1505
|
if (permissions.projectFiles === "read") readOnly.add(project);
|
|
1504
1506
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Text, type AutocompleteItem } from "@earendil-works/pi-tui";
|
|
4
4
|
import { Type } from "typebox";
|
|
5
5
|
|
|
6
6
|
import {
|
|
@@ -78,6 +78,21 @@ const LEGACY_PLAN_NAV_STATUS_KEY = "pi-better-plan-nav";
|
|
|
78
78
|
const DELEGATION_MODE_REQUEST = "pi-better-subagents:delegation-mode-request";
|
|
79
79
|
type DelegationMode = "manual" | "adaptive" | "coordinator";
|
|
80
80
|
|
|
81
|
+
const PLAN_ACTIONS: readonly AutocompleteItem[] = [
|
|
82
|
+
{ value: "clear", label: "clear", description: "Remove the current plan" },
|
|
83
|
+
{ value: "hide", label: "hide", description: "Hide the plan widget" },
|
|
84
|
+
{ value: "show", label: "show", description: "Restore automatic plan display" },
|
|
85
|
+
{ value: "pin auto", label: "pin auto", description: "Use automatic plan pinning" },
|
|
86
|
+
{ value: "pin on", label: "pin on", description: "Keep the plan pinned" },
|
|
87
|
+
{ value: "pin off", label: "pin off", description: "Keep the plan unpinned" },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export function planArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
|
91
|
+
const prefix = argumentPrefix.trimStart().toLowerCase();
|
|
92
|
+
const matches = PLAN_ACTIONS.filter((action) => action.value.startsWith(prefix));
|
|
93
|
+
return matches.length > 0 ? [...matches] : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
81
96
|
function activeDelegationMode(pi: ExtensionAPI): DelegationMode {
|
|
82
97
|
const request: { mode?: string } = {};
|
|
83
98
|
pi.events.emit(DELEGATION_MODE_REQUEST, request);
|
|
@@ -396,6 +411,7 @@ export default function planExtension(pi: ExtensionAPI): void {
|
|
|
396
411
|
|
|
397
412
|
pi.registerCommand("plan", {
|
|
398
413
|
description: "Inspect, show, hide, clear, or configure the current plan",
|
|
414
|
+
getArgumentCompletions: planArgumentCompletions,
|
|
399
415
|
handler: async (args, ctx) => {
|
|
400
416
|
const input = args.trim().toLowerCase();
|
|
401
417
|
if (!input) return showFullPlan(ctx);
|
|
@@ -20,7 +20,7 @@ Sandbox Off On
|
|
|
20
20
|
|
|
21
21
|
Project files - Write & delete
|
|
22
22
|
Outside project - Write
|
|
23
|
-
Stored credentials -
|
|
23
|
+
Stored credentials - Read
|
|
24
24
|
Run commands & applications - On
|
|
25
25
|
Network access - On
|
|
26
26
|
|
|
@@ -74,15 +74,14 @@ select the row.
|
|
|
74
74
|
|
|
75
75
|
**Outside project = Write** (the Subagents default) lets tasks write across your
|
|
76
76
|
home directory and temp, so tool caches just work with no per-tool setup. It
|
|
77
|
-
|
|
77
|
+
retains fixed protections that are not configurable per tool:
|
|
78
78
|
|
|
79
|
-
- **Credentials**
|
|
80
|
-
|
|
79
|
+
- **Credentials** follow the independent Stored credentials row. The default
|
|
80
|
+
`Read` setting exposes the files listed below plus `~/Library/Keychains`,
|
|
81
81
|
`~/.claude/.credentials.json`, `~/.claude.json`, `~/.gnupg`,
|
|
82
82
|
`~/.codex/auth.json`, `~/.cargo/credentials(.toml)`, `~/.pgpass`, and
|
|
83
|
-
`~/.config/rclone
|
|
84
|
-
|
|
85
|
-
project = Read or Write & delete.
|
|
83
|
+
`~/.config/rclone` to confined subagents, while denying writes. Set the row to
|
|
84
|
+
Off to hide them or Read / write when a CLI must refresh credential state.
|
|
86
85
|
- **Code that runs later** cannot be written, removed, or renamed: shell startup
|
|
87
86
|
files (`.bashrc`, `.zshrc`, `.profile`, and the rest), `~/.config/fish`,
|
|
88
87
|
`~/.gitconfig`, `~/.config/git`, `~/.config/systemd/user`,
|
|
@@ -152,8 +151,8 @@ AWS, GitHub CLI, Google Cloud CLI, Azure, Kubernetes, Docker, npm, netrc, Git
|
|
|
152
151
|
credentials, and Pi's file-based auth. These rules override ordinary file access.
|
|
153
152
|
OS vault services such as Keychain and Secret Service, and tokens inherited in
|
|
154
153
|
environment variables, are excluded. Read / write may be needed by a CLI that
|
|
155
|
-
refreshes a token or updates its credential database.
|
|
156
|
-
|
|
154
|
+
refreshes a token or updates its credential database. This row applies
|
|
155
|
+
independently of Project files and Outside project.
|
|
157
156
|
|
|
158
157
|
File and shell operations use the kernel: macOS uses Seatbelt (`sandbox-exec`)
|
|
159
158
|
and Linux uses Bubblewrap (`bwrap`). `read`, `write`, and `edit` keep Pi's normal
|
|
@@ -36,7 +36,6 @@ const fileValues: readonly FileAccess[] = ["off", "read", "write", "read-write"]
|
|
|
36
36
|
const credentialValues: readonly CredentialAccess[] = ["off", "read", "read-write"];
|
|
37
37
|
const FILE_LABELS: Record<FileAccess, string> = { off: "Off", read: "Read", write: "Write", "read-write": "Write & delete" };
|
|
38
38
|
const CREDENTIAL_LABELS: Record<CredentialAccess, string> = { off: "Off", read: "Read", "read-write": "Read / write" };
|
|
39
|
-
const FIXED_CREDENTIALS = "Outside project = Write always hides credential files; change Outside project to edit this row.";
|
|
40
39
|
|
|
41
40
|
/** The context line for the highlighted cell, or undefined. */
|
|
42
41
|
function cellHint(key: string, profile: PermissionProfile | undefined): string | undefined {
|
|
@@ -47,7 +46,7 @@ function cellHint(key: string, profile: PermissionProfile | undefined): string |
|
|
|
47
46
|
? `Write: git and rename-based saves fail ${where} except in worktree folders; set Write & delete if needed.`
|
|
48
47
|
: "Always deletable: temp, hidden ~/.directories and worktree folders (.worktrees/, *-worktrees/).";
|
|
49
48
|
}
|
|
50
|
-
if (key === "storedCredentials"
|
|
49
|
+
if (key === "storedCredentials") return "Known credential files follow this row independently of Outside project.";
|
|
51
50
|
return undefined;
|
|
52
51
|
}
|
|
53
52
|
const columns = ["main", "subagents"] as const;
|
|
@@ -134,12 +133,6 @@ export function createPermissionsPage(
|
|
|
134
133
|
if (key === "enabled" || key === "commands" || key === "network") {
|
|
135
134
|
next[profile][key] = !next[profile][key];
|
|
136
135
|
} else if (key === "storedCredentials") {
|
|
137
|
-
if (next[profile].outsideProject === "write") {
|
|
138
|
-
message = FIXED_CREDENTIALS;
|
|
139
|
-
isError = false;
|
|
140
|
-
requestRender();
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
136
|
const current = next[profile][key];
|
|
144
137
|
next[profile][key] = credentialValues[(credentialValues.indexOf(current) + 1) % credentialValues.length]!;
|
|
145
138
|
} else {
|
|
@@ -257,7 +250,7 @@ export function createPermissionsPage(
|
|
|
257
250
|
const value = (profile: PermissionProfile): string => {
|
|
258
251
|
if (entry.key !== "enabled" && !profile.enabled) return "-";
|
|
259
252
|
if (entry.key === "storedCredentials") {
|
|
260
|
-
return
|
|
253
|
+
return CREDENTIAL_LABELS[profile.storedCredentials];
|
|
261
254
|
}
|
|
262
255
|
const current = profile[entry.key];
|
|
263
256
|
return typeof current === "boolean" ? (current ? "On" : "Off") : FILE_LABELS[current];
|
|
@@ -228,10 +228,7 @@ const CREDENTIAL_LOCATIONS = [
|
|
|
228
228
|
".docker/config.json", ".npmrc", ".netrc", ".git-credentials", ".pi/agent/auth.json",
|
|
229
229
|
] as const;
|
|
230
230
|
|
|
231
|
-
/**
|
|
232
|
-
* Credential stores added by the broad-write profile. They are never readable
|
|
233
|
-
* or writable there, whatever the Stored credentials row says.
|
|
234
|
-
*/
|
|
231
|
+
/** Credential stores added to the Stored credentials row by the broad-write profile. */
|
|
235
232
|
const BROAD_CREDENTIAL_LOCATIONS = [
|
|
236
233
|
"Library/Keychains", ".claude/.credentials.json", ".claude.json", ".gnupg",
|
|
237
234
|
".codex/auth.json", ".cargo/credentials.toml", ".cargo/credentials", ".pgpass", ".config/rclone",
|
|
@@ -386,10 +383,7 @@ function compile(
|
|
|
386
383
|
return {
|
|
387
384
|
writableRoot, denyWrite, home: policy.home,
|
|
388
385
|
...(policy.permissions && {
|
|
389
|
-
|
|
390
|
-
permissions: broadMode
|
|
391
|
-
? { ...policy.permissions, storedCredentials: "off" as const }
|
|
392
|
-
: { ...policy.permissions },
|
|
386
|
+
permissions: { ...policy.permissions },
|
|
393
387
|
credentialPaths: broadMode ? broadCredentialPaths(policy.home, seams) : credentialFilePaths(policy.home, seams),
|
|
394
388
|
compatibilityWrite,
|
|
395
389
|
runtimeWrite,
|
|
@@ -598,7 +592,7 @@ export function evaluateReadAccess(
|
|
|
598
592
|
const permissions = policy.permissions;
|
|
599
593
|
if (!permissions) return { allowed: true, path };
|
|
600
594
|
if (policy.broad) {
|
|
601
|
-
const denied = isCredential(path, policy) ||
|
|
595
|
+
const denied = (isCredential(path, policy) && permissions.storedCredentials === "off") ||
|
|
602
596
|
(permissions.projectFiles === "off" && contains(policy.writableRoot, path));
|
|
603
597
|
return denied ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
|
|
604
598
|
}
|
|
@@ -652,7 +646,8 @@ function evaluateBroadWrite(
|
|
|
652
646
|
for (const denied of [...policy.denyWrite, ...broad.codePaths]) {
|
|
653
647
|
if (contains(denied, path)) return { allowed: false, path, reason: "write-denied", deniedBy: denied };
|
|
654
648
|
}
|
|
655
|
-
if (isCredential(path, policy)) return
|
|
649
|
+
if (isCredential(path, policy)) return canWrite(policy.permissions!.storedCredentials)
|
|
650
|
+
? { allowed: true, path } : { allowed: false, path, reason: "permission-denied" };
|
|
656
651
|
if (policy.runtimeWrite?.some((root) => contains(root, path))) return { allowed: true, path };
|
|
657
652
|
if (contains(policy.writableRoot, path)) {
|
|
658
653
|
return canWrite(policy.permissions!.projectFiles)
|
|
@@ -808,7 +803,8 @@ export function resetRecoverySnapshotClock(): void {
|
|
|
808
803
|
}
|
|
809
804
|
|
|
810
805
|
function broadProtectedPaths(policy: CompiledSandboxWritePolicy, broad: CompiledBroadWrite): string[] {
|
|
811
|
-
return [...policy.denyWrite,
|
|
806
|
+
return [...policy.denyWrite,
|
|
807
|
+
...(policy.permissions?.storedCredentials === "read-write" ? [] : policy.credentialPaths ?? []), ...broad.codePaths,
|
|
812
808
|
...(!canWrite(policy.permissions?.projectFiles) ? [policy.writableRoot] : [])];
|
|
813
809
|
}
|
|
814
810
|
|
|
@@ -927,7 +923,7 @@ function buildBroadProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSea
|
|
|
927
923
|
`(allow file-write-unlink (regex #"^${home}/(.+/)?[.]worktrees/."))`,
|
|
928
924
|
`(allow file-write-unlink (regex #"^${home}/(.+/)?[^/]+-worktrees/."))`,
|
|
929
925
|
);
|
|
930
|
-
//
|
|
926
|
+
// Write-denied controls and credentials override every broad grant above. A rule naming
|
|
931
927
|
// `file-write-unlink` outranks a `file-write*` wildcard whatever their
|
|
932
928
|
// order (verified on the real kernel), so each protected path also gets an
|
|
933
929
|
// explicit unlink denial; otherwise the removal grants above would reopen it.
|
|
@@ -936,8 +932,8 @@ function buildBroadProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSea
|
|
|
936
932
|
if (!canWrite(permissions.projectFiles)) denyWrite(project);
|
|
937
933
|
for (const path of broad.codePaths) denyWrite(path);
|
|
938
934
|
for (const path of policy.credentialPaths ?? []) {
|
|
939
|
-
rules.push(`(deny file-read* (subpath ${sbpl(path)}))`);
|
|
940
|
-
denyWrite(path);
|
|
935
|
+
if (permissions.storedCredentials === "off") rules.push(`(deny file-read* (subpath ${sbpl(path)}))`);
|
|
936
|
+
if (permissions.storedCredentials !== "read-write") denyWrite(path);
|
|
941
937
|
}
|
|
942
938
|
for (const path of policy.denyWrite) denyWrite(path);
|
|
943
939
|
// Renaming an ancestor would move a protected subtree to an unprotected path,
|
|
@@ -1350,9 +1346,11 @@ function maskSources(profilePath: string): { directory: string; file: string } {
|
|
|
1350
1346
|
* the root is read-only; temp, the workspace, hidden home entries and worktree
|
|
1351
1347
|
* folders are writable (removal included); ordinary top-level home folders and
|
|
1352
1348
|
* home itself stay read-only, so sibling repositories cannot even be edited in
|
|
1353
|
-
* place there.
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1349
|
+
* place there. Off credentials are masked by empty mode-000 mounts, Read
|
|
1350
|
+
* credentials are read-only binds, and Read / write credentials follow the
|
|
1351
|
+
* surrounding broad-write rules. Code that runs later and caller-denied paths
|
|
1352
|
+
* are read-only binds. Every writable bind and protected leaf is a mount point,
|
|
1353
|
+
* which Linux refuses to rename. A protected
|
|
1356
1354
|
* symlink inside a writable directory (a stow link in `~/.config`) makes that
|
|
1357
1355
|
* directory read-only with each existing entry bound writable again, so only
|
|
1358
1356
|
* new top-level entries there are refused.
|
|
@@ -1373,14 +1371,15 @@ function buildLinuxBroadCommand(
|
|
|
1373
1371
|
throw new Error("Linux bubblewrap cannot separate removal from writing for Project files; use Write & delete or Read.");
|
|
1374
1372
|
}
|
|
1375
1373
|
const credentials = policy.credentialPaths ?? [];
|
|
1374
|
+
const protectedCredentials = permissions.storedCredentials === "read-write" ? [] : credentials;
|
|
1376
1375
|
const writeDenied = [...broad.codePaths, ...policy.denyWrite];
|
|
1377
1376
|
if (permissions.projectFiles === "read-write" &&
|
|
1378
|
-
[...
|
|
1377
|
+
[...protectedCredentials, ...writeDenied].some((path) => path !== project && contains(path, project))) {
|
|
1379
1378
|
throw new Error("Linux bubblewrap cannot make a project writable inside a protected directory.");
|
|
1380
1379
|
}
|
|
1381
1380
|
const list = seams.listDirectory ?? systemListDirectory;
|
|
1382
1381
|
const exists = (path: string) => (seams.pathExists ?? existsSync)(path);
|
|
1383
|
-
const protectedPaths = [...
|
|
1382
|
+
const protectedPaths = [...protectedCredentials, ...writeDenied];
|
|
1384
1383
|
const insideProtected = (path: string) => protectedPaths.some((guard) => contains(guard, path));
|
|
1385
1384
|
|
|
1386
1385
|
const writable = new Set<string>();
|
|
@@ -1498,7 +1497,10 @@ function buildLinuxBroadCommand(
|
|
|
1498
1497
|
readOnly.add(path);
|
|
1499
1498
|
}
|
|
1500
1499
|
};
|
|
1501
|
-
for (const path of credentials)
|
|
1500
|
+
for (const path of credentials) {
|
|
1501
|
+
if (permissions.storedCredentials === "off") guard(path, true);
|
|
1502
|
+
else if (permissions.storedCredentials === "read") guard(path, false);
|
|
1503
|
+
}
|
|
1502
1504
|
for (const path of writeDenied) guard(path, false);
|
|
1503
1505
|
if (permissions.projectFiles === "read") readOnly.add(project);
|
|
1504
1506
|
|
|
@@ -16,7 +16,7 @@ Use `pi-better-subagents` when you want Pi to launch independent agent work with
|
|
|
16
16
|
- Explicit tool allowlists for child sessions.
|
|
17
17
|
- Durable logs, result retrieval, and [failure observations](docs/failure-observations.md) independent of lifecycle status. A live child handles its own tool errors; the parent is woken only for actionable incidents, and children can classify handled failures with `failure_disposition`.
|
|
18
18
|
- Live background-work navigator for active runs.
|
|
19
|
-
- Harness-owned run timing: every run gets a
|
|
19
|
+
- Harness-owned run timing: every run gets a no-progress wake (10 min), while wall-clock soft deadlines and hard ceilings are opt-in so productive agents are not stopped solely for taking a long time. Configure per spawn with `deadline_minutes`, `grace_minutes`, `max_minutes`, `stuck_minutes`, or globally in `config.json` / `PI_SUBAGENT_*_MINUTES`. See [usage notes](docs/usage.md#run-timing-deadline-ceiling-stuck).
|
|
20
20
|
|
|
21
21
|
## Install
|
|
22
22
|
|
|
@@ -17,7 +17,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
17
17
|
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import * as PiTui from "@earendil-works/pi-tui";
|
|
19
19
|
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
20
|
-
import { matchesKey, Key, truncateToWidth } from "@earendil-works/pi-tui";
|
|
20
|
+
import { matchesKey, Key, truncateToWidth, type AutocompleteItem } from "@earendil-works/pi-tui";
|
|
21
21
|
import { Type } from "@earendil-works/pi-ai";
|
|
22
22
|
import {
|
|
23
23
|
CLOSE_CONFIRM_STATUS_KEY,
|
|
@@ -160,6 +160,18 @@ import {
|
|
|
160
160
|
} from "./catalog-runtime.ts";
|
|
161
161
|
import { defaultUserRoot, type CatalogSnapshot } from "./catalog-store.ts";
|
|
162
162
|
|
|
163
|
+
const SUBAGENT_MODE_ACTIONS: readonly AutocompleteItem[] = [
|
|
164
|
+
{ value: "mode manual", label: "mode manual", description: "Delegate only when explicitly requested" },
|
|
165
|
+
{ value: "mode adaptive", label: "mode adaptive", description: "Delegate substantial independent work" },
|
|
166
|
+
{ value: "mode coordinator", label: "mode coordinator", description: "Delegate role-owned work by default" },
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
export function subagentsArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
|
170
|
+
const prefix = argumentPrefix.trimStart().toLowerCase();
|
|
171
|
+
const matches = SUBAGENT_MODE_ACTIONS.filter((action) => action.value.startsWith(prefix));
|
|
172
|
+
return matches.length > 0 ? [...matches] : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
163
175
|
/** The tools this extension registers — excluded from children by default so a
|
|
164
176
|
* subagent cannot recursively spawn more subagents unless explicitly allowed. */
|
|
165
177
|
const SUBAGENT_TOOLS = [
|
|
@@ -1792,7 +1804,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1792
1804
|
"The tools param is both the tool allowlist AND what determines which extensions load in the child (e.g. tools='read,bash,web_fetch' loads only the web-tools package). Ask for the tools the task needs and nothing more; clean:true gives a built-ins-only child. Without an agent or role, pick a model with the model param (e.g. 'xai/grok-4.5@high'); providerless model patterns are resolved by Pi, while provider/model is deterministic and loads mapped provider extensions.",
|
|
1793
1805
|
...CATALOG_GUIDELINES,
|
|
1794
1806
|
"By default the subagent is sandboxed. Human settings in /sandbox control file, credential-file, command, and network permissions; sandbox:false cannot override an enabled human profile. Without published settings, legacy write confinement applies. Set callback:false to finish quietly — then read the result on demand via subagent_result.",
|
|
1795
|
-
"Every run
|
|
1807
|
+
"Every run has harness-owned stall detection: no progress for stuck_minutes (default 10) wakes you once without stopping the run. Elapsed-time limits are opt-in: deadline_minutes steers the child to wrap up and later stops it after grace_minutes, while max_minutes stops it without grace. Do not set elapsed-time limits merely because a task may take a while; use them only when the work needs a real wall-clock bound.",
|
|
1796
1808
|
"Use git_clone_workspace:true when the subagent will mutate Git in a sandbox. The parent prepares a disposable, self-contained clone with a real .git/ directory inside the sandbox root, so linked-worktree metadata outside the sandbox cannot stall the child.",
|
|
1797
1809
|
],
|
|
1798
1810
|
parameters: Type.Object({
|
|
@@ -2138,6 +2150,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2138
2150
|
agentOperations.registerCommands(pi);
|
|
2139
2151
|
pi.registerCommand("subagents", {
|
|
2140
2152
|
description: "Show or change the current-session delegation mode",
|
|
2153
|
+
getArgumentCompletions: subagentsArgumentCompletions,
|
|
2141
2154
|
async handler(args, ctx) {
|
|
2142
2155
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
2143
2156
|
if (tokens.length === 0) {
|
|
@@ -228,10 +228,7 @@ const CREDENTIAL_LOCATIONS = [
|
|
|
228
228
|
".docker/config.json", ".npmrc", ".netrc", ".git-credentials", ".pi/agent/auth.json",
|
|
229
229
|
] as const;
|
|
230
230
|
|
|
231
|
-
/**
|
|
232
|
-
* Credential stores added by the broad-write profile. They are never readable
|
|
233
|
-
* or writable there, whatever the Stored credentials row says.
|
|
234
|
-
*/
|
|
231
|
+
/** Credential stores added to the Stored credentials row by the broad-write profile. */
|
|
235
232
|
const BROAD_CREDENTIAL_LOCATIONS = [
|
|
236
233
|
"Library/Keychains", ".claude/.credentials.json", ".claude.json", ".gnupg",
|
|
237
234
|
".codex/auth.json", ".cargo/credentials.toml", ".cargo/credentials", ".pgpass", ".config/rclone",
|
|
@@ -386,10 +383,7 @@ function compile(
|
|
|
386
383
|
return {
|
|
387
384
|
writableRoot, denyWrite, home: policy.home,
|
|
388
385
|
...(policy.permissions && {
|
|
389
|
-
|
|
390
|
-
permissions: broadMode
|
|
391
|
-
? { ...policy.permissions, storedCredentials: "off" as const }
|
|
392
|
-
: { ...policy.permissions },
|
|
386
|
+
permissions: { ...policy.permissions },
|
|
393
387
|
credentialPaths: broadMode ? broadCredentialPaths(policy.home, seams) : credentialFilePaths(policy.home, seams),
|
|
394
388
|
compatibilityWrite,
|
|
395
389
|
runtimeWrite,
|
|
@@ -598,7 +592,7 @@ export function evaluateReadAccess(
|
|
|
598
592
|
const permissions = policy.permissions;
|
|
599
593
|
if (!permissions) return { allowed: true, path };
|
|
600
594
|
if (policy.broad) {
|
|
601
|
-
const denied = isCredential(path, policy) ||
|
|
595
|
+
const denied = (isCredential(path, policy) && permissions.storedCredentials === "off") ||
|
|
602
596
|
(permissions.projectFiles === "off" && contains(policy.writableRoot, path));
|
|
603
597
|
return denied ? { allowed: false, path, reason: "read-denied" } : { allowed: true, path };
|
|
604
598
|
}
|
|
@@ -652,7 +646,8 @@ function evaluateBroadWrite(
|
|
|
652
646
|
for (const denied of [...policy.denyWrite, ...broad.codePaths]) {
|
|
653
647
|
if (contains(denied, path)) return { allowed: false, path, reason: "write-denied", deniedBy: denied };
|
|
654
648
|
}
|
|
655
|
-
if (isCredential(path, policy)) return
|
|
649
|
+
if (isCredential(path, policy)) return canWrite(policy.permissions!.storedCredentials)
|
|
650
|
+
? { allowed: true, path } : { allowed: false, path, reason: "permission-denied" };
|
|
656
651
|
if (policy.runtimeWrite?.some((root) => contains(root, path))) return { allowed: true, path };
|
|
657
652
|
if (contains(policy.writableRoot, path)) {
|
|
658
653
|
return canWrite(policy.permissions!.projectFiles)
|
|
@@ -808,7 +803,8 @@ export function resetRecoverySnapshotClock(): void {
|
|
|
808
803
|
}
|
|
809
804
|
|
|
810
805
|
function broadProtectedPaths(policy: CompiledSandboxWritePolicy, broad: CompiledBroadWrite): string[] {
|
|
811
|
-
return [...policy.denyWrite,
|
|
806
|
+
return [...policy.denyWrite,
|
|
807
|
+
...(policy.permissions?.storedCredentials === "read-write" ? [] : policy.credentialPaths ?? []), ...broad.codePaths,
|
|
812
808
|
...(!canWrite(policy.permissions?.projectFiles) ? [policy.writableRoot] : [])];
|
|
813
809
|
}
|
|
814
810
|
|
|
@@ -927,7 +923,7 @@ function buildBroadProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSea
|
|
|
927
923
|
`(allow file-write-unlink (regex #"^${home}/(.+/)?[.]worktrees/."))`,
|
|
928
924
|
`(allow file-write-unlink (regex #"^${home}/(.+/)?[^/]+-worktrees/."))`,
|
|
929
925
|
);
|
|
930
|
-
//
|
|
926
|
+
// Write-denied controls and credentials override every broad grant above. A rule naming
|
|
931
927
|
// `file-write-unlink` outranks a `file-write*` wildcard whatever their
|
|
932
928
|
// order (verified on the real kernel), so each protected path also gets an
|
|
933
929
|
// explicit unlink denial; otherwise the removal grants above would reopen it.
|
|
@@ -936,8 +932,8 @@ function buildBroadProfile(policy: CompiledSandboxWritePolicy, seams: SandboxSea
|
|
|
936
932
|
if (!canWrite(permissions.projectFiles)) denyWrite(project);
|
|
937
933
|
for (const path of broad.codePaths) denyWrite(path);
|
|
938
934
|
for (const path of policy.credentialPaths ?? []) {
|
|
939
|
-
rules.push(`(deny file-read* (subpath ${sbpl(path)}))`);
|
|
940
|
-
denyWrite(path);
|
|
935
|
+
if (permissions.storedCredentials === "off") rules.push(`(deny file-read* (subpath ${sbpl(path)}))`);
|
|
936
|
+
if (permissions.storedCredentials !== "read-write") denyWrite(path);
|
|
941
937
|
}
|
|
942
938
|
for (const path of policy.denyWrite) denyWrite(path);
|
|
943
939
|
// Renaming an ancestor would move a protected subtree to an unprotected path,
|
|
@@ -1350,9 +1346,11 @@ function maskSources(profilePath: string): { directory: string; file: string } {
|
|
|
1350
1346
|
* the root is read-only; temp, the workspace, hidden home entries and worktree
|
|
1351
1347
|
* folders are writable (removal included); ordinary top-level home folders and
|
|
1352
1348
|
* home itself stay read-only, so sibling repositories cannot even be edited in
|
|
1353
|
-
* place there.
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1349
|
+
* place there. Off credentials are masked by empty mode-000 mounts, Read
|
|
1350
|
+
* credentials are read-only binds, and Read / write credentials follow the
|
|
1351
|
+
* surrounding broad-write rules. Code that runs later and caller-denied paths
|
|
1352
|
+
* are read-only binds. Every writable bind and protected leaf is a mount point,
|
|
1353
|
+
* which Linux refuses to rename. A protected
|
|
1356
1354
|
* symlink inside a writable directory (a stow link in `~/.config`) makes that
|
|
1357
1355
|
* directory read-only with each existing entry bound writable again, so only
|
|
1358
1356
|
* new top-level entries there are refused.
|
|
@@ -1373,14 +1371,15 @@ function buildLinuxBroadCommand(
|
|
|
1373
1371
|
throw new Error("Linux bubblewrap cannot separate removal from writing for Project files; use Write & delete or Read.");
|
|
1374
1372
|
}
|
|
1375
1373
|
const credentials = policy.credentialPaths ?? [];
|
|
1374
|
+
const protectedCredentials = permissions.storedCredentials === "read-write" ? [] : credentials;
|
|
1376
1375
|
const writeDenied = [...broad.codePaths, ...policy.denyWrite];
|
|
1377
1376
|
if (permissions.projectFiles === "read-write" &&
|
|
1378
|
-
[...
|
|
1377
|
+
[...protectedCredentials, ...writeDenied].some((path) => path !== project && contains(path, project))) {
|
|
1379
1378
|
throw new Error("Linux bubblewrap cannot make a project writable inside a protected directory.");
|
|
1380
1379
|
}
|
|
1381
1380
|
const list = seams.listDirectory ?? systemListDirectory;
|
|
1382
1381
|
const exists = (path: string) => (seams.pathExists ?? existsSync)(path);
|
|
1383
|
-
const protectedPaths = [...
|
|
1382
|
+
const protectedPaths = [...protectedCredentials, ...writeDenied];
|
|
1384
1383
|
const insideProtected = (path: string) => protectedPaths.some((guard) => contains(guard, path));
|
|
1385
1384
|
|
|
1386
1385
|
const writable = new Set<string>();
|
|
@@ -1498,7 +1497,10 @@ function buildLinuxBroadCommand(
|
|
|
1498
1497
|
readOnly.add(path);
|
|
1499
1498
|
}
|
|
1500
1499
|
};
|
|
1501
|
-
for (const path of credentials)
|
|
1500
|
+
for (const path of credentials) {
|
|
1501
|
+
if (permissions.storedCredentials === "off") guard(path, true);
|
|
1502
|
+
else if (permissions.storedCredentials === "read") guard(path, false);
|
|
1503
|
+
}
|
|
1502
1504
|
for (const path of writeDenied) guard(path, false);
|
|
1503
1505
|
if (permissions.projectFiles === "read") readOnly.add(project);
|
|
1504
1506
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Harness-owned run timing:
|
|
2
|
+
* Harness-owned run timing: optional elapsed-time limits and default-on
|
|
3
3
|
* no-progress (stuck) detection for every subagent run.
|
|
4
4
|
*
|
|
5
5
|
* Timeout control belongs to the harness, not to the prompt or the skill that
|
|
@@ -22,9 +22,10 @@
|
|
|
22
22
|
* with null/absent optional fields treated alike as in #336). A successful
|
|
23
23
|
* `edit`/`write`, a `git commit`, and a success directly after a failure always
|
|
24
24
|
* count, even when repeated. Re-reading the same file or re-running the same
|
|
25
|
-
* command with the same arguments does not. Time spent waiting on a running
|
|
26
|
-
* the stuck window, so a child in the middle
|
|
27
|
-
*
|
|
25
|
+
* command with the same arguments does not. Time spent waiting on a running
|
|
26
|
+
* tool call does not count toward the stuck window, so a child in the middle
|
|
27
|
+
* of a 20-minute test run is not stuck. A caller that needs to bound a
|
|
28
|
+
* possibly hung command can opt into a ceiling.
|
|
28
29
|
*/
|
|
29
30
|
|
|
30
31
|
import { createHash } from "node:crypto";
|
|
@@ -34,9 +35,9 @@ export type TimingReason = TimingStopReason | "stuck";
|
|
|
34
35
|
|
|
35
36
|
/** Built-in defaults, in minutes. `0` disables that control. */
|
|
36
37
|
export const DEFAULT_TIMING_MINUTES = Object.freeze({
|
|
37
|
-
deadline:
|
|
38
|
+
deadline: 0,
|
|
38
39
|
grace: 5,
|
|
39
|
-
max:
|
|
40
|
+
max: 0,
|
|
40
41
|
stuck: 10,
|
|
41
42
|
});
|
|
42
43
|
|
|
@@ -151,9 +152,9 @@ export function resolveRunTiming(input: {
|
|
|
151
152
|
// ---- tool parameters -------------------------------------------------------
|
|
152
153
|
|
|
153
154
|
const TIMING_DESCRIPTIONS = {
|
|
154
|
-
deadline_minutes: "
|
|
155
|
+
deadline_minutes: "Optional soft deadline in minutes (default: none; 0 = none; null or omitted = inherit: in a batch job the shared value, otherwise the default). At the deadline the child is told to stop starting new work, commit what is done, and report, and you get one wake. Grace starts when the message reaches the child (after its current tool call); if it has not finished by then, the harness stops it with reason deadline.",
|
|
155
156
|
grace_minutes: "Minutes after the wrap-up message reaches the child before the run is stopped (default 5; null or omitted = inherit).",
|
|
156
|
-
max_minutes: "
|
|
157
|
+
max_minutes: "Optional hard ceiling in minutes (default: none; 0 = none; null or omitted = inherit). The run is stopped at once, without grace, with reason ceiling.",
|
|
157
158
|
stuck_minutes: "No-progress window in minutes (default 10; 0 = off; null or omitted = inherit). Progress is any successful tool call that is not an exact repeat of an earlier one (same tool, same arguments); edits, writes, commits, and a success after a failure always count. Time inside a running tool call does not count. Wakes you once per stuck spell; never stops the run.",
|
|
158
159
|
} as const;
|
|
159
160
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
4
4
|
"description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -50,12 +50,12 @@
|
|
|
50
50
|
"test": "node --test test/*.test.mjs"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"pi-better-background-tasks": "0.6.
|
|
53
|
+
"pi-better-background-tasks": "0.6.2",
|
|
54
54
|
"pi-better-goal": "0.5.0",
|
|
55
|
-
"pi-better-plan": "0.5.
|
|
56
|
-
"pi-better-sandbox": "0.7.
|
|
55
|
+
"pi-better-plan": "0.5.1",
|
|
56
|
+
"pi-better-sandbox": "0.7.3",
|
|
57
57
|
"pi-better-ssh": "0.1.1",
|
|
58
|
-
"pi-better-subagents": "0.9.
|
|
58
|
+
"pi-better-subagents": "0.9.3",
|
|
59
59
|
"smol-toml": "1.9.0",
|
|
60
60
|
"yaml": "^2.9.1"
|
|
61
61
|
},
|