pi-better-harness 0.40.4 → 0.40.6
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.
|
@@ -278,6 +278,19 @@ function sdkEntry(require: ReturnType<typeof createRequire>): string {
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
/** Internal TUI adapter: no tools are replaced and no result content is changed. */
|
|
281
|
+
const FRAME_MS = 80;
|
|
282
|
+
const IDLE_FRAME_MS = 1000;
|
|
283
|
+
const STALE_RENDER_MS = 400;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Shimmer cadence for the next frame. Fast while any animated row rendered recently, one frame
|
|
287
|
+
* a second once none has, and never off: an unfocused window can stop repainting without the row
|
|
288
|
+
* being gone, and stopping the timer would latch the animation until an unrelated render restarted it.
|
|
289
|
+
*/
|
|
290
|
+
export function shimmerFrameDelay(lastRenderMs: number, nowMs: number): number {
|
|
291
|
+
return nowMs - lastRenderMs <= STALE_RENDER_MS ? FRAME_MS : IDLE_FRAME_MS;
|
|
292
|
+
}
|
|
293
|
+
|
|
281
294
|
export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: () => Theme, compactionPrototype?: CompactionPrototype, customMessagePrototype?: CustomMessagePrototype): MinimalOutputHook {
|
|
282
295
|
for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "invalidate", "render"] as const) {
|
|
283
296
|
if (typeof prototype[name] !== "function") {
|
|
@@ -319,10 +332,16 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
319
332
|
const animationReferences = new WeakMap<ToolComponent, WeakRef<ToolComponent>>();
|
|
320
333
|
const animated = new Map<WeakRef<ToolComponent>, number>();
|
|
321
334
|
const settled = new WeakSet<ToolComponent>();
|
|
322
|
-
|
|
335
|
+
// Frame while the transcript paints, then one frame a second once it stops. An unfocused
|
|
336
|
+
// window may stop repainting, and clearing the timer outright would latch the animation off
|
|
337
|
+
// until an unrelated render happened to restart it. Slowing keeps the saving without the latch.
|
|
338
|
+
let animationTimer: ReturnType<typeof setTimeout> | undefined;
|
|
339
|
+
let animationIdle = false;
|
|
340
|
+
let paintingFrame = false;
|
|
323
341
|
const stopAnimation = () => {
|
|
324
|
-
if (animationTimer !== undefined)
|
|
342
|
+
if (animationTimer !== undefined) clearTimeout(animationTimer);
|
|
325
343
|
animationTimer = undefined;
|
|
344
|
+
animationIdle = false;
|
|
326
345
|
animated.clear();
|
|
327
346
|
};
|
|
328
347
|
const canAnimate = (component: ToolComponent) => {
|
|
@@ -333,30 +352,57 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
333
352
|
&& !settled.has(component);
|
|
334
353
|
};
|
|
335
354
|
const pruneAnimation = () => {
|
|
336
|
-
for (const
|
|
355
|
+
for (const reference of animated.keys()) {
|
|
337
356
|
const component = reference.deref();
|
|
338
|
-
if (!component || !canAnimate(component)
|
|
357
|
+
if (!component || !canAnimate(component)
|
|
339
358
|
|| toolVisibility(component.ui, component) !== true) animated.delete(reference);
|
|
340
359
|
}
|
|
341
360
|
if (!animated.size) stopAnimation();
|
|
342
361
|
};
|
|
362
|
+
const scheduleFrame = (delayMs: number) => {
|
|
363
|
+
if (animationTimer !== undefined) clearTimeout(animationTimer);
|
|
364
|
+
animationTimer = setTimeout(paintFrame, Math.max(0, delayMs));
|
|
365
|
+
animationTimer.unref?.();
|
|
366
|
+
};
|
|
367
|
+
const paintFrame = () => {
|
|
368
|
+
animationTimer = undefined;
|
|
369
|
+
pruneAnimation();
|
|
370
|
+
if (!animated.size) return;
|
|
371
|
+
const uis = new Set<ToolUI>();
|
|
372
|
+
for (const reference of animated.keys()) {
|
|
373
|
+
const component = reference.deref();
|
|
374
|
+
if (!component) continue;
|
|
375
|
+
uis.add(component.ui);
|
|
376
|
+
}
|
|
377
|
+
paintingFrame = true;
|
|
378
|
+
try {
|
|
379
|
+
for (const ui of uis) ui.requestRender();
|
|
380
|
+
} finally {
|
|
381
|
+
paintingFrame = false;
|
|
382
|
+
}
|
|
383
|
+
pruneAnimation();
|
|
384
|
+
if (!animated.size) return;
|
|
385
|
+
// Choose the cadence from the timestamps this frame produced. Reading them before the
|
|
386
|
+
// renders instead would keep seeing the previous idle gap and re-arm the idle period
|
|
387
|
+
// forever, so painting that resumed on a timer-driven frame could never speed back up.
|
|
388
|
+
let newestRender = 0;
|
|
389
|
+
for (const lastRender of animated.values()) {
|
|
390
|
+
if (lastRender > newestRender) newestRender = lastRender;
|
|
391
|
+
}
|
|
392
|
+
const delay = shimmerFrameDelay(newestRender, Date.now());
|
|
393
|
+
animationIdle = delay === IDLE_FRAME_MS;
|
|
394
|
+
scheduleFrame(delay);
|
|
395
|
+
};
|
|
343
396
|
const animationTime = (component: ToolComponent, width: number, indent: number) => {
|
|
344
397
|
if (width <= indent + 3 || !canAnimate(component) || toolVisibility(component.ui, component) === false) return undefined;
|
|
345
398
|
let reference = animationReferences.get(component);
|
|
346
399
|
if (!reference) { reference = new WeakRef(component); animationReferences.set(component, reference); }
|
|
347
400
|
animated.set(reference, Date.now());
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const component = reference.deref();
|
|
354
|
-
if (!component) continue;
|
|
355
|
-
uis.add(component.ui);
|
|
356
|
-
}
|
|
357
|
-
for (const ui of uis) ui.requestRender();
|
|
358
|
-
}, 80);
|
|
359
|
-
animationTimer.unref?.();
|
|
401
|
+
// A render outside the clock means painting resumed. Re-arm at the frame cadence rather
|
|
402
|
+
// than waiting out a pending idle period, which would otherwise cost up to a second.
|
|
403
|
+
if (!paintingFrame && (animationTimer === undefined || animationIdle)) {
|
|
404
|
+
animationIdle = false;
|
|
405
|
+
scheduleFrame(FRAME_MS);
|
|
360
406
|
}
|
|
361
407
|
return toolVisibility(component.ui, component) === true ? Date.now() : undefined;
|
|
362
408
|
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SettingsControl } from "../settings/registry.ts";
|
|
3
|
+
import { readHarnessSetting, updateHarnessSetting } from "../shared-harness-settings.ts";
|
|
4
|
+
import { blockReason, detectForegroundWait, type WaitMatch } from "./waits.ts";
|
|
5
|
+
|
|
6
|
+
const SETTING_KEY = "toolCallGuard";
|
|
7
|
+
|
|
8
|
+
interface GuardSetting { version?: unknown; enabled?: unknown }
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Block a foreground command that waits on an external system.
|
|
12
|
+
*
|
|
13
|
+
* Returns the block result from `tool_call`, which pi turns into a failed tool result carrying
|
|
14
|
+
* `reason`. Nothing is executed. `isEnabled` is read per call so the setting takes effect
|
|
15
|
+
* without reloading the extension.
|
|
16
|
+
*/
|
|
17
|
+
export function installToolCallGuard(
|
|
18
|
+
pi: ExtensionAPI,
|
|
19
|
+
isEnabled: () => boolean,
|
|
20
|
+
onBlock?: (match: WaitMatch, command: string) => void,
|
|
21
|
+
): void {
|
|
22
|
+
pi.on("tool_call", (event) => {
|
|
23
|
+
if (!isEnabled()) return;
|
|
24
|
+
if (event.toolName !== "bash") return;
|
|
25
|
+
// `input` is the current SDK field; older typings and some hosts expose `args`.
|
|
26
|
+
const payload = event as { input?: { command?: unknown }; args?: { command?: unknown } };
|
|
27
|
+
const command = payload.input?.command ?? payload.args?.command;
|
|
28
|
+
if (typeof command !== "string") return;
|
|
29
|
+
const match = detectForegroundWait(command);
|
|
30
|
+
if (!match) return;
|
|
31
|
+
onBlock?.(match, command);
|
|
32
|
+
return { block: true, reason: blockReason(match) };
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function toolCallGuardExtension(pi: ExtensionAPI): void {
|
|
37
|
+
let enabled = true;
|
|
38
|
+
|
|
39
|
+
const read = (): void => {
|
|
40
|
+
const value = readHarnessSetting<GuardSetting>(SETTING_KEY);
|
|
41
|
+
if (value !== undefined && (value?.version !== 1 || typeof value.enabled !== "boolean")) {
|
|
42
|
+
throw new Error("Invalid tool-call guard setting in global settings.json.");
|
|
43
|
+
}
|
|
44
|
+
enabled = value?.enabled !== false;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const setting: SettingsControl = {
|
|
48
|
+
id: "tool-call-guard", label: "Tool call guard", values: ["On", "Off"],
|
|
49
|
+
get: () => enabled ? "On" : "Off",
|
|
50
|
+
change(value: string, _ctx: ExtensionCommandContext) {
|
|
51
|
+
if (value !== "On" && value !== "Off") throw new Error("Invalid tool-call guard value.");
|
|
52
|
+
updateHarnessSetting(SETTING_KEY, () => ({ version: 1, enabled: value === "On" }));
|
|
53
|
+
enabled = value === "On";
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const register = () => pi.events.emit("harness-settings:register", setting);
|
|
58
|
+
let stopSettings: (() => void) | undefined;
|
|
59
|
+
const subscribe = () => { stopSettings ??= pi.events.on("harness-settings:request", register); };
|
|
60
|
+
|
|
61
|
+
subscribe();
|
|
62
|
+
register();
|
|
63
|
+
installToolCallGuard(pi, () => enabled);
|
|
64
|
+
|
|
65
|
+
pi.on("session_start", () => {
|
|
66
|
+
subscribe();
|
|
67
|
+
try { read(); }
|
|
68
|
+
catch {
|
|
69
|
+
// A malformed value must not take the session down; fall back to guarding.
|
|
70
|
+
enabled = true;
|
|
71
|
+
}
|
|
72
|
+
register();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
pi.on("session_shutdown", () => {
|
|
76
|
+
stopSettings?.();
|
|
77
|
+
stopSettings = undefined;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recognise a foreground command that waits on something outside this machine.
|
|
3
|
+
*
|
|
4
|
+
* The guard keys off command shape rather than duration: a five minute test run belongs in the
|
|
5
|
+
* foreground where its output streams, while `gh pr checks --watch` can only end when GitHub
|
|
6
|
+
* says so. Duration cannot tell those apart, and blocking the first would be wrong.
|
|
7
|
+
*/
|
|
8
|
+
export interface WaitMatch {
|
|
9
|
+
/** Which rule matched, quoted back to the caller so the block is explainable. */
|
|
10
|
+
rule: string;
|
|
11
|
+
/** What the command is waiting for. */
|
|
12
|
+
waitsOn: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A foreground pause shorter than this is ordinary command flow, not a wait. */
|
|
16
|
+
export const SLEEP_THRESHOLD_SECONDS = 3;
|
|
17
|
+
|
|
18
|
+
const RULES: readonly { rule: RegExp; waitsOn: string }[] = [
|
|
19
|
+
{ rule: /\bgh\s+pr\s+checks\b[^\n]*--watch\b/, waitsOn: "a pull request's checks" },
|
|
20
|
+
{ rule: /\bgh\s+run\s+(?:view|watch)\b[^\n]*--watch\b/, waitsOn: "a workflow run" },
|
|
21
|
+
{ rule: /\bgh\s+run\s+watch\b/, waitsOn: "a workflow run" },
|
|
22
|
+
{ rule: /\bgh\s+workflow\s+run\b[^\n]*--watch\b/, waitsOn: "a workflow run" },
|
|
23
|
+
// A helper script whose name starts with watch- polls an external system, whatever its flags.
|
|
24
|
+
{ rule: /\bwatch[-_][\w.-]*/, waitsOn: "an external state change" },
|
|
25
|
+
// --watch and --until only mean polling on provider CLIs; `git log --until` is a plain filter.
|
|
26
|
+
{ rule: /\b(?:gh|github|glab)\b[^\n]*--(?:watch|until)\b/, waitsOn: "an external state change" },
|
|
27
|
+
{ rule: /(?:^|[;&|]\s*)watch\s+\S/, waitsOn: "a repeating command" },
|
|
28
|
+
{ rule: /\btail\s+-[a-zA-Z]*[fF]/, waitsOn: "a growing file" },
|
|
29
|
+
{ rule: /\bdocker\s+(?:logs|events)\b[^\n]*\s-f\b/, waitsOn: "a container's log stream" },
|
|
30
|
+
{ rule: /\bkubectl\b[^\n]*(?:--watch\b|\s-w\b)/, waitsOn: "cluster state" },
|
|
31
|
+
// A poll loop only counts when its body actually waits on something.
|
|
32
|
+
{ rule: /\b(?:until|while)\b[^\n]*\b(?:sleep|curl|gh\s|docker\s|kubectl\s|pg_isready)\b/, waitsOn: "a repeated external check" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const SLEEP = /(?:^|[;&|(]\s*|\bsudo\s+)sleep\s+(\d+(?:\.\d+)?)/;
|
|
36
|
+
|
|
37
|
+
export function detectForegroundWait(command: string): WaitMatch | undefined {
|
|
38
|
+
for (const { rule, waitsOn } of RULES) {
|
|
39
|
+
if (rule.test(command)) return { rule: rule.source, waitsOn };
|
|
40
|
+
}
|
|
41
|
+
const sleep = SLEEP.exec(command);
|
|
42
|
+
if (sleep) {
|
|
43
|
+
const seconds = Number(sleep[1]);
|
|
44
|
+
if (Number.isFinite(seconds) && seconds > SLEEP_THRESHOLD_SECONDS) {
|
|
45
|
+
return { rule: SLEEP.source, waitsOn: `${seconds} seconds of wall clock` };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The message the model reads instead of the command's output. */
|
|
52
|
+
export function blockReason(match: WaitMatch): string {
|
|
53
|
+
return [
|
|
54
|
+
`This command waits on ${match.waitsOn}, so it belongs in a background task.`,
|
|
55
|
+
"Spawn it with bg_task_spawn, then call wait_for_operations to park until it reports; a background task survives the end of the turn, while a foreground wait does not.",
|
|
56
|
+
"Read bg_task_status and bg_task_log for the verdict.",
|
|
57
|
+
'To run this command in the foreground anyway, set Tool call guard to Off in Harness settings.',
|
|
58
|
+
].join(" ");
|
|
59
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.40.
|
|
3
|
+
"version": "0.40.6",
|
|
4
4
|
"description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, model routing, and Outcome & Todo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"extensions/model-router/index.ts",
|
|
27
27
|
"extensions/task-context/index.ts",
|
|
28
28
|
"extensions/minimal-output/index.ts",
|
|
29
|
+
"extensions/tool-call-guard/index.ts",
|
|
29
30
|
"extensions/settings/index.ts"
|
|
30
31
|
],
|
|
31
32
|
"image": "https://raw.githubusercontent.com/1aboveio/pi-better-harness/main/docs/images/package-gallery/pi-better-harness.png"
|
|
@@ -51,7 +52,7 @@
|
|
|
51
52
|
],
|
|
52
53
|
"scripts": {
|
|
53
54
|
"prepack": "node ../../scripts/sync-shared-log-utils.mjs && node ../../scripts/stage-harness-dependencies.mjs",
|
|
54
|
-
"typecheck": "tsc --noEmit --allowImportingTsExtensions --module NodeNext --moduleResolution NodeNext --target ES2022 --strict --skipLibCheck --types node extensions/minimal-output/index.ts extensions/minimal-output/hook.ts extensions/settings/index.ts extensions/execution-policy.ts extensions/task-context/index.ts",
|
|
55
|
+
"typecheck": "tsc --noEmit --allowImportingTsExtensions --module NodeNext --moduleResolution NodeNext --target ES2022 --strict --skipLibCheck --types node extensions/minimal-output/index.ts extensions/minimal-output/hook.ts extensions/settings/index.ts extensions/execution-policy.ts extensions/tool-call-guard/index.ts extensions/tool-call-guard/waits.ts extensions/task-context/index.ts",
|
|
55
56
|
"test": "node --import tsx --import ../../scripts/isolate-registry.mjs --test test/*.test.mjs test/*.test.ts test/task-context/*.test.ts"
|
|
56
57
|
},
|
|
57
58
|
"dependencies": {
|