pi-better-harness 0.27.2 → 0.28.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 +10 -7
- package/extensions/minimal-output/hook.ts +72 -4
- package/extensions/minimal-output/index.ts +28 -1
- package/extensions/minimal-output/tool-identity.ts +66 -0
- package/extensions/minimal-output/tool-visibility.ts +61 -0
- package/node_modules/pi-better-goal/README.md +2 -2
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/index.ts +44 -12
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -68,11 +68,12 @@ The bundled harness provides **Tool output: Normal / Minimal** in
|
|
|
68
68
|
`/harness-settings`. `/tool-output minimal`, `/tool-output normal`, and
|
|
69
69
|
`/tool-output` (toggle) remain compatibility shortcuts to the same preference.
|
|
70
70
|
Minimal mode folds each tool call into a single-line header, including running
|
|
71
|
-
calls.
|
|
72
|
-
muted when completed, and error-colored when failed.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
calls. The tool-specific icon and tool name share a state color: accent while
|
|
72
|
+
running, muted when completed, and error-colored when failed. In fullscreen Pi, a soft highlight travels through visible running names; inline
|
|
73
|
+
command/path arguments stay dim. Scrollback terminals keep static state colors.
|
|
74
|
+
Choose **Tool animation: Off** in `/harness-settings` for static running headers.
|
|
75
|
+
Running and failed calls retain text labels so state never depends on color or
|
|
76
|
+
motion alone. Tool rows are indented two columns past
|
|
76
77
|
the assistant text padding. Long
|
|
77
78
|
headers are truncated to the terminal width; result bodies,
|
|
78
79
|
images, boxes, and tool spacers are hidden. Built-in, extension, and MCP calls
|
|
@@ -130,8 +131,10 @@ to the `/agents` catalog. `/sandbox`, `/subagents settings`, `/agents`, and
|
|
|
130
131
|
`/goal settings` remain available in standalone installations.
|
|
131
132
|
Packages without a settings screen are not listed. Pi's `/settings` is unchanged.
|
|
132
133
|
|
|
133
|
-
The hub includes **Tool output** when the bundled renderer
|
|
134
|
-
Changes apply immediately and
|
|
134
|
+
The hub includes **Tool output** and **Tool animation** when the bundled renderer
|
|
135
|
+
extension is loaded. Changes apply immediately and save the default for future
|
|
136
|
+
sessions. The animation preference is stored in `piBetterHarness.toolAnimation`;
|
|
137
|
+
normal output and expanded tool details are never animated.
|
|
135
138
|
The hub also owns **Completions while busy**, shared by Subagents and Background
|
|
136
139
|
Tasks. Choose **Wait until idle** (the default) or **Steer active run**. Changes
|
|
137
140
|
apply immediately and autosave to the current session branch, including across
|
|
@@ -5,8 +5,10 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { stripVTControlCharacters } from "node:util";
|
|
7
7
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { supportsToolShimmer, toolIdentity } from "./tool-identity.ts";
|
|
9
|
+
import { toolVisibility, type ViewportUI } from "./tool-visibility.ts";
|
|
8
10
|
|
|
9
|
-
interface ToolUI {
|
|
11
|
+
interface ToolUI extends ViewportUI {
|
|
10
12
|
requestRender(): void;
|
|
11
13
|
children?: TranscriptComponent[];
|
|
12
14
|
handleViewportInput?(data: string): unknown;
|
|
@@ -70,6 +72,8 @@ interface ToolPrototype {
|
|
|
70
72
|
}
|
|
71
73
|
interface HookState {
|
|
72
74
|
enabled: boolean;
|
|
75
|
+
animationEnabled: boolean;
|
|
76
|
+
stopAnimation(): void;
|
|
73
77
|
owners: number;
|
|
74
78
|
redraw(collapse?: boolean): void;
|
|
75
79
|
restore(): void;
|
|
@@ -79,6 +83,7 @@ interface HookState {
|
|
|
79
83
|
}
|
|
80
84
|
export interface MinimalOutputHook {
|
|
81
85
|
setEnabled(enabled: boolean): void;
|
|
86
|
+
setAnimationEnabled(enabled: boolean): void;
|
|
82
87
|
completeRun(): void;
|
|
83
88
|
restoreCompletedCalls(ids: Iterable<string>): void;
|
|
84
89
|
dispose(): void;
|
|
@@ -146,7 +151,7 @@ function harnessCallDetail(name: string, input: unknown): string | undefined {
|
|
|
146
151
|
}
|
|
147
152
|
}
|
|
148
153
|
|
|
149
|
-
function compactCall(component: ToolComponent, width: number, theme?: Theme, indent = 3): string[] {
|
|
154
|
+
function compactCall(component: ToolComponent, width: number, theme?: Theme, indent = 3, animationTime?: number): string[] {
|
|
150
155
|
if (width <= 0) return [];
|
|
151
156
|
// Render the call alone at a wider width so terminal wrapping becomes truncation.
|
|
152
157
|
const header = Boolean(component.getCallRenderer?.()) && component.callRendererComponent?.render(Math.max(4096, width + 1))
|
|
@@ -170,7 +175,7 @@ function compactCall(component: ToolComponent, width: number, theme?: Theme, ind
|
|
|
170
175
|
const icon = TOOL_ICONS[component.toolName] ?? "\u25c7";
|
|
171
176
|
const title = ` ${icon} ${label}`;
|
|
172
177
|
const styled = theme
|
|
173
|
-
? ` ${
|
|
178
|
+
? ` ${toolIdentity(icon, label, failed ? "error" : running ? "accent" : "muted", theme, animationTime)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "dim", suffix)}`
|
|
174
179
|
: `${title}${detail ? ` ${detail}` : ""}${suffix}`;
|
|
175
180
|
return [truncateToWidth(`${" ".repeat(Math.min(indent, Math.max(0, width - 1)))}${styled}`, width)];
|
|
176
181
|
}
|
|
@@ -304,6 +309,50 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
304
309
|
};
|
|
305
310
|
const seen = new WeakSet<ToolComponent>();
|
|
306
311
|
const components = new Set<WeakRef<ToolComponent>>();
|
|
312
|
+
const animationReferences = new WeakMap<ToolComponent, WeakRef<ToolComponent>>();
|
|
313
|
+
const animated = new Map<WeakRef<ToolComponent>, number>();
|
|
314
|
+
const settled = new WeakSet<ToolComponent>();
|
|
315
|
+
let animationTimer: ReturnType<typeof setInterval> | undefined;
|
|
316
|
+
const stopAnimation = () => {
|
|
317
|
+
if (animationTimer !== undefined) clearInterval(animationTimer);
|
|
318
|
+
animationTimer = undefined;
|
|
319
|
+
animated.clear();
|
|
320
|
+
};
|
|
321
|
+
const canAnimate = (component: ToolComponent) => {
|
|
322
|
+
const theme = getTheme?.();
|
|
323
|
+
return state!.enabled && state!.animationEnabled && component.ui.mode === "fullscreen"
|
|
324
|
+
&& !!theme && supportsToolShimmer(theme)
|
|
325
|
+
&& !component.expanded && !component.result?.isError && (!component.result || component.isPartial)
|
|
326
|
+
&& !settled.has(component);
|
|
327
|
+
};
|
|
328
|
+
const pruneAnimation = () => {
|
|
329
|
+
for (const [reference, lastRender] of animated) {
|
|
330
|
+
const component = reference.deref();
|
|
331
|
+
if (!component || !canAnimate(component) || Date.now() - lastRender > 400
|
|
332
|
+
|| toolVisibility(component.ui, component) !== true) animated.delete(reference);
|
|
333
|
+
}
|
|
334
|
+
if (!animated.size) stopAnimation();
|
|
335
|
+
};
|
|
336
|
+
const animationTime = (component: ToolComponent, width: number, indent: number) => {
|
|
337
|
+
if (width <= indent + 3 || !canAnimate(component) || toolVisibility(component.ui, component) === false) return undefined;
|
|
338
|
+
let reference = animationReferences.get(component);
|
|
339
|
+
if (!reference) { reference = new WeakRef(component); animationReferences.set(component, reference); }
|
|
340
|
+
animated.set(reference, Date.now());
|
|
341
|
+
if (animationTimer === undefined) {
|
|
342
|
+
animationTimer = setInterval(() => {
|
|
343
|
+
pruneAnimation();
|
|
344
|
+
const uis = new Set<ToolUI>();
|
|
345
|
+
for (const reference of animated.keys()) {
|
|
346
|
+
const component = reference.deref();
|
|
347
|
+
if (!component) continue;
|
|
348
|
+
uis.add(component.ui);
|
|
349
|
+
}
|
|
350
|
+
for (const ui of uis) ui.requestRender();
|
|
351
|
+
}, 80);
|
|
352
|
+
animationTimer.unref?.();
|
|
353
|
+
}
|
|
354
|
+
return toolVisibility(component.ui, component) === true ? Date.now() : undefined;
|
|
355
|
+
};
|
|
307
356
|
let completed = new Map<string, object>();
|
|
308
357
|
let groups = new WeakMap<ToolComponent, ToolGroup>();
|
|
309
358
|
let layouts = new WeakMap<ToolComponent, { parent: TranscriptComponent; anchor?: TranscriptComponent }>();
|
|
@@ -344,11 +393,15 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
344
393
|
};
|
|
345
394
|
state = {
|
|
346
395
|
enabled: false,
|
|
396
|
+
animationEnabled: true,
|
|
397
|
+
stopAnimation,
|
|
347
398
|
owners: 0,
|
|
348
399
|
completeRun() {
|
|
400
|
+
stopAnimation();
|
|
349
401
|
const run = {};
|
|
350
402
|
for (const reference of components) {
|
|
351
403
|
const component = reference.deref();
|
|
404
|
+
if (component) settled.add(component);
|
|
352
405
|
if (component?.result && !component.isPartial && !completed.has(component.toolCallId)) {
|
|
353
406
|
completed.set(component.toolCallId, run);
|
|
354
407
|
if (this.enabled) component.setExpanded(false);
|
|
@@ -357,6 +410,11 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
357
410
|
this.redraw();
|
|
358
411
|
},
|
|
359
412
|
restoreCompletedCalls(ids) {
|
|
413
|
+
stopAnimation();
|
|
414
|
+
for (const reference of components) {
|
|
415
|
+
const component = reference.deref();
|
|
416
|
+
if (component) settled.add(component);
|
|
417
|
+
}
|
|
360
418
|
const history = {};
|
|
361
419
|
completed = new Map(Array.from(ids, id => [id, history]));
|
|
362
420
|
groups = new WeakMap();
|
|
@@ -372,8 +430,10 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
372
430
|
else component.invalidate();
|
|
373
431
|
component.ui.requestRender();
|
|
374
432
|
}
|
|
433
|
+
pruneAnimation();
|
|
375
434
|
},
|
|
376
435
|
restore() {
|
|
436
|
+
stopAnimation();
|
|
377
437
|
clearHover();
|
|
378
438
|
for (const [ui, { original, wrapper }] of inputWrappers) {
|
|
379
439
|
if (ui.handleViewportInput === wrapper) ui.handleViewportInput = original;
|
|
@@ -498,6 +558,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
498
558
|
this.ui.handleViewportInput = wrapper;
|
|
499
559
|
}
|
|
500
560
|
if (!seen.has(this)) { seen.add(this); components.add(new WeakRef(this)); }
|
|
561
|
+
pruneAnimation();
|
|
501
562
|
if (!current.enabled || this.expanded) return originals.updateDisplay.apply(this, args);
|
|
502
563
|
const showImages = this.showImages;
|
|
503
564
|
const lastRenderer = this.resultRendererComponent;
|
|
@@ -517,6 +578,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
517
578
|
if (args[0] === false && !mouseHandling && group) group.open = false;
|
|
518
579
|
const result = originals.setExpanded.apply(this, args);
|
|
519
580
|
if (group) group.expanded += Number(this.expanded) - Number(wasExpanded);
|
|
581
|
+
pruneAnimation();
|
|
520
582
|
return result;
|
|
521
583
|
},
|
|
522
584
|
getResultRenderer(this: ToolComponent, ...args: unknown[]) {
|
|
@@ -531,7 +593,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
531
593
|
const padding = layout?.anchor?.outputPad ?? 1;
|
|
532
594
|
const indent = Number.isFinite(padding) ? Math.max(0, Math.floor(padding)) + 2 : 3;
|
|
533
595
|
const group = groupFor(this, layout?.parent);
|
|
534
|
-
if (!group) return highlight(this, compactCall(this, width, getTheme?.(), indent), width);
|
|
596
|
+
if (!group) return highlight(this, compactCall(this, width, getTheme?.(), indent, animationTime(this, width, indent)), width);
|
|
535
597
|
const open = group.open || group.expanded > 0;
|
|
536
598
|
const first = group.members[0] === this;
|
|
537
599
|
const lines: string[] = [];
|
|
@@ -591,6 +653,12 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
591
653
|
current.enabled = enabled;
|
|
592
654
|
current.redraw(enabled);
|
|
593
655
|
},
|
|
656
|
+
setAnimationEnabled(enabled) {
|
|
657
|
+
if (disposed) return;
|
|
658
|
+
current.animationEnabled = enabled;
|
|
659
|
+
if (!enabled) current.stopAnimation();
|
|
660
|
+
current.redraw();
|
|
661
|
+
},
|
|
594
662
|
dispose() {
|
|
595
663
|
if (disposed) return;
|
|
596
664
|
disposed = true;
|
|
@@ -10,6 +10,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
10
10
|
let enabled = false;
|
|
11
11
|
let running = false;
|
|
12
12
|
let initialDefault: boolean | undefined;
|
|
13
|
+
let motionEnabled = true;
|
|
13
14
|
|
|
14
15
|
function defaultEnabled(): boolean {
|
|
15
16
|
if (initialDefault !== undefined) return initialDefault;
|
|
@@ -52,6 +53,16 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
52
53
|
enabled = false;
|
|
53
54
|
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
54
55
|
}
|
|
56
|
+
try {
|
|
57
|
+
const motion = readHarnessSetting<{ version?: unknown; enabled?: unknown }>("toolAnimation");
|
|
58
|
+
if (motion !== undefined && (motion?.version !== 1 || typeof motion.enabled !== "boolean")) {
|
|
59
|
+
throw new Error("Invalid tool-animation default in global settings.json.");
|
|
60
|
+
}
|
|
61
|
+
motionEnabled = motion?.enabled !== false;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
motionEnabled = false;
|
|
64
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
65
|
+
}
|
|
55
66
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
56
67
|
if (entry.type === "custom" && entry.customType === ENTRY) {
|
|
57
68
|
const data = entry.data as { version?: unknown; enabled?: unknown } | undefined;
|
|
@@ -60,6 +71,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
60
71
|
}
|
|
61
72
|
// Observe transcript components from startup, so the toggle also redraws existing rows.
|
|
62
73
|
if (await ensureHook(ctx)) {
|
|
74
|
+
hook!.setAnimationEnabled(motionEnabled);
|
|
63
75
|
hook!.restoreCompletedCalls(ctx.sessionManager.getBranch().flatMap(entry =>
|
|
64
76
|
entry.type === "message" && entry.message.role === "toolResult" ? [entry.message.toolCallId] : []));
|
|
65
77
|
if (enabled) collapseTools(ctx);
|
|
@@ -93,7 +105,21 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
93
105
|
id: "tool-output", label: "Tool output", values: ["Normal", "Minimal"],
|
|
94
106
|
get: () => enabled ? "Minimal" : "Normal", change,
|
|
95
107
|
};
|
|
96
|
-
const
|
|
108
|
+
const animationSetting: SettingsControl = {
|
|
109
|
+
id: "tool-animation", label: "Tool animation", values: ["Shimmer", "Off"],
|
|
110
|
+
get: () => motionEnabled ? "Shimmer" : "Off",
|
|
111
|
+
async change(value, ctx) {
|
|
112
|
+
if (value !== "Shimmer" && value !== "Off") throw new Error("Invalid tool animation mode.");
|
|
113
|
+
if (!await ensureHook(ctx)) throw new Error("Tool animation settings are unavailable in this Pi runtime.");
|
|
114
|
+
updateHarnessSetting("toolAnimation", () => ({ version: 1, enabled: value === "Shimmer" }));
|
|
115
|
+
motionEnabled = value === "Shimmer";
|
|
116
|
+
hook!.setAnimationEnabled(motionEnabled);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
const register = () => {
|
|
120
|
+
pi.events.emit("harness-settings:register", setting);
|
|
121
|
+
pi.events.emit("harness-settings:register", animationSetting);
|
|
122
|
+
};
|
|
97
123
|
let stopSettings: (() => void) | undefined;
|
|
98
124
|
const subscribe = () => { stopSettings ??= pi.events.on("harness-settings:request", register); };
|
|
99
125
|
subscribe();
|
|
@@ -115,6 +141,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
115
141
|
enabled = false;
|
|
116
142
|
running = false;
|
|
117
143
|
initialDefault = undefined;
|
|
144
|
+
motionEnabled = true;
|
|
118
145
|
});
|
|
119
146
|
|
|
120
147
|
pi.registerCommand("tool-output", {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
type RGB = [number, number, number];
|
|
4
|
+
interface ConcreteColor { kind: string; index?: number; r?: number; g?: number; b?: number }
|
|
5
|
+
const concreteColors = (theme: Theme) => (theme as Theme & { colors?: Record<string, ConcreteColor> }).colors;
|
|
6
|
+
const PERIOD = 2400;
|
|
7
|
+
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
8
|
+
|
|
9
|
+
function themeRgb(theme: Theme, token: "accent" | "text"): RGB | undefined {
|
|
10
|
+
const color = concreteColors(theme)?.[token];
|
|
11
|
+
if (color?.kind === "rgb" && color.r !== undefined && color.g !== undefined && color.b !== undefined) {
|
|
12
|
+
return [color.r, color.g, color.b];
|
|
13
|
+
}
|
|
14
|
+
return rgb(theme.getFgAnsi(token));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function supportsToolShimmer(theme: Theme): boolean {
|
|
18
|
+
const base = themeRgb(theme, "accent");
|
|
19
|
+
const light = themeRgb(theme, "text");
|
|
20
|
+
const accent = concreteColors(theme)?.accent;
|
|
21
|
+
return (!!base && !!light && base.some((value, index) => value !== light[index]))
|
|
22
|
+
|| (accent?.kind === "indexed" && accent.index !== undefined && accent.index >= 0 && accent.index < 16);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function rgb(ansi: string): RGB | undefined {
|
|
26
|
+
const direct = /\x1b\[38;2;(\d+);(\d+);(\d+)m/.exec(ansi);
|
|
27
|
+
if (direct) return [Number(direct[1]), Number(direct[2]), Number(direct[3])];
|
|
28
|
+
const indexed = /\x1b\[38;5;(\d+)m/.exec(ansi);
|
|
29
|
+
if (!indexed) return undefined;
|
|
30
|
+
const index = Number(indexed[1]);
|
|
31
|
+
// The first 16 colors are terminal-defined; keep them untouched rather than guess.
|
|
32
|
+
if (index < 16 || index > 255) return undefined;
|
|
33
|
+
if (index >= 232) { const gray = 8 + (index - 232) * 10; return [gray, gray, gray]; }
|
|
34
|
+
const cube = index - 16;
|
|
35
|
+
const channel = (value: number) => value === 0 ? 0 : 55 + value * 40;
|
|
36
|
+
return [channel(Math.floor(cube / 36)), channel(Math.floor(cube / 6) % 6), channel(cube % 6)];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function foreground(color: RGB, mode: "truecolor" | "256color"): string {
|
|
40
|
+
if (mode === "truecolor") return `\x1b[38;2;${color.join(";")}m`;
|
|
41
|
+
const channel = (value: number) => Math.max(0, Math.min(5, Math.round((value - 55) / 40)));
|
|
42
|
+
const index = 16 + channel(color[0]) * 36 + channel(color[1]) * 6 + channel(color[2]);
|
|
43
|
+
return `\x1b[38;5;${index}m`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function toolIdentity(icon: string, label: string, tone: "accent" | "muted" | "error", theme: Theme, time?: number): string {
|
|
47
|
+
if (tone !== "accent" || time === undefined) return theme.fg(tone, `${icon} ${label}`);
|
|
48
|
+
const base = themeRgb(theme, "accent");
|
|
49
|
+
const light = themeRgb(theme, "text");
|
|
50
|
+
if (!supportsToolShimmer(theme)) return theme.fg(tone, `${icon} ${label}`);
|
|
51
|
+
const letters = Array.from(graphemes.segment(label), part => part.segment);
|
|
52
|
+
const radius = Math.max(2, Math.min(4, letters.length / 3));
|
|
53
|
+
const position = (time % PERIOD) / PERIOD * (letters.length + radius * 2) - radius;
|
|
54
|
+
const mode = theme.getColorMode();
|
|
55
|
+
const name = letters.map((letter, index) => {
|
|
56
|
+
const distance = Math.abs(index - position) / radius;
|
|
57
|
+
const shine = distance < 1 ? (1 + Math.cos(distance * Math.PI)) * 0.48 : 0;
|
|
58
|
+
if (!base || !light) {
|
|
59
|
+
const intensity = shine > 0.72 ? "\x1b[1m" : shine > 0.18 ? "" : "\x1b[2m";
|
|
60
|
+
return `\x1b[22m${intensity}${letter}`;
|
|
61
|
+
}
|
|
62
|
+
const color = base.map((value, channel) => Math.round(value + (light[channel] - value) * shine)) as RGB;
|
|
63
|
+
return `${foreground(color, mode)}${letter}`;
|
|
64
|
+
}).join("");
|
|
65
|
+
return theme.fg(tone, `${icon} \x1b[22m${name}\x1b[22m`);
|
|
66
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
interface Rect { x: number; y: number; width: number; height: number }
|
|
2
|
+
interface LegacyComponent {
|
|
3
|
+
mouseLayout?: { children: { component: LegacyComponent; height: number }[] };
|
|
4
|
+
paddingX?: number;
|
|
5
|
+
paddingY?: number;
|
|
6
|
+
}
|
|
7
|
+
interface LayoutBox {
|
|
8
|
+
component: LegacyComponent;
|
|
9
|
+
rect: Rect;
|
|
10
|
+
clip: Rect;
|
|
11
|
+
children: LayoutBox[];
|
|
12
|
+
lineOffset?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface ViewportUI { mode?: string; currentLayout?: { root: LayoutBox }; hasOverlay?(): boolean; hasActiveSelection?(): boolean }
|
|
15
|
+
const frames = new WeakMap<ViewportUI, { root: LayoutBox; known: Set<object>; visible: Set<object> }>();
|
|
16
|
+
|
|
17
|
+
function intersects(a: Rect, b: Rect): boolean {
|
|
18
|
+
return a.width > 0 && a.height > 0 && b.width > 0 && b.height > 0
|
|
19
|
+
&& a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The guarded fullscreen adapter uses the last painted layout, never calls render()
|
|
23
|
+
// to measure visibility, and leaves scrollback hosts on static state colors.
|
|
24
|
+
export function toolVisibility(ui: ViewportUI, component: object): boolean | undefined {
|
|
25
|
+
if (ui.mode !== "fullscreen" || ui.hasOverlay?.() || ui.hasActiveSelection?.()) return false;
|
|
26
|
+
const root = ui.currentLayout?.root;
|
|
27
|
+
if (!root) return undefined;
|
|
28
|
+
if (!root.rect || !root.clip || !Array.isArray(root.children)) return false;
|
|
29
|
+
let frame = frames.get(ui);
|
|
30
|
+
if (frame?.root !== root) {
|
|
31
|
+
frame = { root, known: new Set(), visible: new Set() };
|
|
32
|
+
const current = frame;
|
|
33
|
+
const legacy = (child: LegacyComponent, rect: Rect, clip: Rect) => {
|
|
34
|
+
if (!child || typeof child !== "object") return;
|
|
35
|
+
current.known.add(child);
|
|
36
|
+
if (!intersects(rect, clip)) return;
|
|
37
|
+
current.visible.add(child);
|
|
38
|
+
const children = child.mouseLayout?.children;
|
|
39
|
+
if (!children) return;
|
|
40
|
+
const paddingY = child.paddingY ?? 0;
|
|
41
|
+
const paddingX = child.paddingX ?? 0;
|
|
42
|
+
if (children.reduce((height, entry) => height + entry.height, 0) + paddingY * 2 !== rect.height) return;
|
|
43
|
+
let y = rect.y + paddingY;
|
|
44
|
+
for (const entry of children) {
|
|
45
|
+
legacy(entry.component, { x: rect.x + paddingX, y, width: Math.max(0, rect.width - paddingX * 2), height: entry.height }, clip);
|
|
46
|
+
y += entry.height;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const visit = (box: LayoutBox) => {
|
|
50
|
+
if (!box?.component || !box.rect || !box.clip || !Array.isArray(box.children)) return;
|
|
51
|
+
current.known.add(box.component);
|
|
52
|
+
if (!intersects(box.rect, box.clip)) return;
|
|
53
|
+
current.visible.add(box.component);
|
|
54
|
+
if (box.children.length) for (const child of box.children) visit(child);
|
|
55
|
+
else legacy(box.component, { ...box.rect, y: box.rect.y - (box.lineOffset ?? 0) }, box.clip);
|
|
56
|
+
};
|
|
57
|
+
visit(root);
|
|
58
|
+
frames.set(ui, frame);
|
|
59
|
+
}
|
|
60
|
+
return frame.known.has(component) ? frame.visible.has(component) : undefined;
|
|
61
|
+
}
|
|
@@ -18,7 +18,7 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
|
|
|
18
18
|
- Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
|
|
19
19
|
- A compact goal widget that does not replace Pi's footer.
|
|
20
20
|
- Background activity tracking for subagents and other registered providers.
|
|
21
|
-
- A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (60s through 600s by default), followed by a hold recoverable with `/goal resume`. Delays saturate at Node's timer limit, and observed background drains reset progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
|
|
21
|
+
- A progress-aware follow-up loop with ten identical no-progress retries and linear backoff (60s through 600s by default), followed by a sticky hold recoverable with `/goal resume` or `alt+g`. Conversation, changed callback results, and background drains do not reopen an exhausted hold. Delays saturate at Node's timer limit, and observed background drains reset non-held progress before callback turns can cancel the wake. Pi's network retry policy remains independent.
|
|
22
22
|
- An observable-progress stall state for active goals.
|
|
23
23
|
|
|
24
24
|
## Settings Storage
|
|
@@ -44,7 +44,7 @@ metadata:
|
|
|
44
44
|
workflow-role: coordinator
|
|
45
45
|
```
|
|
46
46
|
|
|
47
|
-
Invoke the skill with Pi's `/skill:name` command, or supervise it with `/goal /skill:name task`. The goal extension resolves the command against Pi's registry, persists its source, and expands the skill on kickoff and continuation (including after session resume). A bound command that disappears or changes source pauses the goal. `/goal /template task` also re-expands prompt templates on continuation; `/goal /extension-command task` dispatches the extension command once, then continues with ordinary goal supervision to avoid repeating side effects. Plain-language goals work as before. This command binding requires Pi 0.84.4 or later. Legacy slash-shaped goals without a binding pause on resume rather than running without the skill. `/workflow` shows a coordinator skill owner; call `release_workflow` after the workflow's completion audit
|
|
47
|
+
Invoke the skill with Pi's `/skill:name` command, or supervise it with `/goal /skill:name task`. The goal extension resolves the command against Pi's registry, persists its source, and expands the skill on kickoff and continuation (including after session resume). A bound command that disappears or changes source pauses the goal. `/goal /template task` also re-expands prompt templates on continuation; `/goal /extension-command task` dispatches the extension command once, then continues with ordinary goal supervision to avoid repeating side effects. Plain-language goals work as before. This command binding requires Pi 0.84.4 or later. Legacy slash-shaped goals without a binding pause on resume rather than running without the skill. `/workflow` shows a coordinator skill owner; call `release_workflow` after the workflow's completion audit, or use `/workflow clear` to release ownership manually. `release_workflow` also pauses an active Goal bound to that coordinator, preserving its objective and incomplete status. Reinvoke the skill explicitly before `/goal resume`; automatic continuation cannot reacquire ownership. Unrelated ordinary Goals are unchanged. Completing an active goal also releases ownership.
|
|
48
48
|
|
|
49
49
|
A skill that is only an alias of a coordinator declares the target instead of a role:
|
|
50
50
|
|
|
@@ -257,6 +257,7 @@ function formatGoal(
|
|
|
257
257
|
`Observable progress: ${stall?.state ?? "unknown"}`,
|
|
258
258
|
continuationStatus,
|
|
259
259
|
settings,
|
|
260
|
+
...(continuation?.blocked && goal.status === "active" ? ["Resume: /goal resume or alt+g"] : []),
|
|
260
261
|
...(goal.status === "paused"
|
|
261
262
|
? [agentResumable(goal, preferences.conversationalResume)
|
|
262
263
|
? 'Resume: say "go" (the agent calls goal_resume), /goal resume, or alt+g'
|
|
@@ -432,10 +433,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
432
433
|
if (!isPokeable(goal)) {
|
|
433
434
|
return;
|
|
434
435
|
}
|
|
435
|
-
|
|
436
|
+
const held = currentContinuationState(ctx, goal.goalId)?.blocked === true;
|
|
437
|
+
setGoal(goalWithStatus(goal, "paused", undefined, held ? undefined : "interrupt"), ctx, "runtime");
|
|
436
438
|
notifyGoal(ctx, currentPermissionHold(ctx, goal.goalId).blockers.length > 0
|
|
437
439
|
? "Goal remains permission-held. Use /goal resume or alt+g for one same-scope retry."
|
|
438
|
-
: preferences.conversationalResume
|
|
440
|
+
: preferences.conversationalResume && !held
|
|
439
441
|
? 'Goal paused. Say "go" to resume it, or use /goal resume.'
|
|
440
442
|
: "Goal paused. Use /goal resume or alt+g to resume it.");
|
|
441
443
|
};
|
|
@@ -446,13 +448,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
446
448
|
escapeAbortSuppression.execution === executionGeneration;
|
|
447
449
|
|
|
448
450
|
/** Why a paused goal cannot become active again, or null when it can. */
|
|
449
|
-
const resumeBlocker = (goal: GoalSnapshot): string | null => {
|
|
451
|
+
const resumeBlocker = (goal: GoalSnapshot, ctx: ExtensionContext): string | null => {
|
|
450
452
|
if (!goal.command && skillCommandName(goal.objective)) {
|
|
451
453
|
return "Invoke the skill directly; this legacy slash-command goal cannot resume as plain text.";
|
|
452
454
|
}
|
|
453
455
|
if (goal.command && !commandAvailable(pi, goal.command)) {
|
|
454
456
|
return `Cannot resume: /${goal.command.name} is no longer registered at its original source.`;
|
|
455
457
|
}
|
|
458
|
+
if (goal.command?.source === "skill") {
|
|
459
|
+
const owner = workflowOwnerFromSkill(goal.command.name.slice("skill:".length), goal.command.path, registeredSkillPath);
|
|
460
|
+
const activeOwner = getWorkflow(ctx);
|
|
461
|
+
if (owner && (activeOwner?.name !== owner.name || activeOwner.path !== owner.path)) {
|
|
462
|
+
return `Reinvoke /${goal.command.name} before resuming its workflow goal.`;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
456
465
|
return null;
|
|
457
466
|
};
|
|
458
467
|
|
|
@@ -469,7 +478,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
469
478
|
if (!current || (current.status !== "paused" && !held)) {
|
|
470
479
|
return { ok: false, message: "Only paused or no-progress-held goals can be resumed." };
|
|
471
480
|
}
|
|
472
|
-
|
|
481
|
+
if (source === "tool" && currentContinuationState(ctx, current.goalId)?.blocked) {
|
|
482
|
+
return { ok: false, message: "Only /goal resume or alt+g can reopen a no-progress hold." };
|
|
483
|
+
}
|
|
484
|
+
const blocker = resumeBlocker(current, ctx);
|
|
473
485
|
if (blocker) {
|
|
474
486
|
return { ok: false, message: blocker };
|
|
475
487
|
}
|
|
@@ -544,7 +556,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
544
556
|
return;
|
|
545
557
|
}
|
|
546
558
|
if (currentPermissionHold(ctx, goal.goalId).blockers.length > 0) return;
|
|
547
|
-
if (
|
|
559
|
+
if (currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
548
560
|
return;
|
|
549
561
|
}
|
|
550
562
|
const signature = `${goal.goalId}:${kind}`;
|
|
@@ -575,6 +587,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
575
587
|
const current = currentGoalSnapshot(ctx);
|
|
576
588
|
return isPokeable(current) && current.goalId === goalId &&
|
|
577
589
|
currentPermissionHold(ctx, goalId).blockers.length === 0 &&
|
|
590
|
+
!currentContinuationState(ctx, goalId)?.blocked &&
|
|
578
591
|
continuationQueuedFor !== goalId && !isForegroundBusy(ctx);
|
|
579
592
|
};
|
|
580
593
|
const goal = currentGoalSnapshot(ctx);
|
|
@@ -637,7 +650,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
637
650
|
const goal = currentGoalSnapshot(ctx);
|
|
638
651
|
const wakePlan = planBackgroundDrainWake(backgroundDrainTracker, goal, snapshot);
|
|
639
652
|
backgroundDrainTracker = wakePlan.nextTracker;
|
|
640
|
-
if (isPokeable(goal) && wakePlan.wakeSignature && currentPermissionHold(ctx, goal.goalId).blockers.length === 0
|
|
653
|
+
if (isPokeable(goal) && wakePlan.wakeSignature && currentPermissionHold(ctx, goal.goalId).blockers.length === 0 &&
|
|
654
|
+
!currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
641
655
|
// Persist external progress now: a foreground/callback turn may cancel its delayed wake.
|
|
642
656
|
clearIdleContinuation();
|
|
643
657
|
resetContinuationState(goal);
|
|
@@ -1101,13 +1115,25 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1101
1115
|
pi.registerTool({
|
|
1102
1116
|
name: "release_workflow",
|
|
1103
1117
|
label: "Release Workflow",
|
|
1104
|
-
description: "Release the active skill-owned workflow after its final handoff and completion audit.",
|
|
1118
|
+
description: "Release the active skill-owned workflow after its final handoff and completion audit. Its bound active Goal pauses; explicitly reinvoke the skill before resuming that Goal.",
|
|
1105
1119
|
parameters: Type.Object({}),
|
|
1106
1120
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
1107
1121
|
const owner = getWorkflow(ctx);
|
|
1108
1122
|
if (!owner) return { content: [{ type: "text", text: "No workflow owns this session." }], details: { released: false } };
|
|
1123
|
+
const goal = getGoal(ctx);
|
|
1124
|
+
let resumeCommand: string | undefined;
|
|
1125
|
+
if (goal?.status === "active" && goal.command?.source === "skill") {
|
|
1126
|
+
const boundOwner = workflowOwnerFromSkill(goal.command.name.slice("skill:".length), goal.command.path, registeredSkillPath);
|
|
1127
|
+
if (boundOwner?.name === owner.name && boundOwner.path === owner.path) {
|
|
1128
|
+
setGoal(goalWithStatus(goal, "paused"), ctx, "tool");
|
|
1129
|
+
resumeCommand = goal.command.name;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1109
1132
|
recordWorkflow(null);
|
|
1110
|
-
return {
|
|
1133
|
+
return {
|
|
1134
|
+
content: [{ type: "text", text: `Released ${owner.name} workflow ownership.${resumeCommand ? ` Its Goal is paused, not complete. Reinvoke /${resumeCommand} before /goal resume.` : ""}` }],
|
|
1135
|
+
details: { released: true, owner: owner.name, goalPaused: resumeCommand !== undefined },
|
|
1136
|
+
};
|
|
1111
1137
|
},
|
|
1112
1138
|
});
|
|
1113
1139
|
|
|
@@ -1211,7 +1237,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1211
1237
|
// A paused goal stays paused while the user talks: the message is ordinary
|
|
1212
1238
|
// conversation. Only /goal resume, the hotkey, or goal_resume resume it.
|
|
1213
1239
|
const goal = getGoal(ctx);
|
|
1214
|
-
if (goal?.status === "active" && currentPermissionHold(ctx, goal.goalId).blockers.length === 0
|
|
1240
|
+
if (goal?.status === "active" && currentPermissionHold(ctx, goal.goalId).blockers.length === 0 &&
|
|
1241
|
+
!currentContinuationState(ctx, goal.goalId)?.blocked) {
|
|
1215
1242
|
executionGeneration += 1;
|
|
1216
1243
|
clearIdleContinuation();
|
|
1217
1244
|
lastAgentEvidence = null;
|
|
@@ -1243,6 +1270,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1243
1270
|
const questionInstruction = snapshot.backgroundRunning
|
|
1244
1271
|
? " A blocking user question (ask_user_question) holds this whole turn until the user answers, and background completions wait behind it. Harvest finished background results before asking, and ask only when the answer is needed to proceed."
|
|
1245
1272
|
: "";
|
|
1273
|
+
const heldInstruction = goal?.status === "active" && currentContinuationState(ctx, goal.goalId)?.blocked
|
|
1274
|
+
? "Automatic Goal continuation is held for no progress. Answer the current user message or inspect delivered background results without restarting autonomous Goal work. Only /goal resume or alt+g reopens this hold."
|
|
1275
|
+
: "";
|
|
1246
1276
|
|
|
1247
1277
|
if (owner) {
|
|
1248
1278
|
if (!workflowAvailable(owner)) {
|
|
@@ -1266,7 +1296,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1266
1296
|
: "") +
|
|
1267
1297
|
`Active workflow: ${owner.name} (${owner.path}). Its task plan owns planning and the parent is a coordinator, not a product-code implementer. Follow the workflow instructions below, including on resumed turns:\n\n${instructions}` +
|
|
1268
1298
|
(isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
|
|
1269
|
-
(questionInstruction ? `\n\n${questionInstruction.trim()}` : "")
|
|
1299
|
+
(questionInstruction ? `\n\n${questionInstruction.trim()}` : "") +
|
|
1300
|
+
(heldInstruction ? `\n\n${heldInstruction}` : ""),
|
|
1270
1301
|
};
|
|
1271
1302
|
}
|
|
1272
1303
|
|
|
@@ -1277,7 +1308,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1277
1308
|
return {
|
|
1278
1309
|
systemPrompt:
|
|
1279
1310
|
`${event.systemPrompt}\n\n` +
|
|
1280
|
-
`Pi Better Goal active objective: ${goal.objective}. Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit
|
|
1311
|
+
`Pi Better Goal active objective: ${goal.objective}. ${heldInstruction || "Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit."}${backgroundInstruction}${questionInstruction}` +
|
|
1281
1312
|
(permissionPrompt ? `\n\n${permissionPrompt}` : ""),
|
|
1282
1313
|
};
|
|
1283
1314
|
});
|
|
@@ -1408,6 +1439,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1408
1439
|
return;
|
|
1409
1440
|
}
|
|
1410
1441
|
const previous = currentContinuationState(ctx, goal.goalId) ?? createContinuationState(goal.goalId);
|
|
1442
|
+
if (previous.blocked) return;
|
|
1411
1443
|
const repeated = previous.lastEvidenceSignature === evidence.signature;
|
|
1412
1444
|
const noProgressRetries = repeated ? previous.noProgressRetries + 1 : 0;
|
|
1413
1445
|
const blocked = repeated && noProgressRetries >= MAX_NO_PROGRESS_RETRIES;
|
|
@@ -1428,7 +1460,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1428
1460
|
if (ctx.hasUI) {
|
|
1429
1461
|
ctx.ui.setStatus(EXTENSION_NAME, "waiting: no progress");
|
|
1430
1462
|
ctx.ui.notify(
|
|
1431
|
-
`Goal automatic continuation is waiting after ${noProgressRetries} identical retries (${evidence.summary}).
|
|
1463
|
+
`Goal automatic continuation is waiting after ${noProgressRetries} identical retries (${evidence.summary}). Use /goal resume or alt+g to resume it.`,
|
|
1432
1464
|
"warning",
|
|
1433
1465
|
);
|
|
1434
1466
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.1",
|
|
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",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"pi-better-background-tasks": "0.8.0",
|
|
57
|
-
"pi-better-goal": "0.8.
|
|
57
|
+
"pi-better-goal": "0.8.3",
|
|
58
58
|
"pi-better-plan": "0.8.0",
|
|
59
59
|
"pi-better-sandbox": "0.12.0",
|
|
60
60
|
"pi-better-ssh": "0.1.2",
|