pi-better-harness 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,7 +17,7 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
17
17
  - Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
18
18
  - A compact goal widget that does not replace Pi's footer.
19
19
  - Background activity tracking for subagents and other registered providers.
20
- - A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (30s through 300s by default), followed by a hold recoverable with `/goal resume`. Delays saturate at Node's timer limit, and observed background drains reset progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
20
+ - A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (60s through 600s by default), followed by a hold recoverable with `/goal resume`. Delays saturate at Node's timer limit, and observed background drains reset progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
21
21
  - An observable-progress stall state for active goals.
22
22
 
23
23
  ## Skill-Owned Workflows
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -50,7 +50,7 @@ import {
50
50
  } from "./types.js";
51
51
 
52
52
  const POLL_INTERVAL_MS = 2_000;
53
- const DEFAULT_IDLE_CONTINUATION_DELAY_MS = 30_000;
53
+ const DEFAULT_IDLE_CONTINUATION_DELAY_MS = 60_000;
54
54
  const DEFAULT_MAX_NO_PROGRESS_RETRIES = 10;
55
55
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
56
56
  const WAKE_DISABLED =
@@ -124,7 +124,7 @@ function parseDurationEnv(raw: string | undefined, fallback: number): number {
124
124
 
125
125
  /**
126
126
  * Linear backoff for no-progress retries: each identical outcome waits one more
127
- * grace period than the last (30s, 60s, 90s, ... by default).
127
+ * grace period than the last (60s, 120s, 180s, ... by default).
128
128
  */
129
129
  export function idleContinuationDelay(noProgressRetries: number, baseMs = IDLE_CONTINUATION_DELAY_MS): number {
130
130
  return Math.min(MAX_TIMER_DELAY_MS, baseMs * (1 + Math.max(0, noProgressRetries)));
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,7 +40,7 @@ const PlanStepSchema = Type.Object({
40
40
  });
41
41
 
42
42
  const WorkflowRowChangeSchema = Type.Object({
43
- id: Type.Optional(Type.String({ description: "Unit id, component id (e.g. C1), or fleet stage (explore, combine, canary, review, cicd). Omit for run-level fields." })),
43
+ id: Type.Optional(Type.String({ description: "Unit id, component id (e.g. C1), or fleet stage (explore, implement, review, ci, canary). Omit for run-level fields." })),
44
44
  target: Type.Optional(StringEnum(["unit", "component", "fleet", "run"] as const, { description: "Required with add. Otherwise only needed when the same id names two kinds of row." })),
45
45
  set: Type.Optional(Type.Object({}, {
46
46
  additionalProperties: true,
@@ -43,7 +43,7 @@ export interface RushPlanUpdateResult {
43
43
  /** Contract values from rush-issues references/task-plan.md. */
44
44
  export const RUSH_UNIT_STATUSES = ["pending", "in-flight", "diagnosing", "succeeded", "blocked", "cancelled"] as const;
45
45
  export const RUSH_UNIT_STAGES = ["pending", "implement", "validate", "self-review", "diagnose", "done"] as const;
46
- export const RUSH_COMPONENT_STATUSES = ["building", "combining", "review", "cicd", "merged", "blocked"] as const;
46
+ export const RUSH_COMPONENT_STATUSES = ["building", "combining", "review", "ci", "cicd", "merged", "blocked"] as const;
47
47
  export const RUSH_FLEET_STATUSES = [
48
48
  "pending", "in-flight", "diagnosing", "succeeded", "failed", "blocked", "cancelled", "not-applicable", "merged",
49
49
  ] as const;
@@ -91,7 +91,9 @@ export function projectRushPlan(data: unknown, runId: string): RushPlan {
91
91
  planRevision: data.planRevision as number,
92
92
  issues,
93
93
  fleet,
94
- warehouseCanaryRequired: data.warehouseCanaryRequired === true,
94
+ // The skill marks canary work per unit (`warehouseCanary`); older plans set a run-level flag.
95
+ warehouseCanaryRequired: data.warehouseCanaryRequired === true ||
96
+ persistedUnits.some((unit: unknown) => isRecord(unit) && unit.warehouseCanary != null && unit.warehouseCanary !== false),
95
97
  spec: isRecord(data.spec) && typeof data.spec.title === "string" ? { title: data.spec.title } : undefined,
96
98
  };
97
99
  }
@@ -132,9 +134,10 @@ function workflowStatus(status: string): DisplayStatus {
132
134
  }
133
135
 
134
136
  function presentRushPlan(plan: RushPlan): PlanPresentation {
135
- const fleet = ["explore", "combine", "canary", "review", "cicd"].map((stage) => {
136
- const status = stage === "canary" && !plan.warehouseCanaryRequired
137
- ? "not-applicable" : plan.fleet[stage]?.status ?? "pending";
137
+ // The warehouse canary only applies to data work; hide it otherwise.
138
+ const stages = ["explore", "implement", "review", "ci", ...(plan.warehouseCanaryRequired ? ["canary"] : [])];
139
+ const fleet = stages.map((stage) => {
140
+ const status = plan.fleet[stage]?.status ?? "pending";
138
141
  const display = workflowStatus(status);
139
142
  const style = statusStyle(display);
140
143
  return { color: style.color, text: ` ${stage} ${style.glyph}${display === "unknown" ? ` ${planText(status)}` : ""}` };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
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",
@@ -6,6 +6,8 @@ const { createBashToolDefinition, createReadToolDefinition, createWriteToolDefin
6
6
  createEditToolDefinition, createLocalBashOperations, getShellConfig } = PiCodingAgent;
7
7
  import { pathToFileURL } from "node:url";
8
8
  import { spawn } from "node:child_process";
9
+ import { randomUUID } from "node:crypto";
10
+ import { unlink } from "node:fs/promises";
9
11
  import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, readlinkSync, writeFileSync } from "node:fs";
10
12
  import { homedir, tmpdir } from "node:os";
11
13
  import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
@@ -117,42 +119,51 @@ export function createTaskBashOperations(
117
119
  const scratch = plan.policy.runtimeWrite?.[0];
118
120
  const taskEnv = { ...process.env, ...options.env,
119
121
  ...(scratch ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : {}) };
120
- const wrapped = maybeBuildSandboxCommand({
121
- policy: plan.policy, profilePath: plan.profilePath,
122
- execPath: "/usr/bin/env", execArgs: ["-i", "--", ...Object.entries(taskEnv)
123
- .filter((entry): entry is [string, string] => entry[1] !== undefined)
124
- .map(([key, value]) => `${key}=${value}`), shell.shell, ...shell.args, command],
125
- }, { sandboxEnabled: true, explicitSandbox: true });
126
- if (!wrapped) throw new Error("Sandbox: no task execution backend is available.");
127
- for (const line of wrapped.notices ?? []) options.onData(Buffer.from(`${line}\n`));
128
122
  if (options.signal?.aborted) throw new Error("aborted");
129
123
  if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout * 1000 > 2147483647)) {
130
124
  throw new Error("Invalid timeout: must be a positive, supported number of seconds");
131
125
  }
132
- // No shell or caller-controlled loader environment runs before the boundary.
133
- return new Promise((resolve, reject) => {
134
- const child = spawn(wrapped.file, wrapped.fileArgs, { cwd, detached: true,
135
- env: { PATH: "/usr/bin:/bin", HOME: plan.policy.home }, stdio: ["ignore", "pipe", "pipe"] });
136
- let timedOut = false;
137
- const kill = () => { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } } };
138
- const timer = options.timeout === undefined ? undefined : setTimeout(() => { timedOut = true; kill(); }, options.timeout * 1000);
139
- if (child.pid) trackDetachedChildPid(child.pid);
140
- const cleanup = () => {
141
- if (child.pid) untrackDetachedChildPid(child.pid);
142
- if (timer) clearTimeout(timer);
143
- options.signal?.removeEventListener("abort", kill);
144
- };
145
- child.stdout.on("data", options.onData);
146
- child.stderr.on("data", options.onData);
147
- options.signal?.addEventListener("abort", kill, { once: true });
148
- if (options.signal?.aborted) kill();
149
- void waitForChildProcess(child).then((exitCode: number | null) => {
150
- cleanup();
151
- if (options.signal?.aborted) reject(new Error("aborted"));
152
- else if (timedOut) reject(new Error(`timeout:${options.timeout}`));
153
- else resolve({ exitCode });
154
- }, (error: unknown) => { cleanup(); reject(error); });
155
- });
126
+ // A sibling launch must never rewrite a profile another process is reading.
127
+ const profilePath = `${plan.profilePath}.${randomUUID()}.bash.sb`;
128
+ try {
129
+ const wrapped = maybeBuildSandboxCommand({
130
+ policy: plan.policy, profilePath,
131
+ execPath: "/usr/bin/env", execArgs: ["-i", "--", ...Object.entries(taskEnv)
132
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
133
+ .map(([key, value]) => `${key}=${value}`), shell.shell, ...shell.args, command],
134
+ }, { sandboxEnabled: true, explicitSandbox: true });
135
+ if (!wrapped) throw new Error("Sandbox: no task execution backend is available.");
136
+ for (const line of wrapped.notices ?? []) options.onData(Buffer.from(`${line}\n`));
137
+ if (options.signal?.aborted) throw new Error("aborted");
138
+ // No shell or caller-controlled loader environment runs before the boundary.
139
+ return await new Promise<{ exitCode: number | null }>((resolve, reject) => {
140
+ const child = spawn(wrapped.file, wrapped.fileArgs, { cwd, detached: true,
141
+ env: { PATH: "/usr/bin:/bin", HOME: plan.policy.home }, stdio: ["ignore", "pipe", "pipe"] });
142
+ let timedOut = false;
143
+ const kill = () => { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } } };
144
+ const timer = options.timeout === undefined ? undefined : setTimeout(() => { timedOut = true; kill(); }, options.timeout * 1000);
145
+ if (child.pid) trackDetachedChildPid(child.pid);
146
+ const cleanup = () => {
147
+ if (child.pid) untrackDetachedChildPid(child.pid);
148
+ if (timer) clearTimeout(timer);
149
+ options.signal?.removeEventListener("abort", kill);
150
+ };
151
+ child.stdout.on("data", options.onData);
152
+ child.stderr.on("data", options.onData);
153
+ options.signal?.addEventListener("abort", kill, { once: true });
154
+ if (options.signal?.aborted) kill();
155
+ void waitForChildProcess(child).then((exitCode: number | null) => {
156
+ cleanup();
157
+ if (options.signal?.aborted) reject(new Error("aborted"));
158
+ else if (timedOut) reject(new Error(`timeout:${options.timeout}`));
159
+ else resolve({ exitCode });
160
+ }, (error: unknown) => { cleanup(); reject(error); });
161
+ });
162
+ } finally {
163
+ await unlink(profilePath).catch((error: NodeJS.ErrnoException) => {
164
+ if (error.code !== "ENOENT") throw error;
165
+ });
166
+ }
156
167
  },
157
168
  };
158
169
  }
@@ -36,10 +36,13 @@ Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or ne
36
36
  ## Delegation Modes
37
37
 
38
38
  `config.json` sets `delegationMode` to `manual`, `adaptive` (default), or
39
- `coordinator`. `/subagents` displays the active mode, and
40
- `/subagents mode manual|adaptive|coordinator` changes it for the current
41
- session without changing config. `/subagents save` writes the current mode as
42
- the next session's default. The navigator's `main` row opens the same control:
39
+ `coordinator`. `/subagents settings` (or `/subagents`) opens a settings page
40
+ for delegation mode and the concurrent-subagent cap (default 4). Edits apply to
41
+ this session; Save as defaults persists both, and Reset to defaults clears
42
+ session overrides. `/subagents mode manual|adaptive|coordinator` and
43
+ `/subagents cap <number>` are command equivalents; `/subagents save` persists
44
+ both. Lowering the cap blocks new single and batch launches without stopping
45
+ running work. The navigator's `main` row retains its mode-only controls:
43
46
  `m` cycles the session mode, `ctrl+s` saves that default, and `x`
44
47
  does not stop it. Manual delegates only on explicit user or workflow request, even with a
45
48
  plan. Adaptive delegates substantial independent work when useful. Coordinator
@@ -21,7 +21,7 @@ export interface SubagentConfig extends TimingSettings {
21
21
  delegationMode?: DelegationMode | null;
22
22
  defaultModel?: string | null;
23
23
  defaultTools?: string | null;
24
- /** Max subagents allowed to run at once. */
24
+ /** Default concurrency cap; /subagents cap overrides it for the current session. */
25
25
  maxConcurrent?: number | null;
26
26
  /**
27
27
  * Tool name → extension package(s) that provide it. Drives which extension
@@ -74,6 +74,14 @@ export interface SubagentConfig extends TimingSettings {
74
74
  /** Concurrency cap when config.json sets none. */
75
75
  export const DEFAULT_MAX_CONCURRENT = 4;
76
76
 
77
+ export function isConcurrencyCap(value: unknown): value is number {
78
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
79
+ }
80
+
81
+ export function normalizeConcurrencyCap(value: unknown): number {
82
+ return isConcurrencyCap(value) ? value : DEFAULT_MAX_CONCURRENT;
83
+ }
84
+
77
85
  /** Built-in default tool set when config.json sets nothing. */
78
86
  export const SAFE_DEFAULT_TOOLS = "read, bash, edit, write, web_search, web_fetch";
79
87
  /** Safe default for a hermetic (clean) child where extension tools don't exist. */
@@ -123,6 +131,25 @@ export function writeDelegationMode(mode: DelegationMode, path = configPath()):
123
131
  if (path === configPath()) cached = current;
124
132
  }
125
133
 
134
+ /** Save human-facing defaults together, preserving unrelated configuration. */
135
+ export function writeSubagentSettings(settings: { delegationMode: DelegationMode; maxConcurrent: number }, path = configPath()): void {
136
+ if (!isDelegationMode(settings.delegationMode)) throw new Error("Invalid delegation mode.");
137
+ if (!isConcurrencyCap(settings.maxConcurrent)) throw new Error("Concurrent subagents must be a positive whole number.");
138
+ let current: Record<string, unknown> = {};
139
+ try {
140
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf-8"));
141
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Config must be a JSON object.");
142
+ current = parsed as Record<string, unknown>;
143
+ } catch (error) {
144
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
145
+ }
146
+ Object.assign(current, settings);
147
+ const pending = `${path}.${process.pid}.tmp`;
148
+ writeFileSync(pending, `${JSON.stringify(current, null, 2)}\n`);
149
+ renameSync(pending, path);
150
+ if (path === configPath()) cached = current as SubagentConfig;
151
+ }
152
+
126
153
  /** Normalize a comma/space tool list to pi's bare comma form: "a, b" → "a,b". */
127
154
  export function normalizeTools(list: string): string {
128
155
  return list.split(",").map((t) => t.trim()).filter(Boolean).join(",");
@@ -1,5 +1,7 @@
1
1
  export type DelegationMode = "manual" | "adaptive" | "coordinator";
2
2
 
3
+ export const DELEGATION_MODES = ["manual", "adaptive", "coordinator"] as const;
4
+
3
5
  export const DELEGATION_MODE_REQUEST = "pi-better-subagents:delegation-mode-request";
4
6
 
5
7
  export function isDelegationMode(value: unknown): value is DelegationMode {
@@ -16,14 +16,33 @@ launch is the result · completion triggers fetch · the foreground never blocks
16
16
 
17
17
  Set `delegationMode` in this package's `config.json` to `manual`, `adaptive`, or
18
18
  `coordinator` (default `adaptive`). This controls foreground delegation guidance,
19
- not child permissions or catalog definitions. `/subagents` reports the active
20
- mode; `/subagents mode manual|adaptive|coordinator` overrides it for the current
21
- session without editing config. `/subagents save` writes the current mode into
22
- `config.json` after confirmation when a UI is available, and clears the session
23
- override so the next session starts there. On the navigator `main` sheet, `m`
24
- cycles the session mode and `ctrl+s` saves that default.
25
- Other arguments show usage. `/reload` retains a session override; new sessions
26
- use config, while resuming a session restores that session's override.
19
+ not child permissions or catalog definitions. `/subagents settings` (or simply
20
+ `/subagents`) opens the terminal settings page with mode and concurrent-subagent
21
+ cap controls, saved defaults, Save as defaults, and Reset to defaults. Changes
22
+ apply immediately to this session; Escape closes the page without undoing them.
23
+ Enter edits the cap as a positive whole number; Escape cancels an unfinished edit.
24
+ Ctrl+S saves both settings as defaults. Sandbox permissions remain in `/sandbox`.
25
+
26
+ Command equivalents also work outside the terminal UI:
27
+
28
+ ```text
29
+ /subagents mode manual|adaptive|coordinator
30
+ /subagents cap 6
31
+ /subagents save
32
+ /subagents reset
33
+ ```
34
+
35
+ The cap defaults to 4 and applies to both single and batch launches. Lowering it
36
+ below the running count leaves existing work untouched and blocks new admissions
37
+ until capacity becomes available. Slots already reserved by an in-flight launch
38
+ remain admitted. Settings apply to this parent Pi process, not
39
+ all Pi sessions on the machine. Saving preserves unrelated config keys and
40
+ clears both session overrides. The command form asks for confirmation when a UI
41
+ is available. Reset restores saved defaults without changing config.
42
+
43
+ `/reload` retains session overrides; new sessions use config, while resuming or
44
+ navigating a branch restores its settings. On the navigator `main` sheet, `m`
45
+ cycles the session mode and `ctrl+s` saves only that mode default.
27
46
 
28
47
  - **Manual:** no proactive delegation, including in plan mode. A user request or
29
48
  explicit workflow requirement may still delegate.
@@ -383,9 +402,12 @@ top.
383
402
  foreground model).
384
403
  - `delegationMode` — foreground policy (`manual`, `adaptive`, or `coordinator`;
385
404
  default `adaptive`). `/subagents mode ...` changes only the current session.
386
- `/subagents save` writes that mode as the config default.
387
- - `maxConcurrent` — how many subagents may run at once (**default 4**). A spawn
388
- past the cap is rejected until a running one finishes.
405
+ `/subagents save` saves mode and cap together as config defaults.
406
+ - `maxConcurrent` — positive whole-number concurrency cap (**default 4**).
407
+ `/subagents cap <number>` overrides it for this session. Both single and batch
408
+ admissions respect the cap; existing runs are never stopped by a cap change.
409
+ Use `/subagents settings` to edit both controls or `/subagents reset` to return
410
+ to saved defaults.
389
411
 
390
412
  ## The allowlist also decides what LOADS
391
413
 
@@ -35,7 +35,8 @@ import {
35
35
  import { spawnDetached, type SpawnResult } from "./spawn.ts";
36
36
  import { parseRun, readRunTranscript, resetParseRunCursor, type Usage } from "./parse.ts";
37
37
  import { finalizeRun as finalizeRunCore } from "./finalization.ts";
38
- import { loadConfig, writeDelegationMode, normalizeTools, resolveExtensionPath, selfDir, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS, DEFAULT_MAX_CONCURRENT } from "./config.ts";
38
+ import { loadConfig, writeDelegationMode, writeSubagentSettings, isConcurrencyCap, normalizeConcurrencyCap, normalizeTools, resolveExtensionPath, selfDir, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS } from "./config.ts";
39
+ import { createSubagentSettingsPage } from "./settings-page.ts";
39
40
  import { STEER_FILE_ENV, readSteerReceipt } from "./child-steer.ts";
40
41
  import {
41
42
  decideTiming,
@@ -52,7 +53,7 @@ import {
52
53
  type TimingStopReason,
53
54
  } from "./timing.ts";
54
55
  import { readAppendedLines, type LogCursor } from "./log-cursor.ts";
55
- import { DELEGATION_MODE_REQUEST, delegationPrompt, isDelegationMode, normalizeDelegationMode, type DelegationMode } from "./delegation.ts";
56
+ import { DELEGATION_MODES, DELEGATION_MODE_REQUEST, delegationPrompt, isDelegationMode, normalizeDelegationMode, type DelegationMode } from "./delegation.ts";
56
57
  import { resolveExtensions, extensionArgs } from "./extensions.ts";
57
58
  import { prepareTaskRuntime } from "./task-policy.ts";
58
59
  import { canonicalizePath, takeRecoverySnapshot } from "./shared-sandbox-core.ts";
@@ -162,10 +163,13 @@ import {
162
163
  import { defaultUserRoot, type CatalogSnapshot } from "./catalog-store.ts";
163
164
 
164
165
  const SUBAGENT_MODE_ACTIONS: readonly AutocompleteItem[] = [
166
+ { value: "settings", label: "settings", description: "Open delegation mode and concurrency settings" },
167
+ { value: "cap", label: "cap <number>", description: "Set a positive concurrent subagent limit for this session" },
168
+ { value: "reset", label: "reset", description: "Reset session mode and cap to saved defaults" },
165
169
  { value: "mode manual", label: "mode manual", description: "Delegate only when explicitly requested" },
166
170
  { value: "mode adaptive", label: "mode adaptive", description: "Delegate substantial independent work" },
167
171
  { value: "mode coordinator", label: "mode coordinator", description: "Delegate role-owned work by default" },
168
- { value: "save", label: "save", description: "Write the current mode as the next session's default" },
172
+ { value: "save", label: "save", description: "Save the current mode and cap as defaults" },
169
173
  ];
170
174
 
171
175
  export function subagentsArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
@@ -1176,7 +1180,6 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
1176
1180
  });
1177
1181
  }
1178
1182
 
1179
- const DELEGATION_MODES = ["manual", "adaptive", "coordinator"] as const;
1180
1183
  const DELEGATION_BLURB: Record<DelegationMode, string> = {
1181
1184
  manual: "only when you or a workflow asks",
1182
1185
  adaptive: "substantial independent work",
@@ -1496,7 +1499,33 @@ function catalogRoleSchema(purpose: string) {
1496
1499
 
1497
1500
  export default function (pi: ExtensionAPI) {
1498
1501
  let delegationOverride: DelegationMode | undefined;
1502
+ let capOverride: number | undefined;
1503
+ const activeConcurrencyCap = (): number => capOverride ?? normalizeConcurrencyCap(loadConfig().maxConcurrent);
1504
+ const changeCap = (cap: number): void => {
1505
+ if (!isConcurrencyCap(cap)) throw new Error("Concurrent subagents must be a positive whole number.");
1506
+ capOverride = cap;
1507
+ pi.appendEntry("pi-better-subagents-cap", { version: 1, maxConcurrent: cap });
1508
+ };
1509
+ const changeMode = (mode: DelegationMode): void => {
1510
+ delegationOverride = mode;
1511
+ pi.appendEntry("pi-better-subagents-delegation", { version: 1, mode });
1512
+ };
1513
+ const resetSettings = (): void => {
1514
+ capOverride = undefined;
1515
+ delegationOverride = undefined;
1516
+ pi.appendEntry("pi-better-subagents-cap", { version: 1, maxConcurrent: null });
1517
+ pi.appendEntry("pi-better-subagents-delegation", { version: 1, mode: null });
1518
+ };
1499
1519
  const activeDelegationMode = (): DelegationMode => delegationOverride ?? normalizeDelegationMode(loadConfig().delegationMode);
1520
+ const saveSettings = (): { ok: boolean; message: string } => {
1521
+ try {
1522
+ writeSubagentSettings({ delegationMode: activeDelegationMode(), maxConcurrent: activeConcurrencyCap() });
1523
+ resetSettings();
1524
+ return { ok: true, message: "Subagent defaults saved." };
1525
+ } catch (error) {
1526
+ return { ok: false, message: `Could not save subagent defaults: ${error instanceof Error ? error.message : String(error)}` };
1527
+ }
1528
+ };
1500
1529
  readDelegation = () => ({
1501
1530
  mode: activeDelegationMode(),
1502
1531
  source: delegationOverride ? "session" : "config",
@@ -1504,8 +1533,7 @@ export default function (pi: ExtensionAPI) {
1504
1533
  cycleDelegation = () => {
1505
1534
  const current = activeDelegationMode();
1506
1535
  const next = DELEGATION_MODES[(DELEGATION_MODES.indexOf(current) + 1) % DELEGATION_MODES.length]!;
1507
- delegationOverride = next;
1508
- pi.appendEntry("pi-better-subagents-delegation", { version: 1, mode: next });
1536
+ changeMode(next);
1509
1537
  };
1510
1538
  saveDelegationDefault = (mode) => {
1511
1539
  try {
@@ -1519,9 +1547,17 @@ export default function (pi: ExtensionAPI) {
1519
1547
  };
1520
1548
  const restoreDelegationMode = (ctx: ExtensionContext): void => {
1521
1549
  delegationOverride = undefined;
1550
+ capOverride = undefined;
1522
1551
  const branch = ctx.sessionManager?.getBranch?.();
1523
1552
  if (!Array.isArray(branch)) return;
1524
1553
  for (const entry of branch) {
1554
+ if (entry.type === "custom" && entry.customType === "pi-better-subagents-cap") {
1555
+ const data = entry.data as { version?: unknown; maxConcurrent?: unknown } | null;
1556
+ if (data?.version === 1) {
1557
+ if (data.maxConcurrent === null) capOverride = undefined;
1558
+ else if (isConcurrencyCap(data.maxConcurrent)) capOverride = data.maxConcurrent;
1559
+ }
1560
+ }
1525
1561
  if (entry.type !== "custom" || entry.customType !== "pi-better-subagents-delegation") continue;
1526
1562
  const data = entry.data as { version?: unknown; mode?: unknown } | null;
1527
1563
  if (data?.version !== 1) continue;
@@ -1905,8 +1941,7 @@ export default function (pi: ExtensionAPI) {
1905
1941
  const blankSelector = blankCatalogSelector(p);
1906
1942
  if (blankSelector) throw new Error(blankSelector);
1907
1943
 
1908
- const cfg = loadConfig();
1909
- const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
1944
+ const maxConcurrent = activeConcurrencyCap();
1910
1945
  const countRunning = () =>
1911
1946
  listActiveMetasForParent(process.pid).filter((m) => effectiveStatus(m) === "running").length;
1912
1947
  // Shared with batch-spawn: reserve before any async work so an interleaved
@@ -1926,7 +1961,7 @@ export default function (pi: ExtensionAPI) {
1926
1961
  }
1927
1962
  if (catalog.jobs.length > 1) {
1928
1963
  const extra = catalog.jobs.length - 1;
1929
- if (extra > 0 && !gate.tryReserve(extra, maxConcurrent)) {
1964
+ if (extra > 0 && !gate.tryReserve(extra, activeConcurrencyCap())) {
1930
1965
  gate.release(1);
1931
1966
  reserved = 0;
1932
1967
  throw new Error(`Choosing split needs ${catalog.jobs.length} subagent slots, but only one was free. Nothing was launched.`);
@@ -2044,7 +2079,6 @@ export default function (pi: ExtensionAPI) {
2044
2079
  };
2045
2080
 
2046
2081
  const cfg = loadConfig();
2047
- const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
2048
2082
  const countRunning = () =>
2049
2083
  listActiveMetasForParent(process.pid).filter((m) => effectiveStatus(m) === "running").length;
2050
2084
  const launchAvailable = p.onCapacity === "launch-available";
@@ -2087,6 +2121,7 @@ export default function (pi: ExtensionAPI) {
2087
2121
  // until each job commits (or the unused remainder is released) closes the
2088
2122
  // interleaving oversubscribe class — a stale plan alone is not enough.
2089
2123
  if (!launchAvailable) {
2124
+ const maxConcurrent = activeConcurrencyCap();
2090
2125
  // planBatchLaunches still produces the public error text (incl. pending).
2091
2126
  planBatchLaunches({
2092
2127
  jobs: p.jobs,
@@ -2122,7 +2157,7 @@ export default function (pi: ExtensionAPI) {
2122
2157
  const merged = mergeJobOptions(p.shared, job);
2123
2158
 
2124
2159
  if (launchAvailable) {
2125
- if (!gate.tryReserve(1, maxConcurrent)) {
2160
+ if (!gate.tryReserve(1, activeConcurrencyCap())) {
2126
2161
  for (let j = i; j < p.jobs.length; j++) {
2127
2162
  skipped.push({ job: j + 1, name: names[j] });
2128
2163
  }
@@ -2235,30 +2270,53 @@ export default function (pi: ExtensionAPI) {
2235
2270
  if (typeof pi.registerCommand === "function") {
2236
2271
  agentOperations.registerCommands(pi);
2237
2272
  pi.registerCommand("subagents", {
2238
- description: "[mode manual|adaptive|coordinator|save] — Session mode, or save it as the next session's default",
2273
+ description: "[settings|mode manual|adaptive|coordinator|cap <number>|save|reset] — Subagent settings",
2239
2274
  getArgumentCompletions: subagentsArgumentCompletions,
2240
2275
  async handler(args, ctx) {
2241
2276
  const tokens = args.trim().split(/\s+/).filter(Boolean);
2242
- if (tokens.length === 0) {
2243
- ctx.ui.notify(`Delegation mode: ${activeDelegationMode()}.`, "info");
2277
+ if (tokens.length === 0 || (tokens.length === 1 && tokens[0] === "settings")) {
2278
+ if (ctx.mode === "tui" && ctx.hasUI && typeof ctx.ui.custom === "function") {
2279
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => createSubagentSettingsPage(theme, {
2280
+ get: () => ({
2281
+ mode: activeDelegationMode(), maxConcurrent: activeConcurrencyCap(),
2282
+ modeSource: delegationOverride ? "session" : "config",
2283
+ capSource: capOverride !== undefined ? "session" : "config",
2284
+ defaultMode: normalizeDelegationMode(loadConfig().delegationMode),
2285
+ defaultCap: normalizeConcurrencyCap(loadConfig().maxConcurrent),
2286
+ }),
2287
+ changeMode, changeCap, save: saveSettings, reset: resetSettings,
2288
+ }, () => { refreshBackgroundWorkNavigator(ctx); tui.requestRender(); }, () => done()));
2289
+ } else {
2290
+ ctx.ui.notify(`Delegation mode: ${activeDelegationMode()}. Concurrent subagents: ${activeConcurrencyCap()}. Use /subagents mode <mode> or /subagents cap <number>; /subagents save persists both.`, "info");
2291
+ }
2244
2292
  return;
2245
2293
  }
2246
2294
  const requested = tokens[1];
2247
2295
  if (tokens.length === 1 && tokens[0] === "save") {
2248
- const mode = activeDelegationMode();
2249
- if (ctx.hasUI && !(await ctx.ui.confirm("Save delegation default?", `Future sessions will start in ${mode}.`))) {
2250
- ctx.ui.notify("Delegation default unchanged.", "info");
2296
+ if (ctx.hasUI && !(await ctx.ui.confirm("Save subagent defaults?", `Future sessions will start in ${activeDelegationMode()} with a cap of ${activeConcurrencyCap()}.`))) {
2297
+ ctx.ui.notify("Subagent defaults unchanged.", "info");
2251
2298
  return;
2252
2299
  }
2253
- const saved = saveDelegationDefault(mode);
2300
+ const saved = saveSettings();
2254
2301
  ctx.ui.notify(saved.message, saved.ok ? "info" : "warning");
2302
+ } else if (tokens.length === 1 && tokens[0] === "reset") {
2303
+ resetSettings();
2304
+ ctx.ui.notify("Session subagent settings reset to saved defaults.", "info");
2255
2305
  } else if (tokens.length === 2 && tokens[0] === "mode" && isDelegationMode(requested)) {
2256
- delegationOverride = requested;
2257
- pi.appendEntry("pi-better-subagents-delegation", { version: 1, mode: delegationOverride });
2258
- ctx.ui.notify(`Delegation mode: ${delegationOverride} (current session).`, "info");
2306
+ changeMode(requested);
2307
+ ctx.ui.notify(`Delegation mode: ${activeDelegationMode()} (current session).`, "info");
2308
+ } else if (tokens.length === 2 && tokens[0] === "cap") {
2309
+ const cap = /^\d+$/.test(requested!) ? Number(requested) : NaN;
2310
+ if (!isConcurrencyCap(cap)) {
2311
+ ctx.ui.notify("Concurrent subagents must be a positive whole number. Nothing changed.", "warning");
2312
+ return;
2313
+ }
2314
+ changeCap(cap);
2315
+ ctx.ui.notify(`Concurrent subagents: ${cap} (current session). Running subagents are unchanged.`, "info");
2259
2316
  } else {
2260
- ctx.ui.notify("Usage: /subagents [mode manual|adaptive|coordinator|save]", "warning");
2317
+ ctx.ui.notify("Usage: /subagents [settings|mode manual|adaptive|coordinator|cap <number>|save|reset]", "warning");
2261
2318
  }
2319
+ refreshBackgroundWorkNavigator(ctx);
2262
2320
  },
2263
2321
  });
2264
2322
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,128 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import * as Tui from "@earendil-works/pi-tui";
3
+ import { isConcurrencyCap } from "./config.ts";
4
+ import { DELEGATION_MODES, type DelegationMode } from "./delegation.ts";
5
+
6
+ export interface SubagentSettingsSnapshot {
7
+ mode: DelegationMode;
8
+ maxConcurrent: number;
9
+ modeSource: "session" | "config";
10
+ capSource: "session" | "config";
11
+ defaultMode: DelegationMode;
12
+ defaultCap: number;
13
+ }
14
+
15
+ export interface SubagentSettingsHandlers {
16
+ get(): SubagentSettingsSnapshot;
17
+ changeMode(mode: DelegationMode): void;
18
+ changeCap(cap: number): void;
19
+ save(): { ok: boolean; message: string };
20
+ reset(): void;
21
+ }
22
+
23
+ const labels = ["Delegation mode", "Concurrent subagents", "Save as defaults", "Reset to defaults"];
24
+
25
+ export function createSubagentSettingsPage(
26
+ theme: Theme,
27
+ handlers: SubagentSettingsHandlers,
28
+ requestRender: () => void,
29
+ close: () => void,
30
+ ): Tui.Component & Tui.Focusable {
31
+ const input = new Tui.Input();
32
+ let focused = false;
33
+ let row = 0;
34
+ let editing = false;
35
+ let message = "";
36
+ let error = false;
37
+ function report(text: string, failed = false): void {
38
+ message = text;
39
+ error = failed;
40
+ requestRender();
41
+ }
42
+ function action(): void {
43
+ try {
44
+ const settings = handlers.get();
45
+ if (row === 0) {
46
+ const next = DELEGATION_MODES[(DELEGATION_MODES.indexOf(settings.mode) + 1) % DELEGATION_MODES.length]!;
47
+ handlers.changeMode(next);
48
+ report("Session mode updated.");
49
+ } else if (row === 1) {
50
+ editing = true;
51
+ input.setValue(String(settings.maxConcurrent));
52
+ input.handleInput("\x05");
53
+ input.focused = focused;
54
+ report("");
55
+ } else if (row === 2) {
56
+ const result = handlers.save();
57
+ report(result.message, !result.ok);
58
+ } else {
59
+ handlers.reset();
60
+ report("Session settings reset to saved defaults.");
61
+ }
62
+ } catch (cause) {
63
+ report(cause instanceof Error ? cause.message : String(cause), true);
64
+ }
65
+ }
66
+ input.onSubmit = (value) => {
67
+ const cap = /^\d+$/.test(value.trim()) ? Number(value.trim()) : NaN;
68
+ if (!isConcurrencyCap(cap)) {
69
+ report("Enter a positive whole number.", true);
70
+ return;
71
+ }
72
+ try {
73
+ handlers.changeCap(cap);
74
+ editing = false;
75
+ input.focused = false;
76
+ report("Session cap updated.");
77
+ } catch (cause) {
78
+ report(cause instanceof Error ? cause.message : String(cause), true);
79
+ }
80
+ };
81
+ input.onEscape = () => {
82
+ editing = false;
83
+ input.focused = false;
84
+ report("");
85
+ };
86
+ return {
87
+ get focused() { return focused; },
88
+ set focused(value: boolean) { focused = value; input.focused = editing && value; },
89
+ invalidate() { input.invalidate(); },
90
+ handleInput(data: string) {
91
+ if (editing) {
92
+ input.handleInput(data);
93
+ requestRender();
94
+ return;
95
+ }
96
+ if (Tui.matchesKey(data, Tui.Key.escape)) close();
97
+ else if (Tui.matchesKey(data, Tui.Key.up)) row = Math.max(0, row - 1);
98
+ else if (Tui.matchesKey(data, Tui.Key.down)) row = Math.min(labels.length - 1, row + 1);
99
+ else if (Tui.matchesKey(data, Tui.Key.enter) || data === " ") action();
100
+ else if (Tui.matchesKey(data, "ctrl+s")) {
101
+ const selected = row;
102
+ row = 2;
103
+ action();
104
+ row = selected;
105
+ }
106
+ requestRender();
107
+ },
108
+ render(width: number) {
109
+ const settings = handlers.get();
110
+ const values = [
111
+ `${settings.mode} (${settings.modeSource})`,
112
+ `${settings.maxConcurrent} (${settings.capSource})`,
113
+ "",
114
+ "",
115
+ ];
116
+ const lines = [theme.fg("accent", theme.bold("Subagent settings")), ""];
117
+ labels.forEach((label, index) => {
118
+ const text = `${index === row ? ">" : " "} ${label}${values[index] ? `: ${values[index]}` : ""}`;
119
+ lines.push(index === row ? theme.fg("accent", theme.bold(text)) : text);
120
+ });
121
+ if (editing) lines.push("", ...input.render(Math.max(1, width)));
122
+ lines.push("", theme.fg("muted", `Saved defaults: ${settings.defaultMode}, cap ${settings.defaultCap}`));
123
+ if (message) lines.push(theme.fg(error ? "error" : "success", message));
124
+ lines.push("", theme.fg("dim", editing ? "Enter apply Esc cancel" : "Up/Down select Enter change Ctrl+S save Esc close"));
125
+ return lines.map((line) => Tui.truncateToWidth(line, Math.max(1, width), ""));
126
+ },
127
+ };
128
+ }
@@ -6,6 +6,8 @@ const { createBashToolDefinition, createReadToolDefinition, createWriteToolDefin
6
6
  createEditToolDefinition, createLocalBashOperations, getShellConfig } = PiCodingAgent;
7
7
  import { pathToFileURL } from "node:url";
8
8
  import { spawn } from "node:child_process";
9
+ import { randomUUID } from "node:crypto";
10
+ import { unlink } from "node:fs/promises";
9
11
  import { accessSync, constants, lstatSync, mkdirSync, mkdtempSync, readlinkSync, writeFileSync } from "node:fs";
10
12
  import { homedir, tmpdir } from "node:os";
11
13
  import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
@@ -117,42 +119,51 @@ export function createTaskBashOperations(
117
119
  const scratch = plan.policy.runtimeWrite?.[0];
118
120
  const taskEnv = { ...process.env, ...options.env,
119
121
  ...(scratch ? { TMPDIR: scratch, TMP: scratch, TEMP: scratch } : {}) };
120
- const wrapped = maybeBuildSandboxCommand({
121
- policy: plan.policy, profilePath: plan.profilePath,
122
- execPath: "/usr/bin/env", execArgs: ["-i", "--", ...Object.entries(taskEnv)
123
- .filter((entry): entry is [string, string] => entry[1] !== undefined)
124
- .map(([key, value]) => `${key}=${value}`), shell.shell, ...shell.args, command],
125
- }, { sandboxEnabled: true, explicitSandbox: true });
126
- if (!wrapped) throw new Error("Sandbox: no task execution backend is available.");
127
- for (const line of wrapped.notices ?? []) options.onData(Buffer.from(`${line}\n`));
128
122
  if (options.signal?.aborted) throw new Error("aborted");
129
123
  if (options.timeout !== undefined && (!Number.isFinite(options.timeout) || options.timeout <= 0 || options.timeout * 1000 > 2147483647)) {
130
124
  throw new Error("Invalid timeout: must be a positive, supported number of seconds");
131
125
  }
132
- // No shell or caller-controlled loader environment runs before the boundary.
133
- return new Promise((resolve, reject) => {
134
- const child = spawn(wrapped.file, wrapped.fileArgs, { cwd, detached: true,
135
- env: { PATH: "/usr/bin:/bin", HOME: plan.policy.home }, stdio: ["ignore", "pipe", "pipe"] });
136
- let timedOut = false;
137
- const kill = () => { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } } };
138
- const timer = options.timeout === undefined ? undefined : setTimeout(() => { timedOut = true; kill(); }, options.timeout * 1000);
139
- if (child.pid) trackDetachedChildPid(child.pid);
140
- const cleanup = () => {
141
- if (child.pid) untrackDetachedChildPid(child.pid);
142
- if (timer) clearTimeout(timer);
143
- options.signal?.removeEventListener("abort", kill);
144
- };
145
- child.stdout.on("data", options.onData);
146
- child.stderr.on("data", options.onData);
147
- options.signal?.addEventListener("abort", kill, { once: true });
148
- if (options.signal?.aborted) kill();
149
- void waitForChildProcess(child).then((exitCode: number | null) => {
150
- cleanup();
151
- if (options.signal?.aborted) reject(new Error("aborted"));
152
- else if (timedOut) reject(new Error(`timeout:${options.timeout}`));
153
- else resolve({ exitCode });
154
- }, (error: unknown) => { cleanup(); reject(error); });
155
- });
126
+ // A sibling launch must never rewrite a profile another process is reading.
127
+ const profilePath = `${plan.profilePath}.${randomUUID()}.bash.sb`;
128
+ try {
129
+ const wrapped = maybeBuildSandboxCommand({
130
+ policy: plan.policy, profilePath,
131
+ execPath: "/usr/bin/env", execArgs: ["-i", "--", ...Object.entries(taskEnv)
132
+ .filter((entry): entry is [string, string] => entry[1] !== undefined)
133
+ .map(([key, value]) => `${key}=${value}`), shell.shell, ...shell.args, command],
134
+ }, { sandboxEnabled: true, explicitSandbox: true });
135
+ if (!wrapped) throw new Error("Sandbox: no task execution backend is available.");
136
+ for (const line of wrapped.notices ?? []) options.onData(Buffer.from(`${line}\n`));
137
+ if (options.signal?.aborted) throw new Error("aborted");
138
+ // No shell or caller-controlled loader environment runs before the boundary.
139
+ return await new Promise<{ exitCode: number | null }>((resolve, reject) => {
140
+ const child = spawn(wrapped.file, wrapped.fileArgs, { cwd, detached: true,
141
+ env: { PATH: "/usr/bin:/bin", HOME: plan.policy.home }, stdio: ["ignore", "pipe", "pipe"] });
142
+ let timedOut = false;
143
+ const kill = () => { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); } } };
144
+ const timer = options.timeout === undefined ? undefined : setTimeout(() => { timedOut = true; kill(); }, options.timeout * 1000);
145
+ if (child.pid) trackDetachedChildPid(child.pid);
146
+ const cleanup = () => {
147
+ if (child.pid) untrackDetachedChildPid(child.pid);
148
+ if (timer) clearTimeout(timer);
149
+ options.signal?.removeEventListener("abort", kill);
150
+ };
151
+ child.stdout.on("data", options.onData);
152
+ child.stderr.on("data", options.onData);
153
+ options.signal?.addEventListener("abort", kill, { once: true });
154
+ if (options.signal?.aborted) kill();
155
+ void waitForChildProcess(child).then((exitCode: number | null) => {
156
+ cleanup();
157
+ if (options.signal?.aborted) reject(new Error("aborted"));
158
+ else if (timedOut) reject(new Error(`timeout:${options.timeout}`));
159
+ else resolve({ exitCode });
160
+ }, (error: unknown) => { cleanup(); reject(error); });
161
+ });
162
+ } finally {
163
+ await unlink(profilePath).catch((error: NodeJS.ErrnoException) => {
164
+ if (error.code !== "ENOENT") throw error;
165
+ });
166
+ }
156
167
  },
157
168
  };
158
169
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.15.2",
3
+ "version": "0.16.0",
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,11 +53,11 @@
53
53
  },
54
54
  "dependencies": {
55
55
  "pi-better-background-tasks": "0.6.3",
56
- "pi-better-goal": "0.6.0",
57
- "pi-better-plan": "0.5.1",
58
- "pi-better-sandbox": "0.8.1",
56
+ "pi-better-goal": "0.6.1",
57
+ "pi-better-plan": "0.5.2",
58
+ "pi-better-sandbox": "0.8.2",
59
59
  "pi-better-ssh": "0.1.1",
60
- "pi-better-subagents": "0.11.0",
60
+ "pi-better-subagents": "0.12.0",
61
61
  "smol-toml": "1.9.0",
62
62
  "yaml": "^2.9.1"
63
63
  },