pi-better-subagents 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,9 +36,15 @@ 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. Manual delegates only on explicit user or workflow request, even with a
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:
46
+ `m` cycles the session mode, `ctrl+s` saves that default, and `x`
47
+ does not stop it. Manual delegates only on explicit user or workflow request, even with a
42
48
  plan. Adaptive delegates substantial independent work when useful. Coordinator
43
49
  uses `agents_catalog` to discover current role descriptions and delegates every
44
50
  nontrivial role-owned task, while the foreground coordinates, integrates, and
package/agent-commands.ts CHANGED
@@ -123,7 +123,7 @@ interface ParsedArgs {
123
123
 
124
124
  export function registerAgentCommands(pi: Pick<ExtensionAPI, "registerCommand">, deps: AgentCommandDeps = {}): void {
125
125
  pi.registerCommand("agents", {
126
- description: "List, inspect, create, reload, or import Codex agent definitions",
126
+ description: "[list|show|create|reload|import-codex] — List, inspect, create, reload, or import agent definitions",
127
127
  getArgumentCompletions(prefix: string) {
128
128
  const commands = ["list", "inspect", "show", "create", "reload", "import-codex", "help"];
129
129
  const first = prefix.trim().split(/\s+/)[0] ?? "";
package/config.ts CHANGED
@@ -8,12 +8,12 @@
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, renameSync, writeFileSync } 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";
15
15
  import { SELF_SPEC } from "./extensions.ts";
16
- import type { DelegationMode } from "./delegation.ts";
16
+ import { isDelegationMode, type DelegationMode } from "./delegation.ts";
17
17
  import type { TimingSettings } from "./timing.ts";
18
18
 
19
19
  export interface SubagentConfig extends TimingSettings {
@@ -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,30 +74,82 @@ 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. */
80
88
  export const SAFE_CLEAN_TOOLS = "read, bash";
81
89
 
82
90
  let cached: SubagentConfig | undefined;
91
+ let configPathForTests: string | undefined;
83
92
 
84
93
  /** Test seam. Pass undefined to reload config.json on the next loadConfig call. */
85
94
  export function setConfigForTests(next: SubagentConfig | undefined): void {
86
95
  cached = next;
87
96
  }
88
97
 
98
+ /** Test seam. Save and load use this file instead of the package config.json. */
99
+ export function setConfigPathForTests(path: string | undefined): void {
100
+ configPathForTests = path;
101
+ cached = undefined;
102
+ }
103
+
104
+ export function configPath(): string {
105
+ return configPathForTests ?? join(dirname(fileURLToPath(import.meta.url)), "config.json");
106
+ }
107
+
89
108
  /** Load config.json from the extension directory. Missing/invalid → {}. */
90
109
  export function loadConfig(): SubagentConfig {
91
110
  if (cached) return cached;
92
111
  try {
93
- const dir = dirname(fileURLToPath(import.meta.url));
94
- cached = JSON.parse(readFileSync(join(dir, "config.json"), "utf-8")) as SubagentConfig;
112
+ cached = JSON.parse(readFileSync(configPath(), "utf-8")) as SubagentConfig;
95
113
  } catch {
96
114
  cached = {};
97
115
  }
98
116
  return cached;
99
117
  }
100
118
 
119
+ /** Write delegationMode into config.json, preserving every other key. */
120
+ export function writeDelegationMode(mode: DelegationMode, path = configPath()): void {
121
+ if (!isDelegationMode(mode)) throw new Error(`Invalid delegation mode: ${String(mode)}`);
122
+ let current: SubagentConfig = {};
123
+ try {
124
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
125
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) current = parsed as SubagentConfig;
126
+ } catch { /* missing file starts as an empty config */ }
127
+ current.delegationMode = mode;
128
+ const pending = `${path}.${process.pid}.tmp`;
129
+ writeFileSync(pending, `${JSON.stringify(current, null, 2)}\n`);
130
+ renameSync(pending, path);
131
+ if (path === configPath()) cached = current;
132
+ }
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
+
101
153
  /** Normalize a comma/space tool list to pi's bare comma form: "a, b" → "a,b". */
102
154
  export function normalizeTools(list: string): string {
103
155
  return list.split(",").map((t) => t.trim()).filter(Boolean).join(",");
package/delegation.ts CHANGED
@@ -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 {
package/docs/usage.md CHANGED
@@ -16,11 +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. Other arguments show usage. `/reload` retains
22
- this override; new sessions use config, while resuming a session restores that
23
- 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.
24
46
 
25
47
  - **Manual:** no proactive delegation, including in plan mode. A user request or
26
48
  explicit workflow requirement may still delegate.
@@ -380,8 +402,12 @@ top.
380
402
  foreground model).
381
403
  - `delegationMode` — foreground policy (`manual`, `adaptive`, or `coordinator`;
382
404
  default `adaptive`). `/subagents mode ...` changes only the current session.
383
- - `maxConcurrent` — how many subagents may run at once (**default 4**). A spawn
384
- 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.
385
411
 
386
412
  ## The allowlist also decides what LOADS
387
413
 
@@ -517,9 +543,11 @@ is unchanged in every mode.
517
543
  - While the main-window list is focused, the title hint changes to
518
544
  `Enter to view · x to stop`; the selected row is marked with `›`. Press
519
545
  `↓` from the bottom row to return to the input line.
520
- - The Subagents lane pins an informational `main` row above child runs. It
521
- shows the foreground model, effort, active tool, context tokens, and active
522
- elapsed time. It is not selectable and can never become an `x` stop target.
546
+ - The Subagents lane pins a `main` row above child runs. It shows the
547
+ foreground model, effort, active tool, context tokens, elapsed time, and
548
+ delegation mode. Enter opens its detail sheet. `m` cycles the session mode.
549
+ `ctrl+s` saves that mode as the config default. `x` still stops
550
+ a child run; it never stops main.
523
551
  - `↑` moves to the previous row when multiple running rows are shown. `↓`
524
552
  moves toward the input line, returning to normal input from the bottom row.
525
553
  - `Enter` opens the selected run's live detail view. `x` stops the selected
@@ -637,6 +665,10 @@ health model:
637
665
  orphaned, lost, and degraded running runs; #65 orphaned/lost result bodies kept.
638
666
  - **Passive live widget** — healthy/quiet unchanged; degraded (and orphaned) may
639
667
  show a short suffix. Still `setWidget` only — never focusable.
668
+ - **Navigator** — lifecycle status and operational health facts remain visible;
669
+ child tool/model incident summaries and history are omitted from rows and detail
670
+ chrome. Tool labels show just their name (for example, `bash`). The original
671
+ transcript, including error results, and transcript-read diagnostics remain available.
640
672
  - **`callback:false`** suppresses coordinator follow-up only; human `ui.notify` and
641
673
  TUI/passive visibility remain.
642
674
 
@@ -162,6 +162,7 @@ export function formatNavigatorHealthFacts(obs) {
162
162
  if (!obs || !isActionableHealth(obs)) return [];
163
163
  const facts = [];
164
164
  for (const f of surfaceableCompactFacts(obs)) {
165
+ if (obs.model?.state === "error" && f === obs.model.listWarning) continue;
165
166
  if (f && f !== obs.status && !facts.includes(f)) facts.push(f);
166
167
  }
167
168
  if (obs.activity === "stale" && !facts.some((f) => /\bstale\b/i.test(f))) {
package/index.ts CHANGED
@@ -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, 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";
@@ -117,7 +118,7 @@ import {
117
118
  import { buildHealthCallbackDelivery } from "./completion.ts";
118
119
  import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
119
120
  import { completionCallbackFields, failureAttentionFields, healthCallbackFields } from "./callback-fields.ts";
120
- import { collectRunFailures, failurePath, failureView, markFailureAttentionDelivered, pendingFailureAttention, prependFailureSummary } from "./failures.ts";
121
+ import { collectRunFailures, failurePath, markFailureAttentionDelivered, pendingFailureAttention } from "./failures.ts";
121
122
  import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
122
123
  import {
123
124
  text,
@@ -162,9 +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" },
172
+ { value: "save", label: "save", description: "Save the current mode and cap as defaults" },
168
173
  ];
169
174
 
170
175
  export function subagentsArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
@@ -1149,9 +1154,6 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
1149
1154
  if (row.model) bits.push(row.effort ? `${row.model} ${row.effort}` : row.model);
1150
1155
  if (row.tool) bits.push(row.tool);
1151
1156
  if (row.spend) bits.push(row.spend);
1152
- const failure = failureView(row.id, metaById.get(row.id)?.cwd ?? "", row.status !== "running" && row.status !== "orphaned");
1153
- // Only a failure that needs action replaces the row's columns; history stays in the detail view.
1154
- const firstFailure = failure.actionable ? failure.text.split("\n")[0] || "" : "";
1155
1157
  return {
1156
1158
  providerId: "subagents",
1157
1159
  id: row.id,
@@ -1164,9 +1166,8 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
1164
1166
  statusTone: statusTone(row.status),
1165
1167
  kind: "subagent",
1166
1168
  elapsed: row.elapsed,
1167
- primary: firstFailure || bits.join(" · ") || "subagent run",
1168
- secondary: firstFailure ? bits.join(" · ") : undefined,
1169
- facts: [firstFailure, ...row.healthFacts].filter(Boolean).slice(0, 2),
1169
+ primary: bits.join(" · ") || "subagent run",
1170
+ facts: row.healthFacts,
1170
1171
  sortStartedAt: metaById.get(row.id)?.startedAt ?? now,
1171
1172
  expiresAt: (() => {
1172
1173
  const meta = metaById.get(row.id);
@@ -1179,6 +1180,15 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
1179
1180
  });
1180
1181
  }
1181
1182
 
1183
+ const DELEGATION_BLURB: Record<DelegationMode, string> = {
1184
+ manual: "only when you or a workflow asks",
1185
+ adaptive: "substantial independent work",
1186
+ coordinator: "every role-owned task",
1187
+ };
1188
+ let readDelegation = (): { mode: DelegationMode; source: "session" | "config" } => ({ mode: "adaptive", source: "config" });
1189
+ let cycleDelegation = (): void => {};
1190
+ let saveDelegationDefault: (mode: DelegationMode) => { ok: boolean; message: string } = () => ({ ok: false, message: "Saving the delegation default is unavailable." });
1191
+
1182
1192
  function mainAgentWorkRow(now: number): BackgroundWorkRow {
1183
1193
  let running = mainAgentStartedAt !== undefined;
1184
1194
  try { running ||= uiCtx?.isIdle() === false; } catch { /* use event state */ }
@@ -1188,10 +1198,12 @@ function mainAgentWorkRow(now: number): BackgroundWorkRow {
1188
1198
  let contextTokens: number | null | undefined;
1189
1199
  try { contextTokens = uiCtx?.getContextUsage()?.tokens; } catch { contextTokens = undefined; }
1190
1200
  const tokens = typeof contextTokens === "number" ? `${fmtTokens(contextTokens)} tok` : undefined;
1201
+ const delegation = readDelegation();
1191
1202
  const bits = [
1192
1203
  effort ? `${model} ${effort}` : model,
1193
- tool ? `tool ${tool}` : undefined,
1204
+ tool,
1194
1205
  tokens,
1206
+ delegation.source === "session" ? `${delegation.mode} · session` : delegation.mode,
1195
1207
  ].filter((bit): bit is string => Boolean(bit));
1196
1208
  return {
1197
1209
  providerId: "subagents",
@@ -1210,15 +1222,43 @@ function mainAgentWorkRow(now: number): BackgroundWorkRow {
1210
1222
  };
1211
1223
  }
1212
1224
 
1225
+ function mainAgentWorkDetail(now: number): BackgroundWorkDetail {
1226
+ const row = mainAgentWorkRow(now);
1227
+ const delegation = readDelegation();
1228
+ return {
1229
+ providerId: "subagents",
1230
+ id: "main",
1231
+ title: "main",
1232
+ status: row.status,
1233
+ statusTone: row.statusTone,
1234
+ subtitle: row.primary,
1235
+ metadata: [
1236
+ { label: "kind", value: "main agent" },
1237
+ { label: "model", value: row.effort ? `${row.model} · effort ${row.effort}` : (row.model || "-") },
1238
+ { label: "tool", value: row.tool || "-" },
1239
+ { label: "context", value: row.tokens || "-" },
1240
+ { label: "elapsed", value: row.elapsed },
1241
+ { label: "mode", value: `${delegation.mode} · ${delegation.source}` },
1242
+ ],
1243
+ evidence: {
1244
+ label: "delegation",
1245
+ text: [
1246
+ `${delegation.mode}: ${DELEGATION_BLURB[delegation.mode]}.`,
1247
+ "m cycles this session. ctrl+s saves that mode as the next session's default.",
1248
+ "x does not stop the main agent.",
1249
+ ].filter(Boolean).join(" "),
1250
+ },
1251
+ footerActions: ["m mode", "ctrl+s save"],
1252
+ };
1253
+ }
1254
+
1213
1255
  function subagentWorkDetail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
1256
+ if (id === "main") return mainAgentWorkDetail(now);
1214
1257
  const detail = navigatorDetail(id, now);
1215
1258
  if (!detail) return null;
1216
1259
  void options;
1217
1260
  const transcript = readRunTranscript(id);
1218
- const view = failureView(id, readMeta(id)?.cwd ?? "", detail.status !== "running" && detail.status !== "orphaned");
1219
- const failure = view.text;
1220
1261
  const metadata = [
1221
- ...(failure ? [{ label: "failure", value: failure.split("\n")[0]! }] : []),
1222
1262
  { label: "provider", value: "Subagents" },
1223
1263
  { label: "id", value: detail.id },
1224
1264
  ...(detail.role ? [{ label: "role", value: String(detail.role) }] : []),
@@ -1235,11 +1275,11 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
1235
1275
  title: detail.name || detail.id,
1236
1276
  status: detail.status,
1237
1277
  statusTone: statusTone(detail.status),
1238
- subtitle: (view.actionable && failure.split("\n")[0]) || (detail.currentTool ? `current tool ${detail.currentTool}` : undefined),
1278
+ subtitle: detail.currentTool || undefined,
1239
1279
  metadata,
1240
- evidence: { label: "transcript", text: prependFailureSummary(detail.output || "(no transcript yet)", failure) },
1280
+ evidence: { label: "transcript", text: detail.output || "(no transcript yet)" },
1241
1281
  transcript: transcript.entries,
1242
- transcriptDiagnostic: failure ? prependFailureSummary(transcript.diagnostic ?? "", failure) : transcript.diagnostic,
1282
+ transcriptDiagnostic: transcript.diagnostic,
1243
1283
  footerActions: [detail.status === "running" || detail.status === "orphaned" ? "x stop" : "x dismiss"],
1244
1284
  };
1245
1285
  }
@@ -1307,6 +1347,16 @@ function ensureSubagentProvider(): void {
1307
1347
  parentRow: (now) => mainAgentWorkRow(now),
1308
1348
  listRows: (now) => subagentWorkRows(now),
1309
1349
  detail: (id, now, options) => subagentWorkDetail(id, now, options),
1350
+ handleDetailInput: (id, data) => {
1351
+ if (id !== "main") return false;
1352
+ if (data === "m") {
1353
+ cycleDelegation();
1354
+ return true;
1355
+ }
1356
+ if (data !== "ctrl+s") return false;
1357
+ saveDelegationDefault(readDelegation().mode);
1358
+ return true;
1359
+ },
1310
1360
  armCloseLabel: (row) => row.status === "running" || row.status === "orphaned" ? "x again to stop" : "x again to dismiss",
1311
1361
  close: (id) => {
1312
1362
  const outcome = navigatorCloseRun(id) as { action: string; id: string; status?: string };
@@ -1449,15 +1499,70 @@ function catalogRoleSchema(purpose: string) {
1449
1499
 
1450
1500
  export default function (pi: ExtensionAPI) {
1451
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
+ };
1452
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
+ };
1529
+ readDelegation = () => ({
1530
+ mode: activeDelegationMode(),
1531
+ source: delegationOverride ? "session" : "config",
1532
+ });
1533
+ cycleDelegation = () => {
1534
+ const current = activeDelegationMode();
1535
+ const next = DELEGATION_MODES[(DELEGATION_MODES.indexOf(current) + 1) % DELEGATION_MODES.length]!;
1536
+ changeMode(next);
1537
+ };
1538
+ saveDelegationDefault = (mode) => {
1539
+ try {
1540
+ writeDelegationMode(mode);
1541
+ } catch (error) {
1542
+ return { ok: false, message: `Could not save the delegation default: ${error instanceof Error ? error.message : String(error)}` };
1543
+ }
1544
+ delegationOverride = undefined;
1545
+ pi.appendEntry("pi-better-subagents-delegation", { version: 1, mode: null });
1546
+ return { ok: true, message: `Delegation default saved: ${mode}.` };
1547
+ };
1453
1548
  const restoreDelegationMode = (ctx: ExtensionContext): void => {
1454
1549
  delegationOverride = undefined;
1550
+ capOverride = undefined;
1455
1551
  const branch = ctx.sessionManager?.getBranch?.();
1456
1552
  if (!Array.isArray(branch)) return;
1457
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
+ }
1458
1561
  if (entry.type !== "custom" || entry.customType !== "pi-better-subagents-delegation") continue;
1459
1562
  const data = entry.data as { version?: unknown; mode?: unknown } | null;
1460
- if (data?.version === 1 && isDelegationMode(data.mode)) delegationOverride = data.mode;
1563
+ if (data?.version !== 1) continue;
1564
+ if (data.mode === null) delegationOverride = undefined;
1565
+ else if (isDelegationMode(data.mode)) delegationOverride = data.mode;
1461
1566
  }
1462
1567
  };
1463
1568
  const unsubscribeDelegationRequest = pi.events?.on?.(DELEGATION_MODE_REQUEST, (data: unknown) => {
@@ -1836,8 +1941,7 @@ export default function (pi: ExtensionAPI) {
1836
1941
  const blankSelector = blankCatalogSelector(p);
1837
1942
  if (blankSelector) throw new Error(blankSelector);
1838
1943
 
1839
- const cfg = loadConfig();
1840
- const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
1944
+ const maxConcurrent = activeConcurrencyCap();
1841
1945
  const countRunning = () =>
1842
1946
  listActiveMetasForParent(process.pid).filter((m) => effectiveStatus(m) === "running").length;
1843
1947
  // Shared with batch-spawn: reserve before any async work so an interleaved
@@ -1857,7 +1961,7 @@ export default function (pi: ExtensionAPI) {
1857
1961
  }
1858
1962
  if (catalog.jobs.length > 1) {
1859
1963
  const extra = catalog.jobs.length - 1;
1860
- if (extra > 0 && !gate.tryReserve(extra, maxConcurrent)) {
1964
+ if (extra > 0 && !gate.tryReserve(extra, activeConcurrencyCap())) {
1861
1965
  gate.release(1);
1862
1966
  reserved = 0;
1863
1967
  throw new Error(`Choosing split needs ${catalog.jobs.length} subagent slots, but only one was free. Nothing was launched.`);
@@ -1975,7 +2079,6 @@ export default function (pi: ExtensionAPI) {
1975
2079
  };
1976
2080
 
1977
2081
  const cfg = loadConfig();
1978
- const maxConcurrent = cfg.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
1979
2082
  const countRunning = () =>
1980
2083
  listActiveMetasForParent(process.pid).filter((m) => effectiveStatus(m) === "running").length;
1981
2084
  const launchAvailable = p.onCapacity === "launch-available";
@@ -2018,6 +2121,7 @@ export default function (pi: ExtensionAPI) {
2018
2121
  // until each job commits (or the unused remainder is released) closes the
2019
2122
  // interleaving oversubscribe class — a stale plan alone is not enough.
2020
2123
  if (!launchAvailable) {
2124
+ const maxConcurrent = activeConcurrencyCap();
2021
2125
  // planBatchLaunches still produces the public error text (incl. pending).
2022
2126
  planBatchLaunches({
2023
2127
  jobs: p.jobs,
@@ -2053,7 +2157,7 @@ export default function (pi: ExtensionAPI) {
2053
2157
  const merged = mergeJobOptions(p.shared, job);
2054
2158
 
2055
2159
  if (launchAvailable) {
2056
- if (!gate.tryReserve(1, maxConcurrent)) {
2160
+ if (!gate.tryReserve(1, activeConcurrencyCap())) {
2057
2161
  for (let j = i; j < p.jobs.length; j++) {
2058
2162
  skipped.push({ job: j + 1, name: names[j] });
2059
2163
  }
@@ -2166,22 +2270,53 @@ export default function (pi: ExtensionAPI) {
2166
2270
  if (typeof pi.registerCommand === "function") {
2167
2271
  agentOperations.registerCommands(pi);
2168
2272
  pi.registerCommand("subagents", {
2169
- description: "Show or change the current-session delegation mode",
2273
+ description: "[settings|mode manual|adaptive|coordinator|cap <number>|save|reset] — Subagent settings",
2170
2274
  getArgumentCompletions: subagentsArgumentCompletions,
2171
2275
  async handler(args, ctx) {
2172
2276
  const tokens = args.trim().split(/\s+/).filter(Boolean);
2173
- if (tokens.length === 0) {
2174
- 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
+ }
2175
2292
  return;
2176
2293
  }
2177
2294
  const requested = tokens[1];
2178
- if (tokens.length === 2 && tokens[0] === "mode" && isDelegationMode(requested)) {
2179
- delegationOverride = requested;
2180
- pi.appendEntry("pi-better-subagents-delegation", { version: 1, mode: delegationOverride });
2181
- ctx.ui.notify(`Delegation mode: ${delegationOverride} (current session).`, "info");
2295
+ if (tokens.length === 1 && tokens[0] === "save") {
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");
2298
+ return;
2299
+ }
2300
+ const saved = saveSettings();
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");
2305
+ } else if (tokens.length === 2 && tokens[0] === "mode" && isDelegationMode(requested)) {
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");
2182
2316
  } else {
2183
- ctx.ui.notify("Usage: /subagents [mode manual|adaptive|coordinator]", "warning");
2317
+ ctx.ui.notify("Usage: /subagents [settings|mode manual|adaptive|coordinator|cap <number>|save|reset]", "warning");
2184
2318
  }
2319
+ refreshBackgroundWorkNavigator(ctx);
2185
2320
  },
2186
2321
  });
2187
2322
  }
package/navigator.mjs CHANGED
@@ -543,7 +543,6 @@ export function buildDetailLines(detail, opts = {}) {
543
543
  const bits = [];
544
544
  if (c.last.reason) bits.push(c.last.reason);
545
545
  if (c.last.aborted) bits.push("aborted");
546
- if (c.last.errorMessage) bits.push(c.last.errorMessage);
547
546
  if (bits.length) lines.push(` last ${bits.join(" · ")}`);
548
547
  }
549
548
  } else {
@@ -566,28 +565,17 @@ export function buildDetailLines(detail, opts = {}) {
566
565
  lines.push(" idle");
567
566
  }
568
567
 
569
- // Model call / error, separate from tool state.
568
+ // Model activity only; incident summaries stay in result tools and logs.
570
569
  lines.push(sectionRule("model", width));
571
570
  if (h?.model) {
572
571
  const m = h.model;
573
- lines.push(` state ${m.state}`);
574
- if (m.listWarning) lines.push(` warning ${m.listWarning}`);
572
+ if (m.state !== "error") lines.push(` state ${m.state}`);
573
+ if (m.state !== "error" && m.listWarning) lines.push(` warning ${m.listWarning}`);
575
574
  if (m.retry) {
576
575
  const a = m.retry.attempt != null ? String(m.retry.attempt) : "?";
577
576
  const max = m.retry.maxAttempts != null ? String(m.retry.maxAttempts) : "?";
578
577
  lines.push(` retry ${a}/${max}`);
579
578
  }
580
- if (m.lastError) {
581
- const age = m.lastError.at != null ? ` · ${fmtDetailTs(m.lastError.at, now)}` : "";
582
- lines.push(` last error ${m.lastError.message}${age}`);
583
- }
584
- if (Array.isArray(m.errorHistory) && m.errorHistory.length > 0) {
585
- const recent = m.errorHistory.slice(-3);
586
- for (const e of recent) {
587
- const age = e.at != null ? ` · ${fmtDetailTs(e.at, now)}` : "";
588
- lines.push(` history ${e.message}${age}`);
589
- }
590
- }
591
579
  if (m.longModelCall) {
592
580
  const age = m.longModelCall.ageMs != null ? ` · ${fmtDetailAge(m.longModelCall.ageMs)}` : "";
593
581
  lines.push(` long call${age}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.10.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
+ }
@@ -66,6 +66,8 @@ export type BackgroundWorkProvider = {
66
66
  detail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null;
67
67
  armCloseLabel(row: BackgroundWorkRow): string;
68
68
  close(id: string): BackgroundWorkCloseOutcome;
69
+ /** Return true when this detail handled the key. The navigator refreshes the sheet. */
70
+ handleDetailInput?(id: string, data: string): boolean;
69
71
  onVisibleChanged?(notify: () => void): () => void;
70
72
  };
71
73
 
@@ -559,7 +561,7 @@ function rowSummary(row: InternalRow): string {
559
561
  const parts: string[] = [];
560
562
  if (row.statusTone !== "running" && row.kind !== "main agent") parts.push(singleLine(row.status));
561
563
  if (row.model) parts.push(row.effort ? `${singleLine(row.model)} ${singleLine(row.effort)}` : singleLine(row.model));
562
- if (row.tool) parts.push(`tool ${singleLine(row.tool)}`);
564
+ if (row.tool) parts.push(singleLine(row.tool));
563
565
  if (row.tokens) parts.push(singleLine(row.tokens));
564
566
  else if (row.primary) parts.push(singleLine(row.primary));
565
567
  if (parts.length) return parts.join(" · ");
@@ -703,12 +705,7 @@ function handleMainListInput(data: string, deps: HostDeps): boolean {
703
705
  return true;
704
706
  }
705
707
  if (deps.matchKey(data, "enter")) {
706
- const selected = selectedMainListRow();
707
- if (selected?.parentRow) {
708
- unfocusMainList();
709
- dismissOverlay();
710
- }
711
- else openNavigator();
708
+ openNavigator();
712
709
  return true;
713
710
  }
714
711
  if (data === "x" || data === "X" || deps.matchKey(data, "x") || deps.matchKey(data, "X")) {
@@ -851,7 +848,8 @@ function createOverlayComponent(
851
848
  let logTailRows: number = DEFAULT_LOG_TAIL_ROWS;
852
849
  const expandedSections = new Set<string>();
853
850
  let detailId: string = initialDetailId;
854
- let detail: BackgroundWorkDetail | null = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? null;
851
+ const initialRow = initialRows.find((row) => row.navigatorId === detailId);
852
+ let detail: BackgroundWorkDetail | null = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? (initialRow ? fallbackDetail(initialRow) : null);
855
853
  applyDefaultExpandedSections(detail, expandedSections);
856
854
  {
857
855
  const idx = overlayState.rows.findIndex((row) => row.navigatorId === detailId);
@@ -891,8 +889,7 @@ function createOverlayComponent(
891
889
  }
892
890
 
893
891
  function activateSelectedRow(): void {
894
- if (selectedRow()?.parentRow) close();
895
- else openDetail();
892
+ openDetail();
896
893
  }
897
894
 
898
895
  function clearCloseArm(): void {
@@ -922,10 +919,6 @@ function createOverlayComponent(
922
919
  function openDetail(): void {
923
920
  const row = selectedRow();
924
921
  if (!row) return;
925
- if (row.parentRow) {
926
- close();
927
- return;
928
- }
929
922
  clearCloseArm();
930
923
  detailId = row.navigatorId;
931
924
  expandedSections.clear();
@@ -1073,6 +1066,15 @@ function createOverlayComponent(
1073
1066
  requestRender();
1074
1067
  }
1075
1068
  }
1069
+ else if (data === "m" || data === "M" || data === "\x13" || deps.matchKey(data, "ctrl+s")) {
1070
+ const row = selectedRow();
1071
+ const provider = row ? state().providers.get(row.providerId) : undefined;
1072
+ const key = data === "m" || data === "M" ? "m" : "ctrl+s";
1073
+ if (!row || !provider?.handleDetailInput?.(row.id, key)) return;
1074
+ detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
1075
+ refreshMainListWidget();
1076
+ requestRender();
1077
+ }
1076
1078
  else if (data === "l" || data === "L") {
1077
1079
  logTailRows = cycleLogTailRows(logTailRows);
1078
1080
  if (detailId) detail = detailFor(detailId, Date.now(), { logTailLines: logTailRows }) ?? detail;
@@ -1091,8 +1093,7 @@ function createOverlayComponent(
1091
1093
  if (idx < 0) return;
1092
1094
  overlayState.selected = idx;
1093
1095
  state().mainListSelectedId = navigatorId;
1094
- if (overlayState.rows[idx]!.parentRow) close();
1095
- else openDetail();
1096
+ openDetail();
1096
1097
  },
1097
1098
  /** Close the overlay (done), e.g. when the navigator is disposed. */
1098
1099
  dismiss() { close(); },
@@ -1150,7 +1151,11 @@ function buildTranscriptDetailLines(
1150
1151
  ...detail.metadata.map((item) => ` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`),
1151
1152
  ];
1152
1153
  const transcriptHeader = ["", dim(section(`transcript · latest ${tailRows} rows`, width), fg)];
1153
- if (detail.transcriptDiagnostic) transcriptHeader.push(` ${dim(detail.transcriptDiagnostic, fg)}`);
1154
+ if (detail.transcriptDiagnostic) {
1155
+ for (const diagnosticRow of detail.transcriptDiagnostic.split(/\r\n|\r|\n/)) {
1156
+ transcriptHeader.push(` ${dim(diagnosticRow, fg)}`);
1157
+ }
1158
+ }
1154
1159
  // Metadata always renders; the transcript tail takes only the rows left over (the tail size is a cap, not a guarantee).
1155
1160
  lines.push(...optionalMetadata);
1156
1161
  if (options.maxRows !== undefined) {
@@ -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
  }