pi-better-harness 0.14.0 → 0.15.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
@@ -63,11 +63,11 @@ need interactive confirmation. Full policy: [pi-better-sandbox](https://github.c
63
63
  ## Minimal Tool Output
64
64
 
65
65
  The bundled harness also provides `/tool-output minimal`, `/tool-output normal`,
66
- and `/tool-output` (toggle). Minimal mode hides every collapsed result body,
67
- including built-in, extension, and MCP text/images, without changing execution,
68
- sandboxing, or agent-facing payloads. Tool headers and error coloring remain;
69
- Ctrl+O reveals results, and fullscreen Pi versions with clickable tool rows
70
- also support clicking headers.
66
+ and `/tool-output` (toggle). Minimal mode leaves the current tool run visible,
67
+ then folds every finished run between model texts into one line. Built-in,
68
+ extension, and MCP calls are included. Execution, sandboxing, and agent-facing
69
+ payloads are unchanged. Ctrl+O unfolds runs and reveals results. Clickable Pi
70
+ builds can also open one folded run by clicking its summary.
71
71
 
72
72
  Normal mode is the default. The preference is saved in the current session,
73
73
  including resume/reload. This is a version-sensitive internal TUI adapter,
@@ -7,9 +7,13 @@ interface ToolComponent {
7
7
  expanded: boolean;
8
8
  showImages: boolean;
9
9
  resultRendererComponent?: unknown;
10
- ui: { requestRender(): void };
10
+ toolName?: string;
11
+ toolCallId?: string;
12
+ result?: { isError?: boolean };
13
+ ui: { requestRender(): void; children?: ChatContainer[] };
11
14
  invalidate(): void;
12
15
  setExpanded(expanded: boolean): void;
16
+ render(width: number): string[];
13
17
  }
14
18
 
15
19
  type Method = (this: ToolComponent, ...args: unknown[]) => unknown;
@@ -21,36 +25,83 @@ interface ToolPrototype {
21
25
  invalidate: Method;
22
26
  [HOOK]?: HookState;
23
27
  }
28
+ interface ChatContainer {
29
+ children: unknown[];
30
+ render: (width: number) => string[];
31
+ mouseLayout?: { width: number; children: Array<{ component: unknown; height: number }> };
32
+ [CHAT]?: { original: (width: number) => string[]; wrapper: (width: number) => string[] };
33
+ }
34
+ interface ContainerPrototype {
35
+ addChild: (child: unknown, ...rest: unknown[]) => unknown;
36
+ render: (width: number) => string[];
37
+ [ADD]?: { original: ContainerPrototype["addChild"]; wrapper: ContainerPrototype["addChild"] };
38
+ }
24
39
  interface HookState {
25
40
  enabled: boolean;
26
41
  owners: number;
42
+ foldsTurns: boolean;
43
+ closeRuns(): void;
27
44
  redraw(collapse?: boolean): void;
28
45
  restore(): void;
46
+ installContainer(prototype: ContainerPrototype): void;
29
47
  }
30
48
  export interface MinimalOutputHook {
31
49
  setEnabled(enabled: boolean): void;
32
50
  dispose(): void;
51
+ readonly foldsTurns: boolean;
52
+ }
53
+ interface FoldSummary {
54
+ render(width: number): string[];
55
+ invalidate(): void;
56
+ handleMouse(event: { type?: string; button?: string }): { handled: true } | undefined;
57
+ setTools(tools: ToolComponent[]): void;
33
58
  }
34
59
 
35
60
  const HOOK = Symbol.for("pi-better-harness.minimal-output-hook");
61
+ const CHAT = Symbol.for("pi-better-harness.minimal-output-chat");
62
+ const ADD = Symbol.for("pi-better-harness.minimal-output-add-child");
36
63
  const emptyResult = () => ({ render: () => [] as string[], invalidate() {} });
37
64
 
38
- /** Resolve the running host's SDK, not a second SDK bundled beside the extension. */
39
- export async function loadToolPrototype(): Promise<ToolPrototype> {
65
+ function hostRequire(): NodeRequire {
40
66
  let require = createRequire(import.meta.url);
41
67
  if (process.argv[1]) {
42
68
  try {
43
- const hostRequire = createRequire(realpathSync(process.argv[1]));
44
- sdkEntry(hostRequire);
45
- require = hostRequire;
69
+ const candidate = createRequire(realpathSync(process.argv[1]));
70
+ sdkEntry(candidate);
71
+ require = candidate;
46
72
  } catch { /* SDK embedding or a test runner: use the extension's SDK. */ }
47
73
  }
48
- const entry = sdkEntry(require);
74
+ return require;
75
+ }
76
+
77
+ /** Resolve the running host's SDK, not a second SDK bundled beside the extension. */
78
+ export async function loadToolPrototype(): Promise<ToolPrototype> {
79
+ const entry = sdkEntry(hostRequire());
49
80
  const path = join(dirname(entry), "modes/interactive/components/tool-execution.js");
50
81
  const module = await import(pathToFileURL(path).href);
51
82
  return module.ToolExecutionComponent.prototype;
52
83
  }
53
84
 
85
+ /** Chat rows are Container children. Patch that host class, not a second copy. */
86
+ export async function loadContainerPrototype(): Promise<ContainerPrototype> {
87
+ const entry = sdkEntry(hostRequire());
88
+ const tuiPath = createRequire(entry).resolve("@earendil-works/pi-tui");
89
+ const tui = await import(pathToFileURL(tuiPath).href);
90
+ const prototype = tui.Container?.prototype;
91
+ if (typeof prototype?.addChild !== "function" || typeof prototype?.render !== "function") {
92
+ throw new Error("Pi's chat container API is incompatible: missing Container.addChild. Turn folding is unavailable.");
93
+ }
94
+ return prototype;
95
+ }
96
+
97
+ export async function loadMutedText(): Promise<(text: string) => string> {
98
+ const entry = sdkEntry(hostRequire());
99
+ const theme = await import(pathToFileURL(join(dirname(entry), "modes/interactive/theme/theme.js")).href);
100
+ return (text) => {
101
+ try { return theme.theme.fg("muted", text); } catch { return text; }
102
+ };
103
+ }
104
+
54
105
  // Pi's ESM-only export cannot be resolved with require.resolve on older SDKs.
55
106
  function sdkEntry(require: ReturnType<typeof createRequire>): string {
56
107
  for (const directory of require.resolve.paths("@earendil-works/pi-coding-agent") ?? []) {
@@ -64,8 +115,37 @@ function sdkEntry(require: ReturnType<typeof createRequire>): string {
64
115
  throw new Error("Could not locate the running Pi SDK. Normal output remains enabled.");
65
116
  }
66
117
 
118
+ function hasModelText(component: unknown): boolean {
119
+ const content = (component as { lastMessage?: { content?: unknown } } | null)?.lastMessage?.content;
120
+ if (!Array.isArray(content)) return false;
121
+ return content.some((block) => block?.type === "text" && typeof block.text === "string" && block.text.trim().length > 0);
122
+ }
123
+
124
+ function formatRun(tools: ToolComponent[], open: boolean): string {
125
+ const counts = new Map<string, number>();
126
+ let errors = 0;
127
+ for (const tool of tools) {
128
+ const name = tool.toolName || "tool";
129
+ counts.set(name, (counts.get(name) ?? 0) + 1);
130
+ if (tool.result?.isError) errors += 1;
131
+ }
132
+ const names = [...counts].map(([name, count]) => count > 1 ? `${name} ×${count}` : name);
133
+ const noun = tools.length === 1 ? "tool" : "tools";
134
+ const error = errors === 0 ? "" : errors === 1 ? " · 1 error" : ` · ${errors} errors`;
135
+ return `${open ? "▾" : "▸"} ${tools.length} ${noun}${error} · ${names.join(", ")}`;
136
+ }
137
+
138
+ function clip(text: string, width: number): string {
139
+ if (width <= 0 || text.length <= width) return text;
140
+ return width === 1 ? "…" : `${text.slice(0, width - 1)}…`;
141
+ }
142
+
67
143
  /** Internal TUI adapter: no tools are replaced and no result content is changed. */
68
- export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutputHook {
144
+ export function installMinimalOutputHook(
145
+ prototype: ToolPrototype,
146
+ containerPrototype?: ContainerPrototype,
147
+ muted: (text: string) => string = (text) => text,
148
+ ): MinimalOutputHook {
69
149
  for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "invalidate"] as const) {
70
150
  if (typeof prototype[name] !== "function") {
71
151
  throw new Error(`Pi's tool display API is incompatible: missing ${name}. Normal output remains enabled.`);
@@ -80,18 +160,131 @@ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutpu
80
160
  };
81
161
  const seen = new WeakSet<ToolComponent>();
82
162
  const components = new Set<WeakRef<ToolComponent>>();
163
+ const chats = new Set<WeakRef<ChatContainer>>();
164
+ const summaries = new WeakMap<ToolComponent, FoldSummary>();
165
+ const openedAt = new WeakMap<ToolComponent, number>();
166
+ let generation = 0;
167
+ let containerHooked = false;
168
+ let installedContainer: ContainerPrototype | undefined;
169
+ const isOpen = (tool: ToolComponent) => openedAt.get(tool) === generation;
170
+ const laterModelText = (children: unknown[], index: number) => {
171
+ for (let i = index; i < children.length; i++) {
172
+ if (hasModelText(children[i])) return true;
173
+ }
174
+ return false;
175
+ };
176
+ const attachFromTool = (tool: ToolComponent) => {
177
+ for (const child of tool.ui?.children ?? []) {
178
+ if (child?.children?.includes(tool)) wrapChat(child);
179
+ }
180
+ };
181
+ const wrapChat = (container: ChatContainer) => {
182
+ if (!container || seen.has(container as unknown as ToolComponent) || container[CHAT] || typeof container.render !== "function") return;
183
+ const original = container.render;
184
+ const wrapper = function (this: ChatContainer, width: number) {
185
+ if (!current.enabled || !Array.isArray(this.children) || !needsFold(this.children)) return original.call(this, width);
186
+ const lines: string[] = [];
187
+ const mouseChildren: Array<{ component: unknown; height: number }> = [];
188
+ const push = (component: { render(width: number): string[] }) => {
189
+ const childLines = component.render(width) ?? [];
190
+ mouseChildren.push({ component, height: childLines.length });
191
+ lines.push(...childLines);
192
+ };
193
+ let index = 0;
194
+ while (index < this.children.length) {
195
+ const child = this.children[index];
196
+ if (!seen.has(child as ToolComponent)) {
197
+ push(child as { render(width: number): string[] });
198
+ index += 1;
199
+ continue;
200
+ }
201
+ let end = index;
202
+ while (end < this.children.length && seen.has(this.children[end] as ToolComponent)) end += 1;
203
+ const tools = this.children.slice(index, end) as ToolComponent[];
204
+ const closed = laterModelText(this.children, end) && tools.every((tool) => !tool.expanded);
205
+ if (!closed) {
206
+ for (const tool of tools) push(tool);
207
+ } else {
208
+ const open = isOpen(tools[0]!);
209
+ let summary = summaries.get(tools[0]!);
210
+ if (!summary) {
211
+ summary = createSummary(tools[0]!);
212
+ summaries.set(tools[0]!, summary);
213
+ }
214
+ summary.setTools(tools);
215
+ push(summary);
216
+ if (open) for (const tool of tools) push(tool);
217
+ }
218
+ index = end;
219
+ }
220
+ this.mouseLayout = { width, children: mouseChildren };
221
+ return lines;
222
+ };
223
+ container[CHAT] = { original, wrapper };
224
+ container.render = wrapper;
225
+ chats.add(new WeakRef(container));
226
+ };
227
+ const createSummary = (anchor: ToolComponent): FoldSummary => {
228
+ let tools = [anchor];
229
+ return {
230
+ setTools(next) { tools = next; },
231
+ invalidate() {},
232
+ render(width) {
233
+ return ["", muted(clip(formatRun(tools, isOpen(anchor)), width))];
234
+ },
235
+ handleMouse(event) {
236
+ if (event.type !== "click" || (event.button && event.button !== "left")) return undefined;
237
+ if (isOpen(anchor)) openedAt.delete(anchor);
238
+ else openedAt.set(anchor, generation);
239
+ anchor.ui.requestRender();
240
+ return { handled: true };
241
+ },
242
+ };
243
+ };
244
+ const needsFold = (children: unknown[]) => {
245
+ let index = 0;
246
+ while (index < children.length) {
247
+ if (!seen.has(children[index] as ToolComponent)) { index += 1; continue; }
248
+ let end = index;
249
+ while (end < children.length && seen.has(children[end] as ToolComponent)) end += 1;
250
+ const tools = children.slice(index, end) as ToolComponent[];
251
+ if (laterModelText(children, end) && tools.every((tool) => !tool.expanded)) return true;
252
+ index = end;
253
+ }
254
+ return false;
255
+ };
83
256
  state = {
84
257
  enabled: false,
85
258
  owners: 0,
259
+ foldsTurns: false,
260
+ closeRuns() { generation += 1; },
86
261
  redraw(collapse = false) {
87
262
  for (const reference of components) {
88
263
  const component = reference.deref();
89
264
  if (!component) { components.delete(reference); continue; }
90
265
  if (collapse) component.setExpanded(false);
91
266
  else component.invalidate();
267
+ attachFromTool(component);
92
268
  component.ui.requestRender();
93
269
  }
94
270
  },
271
+ installContainer(next) {
272
+ if (containerHooked) return;
273
+ if (typeof next.addChild !== "function") {
274
+ throw new Error("Pi's chat container API is incompatible: missing Container.addChild. Turn folding is unavailable.");
275
+ }
276
+ const original = next[ADD]?.original ?? next.addChild;
277
+ const wrapper: ContainerPrototype["addChild"] = function (this: ChatContainer, child, ...rest) {
278
+ const result = original.call(this, child, ...rest);
279
+ if (seen.has(child as ToolComponent)) wrapChat(this);
280
+ return result;
281
+ };
282
+ next.addChild = wrapper;
283
+ next[ADD] = { original, wrapper };
284
+ containerHooked = true;
285
+ installedContainer = next;
286
+ state!.foldsTurns = true;
287
+ },
95
288
  restore() {
96
289
  for (const name of Object.keys(originals) as Array<keyof typeof originals>) {
97
290
  // Do not remove another extension's subsequently installed wrapper.
@@ -99,12 +292,23 @@ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutpu
99
292
  }
100
293
  delete prototype[HOOK];
101
294
  components.clear();
295
+ if (installedContainer && installedContainer[ADD]?.wrapper === installedContainer.addChild) {
296
+ installedContainer.addChild = installedContainer[ADD].original;
297
+ delete installedContainer[ADD];
298
+ }
299
+ for (const reference of chats) {
300
+ const container = reference.deref();
301
+ if (container?.[CHAT] && container.render === container[CHAT].wrapper) container.render = container[CHAT].original;
302
+ if (container) delete container[CHAT];
303
+ }
304
+ chats.clear();
102
305
  },
103
306
  };
104
307
  const current = state;
105
308
  const wrappers = {
106
309
  updateDisplay(this: ToolComponent, ...args: unknown[]) {
107
310
  if (!seen.has(this)) { seen.add(this); components.add(new WeakRef(this)); }
311
+ attachFromTool(this);
108
312
  if (!current.enabled || this.expanded) return originals.updateDisplay.apply(this, args);
109
313
  const showImages = this.showImages;
110
314
  const lastRenderer = this.resultRendererComponent;
@@ -128,12 +332,15 @@ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutpu
128
332
  prototype[HOOK] = state;
129
333
  }
130
334
  const current = state;
335
+ if (containerPrototype) current.installContainer(containerPrototype);
131
336
  current.owners += 1;
132
337
  let disposed = false;
133
338
  return {
339
+ foldsTurns: current.foldsTurns,
134
340
  setEnabled(enabled) {
135
341
  if (disposed) return;
136
342
  current.enabled = enabled;
343
+ if (enabled) current.closeRuns();
137
344
  current.redraw(enabled);
138
345
  },
139
346
  dispose() {
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { installMinimalOutputHook, loadToolPrototype, type MinimalOutputHook } from "./hook.ts";
2
+ import { installMinimalOutputHook, loadContainerPrototype, loadMutedText, loadToolPrototype, type MinimalOutputHook } from "./hook.ts";
3
3
 
4
4
  const ENTRY = "pi-better-harness-tool-output";
5
5
 
@@ -24,7 +24,12 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
24
24
  }
25
25
  if (hook) return true;
26
26
  try {
27
- hook = installMinimalOutputHook(await loadToolPrototype());
27
+ const [toolPrototype, containerPrototype, muted] = await Promise.all([
28
+ loadToolPrototype(),
29
+ loadContainerPrototype().catch(() => undefined),
30
+ loadMutedText().catch(() => undefined),
31
+ ]);
32
+ hook = installMinimalOutputHook(toolPrototype, containerPrototype, muted);
28
33
  return true;
29
34
  } catch (error) {
30
35
  ctx.ui.notify(`Minimal tool output is unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
@@ -56,11 +61,11 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
56
61
  });
57
62
 
58
63
  pi.registerCommand("tool-output", {
59
- description: "Tool output: minimal | normal (no argument toggles). Display only; agent results are unchanged.",
64
+ description: "[minimal|normal] — Fold finished tool runs, or restore normal output. No argument toggles. Display only.",
60
65
  getArgumentCompletions: (argumentPrefix) => {
61
66
  const prefix = argumentPrefix.trimStart().toLowerCase();
62
67
  const matches = [
63
- { value: "minimal", label: "minimal", description: "Hide collapsed tool result bodies" },
68
+ { value: "minimal", label: "minimal", description: "Fold finished tool runs after the next model text" },
64
69
  { value: "normal", label: "normal", description: "Restore ordinary tool output" },
65
70
  ].filter((option) => option.value.startsWith(prefix));
66
71
  return matches.length > 0 ? matches : null;
@@ -78,7 +83,9 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
78
83
  pi.appendEntry(ENTRY, { version: 1, enabled });
79
84
  status(ctx);
80
85
  ctx.ui.notify(enabled
81
- ? "Minimal tool output on. Click a tool in fullscreen mode or use Ctrl+O to reveal results."
86
+ ? hook!.foldsTurns
87
+ ? "Minimal tool output on. Finished tool runs fold after the next model text. Ctrl+O reveals them; click a folded run when the terminal supports it."
88
+ : "Minimal tool output on, but turn folding is unavailable in this Pi version. Ctrl+O reveals results."
82
89
  : "Normal tool output restored.", "info");
83
90
  },
84
91
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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) {
@@ -121,8 +121,9 @@ repository's `.git/hooks` or `.git/config` (`core.hooksPath`, `core.fsmonitor`,
121
121
  Write, those paths are also protected from removal and renaming.
122
122
 
123
123
  Space applies every change immediately, including higher permission levels,
124
- capabilities switched on, and a sandbox switched off. Saving looser defaults
125
- still needs a second Enter, with a prompt listing what would loosen. The focused
124
+ capabilities switched on, and a sandbox switched off. `ctrl+s` saves the
125
+ defaults from any row on the first press, and the note names anything that
126
+ loosened. Enter on the Save row still saves. The focused
126
127
  row has a full-width background and bold text; the selected Main/Subagents cell
127
128
  also uses inverse styling. No model
128
129
  tool can change these settings. Other rows toggle Off/On. Detail cells under an
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-sandbox",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Pi extension with independent Main and Subagents permission profiles, file guards, and OS sandbox enforcement.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,7 +9,7 @@ export interface PermissionPageHandlers {
9
9
  getConfig(): PermissionSettings;
10
10
  change(settings: PermissionSettings): void | Promise<void>;
11
11
  save(settings: PermissionSettings): void | Promise<void>;
12
- /** What saving `settings` would loosen versus the saved defaults; non-empty requires a confirming second Enter. */
12
+ /** What saving `settings` would loosen versus the saved defaults. Reported after the save. */
13
13
  loosening?(settings: PermissionSettings): string[];
14
14
  /** Trusted-tool candidates registered in the running Pi (builtins and harness tools excluded). */
15
15
  discoverTools?(): readonly Pick<DiscoveredTool, "name" | "package">[];
@@ -128,7 +128,6 @@ export function createPermissionsPage(
128
128
  let row = 0;
129
129
  let column = 0;
130
130
  let busy = false;
131
- let pendingConfirmation: string | undefined;
132
131
 
133
132
  function report(error: unknown): void {
134
133
  message = errorText(error);
@@ -202,19 +201,10 @@ export function createPermissionsPage(
202
201
  report(error);
203
202
  return;
204
203
  }
205
- const key = JSON.stringify(settings);
206
- if (loosened.length && pendingConfirmation !== key) {
207
- pendingConfirmation = key;
208
- message = `Looser defaults (${loosened.join("; ")}). Press Enter again to save.`;
209
- isError = false;
210
- requestRender();
211
- return;
212
- }
213
- pendingConfirmation = undefined;
214
204
  busy = true;
215
205
  try {
216
206
  await handlers.save(snapshot(settings));
217
- message = "Defaults saved.";
207
+ message = loosened.length ? `Defaults saved. Looser: ${loosened.join("; ")}.` : "Defaults saved.";
218
208
  isError = false;
219
209
  requestRender();
220
210
  } catch (error) {
@@ -238,7 +228,6 @@ export function createPermissionsPage(
238
228
  return {
239
229
  invalidate() {},
240
230
  handleInput(data: string) {
241
- if (!matchesKey(data, Key.enter)) pendingConfirmation = undefined;
242
231
  if (matchesKey(data, Key.escape)) {
243
232
  close();
244
233
  } else if (matchesKey(data, Key.up)) {
@@ -257,6 +246,8 @@ export function createPermissionsPage(
257
246
  requestRender();
258
247
  } else if (matchesKey(data, Key.space)) {
259
248
  void change();
249
+ } else if (matchesKey(data, "ctrl+s")) {
250
+ void save();
260
251
  } else if (matchesKey(data, Key.enter) && row === saveRow()) {
261
252
  void save();
262
253
  } else if (matchesKey(data, Key.enter)) {
@@ -330,7 +321,7 @@ export function createPermissionsPage(
330
321
  const contextual = tool ? (tool.kind === "guarded" ? GUARDED_HINT : TRUSTED_HINT)
331
322
  : selected && settings ? cellHint(selected, settings[columns[column]!]) : undefined;
332
323
  for (const hint of [
333
- "↑↓ Select · ←→ Column/fold · Space Toggle tool/group · Enter Fold/Save · Esc Back",
324
+ "↑↓ Select · ←→ Column/fold · Space Toggle · ctrl+s Save · Enter Fold · Esc Back",
334
325
  "Changes apply to new launches. Background tasks follow their launcher.",
335
326
  "Stored credentials: known files only; excludes OS vaults and environment tokens.",
336
327
  "Trusted tools run outside the file rules.",
@@ -338,8 +329,7 @@ export function createPermissionsPage(
338
329
  ]) output.push(theme.fg("dim", truncateToWidth(hint, w, "")));
339
330
  if (message) {
340
331
  const clean = message.replace(/[\r\n]+/g, " ");
341
- const confirmation = /^(.*) (Press Enter again to save\.)$/.exec(clean);
342
- const messages = confirmation && visibleWidth(clean) > w ? [confirmation[1]!, confirmation[2]!] : [clean];
332
+ const messages = visibleWidth(clean) > w && clean.includes(". ") ? clean.split(/(?<=\. )/) : [clean];
343
333
  for (const text of messages) output.push(theme.fg(isError ? "error" : "muted", truncateToWidth(text, w, "")));
344
334
  }
345
335
  return output;
@@ -38,7 +38,10 @@ Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or ne
38
38
  `config.json` sets `delegationMode` to `manual`, `adaptive` (default), or
39
39
  `coordinator`. `/subagents` displays the active mode, and
40
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
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:
43
+ `m` cycles the session mode, `ctrl+s` saves that default, and `x`
44
+ does not stop it. Manual delegates only on explicit user or workflow request, even with a
42
45
  plan. Adaptive delegates substantial independent work when useful. Coordinator
43
46
  uses `agents_catalog` to discover current role descriptions and delegates every
44
47
  nontrivial role-owned task, while the foreground coordinates, integrates, and
@@ -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] ?? "";
@@ -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 {
@@ -80,24 +80,49 @@ export const SAFE_DEFAULT_TOOLS = "read, bash, edit, write, web_search, web_fetc
80
80
  export const SAFE_CLEAN_TOOLS = "read, bash";
81
81
 
82
82
  let cached: SubagentConfig | undefined;
83
+ let configPathForTests: string | undefined;
83
84
 
84
85
  /** Test seam. Pass undefined to reload config.json on the next loadConfig call. */
85
86
  export function setConfigForTests(next: SubagentConfig | undefined): void {
86
87
  cached = next;
87
88
  }
88
89
 
90
+ /** Test seam. Save and load use this file instead of the package config.json. */
91
+ export function setConfigPathForTests(path: string | undefined): void {
92
+ configPathForTests = path;
93
+ cached = undefined;
94
+ }
95
+
96
+ export function configPath(): string {
97
+ return configPathForTests ?? join(dirname(fileURLToPath(import.meta.url)), "config.json");
98
+ }
99
+
89
100
  /** Load config.json from the extension directory. Missing/invalid → {}. */
90
101
  export function loadConfig(): SubagentConfig {
91
102
  if (cached) return cached;
92
103
  try {
93
- const dir = dirname(fileURLToPath(import.meta.url));
94
- cached = JSON.parse(readFileSync(join(dir, "config.json"), "utf-8")) as SubagentConfig;
104
+ cached = JSON.parse(readFileSync(configPath(), "utf-8")) as SubagentConfig;
95
105
  } catch {
96
106
  cached = {};
97
107
  }
98
108
  return cached;
99
109
  }
100
110
 
111
+ /** Write delegationMode into config.json, preserving every other key. */
112
+ export function writeDelegationMode(mode: DelegationMode, path = configPath()): void {
113
+ if (!isDelegationMode(mode)) throw new Error(`Invalid delegation mode: ${String(mode)}`);
114
+ let current: SubagentConfig = {};
115
+ try {
116
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
117
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) current = parsed as SubagentConfig;
118
+ } catch { /* missing file starts as an empty config */ }
119
+ current.delegationMode = mode;
120
+ const pending = `${path}.${process.pid}.tmp`;
121
+ writeFileSync(pending, `${JSON.stringify(current, null, 2)}\n`);
122
+ renameSync(pending, path);
123
+ if (path === configPath()) cached = current;
124
+ }
125
+
101
126
  /** Normalize a comma/space tool list to pi's bare comma form: "a, b" → "a,b". */
102
127
  export function normalizeTools(list: string): string {
103
128
  return list.split(",").map((t) => t.trim()).filter(Boolean).join(",");
@@ -18,9 +18,12 @@ Set `delegationMode` in this package's `config.json` to `manual`, `adaptive`, or
18
18
  `coordinator` (default `adaptive`). This controls foreground delegation guidance,
19
19
  not child permissions or catalog definitions. `/subagents` reports the active
20
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.
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.
24
27
 
25
28
  - **Manual:** no proactive delegation, including in plan mode. A user request or
26
29
  explicit workflow requirement may still delegate.
@@ -380,6 +383,7 @@ top.
380
383
  foreground model).
381
384
  - `delegationMode` — foreground policy (`manual`, `adaptive`, or `coordinator`;
382
385
  default `adaptive`). `/subagents mode ...` changes only the current session.
386
+ `/subagents save` writes that mode as the config default.
383
387
  - `maxConcurrent` — how many subagents may run at once (**default 4**). A spawn
384
388
  past the cap is rejected until a running one finishes.
385
389
 
@@ -517,9 +521,11 @@ is unchanged in every mode.
517
521
  - While the main-window list is focused, the title hint changes to
518
522
  `Enter to view · x to stop`; the selected row is marked with `›`. Press
519
523
  `↓` 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.
524
+ - The Subagents lane pins a `main` row above child runs. It shows the
525
+ foreground model, effort, active tool, context tokens, elapsed time, and
526
+ delegation mode. Enter opens its detail sheet. `m` cycles the session mode.
527
+ `ctrl+s` saves that mode as the config default. `x` still stops
528
+ a child run; it never stops main.
523
529
  - `↑` moves to the previous row when multiple running rows are shown. `↓`
524
530
  moves toward the input line, returning to normal input from the bottom row.
525
531
  - `Enter` opens the selected run's live detail view. `x` stops the selected
@@ -637,6 +643,10 @@ health model:
637
643
  orphaned, lost, and degraded running runs; #65 orphaned/lost result bodies kept.
638
644
  - **Passive live widget** — healthy/quiet unchanged; degraded (and orphaned) may
639
645
  show a short suffix. Still `setWidget` only — never focusable.
646
+ - **Navigator** — lifecycle status and operational health facts remain visible;
647
+ child tool/model incident summaries and history are omitted from rows and detail
648
+ chrome. Tool labels show just their name (for example, `bash`). The original
649
+ transcript, including error results, and transcript-read diagnostics remain available.
640
650
  - **`callback:false`** suppresses coordinator follow-up only; human `ui.notify` and
641
651
  TUI/passive visibility remain.
642
652
 
@@ -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))) {
@@ -35,7 +35,7 @@ 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, normalizeTools, resolveExtensionPath, selfDir, SAFE_DEFAULT_TOOLS, SAFE_CLEAN_TOOLS, DEFAULT_MAX_CONCURRENT } from "./config.ts";
39
39
  import { STEER_FILE_ENV, readSteerReceipt } from "./child-steer.ts";
40
40
  import {
41
41
  decideTiming,
@@ -117,7 +117,7 @@ import {
117
117
  import { buildHealthCallbackDelivery } from "./completion.ts";
118
118
  import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
119
119
  import { completionCallbackFields, failureAttentionFields, healthCallbackFields } from "./callback-fields.ts";
120
- import { collectRunFailures, failurePath, failureView, markFailureAttentionDelivered, pendingFailureAttention, prependFailureSummary } from "./failures.ts";
120
+ import { collectRunFailures, failurePath, markFailureAttentionDelivered, pendingFailureAttention } from "./failures.ts";
121
121
  import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
122
122
  import {
123
123
  text,
@@ -165,6 +165,7 @@ const SUBAGENT_MODE_ACTIONS: readonly AutocompleteItem[] = [
165
165
  { value: "mode manual", label: "mode manual", description: "Delegate only when explicitly requested" },
166
166
  { value: "mode adaptive", label: "mode adaptive", description: "Delegate substantial independent work" },
167
167
  { 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" },
168
169
  ];
169
170
 
170
171
  export function subagentsArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
@@ -1149,9 +1150,6 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
1149
1150
  if (row.model) bits.push(row.effort ? `${row.model} ${row.effort}` : row.model);
1150
1151
  if (row.tool) bits.push(row.tool);
1151
1152
  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
1153
  return {
1156
1154
  providerId: "subagents",
1157
1155
  id: row.id,
@@ -1164,9 +1162,8 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
1164
1162
  statusTone: statusTone(row.status),
1165
1163
  kind: "subagent",
1166
1164
  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),
1165
+ primary: bits.join(" · ") || "subagent run",
1166
+ facts: row.healthFacts,
1170
1167
  sortStartedAt: metaById.get(row.id)?.startedAt ?? now,
1171
1168
  expiresAt: (() => {
1172
1169
  const meta = metaById.get(row.id);
@@ -1179,6 +1176,16 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
1179
1176
  });
1180
1177
  }
1181
1178
 
1179
+ const DELEGATION_MODES = ["manual", "adaptive", "coordinator"] as const;
1180
+ const DELEGATION_BLURB: Record<DelegationMode, string> = {
1181
+ manual: "only when you or a workflow asks",
1182
+ adaptive: "substantial independent work",
1183
+ coordinator: "every role-owned task",
1184
+ };
1185
+ let readDelegation = (): { mode: DelegationMode; source: "session" | "config" } => ({ mode: "adaptive", source: "config" });
1186
+ let cycleDelegation = (): void => {};
1187
+ let saveDelegationDefault: (mode: DelegationMode) => { ok: boolean; message: string } = () => ({ ok: false, message: "Saving the delegation default is unavailable." });
1188
+
1182
1189
  function mainAgentWorkRow(now: number): BackgroundWorkRow {
1183
1190
  let running = mainAgentStartedAt !== undefined;
1184
1191
  try { running ||= uiCtx?.isIdle() === false; } catch { /* use event state */ }
@@ -1188,10 +1195,12 @@ function mainAgentWorkRow(now: number): BackgroundWorkRow {
1188
1195
  let contextTokens: number | null | undefined;
1189
1196
  try { contextTokens = uiCtx?.getContextUsage()?.tokens; } catch { contextTokens = undefined; }
1190
1197
  const tokens = typeof contextTokens === "number" ? `${fmtTokens(contextTokens)} tok` : undefined;
1198
+ const delegation = readDelegation();
1191
1199
  const bits = [
1192
1200
  effort ? `${model} ${effort}` : model,
1193
- tool ? `tool ${tool}` : undefined,
1201
+ tool,
1194
1202
  tokens,
1203
+ delegation.source === "session" ? `${delegation.mode} · session` : delegation.mode,
1195
1204
  ].filter((bit): bit is string => Boolean(bit));
1196
1205
  return {
1197
1206
  providerId: "subagents",
@@ -1210,15 +1219,43 @@ function mainAgentWorkRow(now: number): BackgroundWorkRow {
1210
1219
  };
1211
1220
  }
1212
1221
 
1222
+ function mainAgentWorkDetail(now: number): BackgroundWorkDetail {
1223
+ const row = mainAgentWorkRow(now);
1224
+ const delegation = readDelegation();
1225
+ return {
1226
+ providerId: "subagents",
1227
+ id: "main",
1228
+ title: "main",
1229
+ status: row.status,
1230
+ statusTone: row.statusTone,
1231
+ subtitle: row.primary,
1232
+ metadata: [
1233
+ { label: "kind", value: "main agent" },
1234
+ { label: "model", value: row.effort ? `${row.model} · effort ${row.effort}` : (row.model || "-") },
1235
+ { label: "tool", value: row.tool || "-" },
1236
+ { label: "context", value: row.tokens || "-" },
1237
+ { label: "elapsed", value: row.elapsed },
1238
+ { label: "mode", value: `${delegation.mode} · ${delegation.source}` },
1239
+ ],
1240
+ evidence: {
1241
+ label: "delegation",
1242
+ text: [
1243
+ `${delegation.mode}: ${DELEGATION_BLURB[delegation.mode]}.`,
1244
+ "m cycles this session. ctrl+s saves that mode as the next session's default.",
1245
+ "x does not stop the main agent.",
1246
+ ].filter(Boolean).join(" "),
1247
+ },
1248
+ footerActions: ["m mode", "ctrl+s save"],
1249
+ };
1250
+ }
1251
+
1213
1252
  function subagentWorkDetail(id: string, now: number, options?: { logTailLines?: number }): BackgroundWorkDetail | null {
1253
+ if (id === "main") return mainAgentWorkDetail(now);
1214
1254
  const detail = navigatorDetail(id, now);
1215
1255
  if (!detail) return null;
1216
1256
  void options;
1217
1257
  const transcript = readRunTranscript(id);
1218
- const view = failureView(id, readMeta(id)?.cwd ?? "", detail.status !== "running" && detail.status !== "orphaned");
1219
- const failure = view.text;
1220
1258
  const metadata = [
1221
- ...(failure ? [{ label: "failure", value: failure.split("\n")[0]! }] : []),
1222
1259
  { label: "provider", value: "Subagents" },
1223
1260
  { label: "id", value: detail.id },
1224
1261
  ...(detail.role ? [{ label: "role", value: String(detail.role) }] : []),
@@ -1235,11 +1272,11 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
1235
1272
  title: detail.name || detail.id,
1236
1273
  status: detail.status,
1237
1274
  statusTone: statusTone(detail.status),
1238
- subtitle: (view.actionable && failure.split("\n")[0]) || (detail.currentTool ? `current tool ${detail.currentTool}` : undefined),
1275
+ subtitle: detail.currentTool || undefined,
1239
1276
  metadata,
1240
- evidence: { label: "transcript", text: prependFailureSummary(detail.output || "(no transcript yet)", failure) },
1277
+ evidence: { label: "transcript", text: detail.output || "(no transcript yet)" },
1241
1278
  transcript: transcript.entries,
1242
- transcriptDiagnostic: failure ? prependFailureSummary(transcript.diagnostic ?? "", failure) : transcript.diagnostic,
1279
+ transcriptDiagnostic: transcript.diagnostic,
1243
1280
  footerActions: [detail.status === "running" || detail.status === "orphaned" ? "x stop" : "x dismiss"],
1244
1281
  };
1245
1282
  }
@@ -1307,6 +1344,16 @@ function ensureSubagentProvider(): void {
1307
1344
  parentRow: (now) => mainAgentWorkRow(now),
1308
1345
  listRows: (now) => subagentWorkRows(now),
1309
1346
  detail: (id, now, options) => subagentWorkDetail(id, now, options),
1347
+ handleDetailInput: (id, data) => {
1348
+ if (id !== "main") return false;
1349
+ if (data === "m") {
1350
+ cycleDelegation();
1351
+ return true;
1352
+ }
1353
+ if (data !== "ctrl+s") return false;
1354
+ saveDelegationDefault(readDelegation().mode);
1355
+ return true;
1356
+ },
1310
1357
  armCloseLabel: (row) => row.status === "running" || row.status === "orphaned" ? "x again to stop" : "x again to dismiss",
1311
1358
  close: (id) => {
1312
1359
  const outcome = navigatorCloseRun(id) as { action: string; id: string; status?: string };
@@ -1450,6 +1497,26 @@ function catalogRoleSchema(purpose: string) {
1450
1497
  export default function (pi: ExtensionAPI) {
1451
1498
  let delegationOverride: DelegationMode | undefined;
1452
1499
  const activeDelegationMode = (): DelegationMode => delegationOverride ?? normalizeDelegationMode(loadConfig().delegationMode);
1500
+ readDelegation = () => ({
1501
+ mode: activeDelegationMode(),
1502
+ source: delegationOverride ? "session" : "config",
1503
+ });
1504
+ cycleDelegation = () => {
1505
+ const current = activeDelegationMode();
1506
+ 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 });
1509
+ };
1510
+ saveDelegationDefault = (mode) => {
1511
+ try {
1512
+ writeDelegationMode(mode);
1513
+ } catch (error) {
1514
+ return { ok: false, message: `Could not save the delegation default: ${error instanceof Error ? error.message : String(error)}` };
1515
+ }
1516
+ delegationOverride = undefined;
1517
+ pi.appendEntry("pi-better-subagents-delegation", { version: 1, mode: null });
1518
+ return { ok: true, message: `Delegation default saved: ${mode}.` };
1519
+ };
1453
1520
  const restoreDelegationMode = (ctx: ExtensionContext): void => {
1454
1521
  delegationOverride = undefined;
1455
1522
  const branch = ctx.sessionManager?.getBranch?.();
@@ -1457,7 +1524,9 @@ export default function (pi: ExtensionAPI) {
1457
1524
  for (const entry of branch) {
1458
1525
  if (entry.type !== "custom" || entry.customType !== "pi-better-subagents-delegation") continue;
1459
1526
  const data = entry.data as { version?: unknown; mode?: unknown } | null;
1460
- if (data?.version === 1 && isDelegationMode(data.mode)) delegationOverride = data.mode;
1527
+ if (data?.version !== 1) continue;
1528
+ if (data.mode === null) delegationOverride = undefined;
1529
+ else if (isDelegationMode(data.mode)) delegationOverride = data.mode;
1461
1530
  }
1462
1531
  };
1463
1532
  const unsubscribeDelegationRequest = pi.events?.on?.(DELEGATION_MODE_REQUEST, (data: unknown) => {
@@ -2166,7 +2235,7 @@ export default function (pi: ExtensionAPI) {
2166
2235
  if (typeof pi.registerCommand === "function") {
2167
2236
  agentOperations.registerCommands(pi);
2168
2237
  pi.registerCommand("subagents", {
2169
- description: "Show or change the current-session delegation mode",
2238
+ description: "[mode manual|adaptive|coordinator|save] — Session mode, or save it as the next session's default",
2170
2239
  getArgumentCompletions: subagentsArgumentCompletions,
2171
2240
  async handler(args, ctx) {
2172
2241
  const tokens = args.trim().split(/\s+/).filter(Boolean);
@@ -2175,12 +2244,20 @@ export default function (pi: ExtensionAPI) {
2175
2244
  return;
2176
2245
  }
2177
2246
  const requested = tokens[1];
2178
- if (tokens.length === 2 && tokens[0] === "mode" && isDelegationMode(requested)) {
2247
+ 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");
2251
+ return;
2252
+ }
2253
+ const saved = saveDelegationDefault(mode);
2254
+ ctx.ui.notify(saved.message, saved.ok ? "info" : "warning");
2255
+ } else if (tokens.length === 2 && tokens[0] === "mode" && isDelegationMode(requested)) {
2179
2256
  delegationOverride = requested;
2180
2257
  pi.appendEntry("pi-better-subagents-delegation", { version: 1, mode: delegationOverride });
2181
2258
  ctx.ui.notify(`Delegation mode: ${delegationOverride} (current session).`, "info");
2182
2259
  } else {
2183
- ctx.ui.notify("Usage: /subagents [mode manual|adaptive|coordinator]", "warning");
2260
+ ctx.ui.notify("Usage: /subagents [mode manual|adaptive|coordinator|save]", "warning");
2184
2261
  }
2185
2262
  },
2186
2263
  });
@@ -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}`);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",
@@ -52,12 +52,12 @@
52
52
  "test": "node --test test/*.test.mjs"
53
53
  },
54
54
  "dependencies": {
55
- "pi-better-background-tasks": "0.6.2",
55
+ "pi-better-background-tasks": "0.6.3",
56
56
  "pi-better-goal": "0.6.0",
57
57
  "pi-better-plan": "0.5.1",
58
- "pi-better-sandbox": "0.8.0",
58
+ "pi-better-sandbox": "0.8.1",
59
59
  "pi-better-ssh": "0.1.1",
60
- "pi-better-subagents": "0.10.0",
60
+ "pi-better-subagents": "0.11.0",
61
61
  "smol-toml": "1.9.0",
62
62
  "yaml": "^2.9.1"
63
63
  },