pi-better-harness 0.29.2 → 0.30.1

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.
@@ -21,10 +21,6 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
21
21
  return initialDefault = value?.enabled === true;
22
22
  }
23
23
 
24
- function status(ctx: ExtensionContext): void {
25
- ctx.ui.setStatus(ENTRY, enabled ? "tools: minimal" : undefined);
26
- }
27
-
28
24
  function collapseTools(ctx: ExtensionContext): void {
29
25
  // setToolsExpanded is a no-op when already false; force a redraw of restored transcript rows.
30
26
  if (!ctx.ui.getToolsExpanded()) ctx.ui.setToolsExpanded(true);
@@ -77,7 +73,6 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
77
73
  if (enabled) collapseTools(ctx);
78
74
  hook!.setEnabled(enabled);
79
75
  } else enabled = false;
80
- status(ctx);
81
76
  }
82
77
 
83
78
  async function change(value: string, ctx: ExtensionContext): Promise<void> {
@@ -98,7 +93,6 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
98
93
  if (enabled) collapseTools(ctx);
99
94
  hook!.setEnabled(enabled);
100
95
  if (enabled && !running) hook!.completeRun();
101
- status(ctx);
102
96
  }
103
97
 
104
98
  const setting: SettingsControl = {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -95,6 +95,10 @@ export function withWindowsLogRedirect(spec: CommandSpec, logPath: string): Comm
95
95
  return {
96
96
  ...spec,
97
97
  shell: true,
98
+ // The trampoline only redirects and execs an absolute program; sourcing
99
+ // the login profile (/etc/profile and friends) adds seconds of cold-start
100
+ // cost on a Windows runner and nothing the exec needs.
101
+ loginShell: false,
98
102
  // The MSYS2 runtime rewrites POSIX-looking argv (e.g. `/c`, `/opt/x.sh`)
99
103
  // when exec'ing native Windows binaries. Node spawn passed argv verbatim,
100
104
  // so conversion is disabled to keep raw-argv semantics unchanged. The
@@ -404,7 +408,7 @@ export function commandExecution(spec: CommandSpec): { execPath: string; execArg
404
408
  const [command, ...args] = spec.argv!;
405
409
  return { execPath: command!, execArgs: args };
406
410
  }
407
- return { execPath: resolveDefaultShell(), execArgs: ["-lc", spec.command!] };
411
+ return { execPath: resolveDefaultShell(), execArgs: [spec.loginShell === false ? "-c" : "-lc", spec.command!] };
408
412
  }
409
413
 
410
414
  /** Stdio for a spawned task: stdin is always ignored; stdout/stderr are piped (collected) or ignored (redirected into the log by the child itself). */
@@ -174,8 +174,9 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
174
174
  }
175
175
  }
176
176
 
177
- export function navigatorFooterHint(count: number): string | null {
178
- return count > 0 ? `← work · ${count}` : null;
177
+ export function navigatorFooterHint(_count: number): string | null {
178
+ // Retain the compatibility helper so refresh/reload clears any old badge.
179
+ return null;
179
180
  }
180
181
 
181
182
  export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
@@ -58,6 +58,8 @@ export interface ResolvedSshIdentity {
58
58
  export const DEFAULT_SSH_CONNECT_TIMEOUT_SECONDS = 10;
59
59
  export const DEFAULT_SSH_CONTROL_PERSIST_SECONDS = 600;
60
60
  export const MAX_SSH_CONTROL_PATH_BYTES = 100;
61
+ /** The longest socket name under a ControlPath root: `cm-` plus a 32-char hash. */
62
+ const MUX_CONTROL_PATH_NAME_TEMPLATE = `cm-${"0".repeat(32)}`;
61
63
  export const DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS = 120_000;
62
64
 
63
65
  export interface RemoteRunner {
@@ -111,18 +113,33 @@ export interface SshMuxCleanupResult {
111
113
  commandResult: CommandResult;
112
114
  }
113
115
 
116
+ export interface SshMuxForwardResult {
117
+ target: string;
118
+ localPath: string;
119
+ remotePath: string;
120
+ detail: string;
121
+ commandResult: CommandResult;
122
+ }
123
+
114
124
  export interface SshMuxController {
115
125
  readonly controlPath: string;
116
126
  ensure(): Promise<SshMuxEnsureResult>;
127
+ /** Forward a local Unix socket to a remote Unix socket through the running ControlMaster (`ssh -O forward -L local:remote`). */
128
+ forwardUnixSocket(localPath: string, remotePath: string): Promise<SshMuxForwardResult>;
129
+ /** Cancel a forward made by `forwardUnixSocket` (`ssh -O cancel -L local:remote`) and remove the local socket file. */
130
+ cancelUnixSocketForward(localPath: string, remotePath: string): Promise<SshMuxForwardResult>;
117
131
  status(): Promise<SshMuxStatus>;
118
132
  cleanup(): Promise<SshMuxCleanupResult>;
119
133
  withMux(commandSpec: CommandSpec): CommandSpec;
120
134
  }
121
135
 
122
136
  export class SshMuxError extends Error {
123
- constructor(message: string, readonly commandResult: CommandResult) {
137
+ // Declared, not a parameter property: Node's native type stripping (pi-better-server) cannot run those.
138
+ readonly commandResult: CommandResult;
139
+ constructor(message: string, commandResult: CommandResult) {
124
140
  super(message);
125
141
  this.name = "SshMuxError";
142
+ this.commandResult = commandResult;
126
143
  }
127
144
  }
128
145
 
@@ -311,7 +328,10 @@ export function wrapRemoteBashCommand(input: RemoteBashCommandInput): string {
311
328
 
312
329
  export function defaultSshControlPathRoot(): string {
313
330
  const agentDirectory = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
314
- return join(agentDirectory, "ssh-control");
331
+ const root = join(agentDirectory, "ssh-control");
332
+ // A long agent directory would push the socket past the unix path limit; fall back to a short per-user root.
333
+ if (Buffer.byteLength(join(root, MUX_CONTROL_PATH_NAME_TEMPLATE)) <= MAX_SSH_CONTROL_PATH_BYTES) return root;
334
+ return join("/tmp", `pi-ssh-${process.getuid?.() ?? "user"}`);
315
335
  }
316
336
 
317
337
  /**
@@ -328,6 +348,26 @@ export function createSshMuxController(options: SshMuxControllerOptions): SshMux
328
348
  const target = options.identity.target;
329
349
 
330
350
  const prepareRoot = (): void => ensureControlPathRoot(controlPathRoot);
351
+ const forwardOperation = async (operation: "forward" | "cancel", localPath: string, remotePath: string): Promise<SshMuxForwardResult> => {
352
+ for (const [name, value] of [["localPath", localPath], ["remotePath", remotePath]] as const) {
353
+ if (!value || /[\s:]/.test(value)) throw new Error(`ssh forward ${name} must be non-empty and contain no whitespace or colon`);
354
+ }
355
+ prepareRoot();
356
+ const commandResult = await options.runner.runOnce(muxCommandSpec(options.commandSpec, target, [
357
+ "-o", "ControlMaster=no",
358
+ "-o", `ControlPath=${controlPath}`,
359
+ "-O", operation,
360
+ "-L", `${localPath}:${remotePath}`,
361
+ ], false));
362
+ if (operation === "cancel") rmSync(localPath, { force: true });
363
+ if (!commandSucceeded(commandResult)) {
364
+ throw new SshMuxError(
365
+ `SSH ${operation} of ${localPath} -> ${target}:${remotePath} failed${resultDetail(commandResult) ? `: ${resultDetail(commandResult)}` : "."}`,
366
+ commandResult,
367
+ );
368
+ }
369
+ return { target, localPath, remotePath, detail: resultDetail(commandResult), commandResult };
370
+ };
331
371
  const buildControlCommand = (operation: "check" | "exit"): CommandSpec => {
332
372
  prepareRoot();
333
373
  return muxCommandSpec(options.commandSpec, target, [
@@ -350,6 +390,11 @@ export function createSshMuxController(options: SshMuxControllerOptions): SshMux
350
390
  return {
351
391
  controlPath,
352
392
  status,
393
+ forwardUnixSocket: async (localPath, remotePath) => {
394
+ rmSync(localPath, { force: true }); // ssh will not bind over a stale socket file
395
+ return await forwardOperation("forward", localPath, remotePath);
396
+ },
397
+ cancelUnixSocketForward: (localPath, remotePath) => forwardOperation("cancel", localPath, remotePath),
353
398
  withMux: (commandSpec) => {
354
399
  prepareRoot();
355
400
  return muxCommandSpec(commandSpec, target, [
@@ -452,6 +497,9 @@ function ensureControlPathRoot(controlPathRoot: string): void {
452
497
  if (!stats.isDirectory() || stats.isSymbolicLink()) {
453
498
  throw new Error(`ssh mux ControlPath root must be a real directory: ${controlPathRoot}`);
454
499
  }
500
+ if (process.getuid && stats.uid !== process.getuid()) {
501
+ throw new Error(`ssh mux ControlPath root must be owned by the current user: ${controlPathRoot}`);
502
+ }
455
503
  chmodSync(controlPathRoot, 0o700);
456
504
  }
457
505
 
@@ -22,6 +22,8 @@ export interface CommandSpec {
22
22
  command?: string;
23
23
  argv?: string[];
24
24
  shell?: boolean;
25
+ /** Run the shell as a login shell (`bash -lc`, the default). False runs `bash -c`, skipping profile sourcing. */
26
+ loginShell?: boolean;
25
27
  cwd?: string;
26
28
  env?: Record<string, string>;
27
29
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -703,11 +703,8 @@ export default function (pi: ExtensionAPI): void {
703
703
  }
704
704
  }
705
705
  if (waitingOnAnswer > 0) return `${waitingOnAnswer} background done; waiting on your answer`;
706
- const background = snapshot?.backgroundRunning
707
- ? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
708
- : undefined;
709
- if (goal?.status === "paused") return background ? `${pausedGoalStatus(goal, preferences.conversationalResume)} · ${background}` : pausedGoalStatus(goal, preferences.conversationalResume);
710
- if (background) return background;
706
+ if (goal?.status === "paused") return pausedGoalStatus(goal, preferences.conversationalResume);
707
+ if (snapshot?.backgroundRunning) return undefined;
711
708
  const continuation = goal ? currentContinuationState(ctx, goal.goalId) : null;
712
709
  if (continuation?.blocked) return "waiting: no progress";
713
710
  const goalStall = observeGoalStall(goal, continuation, {
@@ -322,12 +322,11 @@ directory. Existing evidence is retained; enforcement is unaffected.
322
322
  /sandbox rules open the write-denied paths editor
323
323
  ```
324
324
 
325
- The footer shows `sandbox · available` when a backend is available but inactive,
326
- `sandbox · inactive` when inactive without a backend, and
327
- `sandbox · on · <project>` while protection is active. Explicitly enabled
328
- sessions report `UNAVAILABLE` or `FAILED` when protection cannot be applied.
329
- Both surfaces report what the runtime actually resolved — which backend, which
330
- executable — never what was merely configured.
325
+ Sandbox status is not displayed in the footer. Use `/sandbox` to inspect the
326
+ permission table, or the text status report outside the TUI. Explicitly enabled
327
+ sessions still warn when protection cannot be applied. Status reports describe
328
+ what the runtime actually resolved, including the backend and executable, never
329
+ what was merely configured.
331
330
 
332
331
  `/sandbox off` and `/sandbox default off` need interactive confirmation and are
333
332
  refused outright when there is no interactive UI. There is no tool for changing
@@ -42,12 +42,9 @@ import { defaultSandboxPermissions, describeLoosening, type SandboxPermissionSet
42
42
  import { openPermissionsPage } from "./permissions-page.ts";
43
43
  import { appendSessionPermissions, readSessionPermissions } from "./session-permissions.ts";
44
44
 
45
- import { footerTone, formatFooterStatus } from "./status.ts";
46
45
  import { ForegroundSandboxController, type ForegroundSandboxStatus } from "./state.ts";
47
46
  import { SandboxDiagnostics, diagnosticPackageVersion } from "./shared-sandbox-diagnostics.ts";
48
47
 
49
- const FOOTER_KEY = "sandbox";
50
-
51
48
  export default function piBetterSandbox(pi: ExtensionAPI): void {
52
49
  const controller = new ForegroundSandboxController();
53
50
  let diagnosticsWarning: ((error: unknown) => void) | undefined;
@@ -97,11 +94,8 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
97
94
  }
98
95
  });
99
96
 
100
- let paintFooter: ((status: ForegroundSandboxStatus) => void) | undefined;
101
-
102
97
  const announce = (status: ForegroundSandboxStatus): void => {
103
98
  publishForegroundSandboxPolicy(pi.events, status);
104
- paintFooter?.(status);
105
99
  };
106
100
 
107
101
  // A consumer that loaded after the last publication can ask for the current
@@ -115,13 +109,6 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
115
109
  const denyRules = new DenyRuleManager({ controller, onStateChange: announce });
116
110
 
117
111
  const restorePermissions = (ctx: ExtensionContext): void => {
118
- paintFooter = (status) => {
119
- ctx.ui.setStatus(
120
- FOOTER_KEY,
121
- formatFooterStatus(status, (tone, text) => ctx.ui.theme.fg(tone, text)),
122
- );
123
- };
124
-
125
112
  // Session policy takes precedence over global defaults. Never skip an
126
113
  // invalid latest entry to recover an older, potentially broader policy.
127
114
  let settings = defaultSandboxPermissions();
@@ -151,8 +138,8 @@ export default function piBetterSandbox(pi: ExtensionAPI): void {
151
138
 
152
139
  // Then the rules are re-read and re-resolved, because the same global
153
140
  // template set means different absolute paths in a different project.
154
- // Loading is what announces the session's first policy, so consumers and
155
- // the footer never see the pre-rule state.
141
+ // Loading announces the session's first policy, so consumers never see
142
+ // the pre-rule state.
156
143
  const report = denyRules.load();
157
144
  const status = report.status;
158
145
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-ssh",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Pi extension for safe synchronous remote bash commands over reusable SSH connections.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -58,6 +58,8 @@ export interface ResolvedSshIdentity {
58
58
  export const DEFAULT_SSH_CONNECT_TIMEOUT_SECONDS = 10;
59
59
  export const DEFAULT_SSH_CONTROL_PERSIST_SECONDS = 600;
60
60
  export const MAX_SSH_CONTROL_PATH_BYTES = 100;
61
+ /** The longest socket name under a ControlPath root: `cm-` plus a 32-char hash. */
62
+ const MUX_CONTROL_PATH_NAME_TEMPLATE = `cm-${"0".repeat(32)}`;
61
63
  export const DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS = 120_000;
62
64
 
63
65
  export interface RemoteRunner {
@@ -111,18 +113,33 @@ export interface SshMuxCleanupResult {
111
113
  commandResult: CommandResult;
112
114
  }
113
115
 
116
+ export interface SshMuxForwardResult {
117
+ target: string;
118
+ localPath: string;
119
+ remotePath: string;
120
+ detail: string;
121
+ commandResult: CommandResult;
122
+ }
123
+
114
124
  export interface SshMuxController {
115
125
  readonly controlPath: string;
116
126
  ensure(): Promise<SshMuxEnsureResult>;
127
+ /** Forward a local Unix socket to a remote Unix socket through the running ControlMaster (`ssh -O forward -L local:remote`). */
128
+ forwardUnixSocket(localPath: string, remotePath: string): Promise<SshMuxForwardResult>;
129
+ /** Cancel a forward made by `forwardUnixSocket` (`ssh -O cancel -L local:remote`) and remove the local socket file. */
130
+ cancelUnixSocketForward(localPath: string, remotePath: string): Promise<SshMuxForwardResult>;
117
131
  status(): Promise<SshMuxStatus>;
118
132
  cleanup(): Promise<SshMuxCleanupResult>;
119
133
  withMux(commandSpec: CommandSpec): CommandSpec;
120
134
  }
121
135
 
122
136
  export class SshMuxError extends Error {
123
- constructor(message: string, readonly commandResult: CommandResult) {
137
+ // Declared, not a parameter property: Node's native type stripping (pi-better-server) cannot run those.
138
+ readonly commandResult: CommandResult;
139
+ constructor(message: string, commandResult: CommandResult) {
124
140
  super(message);
125
141
  this.name = "SshMuxError";
142
+ this.commandResult = commandResult;
126
143
  }
127
144
  }
128
145
 
@@ -311,7 +328,10 @@ export function wrapRemoteBashCommand(input: RemoteBashCommandInput): string {
311
328
 
312
329
  export function defaultSshControlPathRoot(): string {
313
330
  const agentDirectory = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
314
- return join(agentDirectory, "ssh-control");
331
+ const root = join(agentDirectory, "ssh-control");
332
+ // A long agent directory would push the socket past the unix path limit; fall back to a short per-user root.
333
+ if (Buffer.byteLength(join(root, MUX_CONTROL_PATH_NAME_TEMPLATE)) <= MAX_SSH_CONTROL_PATH_BYTES) return root;
334
+ return join("/tmp", `pi-ssh-${process.getuid?.() ?? "user"}`);
315
335
  }
316
336
 
317
337
  /**
@@ -328,6 +348,26 @@ export function createSshMuxController(options: SshMuxControllerOptions): SshMux
328
348
  const target = options.identity.target;
329
349
 
330
350
  const prepareRoot = (): void => ensureControlPathRoot(controlPathRoot);
351
+ const forwardOperation = async (operation: "forward" | "cancel", localPath: string, remotePath: string): Promise<SshMuxForwardResult> => {
352
+ for (const [name, value] of [["localPath", localPath], ["remotePath", remotePath]] as const) {
353
+ if (!value || /[\s:]/.test(value)) throw new Error(`ssh forward ${name} must be non-empty and contain no whitespace or colon`);
354
+ }
355
+ prepareRoot();
356
+ const commandResult = await options.runner.runOnce(muxCommandSpec(options.commandSpec, target, [
357
+ "-o", "ControlMaster=no",
358
+ "-o", `ControlPath=${controlPath}`,
359
+ "-O", operation,
360
+ "-L", `${localPath}:${remotePath}`,
361
+ ], false));
362
+ if (operation === "cancel") rmSync(localPath, { force: true });
363
+ if (!commandSucceeded(commandResult)) {
364
+ throw new SshMuxError(
365
+ `SSH ${operation} of ${localPath} -> ${target}:${remotePath} failed${resultDetail(commandResult) ? `: ${resultDetail(commandResult)}` : "."}`,
366
+ commandResult,
367
+ );
368
+ }
369
+ return { target, localPath, remotePath, detail: resultDetail(commandResult), commandResult };
370
+ };
331
371
  const buildControlCommand = (operation: "check" | "exit"): CommandSpec => {
332
372
  prepareRoot();
333
373
  return muxCommandSpec(options.commandSpec, target, [
@@ -350,6 +390,11 @@ export function createSshMuxController(options: SshMuxControllerOptions): SshMux
350
390
  return {
351
391
  controlPath,
352
392
  status,
393
+ forwardUnixSocket: async (localPath, remotePath) => {
394
+ rmSync(localPath, { force: true }); // ssh will not bind over a stale socket file
395
+ return await forwardOperation("forward", localPath, remotePath);
396
+ },
397
+ cancelUnixSocketForward: (localPath, remotePath) => forwardOperation("cancel", localPath, remotePath),
353
398
  withMux: (commandSpec) => {
354
399
  prepareRoot();
355
400
  return muxCommandSpec(commandSpec, target, [
@@ -452,6 +497,9 @@ function ensureControlPathRoot(controlPathRoot: string): void {
452
497
  if (!stats.isDirectory() || stats.isSymbolicLink()) {
453
498
  throw new Error(`ssh mux ControlPath root must be a real directory: ${controlPathRoot}`);
454
499
  }
500
+ if (process.getuid && stats.uid !== process.getuid()) {
501
+ throw new Error(`ssh mux ControlPath root must be owned by the current user: ${controlPathRoot}`);
502
+ }
455
503
  chmodSync(controlPathRoot, 0o700);
456
504
  }
457
505
 
@@ -42,8 +42,21 @@ this session and persist across reloads; Ctrl+S saves both as defaults, and
42
42
  Reset to defaults clears
43
43
  session overrides. `/subagents mode manual|adaptive|coordinator` and
44
44
  `/subagents cap <number>` are command equivalents; `/subagents save` persists
45
- both. Lowering the cap blocks new single and batch launches without stopping
46
- running work. The navigator's `main` row retains its mode-only controls:
45
+ both. Lowering the cap blocks new single and batch launches, including launches
46
+ still preparing, without stopping running work. Saved configuration changes are
47
+ picked up on subsequent reads; explicit session overrides still take precedence.
48
+ Before every model request, including continuations after tool calls,
49
+ the foreground receives one temporary settings block with the current mode, cap,
50
+ setting sources, revision, and running/reserved/available slots. The latest block
51
+ replaces older snapshots and delegation guidance; it is not saved as conversation
52
+ history. Changes reach the model at its next request, not during an in-flight
53
+ response, and are not broadcast to running children. The cap is a ceiling, not a
54
+ target number of agents. `subagent_settings` inspects the same effective state
55
+ without changing it. Its revision is a stable fingerprint of mode, cap, and
56
+ sources; occupancy changes do not change the revision. Admission rechecks capacity
57
+ when spawning because the snapshot can become stale.
58
+
59
+ The navigator's `main` row retains its mode-only controls:
47
60
  `m` cycles the session mode, `ctrl+s` saves that default, and `x`
48
61
  does not stop it. Manual delegates only on explicit user or workflow request, even with a
49
62
  plan. Adaptive delegates substantial independent work when useful. Coordinator
@@ -8,7 +8,7 @@
8
8
  * `defaultTools` absent → the built-in SAFE_DEFAULT_TOOLS.
9
9
  */
10
10
 
11
- import { existsSync, readFileSync } from "node:fs";
11
+ import { existsSync, readFileSync, statSync } from "node:fs";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { dirname, join } from "node:path";
14
14
  import { homedir } from "node:os";
@@ -89,19 +89,23 @@ export const SAFE_DEFAULT_TOOLS = "read, bash, edit, write, web_search, web_fetc
89
89
  export const SAFE_CLEAN_TOOLS = "read, bash";
90
90
 
91
91
  let cached: SubagentConfig | undefined;
92
+ let cachedIdentity: string | undefined;
93
+ let configOverrideForTests = false;
92
94
  let configPathForTests: string | undefined;
93
95
  let bundledConfigPathForTests: string | undefined;
94
96
 
95
97
  /** Test seam. Pass undefined to reload config.json on the next loadConfig call. */
96
98
  export function setConfigForTests(next: SubagentConfig | undefined): void {
97
99
  cached = next;
100
+ cachedIdentity = undefined;
101
+ configOverrideForTests = next !== undefined;
98
102
  }
99
103
 
100
104
  /** Test seam. Isolate user config, optionally supplying a package fallback. */
101
105
  export function setConfigPathForTests(path: string | undefined, bundledPath?: string): void {
102
106
  configPathForTests = path;
103
107
  bundledConfigPathForTests = bundledPath;
104
- cached = undefined;
108
+ setConfigForTests(undefined);
105
109
  }
106
110
 
107
111
  export function configPath(): string {
@@ -128,8 +132,12 @@ function savedConfig(): SubagentConfig {
128
132
  return user === undefined ? {} : parseConfig(user);
129
133
  }
130
134
 
135
+ function bundledConfigPath(): string | undefined {
136
+ return bundledConfigPathForTests ?? (configPathForTests ? undefined : join(selfDir(), "config.json"));
137
+ }
138
+
131
139
  function bundledConfig(): SubagentConfig {
132
- const path = bundledConfigPathForTests ?? (configPathForTests ? undefined : join(selfDir(), "config.json"));
140
+ const path = bundledConfigPath();
133
141
  if (!path) return {};
134
142
  try {
135
143
  return readConfig(path);
@@ -138,12 +146,31 @@ function bundledConfig(): SubagentConfig {
138
146
  }
139
147
  }
140
148
 
149
+ function configIdentity(): string {
150
+ const paths = [configPath(), bundledConfigPath(),
151
+ configPathForTests ? undefined : join(piAgentDir(), "extensions", "pi-better-subagents-config.json")];
152
+ return JSON.stringify(paths.map(path => {
153
+ if (!path) return null;
154
+ try {
155
+ const stat = statSync(path, { bigint: true });
156
+ return [path, stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].map(String);
157
+ } catch (error) {
158
+ return [path, (error as NodeJS.ErrnoException).code];
159
+ }
160
+ }));
161
+ }
162
+
141
163
  /** Saved user choices take precedence; missing/invalid files contribute no keys. */
142
164
  export function loadConfig(): SubagentConfig {
143
- if (cached) return cached;
165
+ if (configOverrideForTests) return cached!;
166
+ const identity = configIdentity();
167
+ if (cached && cachedIdentity === identity) return cached;
144
168
  let user: SubagentConfig = {};
145
169
  try { user = savedConfig(); } catch { /* retain package fallback */ }
146
170
  cached = { ...bundledConfig(), ...user };
171
+ // Keep the pre-read identity: migration or a concurrent replacement must
172
+ // trigger another read rather than label an older value with a newer stat.
173
+ cachedIdentity = identity;
147
174
  return cached;
148
175
  }
149
176
 
@@ -162,10 +189,10 @@ export function writeSubagentSettings(settings: { delegationMode: DelegationMode
162
189
 
163
190
  function writeConfigSettings(settings: Partial<SubagentConfig>, path: string): void {
164
191
  if (path === configPath()) savedConfig();
165
- const current = updateHarnessSetting("subagents", previous => ({
192
+ updateHarnessSetting("subagents", previous => ({
166
193
  ...(previous === undefined ? {} : parseConfig(previous)), ...settings,
167
194
  }), { agentDir: () => dirname(path) });
168
- if (path === configPath()) cached = { ...bundledConfig(), ...current };
195
+ if (path === configPath()) setConfigForTests(undefined);
169
196
  }
170
197
 
171
198
  /** Normalize a comma/space tool list to pi's bare comma form: "a, b" → "a,b". */
@@ -97,6 +97,7 @@ thresholds continue to take precedence for subagent health.
97
97
  |------|---------|--------------|
98
98
  | `subagent_spawn` | never | Launch a task in a background subagent; returns a run id at once. Params: `prompt`, `name`, `model`, `tools` (allowlist), `exclude_tools`, `sandbox`, `sandbox_dir`, `callback`, `clean`, `cwd`, `git_clone_workspace`, `approve`, `allow_nested`, and the [timing](#run-timing-deadline-ceiling-stuck) overrides `deadline_minutes`, `grace_minutes`, `max_minutes`, `stuck_minutes`. |
99
99
  | `subagent_spawn_batch` | never | Launch several independent subagents at once. Each job becomes a normal run. Params: `batchName`, `shared` (options applied to every job), `jobs[]` (each needs `prompt`; same optional params as `subagent_spawn`), `onCapacity` (`reject` or `launch-available`). |
100
+ | `subagent_settings` | never | Read-only effective settings and capacity snapshot. No parameters. Returns `delegationMode`, `maxConcurrent`, per-setting `sources` (`session` or `config`), a stable settings `revision`, `runningCount`, `pendingCount`, `occupiedSlots`, and `availableSlots`. The limit is a ceiling, not a target; spawn admission rechecks live capacity. |
100
101
  | `subagent_list` | never | Compact current-session list (default 10 rows / 1 KiB page). Params: `all` (machine-global / foreign session), `limit` (default 10, max 100), `cursor`, `max_bytes` (max 4 KiB), `status` (`running`, `completed`, `failed`, `killed`, `exited`, durable `orphaned`, `lost`). Incident counts are compact; no spend/tool histories. |
101
102
  | `subagent_output` | never | Bounded current-session excerpt (default 1 KiB / 10 lines). Params: `id`, `lines` (`tail_lines` is a deprecated alias), `cursor` (a returned `nextCursor`, `statusCursor`, or `incidentCursor`), `max_bytes` (max 4 KiB; raw 16 KiB default, 64 KiB max), `mode` (`raw` pages retained log bytes), `include` (`["cost"]`, `["tools"]`, or both: opt-in spend and tool-count lines), `all` (foreign-session id). Missing/unreadable logs are gaps, not empty healthy output. |
102
103
  | `subagent_result` | never | Finished-run answer for the current session (2048-byte page). Params: `id`, `cursor` (a returned `nextCursor` continues the answer; `statusCursor` / `incidentCursor` also accepted), `max_bytes` (maximum 8192 bytes), `lines` (optional line cap per answer page), `mode` (`raw`), `include` (`["cost"]`, `["tools"]`, or both), `all` (foreign-session id). Failures and exceptional lifecycle facts come before progress; no tool-name histories. TUI folding is display-only. |
@@ -0,0 +1,63 @@
1
+ import { createHash } from "node:crypto";
2
+ import { delegationPrompt, type DelegationMode } from "./delegation.ts";
3
+
4
+ export const SUBAGENT_SETTINGS_CONTEXT = "pi-better-subagents-settings";
5
+
6
+ export interface EffectiveSubagentSettings {
7
+ revision: string;
8
+ delegationMode: DelegationMode;
9
+ maxConcurrent: number;
10
+ sources: { delegationMode: "session" | "config"; maxConcurrent: "session" | "config" };
11
+ runningCount: number;
12
+ pendingCount: number;
13
+ occupiedSlots: number;
14
+ availableSlots: number;
15
+ }
16
+
17
+ export function effectiveSubagentSettings(
18
+ settings: Pick<EffectiveSubagentSettings, "delegationMode" | "maxConcurrent" | "sources">,
19
+ runningCount: number,
20
+ pendingCount: number,
21
+ ): EffectiveSubagentSettings {
22
+ const revision = createHash("sha256").update(JSON.stringify([
23
+ settings.delegationMode, settings.maxConcurrent, settings.sources.delegationMode, settings.sources.maxConcurrent,
24
+ ])).digest("hex").slice(0, 12);
25
+ const occupiedSlots = runningCount + pendingCount;
26
+ return { ...settings, revision, runningCount, pendingCount, occupiedSlots,
27
+ availableSlots: Math.max(0, settings.maxConcurrent - occupiedSlots) };
28
+ }
29
+
30
+ export function formatSubagentSettings(settings: EffectiveSubagentSettings): string {
31
+ return [
32
+ `Subagent settings: revision ${settings.revision}`,
33
+ `Delegation: ${settings.delegationMode} (${settings.sources.delegationMode})`,
34
+ `Concurrency limit: ${settings.maxConcurrent} (${settings.sources.maxConcurrent})`,
35
+ `Running: ${settings.runningCount}; reserved: ${settings.pendingCount}`,
36
+ `Occupied slots: ${settings.occupiedSlots}`,
37
+ `Available slots: ${settings.availableSlots}`,
38
+ "The limit is a ceiling, not a target. Launch ready, independent, useful work within available capacity. " +
39
+ "This is a point-in-time snapshot; spawn admission rechecks capacity. Lowering the limit does not stop existing runs.",
40
+ delegationPrompt(settings.delegationMode),
41
+ ].join("\n");
42
+ }
43
+
44
+ export function subagentSettingsTool(Type: any, read: () => EffectiveSubagentSettings) {
45
+ return {
46
+ name: "subagent_settings",
47
+ label: "Subagent Settings",
48
+ description: "Inspect effective subagent delegation mode, concurrency limit, setting sources, revision, and running/reserved/available slots. Read-only; does not change settings or launch work. The limit is a ceiling, not a target.",
49
+ parameters: Type.Object({}),
50
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
51
+ outputSchema: Type.Object({
52
+ revision: Type.String(), delegationMode: Type.String(), maxConcurrent: Type.Number({ minimum: 1, multipleOf: 1 }),
53
+ sources: Type.Object({ delegationMode: Type.String(), maxConcurrent: Type.String() }),
54
+ runningCount: Type.Number({ minimum: 0, multipleOf: 1 }), pendingCount: Type.Number({ minimum: 0, multipleOf: 1 }),
55
+ occupiedSlots: Type.Number({ minimum: 0, multipleOf: 1 }), availableSlots: Type.Number({ minimum: 0, multipleOf: 1 }),
56
+ }),
57
+ async execute() {
58
+ const settings = read();
59
+ return { content: [{ type: "text" as const, text: formatSubagentSettings(settings) }],
60
+ details: settings, structuredContent: settings };
61
+ },
62
+ };
63
+ }
@@ -37,6 +37,7 @@ import { parseRun, readRunTranscript, resetParseRunCursor, type Usage } from "./
37
37
  import { finalizeRun as finalizeRunCore } from "./finalization.ts";
38
38
  import { loadConfig, writeDelegationMode, writeSubagentSettings, isConcurrencyCap, normalizeConcurrencyCap, normalizeTools, resolveExtensionPath, selfDir, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS } from "./config.ts";
39
39
  import { createSubagentSettingsPage } from "./settings-page.ts";
40
+ import { effectiveSubagentSettings, formatSubagentSettings, subagentSettingsTool, SUBAGENT_SETTINGS_CONTEXT } from "./effective-settings.ts";
40
41
  import { STEER_FILE_ENV, readSteerReceipt } from "./child-steer.ts";
41
42
  import {
42
43
  decideTiming,
@@ -53,7 +54,7 @@ import {
53
54
  type TimingStopReason,
54
55
  } from "./timing.ts";
55
56
  import { readAppendedLines, type LogCursor } from "./log-cursor.ts";
56
- import { DELEGATION_MODES, DELEGATION_MODE_REQUEST, delegationPrompt, isDelegationMode, normalizeDelegationMode, type DelegationMode } from "./delegation.ts";
57
+ import { DELEGATION_MODES, DELEGATION_MODE_REQUEST, isDelegationMode, normalizeDelegationMode, type DelegationMode } from "./delegation.ts";
57
58
  import { appendExecutionPolicy } from "./shared-execution-policy.ts";
58
59
  import { resolveExtensions, extensionArgs } from "./extensions.ts";
59
60
  import { prepareTaskRuntime } from "./task-policy.ts";
@@ -186,6 +187,7 @@ const SUBAGENT_TOOLS = [
186
187
  "subagent_spawn",
187
188
  "subagent_spawn_batch",
188
189
  "subagent_list",
190
+ "subagent_settings",
189
191
  "subagent_output",
190
192
  "subagent_stop",
191
193
  "subagent_result",
@@ -570,7 +572,15 @@ function syncWidgetNavSelection(running: RunMeta[]): void {
570
572
  */
571
573
  function renderWidget(): void {
572
574
  const ctx = uiCtx;
573
- if (!ctx || !ctx.hasUI) return;
575
+ if (!ctx) return;
576
+ // A run can exit after Pi replaced the session that captured this ctx; Pi then throws on any
577
+ // property read. That session's UI is gone, so drop the ctx instead of failing the finalizer.
578
+ try {
579
+ if (!ctx.hasUI) return;
580
+ } catch {
581
+ if (uiCtx === ctx) uiCtx = undefined;
582
+ return;
583
+ }
574
584
  updateNavigatorFooter(ctx);
575
585
  try { ctx.ui.setWidget("subagents", WIDGET_CLEAR); } catch { /* ignore */ }
576
586
  spendCache.clear();
@@ -1522,6 +1532,13 @@ export default function (pi: ExtensionAPI) {
1522
1532
  pi.appendEntry("pi-better-subagents-delegation", { version: 1, mode: null });
1523
1533
  };
1524
1534
  const activeDelegationMode = (): DelegationMode => delegationOverride ?? normalizeDelegationMode(loadConfig().delegationMode);
1535
+ const countRunning = () =>
1536
+ listActiveMetasForParent(process.pid).filter((meta) => effectiveStatus(meta) === "running").length;
1537
+ const effectiveSettings = () => effectiveSubagentSettings({
1538
+ delegationMode: activeDelegationMode(), maxConcurrent: activeConcurrencyCap(),
1539
+ sources: { delegationMode: delegationOverride ? "session" : "config",
1540
+ maxConcurrent: capOverride !== undefined ? "session" : "config" },
1541
+ }, countRunning(), getSharedCapacityGate(countRunning).pending);
1525
1542
  const saveSettings = (): { ok: boolean; message: string } => {
1526
1543
  try {
1527
1544
  writeSubagentSettings({ delegationMode: activeDelegationMode(), maxConcurrent: activeConcurrencyCap() });
@@ -1838,7 +1855,9 @@ export default function (pi: ExtensionAPI) {
1838
1855
  const snapshot = taskRuntime ? takeRecoverySnapshot(taskRuntime.policy.permissions) : undefined;
1839
1856
  if (snapshot?.started) {
1840
1857
  void snapshot.done.then((outcome) => {
1841
- if (!outcome.ok) ctx.ui?.notify?.(`Subagent ${id}: recovery snapshot failed (run continues): ${outcome.detail}`, "warning");
1858
+ if (outcome.ok) return;
1859
+ // The snapshot can finish after the session was replaced and ctx went stale.
1860
+ try { ctx.ui?.notify?.(`Subagent ${id}: recovery snapshot failed (run continues): ${outcome.detail}`, "warning"); } catch { /* ignore */ }
1842
1861
  });
1843
1862
  }
1844
1863
  const cmd = taskRuntime ? { file: taskRuntime.file, fileArgs: [...taskRuntime.fileArgs, ...args] } : { file: piBin, fileArgs: args };
@@ -1846,6 +1865,13 @@ export default function (pi: ExtensionAPI) {
1846
1865
 
1847
1866
  let spawned: ReturnType<typeof spawnDetached>;
1848
1867
  try {
1868
+ // Reservations prevent competing admissions, but do not grandfather
1869
+ // unstarted work after a cap reduction. No await separates this live
1870
+ // occupancy check, child creation, and the running metadata below.
1871
+ const maxConcurrent = activeConcurrencyCap();
1872
+ if (countRunning() >= maxConcurrent) {
1873
+ throw new Error(`Max concurrent subagents (${maxConcurrent}) reached. Stop or let some finish first.`);
1874
+ }
1849
1875
  spawned = spawnDetached({ file: cmd.file, fileArgs: cmd.fileArgs, cwd, logPath: logPathFor(id),
1850
1876
  env: { [STEER_FILE_ENV]: steerPathFor(id) } });
1851
1877
  } catch (error) {
@@ -1956,8 +1982,6 @@ export default function (pi: ExtensionAPI) {
1956
1982
  if (blankSelector) throw new Error(blankSelector);
1957
1983
 
1958
1984
  const maxConcurrent = activeConcurrencyCap();
1959
- const countRunning = () =>
1960
- listActiveMetasForParent(process.pid).filter((m) => effectiveStatus(m) === "running").length;
1961
1985
  // Shared with batch-spawn: reserve before any async work so an interleaved
1962
1986
  // batch cannot oversubscribe after this check and before writeMeta.
1963
1987
  const gate = getSharedCapacityGate(countRunning);
@@ -2093,8 +2117,6 @@ export default function (pi: ExtensionAPI) {
2093
2117
  };
2094
2118
 
2095
2119
  const cfg = loadConfig();
2096
- const countRunning = () =>
2097
- listActiveMetasForParent(process.pid).filter((m) => effectiveStatus(m) === "running").length;
2098
2120
  const launchAvailable = p.onCapacity === "launch-available";
2099
2121
  // Shared with single-spawn. Reservations count against maxConcurrent so a
2100
2122
  // concurrent single spawn cannot take a slot the batch already admitted.
@@ -2248,6 +2270,7 @@ export default function (pi: ExtensionAPI) {
2248
2270
  getActiveOrigin: (ctx?: unknown) => activeCallbackOrigin ?? verifiedOriginFromContext(ctx as ExtensionContext | undefined),
2249
2271
  };
2250
2272
  pi.registerTool(subagentListTool(Type, toolSession));
2273
+ pi.registerTool(subagentSettingsTool(Type, effectiveSettings));
2251
2274
  pi.registerTool(subagentOutputTool(Type, toolSession));
2252
2275
  const acceptanceResultTool = subagentResultTool(Type, toolSession);
2253
2276
  pi.registerTool(acceptanceResultTool);
@@ -2288,13 +2311,16 @@ export default function (pi: ExtensionAPI) {
2288
2311
  const openSettings = async (ctx: ExtensionCommandContext): Promise<void> => {
2289
2312
  if (ctx.mode === "tui" && ctx.hasUI && typeof ctx.ui.custom === "function") {
2290
2313
  await ctx.ui.custom<void>((tui, theme, _kb, done) => createSubagentSettingsPage(theme, {
2291
- get: () => ({
2292
- mode: activeDelegationMode(), maxConcurrent: activeConcurrencyCap(),
2293
- modeSource: delegationOverride ? "session" : "config",
2294
- capSource: capOverride !== undefined ? "session" : "config",
2295
- defaultMode: normalizeDelegationMode(loadConfig().delegationMode),
2296
- defaultCap: normalizeConcurrencyCap(loadConfig().maxConcurrent),
2297
- }),
2314
+ get: () => {
2315
+ const settings = effectiveSettings();
2316
+ return {
2317
+ mode: settings.delegationMode, maxConcurrent: settings.maxConcurrent,
2318
+ modeSource: settings.sources.delegationMode,
2319
+ capSource: settings.sources.maxConcurrent,
2320
+ defaultMode: normalizeDelegationMode(loadConfig().delegationMode),
2321
+ defaultCap: normalizeConcurrencyCap(loadConfig().maxConcurrent),
2322
+ };
2323
+ },
2298
2324
  changeMode, changeCap, save: saveSettings, reset: resetSettings,
2299
2325
  }, () => { refreshBackgroundWorkNavigator(ctx); tui.requestRender(); }, () => done()));
2300
2326
  } else {
@@ -2354,9 +2380,18 @@ export default function (pi: ExtensionAPI) {
2354
2380
  pi.registerTool(discoveryTool as Parameters<ExtensionAPI["registerTool"]>[0]);
2355
2381
 
2356
2382
  pi.on("before_agent_start", (event) => ({
2357
- systemPrompt: `${appendExecutionPolicy(event.systemPrompt)}\n\n${delegationPrompt(activeDelegationMode())}`,
2383
+ systemPrompt: `${appendExecutionPolicy(event.systemPrompt)}\n\nUse the latest Subagent settings context block as the authoritative delegation mode and capacity snapshot. Its delegation guidance replaces earlier mode-specific guidance, including guidance in a plan. Inspect subagent_settings when needed; never treat the concurrency limit as a target number of agents.`,
2358
2384
  }));
2359
2385
 
2386
+ pi.on("context", (event) => {
2387
+ const settings = effectiveSettings();
2388
+ return { messages: [
2389
+ ...event.messages.filter(message => message.role !== "custom" || message.customType !== SUBAGENT_SETTINGS_CONTEXT),
2390
+ { role: "custom" as const, customType: SUBAGENT_SETTINGS_CONTEXT,
2391
+ content: formatSubagentSettings(settings), display: false, details: settings, timestamp: Date.now() },
2392
+ ] };
2393
+ });
2394
+
2360
2395
  // ---- live-status lifecycle -----------------------------------------
2361
2396
  pi.on("agent_start", async (_event, ctx) => {
2362
2397
  setCallbackBatchContext(pi, ctx);
@@ -2472,6 +2507,9 @@ export default function (pi: ExtensionAPI) {
2472
2507
  // running/orphaned children before dropping the session origin, so they
2473
2508
  // cannot become live process groups with no coordinator.
2474
2509
  stopCurrentSessionSubagents(ctx);
2510
+ // The stopped children exit after this handler returns; their finalizers
2511
+ // must not reach for a ctx that Pi invalidates once the session is replaced.
2512
+ uiCtx = undefined;
2475
2513
  agentSessionSettings.clear();
2476
2514
  catalogModelRegistry = undefined;
2477
2515
  activeCallbackOrigin = undefined;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -174,8 +174,9 @@ export function isNavigatorUiAvailable(ctx: ExtensionContext | undefined): boole
174
174
  }
175
175
  }
176
176
 
177
- export function navigatorFooterHint(count: number): string | null {
178
- return count > 0 ? `← work · ${count}` : null;
177
+ export function navigatorFooterHint(_count: number): string | null {
178
+ // Retain the compatibility helper so refresh/reload clears any old badge.
179
+ return null;
179
180
  }
180
181
 
181
182
  export function applyNavigatorFooter(ui: { setStatus(key: string, value: string | undefined): void }, count: number): string | null {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.29.2",
3
+ "version": "0.30.1",
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",
@@ -53,12 +53,12 @@
53
53
  "test": "node --test test/*.test.mjs"
54
54
  },
55
55
  "dependencies": {
56
- "pi-better-background-tasks": "0.9.0",
57
- "pi-better-goal": "0.9.0",
56
+ "pi-better-background-tasks": "0.9.1",
57
+ "pi-better-goal": "0.9.1",
58
58
  "pi-better-plan": "0.9.1",
59
- "pi-better-sandbox": "0.12.0",
60
- "pi-better-ssh": "0.1.2",
61
- "pi-better-subagents": "0.18.0",
59
+ "pi-better-sandbox": "0.12.1",
60
+ "pi-better-ssh": "0.1.3",
61
+ "pi-better-subagents": "0.19.1",
62
62
  "proper-lockfile": "^4.1.2",
63
63
  "smol-toml": "1.9.0",
64
64
  "yaml": "^2.9.1"