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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- // The broad-write profile's credential deny list is not optional.
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 { allowed: false, path, reason: "permission-denied" };
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, ...(policy.credentialPaths ?? []), ...broad.codePaths,
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
- // The fixed deny list overrides every grant above. A rule naming
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. Credentials are masked by empty mode-000 mounts; code that runs
1354
- * later and caller-denied paths are read-only binds. Every writable bind and
1355
- * protected leaf is a mount point, which Linux refuses to rename. A protected
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
- [...credentials, ...writeDenied].some((path) => path !== project && contains(path, project))) {
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 = [...credentials, ...writeDenied];
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) guard(path, true);
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
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 - Off (fixed)
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
- keeps one fixed deny list that is not configurable per tool:
77
+ retains fixed protections that are not configurable per tool:
78
78
 
79
- - **Credentials** are neither readable nor writable, whatever the Stored
80
- credentials row says: the files listed below plus `~/Library/Keychains`,
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`. The table shows
84
- "Off (fixed)". Tasks that need git over SSH, `gh`, or cloud CLIs need Outside
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. Under Outside project =
156
- Write, credentials are always Off (see above).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
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",
@@ -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" && profile?.outsideProject === "write") return FIXED_CREDENTIALS;
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 profile.outsideProject === "write" ? "Off (fixed)" : CREDENTIAL_LABELS[profile.storedCredentials];
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
- // The broad-write profile's credential deny list is not optional.
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 { allowed: false, path, reason: "permission-denied" };
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, ...(policy.credentialPaths ?? []), ...broad.codePaths,
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
- // The fixed deny list overrides every grant above. A rule naming
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. Credentials are masked by empty mode-000 mounts; code that runs
1354
- * later and caller-denied paths are read-only binds. Every writable bind and
1355
- * protected leaf is a mount point, which Linux refuses to rename. A protected
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
- [...credentials, ...writeDenied].some((path) => path !== project && contains(path, project))) {
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 = [...credentials, ...writeDenied];
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) guard(path, true);
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 soft deadline (30 min, then a wrap-up steer into the child and one parent wake, stopped after 5 min grace), a hard ceiling (90 min), and a no-progress wake (10 min). Override 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).
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 is timed by the harness: a soft deadline (default 30 min) steers the child to wrap up and wakes you once, the run is stopped after grace_minutes (reason deadline), a hard ceiling (default 90 min) stops it without grace (reason ceiling), and no progress for stuck_minutes (default 10) wakes you once (reason stuck). Set deadline_minutes/max_minutes/stuck_minutes to fit the task instead of writing a time limit into the prompt; do not stop a slow child that is still making progress.",
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) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- // The broad-write profile's credential deny list is not optional.
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 { allowed: false, path, reason: "permission-denied" };
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, ...(policy.credentialPaths ?? []), ...broad.codePaths,
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
- // The fixed deny list overrides every grant above. A rule naming
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. Credentials are masked by empty mode-000 mounts; code that runs
1354
- * later and caller-denied paths are read-only binds. Every writable bind and
1355
- * protected leaf is a mount point, which Linux refuses to rename. A protected
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
- [...credentials, ...writeDenied].some((path) => path !== project && contains(path, project))) {
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 = [...credentials, ...writeDenied];
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) guard(path, true);
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: a soft deadline with grace, a hard ceiling, and
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 tool call does not count toward
26
- * the stuck window, so a child in the middle of a 20-minute test run is not
27
- * stuck; a hung command is bounded by the deadline and the ceiling instead.
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: 30,
38
+ deadline: 0,
38
39
  grace: 5,
39
- max: 90,
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: "Soft deadline in minutes (default 30; 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
+ 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: "Hard ceiling in minutes (default 90; 0 = none; null or omitted = inherit). The run is stopped at once, without grace, with reason ceiling.",
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.2",
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.1",
53
+ "pi-better-background-tasks": "0.6.2",
54
54
  "pi-better-goal": "0.5.0",
55
- "pi-better-plan": "0.5.0",
56
- "pi-better-sandbox": "0.7.2",
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.2",
58
+ "pi-better-subagents": "0.9.3",
59
59
  "smol-toml": "1.9.0",
60
60
  "yaml": "^2.9.1"
61
61
  },