pi-better-harness 0.26.0 → 0.27.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
@@ -76,7 +76,10 @@ stays quieter than conversation text. Tool rows are indented two columns past
76
76
  the assistant text padding. Long
77
77
  headers are truncated to the terminal width; result bodies,
78
78
  images, boxes, and tool spacers are hidden. Built-in, extension, and MCP calls
79
- are included. Execution, sandboxing, and agent-facing payloads are unchanged.
79
+ are included. Tools without a custom call renderer use a generic icon and a
80
+ compact argument hint. Compaction summaries also collapse to one quiet row with
81
+ the pre-compaction token count; Ctrl+O reveals their original summary.
82
+ Execution, sandboxing, and agent-facing payloads are unchanged.
80
83
  When a foreground run ends, each consecutive block of tool calls folds further
81
84
  into one disclosure row showing the call count and any failures. Only the failure
82
85
  count uses the error color; the disclosure and total stay muted. Assistant text
@@ -34,6 +34,14 @@ interface TranscriptComponent extends Component {
34
34
  lastMessage?: { content: { type: string; text?: string; thinking?: string }[]; stopReason?: string };
35
35
  }
36
36
  interface ToolGroup { members: ToolComponent[]; open: boolean; expanded: number }
37
+ interface CompactionComponent extends Component {
38
+ expanded: boolean;
39
+ message: { tokensBefore: number };
40
+ setExpanded(expanded: boolean): void;
41
+ }
42
+ interface CompactionPrototype {
43
+ render: CompactionComponent["render"];
44
+ }
37
45
 
38
46
  type Method = (this: ToolComponent, ...args: unknown[]) => unknown;
39
47
  interface ToolMouseEvent { type: string; button: string; x: number; y: number; screenX: number; screenY: number; width: number; shift?: boolean; alt?: boolean; ctrl?: boolean }
@@ -129,7 +137,7 @@ function harnessCallDetail(name: string, input: unknown): string | undefined {
129
137
  function compactCall(component: ToolComponent, width: number, theme?: Theme, indent = 3): string[] {
130
138
  if (width <= 0) return [];
131
139
  // Render the call alone at a wider width so terminal wrapping becomes truncation.
132
- const header = component.callRendererComponent?.render(Math.max(4096, width + 1))
140
+ const header = Boolean(component.getCallRenderer?.()) && component.callRendererComponent?.render(Math.max(4096, width + 1))
133
141
  .map((line) => stripVTControlCharacters(line).trim())
134
142
  .find((line) => line.length > 0);
135
143
  let text = header;
@@ -211,6 +219,16 @@ export async function loadToolPrototype(): Promise<ToolPrototype> {
211
219
  return module.ToolExecutionComponent.prototype;
212
220
  }
213
221
 
222
+ export async function loadCompactionPrototype(): Promise<CompactionPrototype> {
223
+ const entry = sdkEntry(hostRequire());
224
+ const path = bundledHostEntry(entry) ?? join(dirname(entry), "modes/interactive/components/compaction-summary-message.js");
225
+ const module = await import(pathToFileURL(path).href);
226
+ if (!module.CompactionSummaryMessageComponent?.prototype) {
227
+ throw new Error("Pi's runtime does not expose its compaction display class.");
228
+ }
229
+ return module.CompactionSummaryMessageComponent.prototype;
230
+ }
231
+
214
232
  // Pi's ESM-only export cannot be resolved with require.resolve on older SDKs.
215
233
  function sdkEntry(require: ReturnType<typeof createRequire>): string {
216
234
  for (const directory of require.resolve.paths("@earendil-works/pi-coding-agent") ?? []) {
@@ -225,7 +243,7 @@ function sdkEntry(require: ReturnType<typeof createRequire>): string {
225
243
  }
226
244
 
227
245
  /** Internal TUI adapter: no tools are replaced and no result content is changed. */
228
- export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: () => Theme): MinimalOutputHook {
246
+ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: () => Theme, compactionPrototype?: CompactionPrototype): MinimalOutputHook {
229
247
  for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "invalidate", "render"] as const) {
230
248
  if (typeof prototype[name] !== "function") {
231
249
  throw new Error(`Pi's tool display API is incompatible: missing ${name}. Normal output remains enabled.`);
@@ -359,6 +377,38 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
359
377
  },
360
378
  };
361
379
  const current = state;
380
+ const compactions = new Set<WeakRef<CompactionComponent>>();
381
+ const seenCompactions = new WeakSet<CompactionComponent>();
382
+ if (compactionPrototype) {
383
+ const originalRender = compactionPrototype.render;
384
+ const render = function(this: CompactionComponent, width: number): string[] {
385
+ if (!seenCompactions.has(this)) { seenCompactions.add(this); compactions.add(new WeakRef(this)); }
386
+ if (!current.enabled || this.expanded) return originalRender.call(this, width);
387
+ const detail = `from ${this.message.tokensBefore.toLocaleString()} tokens`;
388
+ const theme = getTheme?.();
389
+ const text = theme
390
+ ? ` ${theme.fg("muted", "\u25c7")} ${theme.fg("muted", "Compaction")} ${theme.fg("dim", detail)}`
391
+ : ` \u25c7 Compaction ${detail}`;
392
+ return width > 0 ? [truncateToWidth(`${" ".repeat(Math.min(3, width - 1))}${text}`, width)] : [];
393
+ };
394
+ compactionPrototype.render = render;
395
+ const redraw = current.redraw;
396
+ current.redraw = function(collapse) {
397
+ for (const reference of compactions) {
398
+ const component = reference.deref();
399
+ if (!component) { compactions.delete(reference); continue; }
400
+ if (collapse) component.setExpanded(false);
401
+ else component.invalidate();
402
+ }
403
+ redraw.call(this, collapse);
404
+ };
405
+ const restore = current.restore;
406
+ current.restore = function() {
407
+ if (compactionPrototype.render === render) compactionPrototype.render = originalRender;
408
+ compactions.clear();
409
+ restore.call(this);
410
+ };
411
+ }
362
412
  const wrappers = {
363
413
  updateDisplay(this: ToolComponent, ...args: unknown[]) {
364
414
  if (typeof this.ui.handleViewportInput === "function" && !inputWrappers.has(this.ui)) {
@@ -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, loadToolPrototype, loadCompactionPrototype, type MinimalOutputHook } from "./hook.ts";
3
3
  import type { SettingsControl } from "../settings/registry.ts";
4
4
  import { readHarnessSetting, updateHarnessSetting } from "../shared-harness-settings.ts";
5
5
 
@@ -37,7 +37,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
37
37
  }
38
38
  if (hook) return true;
39
39
  try {
40
- hook = installMinimalOutputHook(await loadToolPrototype(), () => ctx.ui.theme);
40
+ hook = installMinimalOutputHook(await loadToolPrototype(), () => ctx.ui.theme, await loadCompactionPrototype());
41
41
  return true;
42
42
  } catch (error) {
43
43
  ctx.ui.notify(`Minimal tool output is unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -204,7 +204,7 @@ export function spawnTask(
204
204
  // Resolved before any task directory, log, or metadata exists so a blocked
205
205
  // launch leaves nothing behind. Structured SSH must also honor launch restrictions.
206
206
  const intent = readTaskIntent(params);
207
- const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh);
207
+ const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh, params);
208
208
  const id = nextTaskId();
209
209
  const cwd = params.cwd ?? defaultCwd;
210
210
  const logPath = logPathFor(id);
@@ -495,7 +495,7 @@ export function startWatchTask(
495
495
  }
496
496
  const blindChecks = readBlindChecks(params.blind_checks);
497
497
  const intent = readTaskIntent(params);
498
- const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh);
498
+ const sandboxPlan = resolveForegroundSandboxPlan(pi, !!params.ssh, params, "bg_task_watch");
499
499
  const id = nextTaskId();
500
500
  const cwd = params.cwd ?? defaultCwd;
501
501
  const now = Date.now();
@@ -514,7 +514,7 @@ export function startWatchTask(
514
514
  const sandboxNotices: string[] = [];
515
515
  const launchSpec = remoteTask
516
516
  ? commandSpec
517
- : confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id), {}, (line) => sandboxNotices.push(line));
517
+ : confineCommandSpec(commandSpec, sandboxPlan, sandboxProfilePathFor(id), {}, (line) => sandboxNotices.push(line), "bg_task_watch");
518
518
  const meta: BackgroundTaskMeta = {
519
519
  id,
520
520
  name: params.name,
@@ -29,7 +29,7 @@ import { dirname } from "node:path";
29
29
 
30
30
  import { commandExecution } from "./process.js";
31
31
  import { baseDir } from "./registry.js";
32
- import { compileWritePolicy, maybeBuildSandboxCommand, type SandboxSeams } from "./shared-sandbox-core.js";
32
+ import { compileWritePolicy, describeSandboxSupport, maybeBuildSandboxCommand, type SandboxSeams } from "./shared-sandbox-core.js";
33
33
 
34
34
  type SandboxPermissions = {
35
35
  /** `write` = read, create and overwrite in place; `read-write` = Write & delete. */
@@ -40,6 +40,18 @@ type SandboxPermissions = {
40
40
  network: boolean;
41
41
  };
42
42
  import type { CommandSpec } from "./types.js";
43
+ import { SandboxDiagnostics, diagnosticPackageVersion, type DiagnosticResource } from "./shared-sandbox-diagnostics.js";
44
+
45
+ function backgroundDiagnostics(policy: unknown): SandboxDiagnostics | undefined {
46
+ try {
47
+ return new SandboxDiagnostics({ context: "background",
48
+ version: diagnosticPackageVersion(new URL("../package.json", import.meta.url)),
49
+ policy: () => policy,
50
+ backend: () => { const support = describeSandboxSupport(); return support.supported ? support.backend : undefined; },
51
+ onError: () => { console.error("Sandbox diagnostics collection gap; enforcement unchanged."); },
52
+ });
53
+ } catch { return undefined; }
54
+ }
43
55
 
44
56
  /**
45
57
  * What an operator can do about a missing backend here.
@@ -225,16 +237,25 @@ export function currentForegroundSandboxPolicy(pi: unknown): ForegroundSandboxPo
225
237
  * Throws for every state that is neither confinable nor intentionally
226
238
  * unconfined, which keeps a blocked launch from leaving task state behind.
227
239
  */
228
- export function resolveForegroundSandboxPlan(pi: unknown, remote = false): ForegroundSandboxPlan {
240
+ export function resolveForegroundSandboxPlan(pi: unknown, remote = false, operation?: unknown, tool = "bg_task_spawn"): ForegroundSandboxPlan {
229
241
  const policy = currentForegroundSandboxPolicy(pi);
230
242
  if (remote && !policy?.permissions) return UNCONFINED; // Preserve legacy SSH behavior.
231
- const plan = planFor(policy);
232
- if (remote && plan.confined) {
233
- throw new ForegroundSandboxBlockedError(policy!, !plan.permissions?.network
234
- ? "Main profile disables network. SSH launches are blocked."
235
- : "Structured SSH cannot apply local file and credential permissions yet. Use SSH through confined bash, or change Main permissions in /sandbox.");
243
+ try {
244
+ const plan = planFor(policy);
245
+ if (remote && plan.confined) {
246
+ throw new ForegroundSandboxBlockedError(policy!, !plan.permissions?.network
247
+ ? "Main profile disables network. SSH launches are blocked."
248
+ : "Structured SSH cannot apply local file and credential permissions yet. Use SSH through confined bash, or change Main permissions in /sandbox.");
249
+ }
250
+ return plan;
251
+ } catch (error) {
252
+ if (error instanceof ForegroundSandboxBlockedError) {
253
+ const resource: DiagnosticResource = policy?.permissions?.enabled && !policy.permissions.commands ? "command-execution"
254
+ : remote && policy?.permissions?.network === false ? "network-access" : "sandbox-backend";
255
+ backgroundDiagnostics(policy)?.observe({ tool, operation, resource, basis: "policy-refusal", outcome: "denied" });
256
+ }
257
+ throw error;
236
258
  }
237
- return plan;
238
259
  }
239
260
 
240
261
  /** The plan for one already-read policy. Exposed for tests and reuse. */
@@ -281,6 +302,7 @@ export function confineCommandSpec(
281
302
  seams: SandboxSeams = {},
282
303
  /** Receives lines to show with the launch (e.g. a placeholder left in the user's files). */
283
304
  onNotice: (line: string) => void = () => {},
305
+ tool = "bg_task_spawn",
284
306
  ): CommandSpec {
285
307
  if (!plan.confined) return spec;
286
308
 
@@ -330,9 +352,13 @@ export function confineCommandSpec(
330
352
  seams,
331
353
  );
332
354
  } catch (error) {
355
+ backgroundDiagnostics(plan)?.observe({ tool, operation: spec, resource: "sandbox-backend", basis: "policy-refusal", outcome: "denied" });
333
356
  throw blocked(plan, error instanceof Error ? error.message : String(error));
334
357
  }
335
- if (!command) throw blocked(plan, "no sandbox backend was applied");
358
+ if (!command) {
359
+ backgroundDiagnostics(plan)?.observe({ tool, operation: spec, resource: "sandbox-backend", basis: "policy-refusal", outcome: "denied" });
360
+ throw blocked(plan, "no sandbox backend was applied");
361
+ }
336
362
  for (const line of command.notices ?? []) onNotice(line);
337
363
 
338
364
  return { ...spec, argv: [command.file, ...command.fileArgs], shell: false };