pi-better-harness 0.26.1 → 0.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/extensions/minimal-output/hook.ts +78 -1
- package/extensions/minimal-output/index.ts +2 -2
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/runtime.ts +3 -3
- package/node_modules/pi-better-background-tasks/src/sandbox.ts +35 -9
- package/node_modules/pi-better-background-tasks/src/shared-sandbox-diagnostics.ts +491 -0
- package/node_modules/pi-better-sandbox/README.md +31 -0
- package/node_modules/pi-better-sandbox/commands.ts +13 -2
- package/node_modules/pi-better-sandbox/diagnostics-command.ts +27 -0
- package/node_modules/pi-better-sandbox/index.ts +25 -5
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/shared-sandbox-diagnostics.ts +491 -0
- package/node_modules/pi-better-sandbox/shared-task-files.ts +46 -12
- package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +48 -14
- package/node_modules/pi-better-subagents/failures.ts +16 -1
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/shared-sandbox-diagnostics.ts +491 -0
- package/node_modules/pi-better-subagents/shared-task-files.ts +46 -12
- package/node_modules/pi-better-subagents/shared-task-sandbox.ts +48 -14
- package/node_modules/pi-better-subagents/task-guard.ts +13 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -79,6 +79,11 @@ images, boxes, and tool spacers are hidden. Built-in, extension, and MCP calls
|
|
|
79
79
|
are included. Tools without a custom call renderer use a generic icon and a
|
|
80
80
|
compact argument hint. Compaction summaries also collapse to one quiet row with
|
|
81
81
|
the pre-compaction token count; Ctrl+O reveals their original summary.
|
|
82
|
+
Custom transcript messages from any extension also fold to a quiet disclosure
|
|
83
|
+
row with the message type and first content line. This includes background
|
|
84
|
+
completion batches and subagent health/timing alerts. Ctrl+O or a fullscreen
|
|
85
|
+
click reveals the original content and any custom renderer. Direct UI notices
|
|
86
|
+
are unchanged.
|
|
82
87
|
Execution, sandboxing, and agent-facing payloads are unchanged.
|
|
83
88
|
When a foreground run ends, each consecutive block of tool calls folds further
|
|
84
89
|
into one disclosure row showing the call count and any failures. Only the failure
|
|
@@ -105,7 +110,7 @@ Normal mode is the initial default. Changing tool output saves the choice in
|
|
|
105
110
|
global `settings.json` under `piBetterHarness.toolOutput`, as well as the current
|
|
106
111
|
session. New sessions inherit it; resumed branches retain their own saved choice.
|
|
107
112
|
This is a version-sensitive internal TUI adapter,
|
|
108
|
-
tested with Pi 0.82.1, 0.99.1, and the bundled Pi 1.0.
|
|
113
|
+
tested with Pi 0.82.1, 0.99.1, and the bundled Pi 1.0.4 CLI; incompatible APIs
|
|
109
114
|
produce a warning and leave ordinary output enabled. Print/RPC output and
|
|
110
115
|
exported transcripts are unchanged.
|
|
111
116
|
|
|
@@ -42,6 +42,18 @@ interface CompactionComponent extends Component {
|
|
|
42
42
|
interface CompactionPrototype {
|
|
43
43
|
render: CompactionComponent["render"];
|
|
44
44
|
}
|
|
45
|
+
interface CustomMessageComponent extends Component {
|
|
46
|
+
_expanded: boolean;
|
|
47
|
+
outputPad?: number;
|
|
48
|
+
message: { customType: string; content: string | { type: string; text?: string }[] };
|
|
49
|
+
setExpanded(expanded: boolean): void;
|
|
50
|
+
}
|
|
51
|
+
interface CustomMessagePrototype {
|
|
52
|
+
render: CustomMessageComponent["render"];
|
|
53
|
+
setExpanded: CustomMessageComponent["setExpanded"];
|
|
54
|
+
invalidate(): void;
|
|
55
|
+
handleMouse?(event: ToolMouseEvent): unknown;
|
|
56
|
+
}
|
|
45
57
|
|
|
46
58
|
type Method = (this: ToolComponent, ...args: unknown[]) => unknown;
|
|
47
59
|
interface ToolMouseEvent { type: string; button: string; x: number; y: number; screenX: number; screenY: number; width: number; shift?: boolean; alt?: boolean; ctrl?: boolean }
|
|
@@ -229,6 +241,17 @@ export async function loadCompactionPrototype(): Promise<CompactionPrototype> {
|
|
|
229
241
|
return module.CompactionSummaryMessageComponent.prototype;
|
|
230
242
|
}
|
|
231
243
|
|
|
244
|
+
export async function loadCustomMessagePrototype(): Promise<CustomMessagePrototype> {
|
|
245
|
+
const entry = sdkEntry(hostRequire());
|
|
246
|
+
const path = bundledHostEntry(entry) ?? join(dirname(entry), "modes/interactive/components/custom-message.js");
|
|
247
|
+
const module = await import(pathToFileURL(path).href);
|
|
248
|
+
const prototype = module.CustomMessageComponent?.prototype;
|
|
249
|
+
if (!prototype || ["render", "setExpanded", "invalidate"].some(name => typeof prototype[name] !== "function")) {
|
|
250
|
+
throw new Error("Pi's runtime does not expose its expandable custom-message display class.");
|
|
251
|
+
}
|
|
252
|
+
return prototype;
|
|
253
|
+
}
|
|
254
|
+
|
|
232
255
|
// Pi's ESM-only export cannot be resolved with require.resolve on older SDKs.
|
|
233
256
|
function sdkEntry(require: ReturnType<typeof createRequire>): string {
|
|
234
257
|
for (const directory of require.resolve.paths("@earendil-works/pi-coding-agent") ?? []) {
|
|
@@ -243,7 +266,7 @@ function sdkEntry(require: ReturnType<typeof createRequire>): string {
|
|
|
243
266
|
}
|
|
244
267
|
|
|
245
268
|
/** Internal TUI adapter: no tools are replaced and no result content is changed. */
|
|
246
|
-
export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: () => Theme, compactionPrototype?: CompactionPrototype): MinimalOutputHook {
|
|
269
|
+
export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: () => Theme, compactionPrototype?: CompactionPrototype, customMessagePrototype?: CustomMessagePrototype): MinimalOutputHook {
|
|
247
270
|
for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "invalidate", "render"] as const) {
|
|
248
271
|
if (typeof prototype[name] !== "function") {
|
|
249
272
|
throw new Error(`Pi's tool display API is incompatible: missing ${name}. Normal output remains enabled.`);
|
|
@@ -409,6 +432,60 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
409
432
|
restore.call(this);
|
|
410
433
|
};
|
|
411
434
|
}
|
|
435
|
+
if (customMessagePrototype) {
|
|
436
|
+
const originalRender = customMessagePrototype.render;
|
|
437
|
+
const originalMouse = customMessagePrototype.handleMouse;
|
|
438
|
+
const renderDescriptor = Object.getOwnPropertyDescriptor(customMessagePrototype, "render");
|
|
439
|
+
const mouseDescriptor = Object.getOwnPropertyDescriptor(customMessagePrototype, "handleMouse");
|
|
440
|
+
const messages = new Set<WeakRef<CustomMessageComponent>>();
|
|
441
|
+
const seenMessages = new WeakSet<CustomMessageComponent>();
|
|
442
|
+
const render = function(this: CustomMessageComponent, width: number): string[] {
|
|
443
|
+
if (!seenMessages.has(this)) { seenMessages.add(this); messages.add(new WeakRef(this)); }
|
|
444
|
+
if (!current.enabled || this._expanded) return originalRender.call(this, width);
|
|
445
|
+
if (width <= 0) return [];
|
|
446
|
+
const label = stripVTControlCharacters(this.message.customType).replace(/[-_\s]+/g, " ").trim().replace(/^./, letter => letter.toUpperCase());
|
|
447
|
+
const content = typeof this.message.content === "string" ? this.message.content
|
|
448
|
+
: this.message.content.filter(block => block.type === "text").map(block => block.text ?? "").join("\n");
|
|
449
|
+
const detail = stripVTControlCharacters(content).split(/\r?\n/).map(line => line.replace(/\s+/g, " ").trim()).find(Boolean) ?? "";
|
|
450
|
+
const theme = getTheme?.();
|
|
451
|
+
const text = theme
|
|
452
|
+
? ` ${theme.fg("muted", "\u25b8")} ${theme.fg("muted", label)}${detail ? ` ${theme.fg("dim", detail)}` : ""}`
|
|
453
|
+
: ` \u25b8 ${label}${detail ? ` ${detail}` : ""}`;
|
|
454
|
+
const padding = this.outputPad ?? 1;
|
|
455
|
+
const indent = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) + 2 : 3;
|
|
456
|
+
return [truncateToWidth(`${" ".repeat(Math.min(indent, width - 1))}${text}`, width)];
|
|
457
|
+
};
|
|
458
|
+
const mouse = function(this: CustomMessageComponent, event: ToolMouseEvent): unknown {
|
|
459
|
+
if (current.enabled && !this._expanded && event.type === "click" && event.button === "left"
|
|
460
|
+
&& event.y === 0 && !event.shift && !event.alt && !event.ctrl) {
|
|
461
|
+
this.setExpanded(true);
|
|
462
|
+
return { handled: true, render: true };
|
|
463
|
+
}
|
|
464
|
+
return originalMouse?.call(this, event);
|
|
465
|
+
};
|
|
466
|
+
customMessagePrototype.render = render;
|
|
467
|
+
customMessagePrototype.handleMouse = mouse;
|
|
468
|
+
const redraw = current.redraw;
|
|
469
|
+
current.redraw = function(collapse) {
|
|
470
|
+
for (const reference of messages) {
|
|
471
|
+
const component = reference.deref();
|
|
472
|
+
if (!component) { messages.delete(reference); continue; }
|
|
473
|
+
if (collapse) component.setExpanded(false);
|
|
474
|
+
else component.invalidate();
|
|
475
|
+
}
|
|
476
|
+
redraw.call(this, collapse);
|
|
477
|
+
};
|
|
478
|
+
const restore = current.restore;
|
|
479
|
+
current.restore = function() {
|
|
480
|
+
for (const [name, wrapper, descriptor] of [["render", render, renderDescriptor], ["handleMouse", mouse, mouseDescriptor]] as const) {
|
|
481
|
+
if (customMessagePrototype[name] !== wrapper) continue;
|
|
482
|
+
if (descriptor) Object.defineProperty(customMessagePrototype, name, descriptor);
|
|
483
|
+
else Reflect.deleteProperty(customMessagePrototype, name);
|
|
484
|
+
}
|
|
485
|
+
messages.clear();
|
|
486
|
+
restore.call(this);
|
|
487
|
+
};
|
|
488
|
+
}
|
|
412
489
|
const wrappers = {
|
|
413
490
|
updateDisplay(this: ToolComponent, ...args: unknown[]) {
|
|
414
491
|
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, loadCompactionPrototype, type MinimalOutputHook } from "./hook.ts";
|
|
2
|
+
import { installMinimalOutputHook, loadToolPrototype, loadCompactionPrototype, loadCustomMessagePrototype, 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, await loadCompactionPrototype());
|
|
40
|
+
hook = installMinimalOutputHook(await loadToolPrototype(), () => ctx.ui.theme, await loadCompactionPrototype(), await loadCustomMessagePrototype());
|
|
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");
|
|
@@ -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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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)
|
|
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 };
|