pi-better-harness 0.19.0 → 0.21.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 +38 -4
- package/extensions/minimal-output/hook.ts +20 -1
- package/extensions/minimal-output/index.ts +3 -3
- package/extensions/settings/index.ts +46 -0
- package/extensions/settings/page.ts +79 -0
- package/extensions/settings/registry.ts +27 -0
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +206 -15
- package/node_modules/pi-better-background-tasks/src/shared-failure-observations.ts +51 -6
- package/node_modules/pi-better-background-tasks/src/shared-permission-blocker.ts +48 -0
- package/node_modules/pi-better-background-tasks/src/tools.ts +13 -3
- package/node_modules/pi-better-goal/package.json +1 -1
- package/node_modules/pi-better-goal/src/continuation.ts +5 -1
- package/node_modules/pi-better-goal/src/goal-state.ts +1 -1
- package/node_modules/pi-better-goal/src/index.ts +143 -33
- package/node_modules/pi-better-goal/src/permission-hold.ts +150 -0
- package/node_modules/pi-better-goal/src/shared-permission-blocker.ts +48 -0
- package/node_modules/pi-better-goal/src/types.ts +4 -2
- package/node_modules/pi-better-sandbox/README.md +34 -11
- package/node_modules/pi-better-sandbox/index.ts +26 -18
- package/node_modules/pi-better-sandbox/package.json +1 -1
- package/node_modules/pi-better-sandbox/permissions-page.ts +8 -7
- package/node_modules/pi-better-sandbox/permissions.ts +1 -1
- package/node_modules/pi-better-sandbox/shared-permission-blocker.ts +48 -0
- package/node_modules/pi-better-sandbox/shared-task-sandbox.ts +2 -1
- package/node_modules/pi-better-sandbox/shared-task-tools.ts +16 -7
- package/node_modules/pi-better-ssh/README.md +20 -0
- package/node_modules/pi-better-subagents/child-incidents.ts +6 -2
- package/node_modules/pi-better-subagents/docs/failure-observations.md +34 -0
- package/node_modules/pi-better-subagents/docs/usage.md +27 -2
- package/node_modules/pi-better-subagents/failures.ts +1 -1
- package/node_modules/pi-better-subagents/incident-model.ts +24 -4
- package/node_modules/pi-better-subagents/index.ts +31 -15
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/shared-callback-batcher.ts +206 -15
- package/node_modules/pi-better-subagents/shared-failure-observations.ts +51 -6
- package/node_modules/pi-better-subagents/shared-permission-blocker.ts +48 -0
- package/node_modules/pi-better-subagents/shared-task-sandbox.ts +2 -1
- package/node_modules/pi-better-subagents/shared-task-tools.ts +16 -7
- package/node_modules/pi-better-subagents/tools.ts +32 -6
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -65,12 +65,13 @@ need interactive confirmation. Full policy: [pi-better-sandbox](https://github.c
|
|
|
65
65
|
## Minimal Tool Output
|
|
66
66
|
|
|
67
67
|
The bundled harness also provides `/tool-output minimal`, `/tool-output normal`,
|
|
68
|
-
and `/tool-output` (toggle). Minimal mode
|
|
69
|
-
running calls
|
|
68
|
+
and `/tool-output` (toggle). Minimal mode folds each tool call into a single-line
|
|
69
|
+
header, including running calls. Long headers are truncated to the terminal
|
|
70
|
+
width; result bodies, images, boxes, and tool spacers are hidden. Built-in,
|
|
70
71
|
extension, and MCP calls are included. Execution, sandboxing, and agent-facing
|
|
71
72
|
payloads are unchanged. Ctrl+O reveals the original tool blocks; Ctrl+O again
|
|
72
|
-
|
|
73
|
-
are also hidden in minimal mode and remain available when
|
|
73
|
+
folds them. `/tool-output normal` restores ordinary rendering. Error result
|
|
74
|
+
bodies are also hidden in minimal mode and remain available when expanded.
|
|
74
75
|
|
|
75
76
|
Normal mode is the default. The preference is saved in the current session,
|
|
76
77
|
including resume/reload. This is a version-sensitive internal TUI adapter,
|
|
@@ -85,6 +86,39 @@ Load the bundled harness or run it directly from a checkout:
|
|
|
85
86
|
pi -e ./packages/pi-better-harness/extensions/minimal-output/index.ts
|
|
86
87
|
```
|
|
87
88
|
|
|
89
|
+
## Harness Settings
|
|
90
|
+
|
|
91
|
+
The bundled harness provides `/harness-settings`, using Pi's native settings
|
|
92
|
+
list. It opens the settings screens of loaded Sandbox, Subagents, and Goal
|
|
93
|
+
packages without duplicating their configuration. `/sandbox`, `/subagents
|
|
94
|
+
settings`, and `/goal settings` remain available in standalone installations.
|
|
95
|
+
Packages without a settings screen are not listed. Pi's `/settings` is unchanged.
|
|
96
|
+
|
|
97
|
+
The hub also owns **Completions while busy**, shared by Subagents and Background
|
|
98
|
+
Tasks. Choose **Wait until idle** (the default) or **Steer active run**. Changes
|
|
99
|
+
apply immediately and autosave to the current session branch, including across
|
|
100
|
+
reloads. Press **Ctrl+S** to save the current choice as your default for future
|
|
101
|
+
Pi sessions; changing a session afterward does not change that saved default.
|
|
102
|
+
Saving a default leaves already-open sessions unchanged, including when
|
|
103
|
+
navigating to a branch without a session override.
|
|
104
|
+
The user default is stored in
|
|
105
|
+
`<agent-dir>/extensions/pi-better-callback-preferences.json` and also applies
|
|
106
|
+
when either callback package is loaded standalone. The former
|
|
107
|
+
`PI_BETTER_CALLBACK_WHILE_BUSY` environment variable is no longer supported.
|
|
108
|
+
|
|
109
|
+
Next-prompt inference is deferred pending safe public auth/header resolution in
|
|
110
|
+
Pi's SDK. It is not loaded by the bundle, has no toggle or preference store in
|
|
111
|
+
this version, and makes no auxiliary model requests. The integration blocker is
|
|
112
|
+
[issue #426](https://github.com/1aboveio/pi-better-harness/issues/426).
|
|
113
|
+
|
|
114
|
+
The hub is TUI-only. The standalone-package installer does not install
|
|
115
|
+
Harness-only extensions; load the bundle or the settings extension from the
|
|
116
|
+
checkout to use the hub:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
pi -e ./packages/pi-better-harness/extensions/settings/index.ts
|
|
120
|
+
```
|
|
121
|
+
|
|
88
122
|
## When To Use
|
|
89
123
|
|
|
90
124
|
Use the installer when you want every core extension with standalone package identities. Install an individual package instead when you only need the sandbox, subagents, shell task supervision, synchronous SSH, goal tracking, or plans.
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
1
2
|
import { readFileSync, realpathSync } from "node:fs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { stripVTControlCharacters } from "node:util";
|
|
5
7
|
|
|
6
8
|
interface ToolComponent {
|
|
9
|
+
toolName: string;
|
|
10
|
+
args: unknown;
|
|
11
|
+
callRendererComponent?: Component;
|
|
7
12
|
expanded: boolean;
|
|
8
13
|
showImages: boolean;
|
|
9
14
|
resultRendererComponent?: unknown;
|
|
@@ -37,6 +42,20 @@ export interface MinimalOutputHook {
|
|
|
37
42
|
const HOOK = Symbol.for("pi-better-harness.minimal-output-hook");
|
|
38
43
|
const emptyResult = () => ({ render: () => [] as string[], invalidate() {} });
|
|
39
44
|
|
|
45
|
+
function compactCall(component: ToolComponent, width: number): string[] {
|
|
46
|
+
if (width <= 0) return [];
|
|
47
|
+
// Render the call alone at a wider width so terminal wrapping becomes truncation.
|
|
48
|
+
const header = component.callRendererComponent?.render(Math.max(4096, width + 1))
|
|
49
|
+
.map((line) => stripVTControlCharacters(line).trim())
|
|
50
|
+
.find((line) => line.length > 0);
|
|
51
|
+
let text = header;
|
|
52
|
+
if (!text) {
|
|
53
|
+
const args = JSON.stringify(component.args);
|
|
54
|
+
text = `${component.toolName}${args && args !== "{}" ? ` ${args}` : ""}`;
|
|
55
|
+
}
|
|
56
|
+
return [truncateToWidth(text, width)];
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
function hostRequire(): NodeRequire {
|
|
41
60
|
let require = createRequire(import.meta.url);
|
|
42
61
|
if (process.argv[1]) {
|
|
@@ -145,7 +164,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutpu
|
|
|
145
164
|
return current.enabled && !this.expanded ? "" : originals.getTextOutput.apply(this, args);
|
|
146
165
|
},
|
|
147
166
|
render(this: ToolComponent, width: number): string[] {
|
|
148
|
-
return current.enabled && !this.expanded ?
|
|
167
|
+
return current.enabled && !this.expanded ? compactCall(this, width) : originals.render.call(this, width);
|
|
149
168
|
},
|
|
150
169
|
};
|
|
151
170
|
Object.assign(prototype, wrappers);
|
|
@@ -56,11 +56,11 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
pi.registerCommand("tool-output", {
|
|
59
|
-
description: "[minimal|normal] —
|
|
59
|
+
description: "[minimal|normal] — Fold tool output into single-line call headers, or restore normal output. No argument toggles. Display only.",
|
|
60
60
|
getArgumentCompletions: (argumentPrefix) => {
|
|
61
61
|
const prefix = argumentPrefix.trimStart().toLowerCase();
|
|
62
62
|
const matches = [
|
|
63
|
-
{ value: "minimal", label: "minimal", description: "
|
|
63
|
+
{ value: "minimal", label: "minimal", description: "Show compact call headers without result bodies" },
|
|
64
64
|
{ value: "normal", label: "normal", description: "Restore ordinary tool output" },
|
|
65
65
|
].filter((option) => option.value.startsWith(prefix));
|
|
66
66
|
return matches.length > 0 ? matches : null;
|
|
@@ -78,7 +78,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
78
78
|
pi.appendEntry(ENTRY, { version: 1, enabled });
|
|
79
79
|
status(ctx);
|
|
80
80
|
ctx.ui.notify(enabled
|
|
81
|
-
? "Minimal tool output on.
|
|
81
|
+
? "Minimal tool output on. Compact call headers remain visible. Ctrl+O reveals results."
|
|
82
82
|
: "Normal tool output restored.", "info");
|
|
83
83
|
},
|
|
84
84
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { chooseHarnessSetting } from "./page.ts";
|
|
3
|
+
import { createSettingsRegistry } from "./registry.ts";
|
|
4
|
+
import { changeCallbackSetting, getCallbackSettings, saveCallbackDefault } from "pi-better-background-tasks/src/shared-callback-batcher.ts";
|
|
5
|
+
|
|
6
|
+
export default function harnessSettingsExtension(pi: ExtensionAPI): void {
|
|
7
|
+
const registry = createSettingsRegistry(pi);
|
|
8
|
+
pi.on("session_start", () => registry.refresh());
|
|
9
|
+
pi.on("session_shutdown", () => registry.dispose());
|
|
10
|
+
|
|
11
|
+
pi.registerCommand("harness-settings", {
|
|
12
|
+
description: "Open loaded package settings",
|
|
13
|
+
handler: async (_args, ctx) => {
|
|
14
|
+
if (ctx.mode !== "tui") { ctx.ui.notify("Harness settings requires the interactive TUI.", "warning"); return; }
|
|
15
|
+
registry.refresh();
|
|
16
|
+
let selected: string | undefined;
|
|
17
|
+
const reported = new Set<string>();
|
|
18
|
+
const readSettings = (): ReturnType<typeof getCallbackSettings> => {
|
|
19
|
+
try { return getCallbackSettings(ctx); }
|
|
20
|
+
catch (error) {
|
|
21
|
+
const message = `Callback default unavailable; using Wait until idle: ${error instanceof Error ? error.message : String(error)}`;
|
|
22
|
+
if (!reported.has(message)) { ctx.ui.notify(message, "error"); reported.add(message); }
|
|
23
|
+
return { mode: "hold", source: "default" };
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
while (true) {
|
|
27
|
+
const links = registry.list();
|
|
28
|
+
try {
|
|
29
|
+
selected = await chooseHarnessSetting(ctx, links, selected, {
|
|
30
|
+
get: readSettings,
|
|
31
|
+
change: mode => changeCallbackSetting(pi, ctx, mode),
|
|
32
|
+
save: () => saveCallbackDefault(readSettings().mode),
|
|
33
|
+
});
|
|
34
|
+
} catch (error) {
|
|
35
|
+
ctx.ui.notify(`Settings unavailable: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!selected) return;
|
|
39
|
+
const link = links.find(item => `link:${item.id}` === selected);
|
|
40
|
+
if (!link) continue;
|
|
41
|
+
try { await link.open(ctx); }
|
|
42
|
+
catch (error) { ctx.ui.notify(`Settings unavailable: ${error instanceof Error ? error.message : String(error)}`, "error"); }
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, SettingsList, Text, getKeybindings, setKeybindings, KeybindingsManager, TUI_KEYBINDINGS, matchesKey, type SettingItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { SettingsLink } from "./registry.ts";
|
|
4
|
+
|
|
5
|
+
export interface CallbackSettingsControl {
|
|
6
|
+
get(): { mode: "hold" | "steer"; source: "session" | "default" };
|
|
7
|
+
change(mode: "hold" | "steer"): void;
|
|
8
|
+
save(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const CALLBACK_SETTING_ID = "callbacks:while-busy";
|
|
12
|
+
const callbackLabel = (mode: "hold" | "steer") => mode === "steer" ? "Steer active run" : "Wait until idle";
|
|
13
|
+
|
|
14
|
+
export async function chooseHarnessSetting(ctx: any, links: SettingsLink[], selected?: string, callbacks?: CallbackSettingsControl): Promise<string | undefined> {
|
|
15
|
+
return ctx.ui.custom((tui: any, theme: Theme, _kb: unknown, done: (id?: string) => void) => {
|
|
16
|
+
let callbackMode = callbacks?.get().mode ?? "hold";
|
|
17
|
+
const callbackSetting: SettingItem | undefined = callbacks ? {
|
|
18
|
+
id: CALLBACK_SETTING_ID,
|
|
19
|
+
label: "Completions while busy",
|
|
20
|
+
currentValue: callbackLabel(callbackMode),
|
|
21
|
+
description: callbacks.get().source === "session" ? "Session setting" : "User default",
|
|
22
|
+
values: ["Wait until idle", "Steer active run"],
|
|
23
|
+
} : undefined;
|
|
24
|
+
const items: SettingItem[] = [
|
|
25
|
+
...(callbackSetting ? [callbackSetting] : []),
|
|
26
|
+
...links.map(link => ({ id: `link:${link.id}`, label: link.label, currentValue: link.command, description: "Open package settings", values: [link.command] })),
|
|
27
|
+
];
|
|
28
|
+
const feedback = new Text("", 0, 0);
|
|
29
|
+
const report = (message: string, error = false) => {
|
|
30
|
+
feedback.setText(theme.fg(error ? "error" : "muted", message));
|
|
31
|
+
tui.requestRender?.();
|
|
32
|
+
};
|
|
33
|
+
const list = new SettingsList(items, 10, {
|
|
34
|
+
label: (text, active) => theme.fg(active ? "accent" : "text", text),
|
|
35
|
+
value: (text, active) => active ? theme.inverse(text) : theme.fg("muted", text),
|
|
36
|
+
description: text => theme.fg("muted", text), cursor: "> ", hint: text => theme.fg("dim", text),
|
|
37
|
+
}, (id, value) => {
|
|
38
|
+
if (id !== CALLBACK_SETTING_ID || !callbacks || !callbackSetting) { done(id); return; }
|
|
39
|
+
try {
|
|
40
|
+
const nextMode = value === "Steer active run" ? "steer" : "hold";
|
|
41
|
+
callbacks.change(nextMode);
|
|
42
|
+
callbackMode = nextMode;
|
|
43
|
+
callbackSetting.description = "Session setting";
|
|
44
|
+
report("Session setting saved.");
|
|
45
|
+
} catch (error) {
|
|
46
|
+
list.updateValue(CALLBACK_SETTING_ID, callbackLabel(callbackMode));
|
|
47
|
+
report(error instanceof Error ? error.message : String(error), true);
|
|
48
|
+
}
|
|
49
|
+
}, () => done(), { enableSearch: false });
|
|
50
|
+
|
|
51
|
+
const index = selected ? items.findIndex(item => item.id === selected) : 0;
|
|
52
|
+
if (index > 0) {
|
|
53
|
+
// SettingsList has no public selection setter. Restore synchronously using
|
|
54
|
+
// default navigation, then return the untouched user bindings before input.
|
|
55
|
+
const bindings = getKeybindings();
|
|
56
|
+
try {
|
|
57
|
+
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
|
|
58
|
+
for (let i = 0; i < index; i++) list.handleInput("\x1b[B");
|
|
59
|
+
} finally { setKeybindings(bindings); }
|
|
60
|
+
}
|
|
61
|
+
const page = new Container();
|
|
62
|
+
page.addChild(new Text(theme.fg("accent", theme.bold("Harness settings")), 0, 0));
|
|
63
|
+
page.addChild(list);
|
|
64
|
+
if (callbacks) page.addChild(new Text(theme.fg("dim", "Callbacks autosave to this session. Ctrl+S saves the default for future sessions."), 0, 0));
|
|
65
|
+
page.addChild(feedback);
|
|
66
|
+
return {
|
|
67
|
+
render: (width: number) => page.render(width),
|
|
68
|
+
invalidate: () => page.invalidate(),
|
|
69
|
+
handleInput(data: string) {
|
|
70
|
+
if (callbacks && matchesKey(data, "ctrl+s")) {
|
|
71
|
+
try { callbacks.save(); report("Callback default saved."); }
|
|
72
|
+
catch (error) { report(error instanceof Error ? error.message : String(error), true); }
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
list.handleInput(data);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export interface SettingsLink {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
command: string;
|
|
7
|
+
open(ctx: ExtensionCommandContext): Promise<void> | void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function createSettingsRegistry(pi: ExtensionAPI) {
|
|
11
|
+
const links = new Map<string, SettingsLink>();
|
|
12
|
+
const conflicts = new Set<string>();
|
|
13
|
+
const stop = pi.events.on("harness-settings:register", (candidate: unknown) => {
|
|
14
|
+
const link = candidate as Partial<SettingsLink> | null;
|
|
15
|
+
if (!link || !/^[a-z][a-z0-9-]*$/.test(link.id ?? "") || typeof link.label !== "string" ||
|
|
16
|
+
typeof link.command !== "string" || !link.command.startsWith("/") || typeof link.open !== "function") return;
|
|
17
|
+
if (conflicts.has(link.id!)) return;
|
|
18
|
+
const prior = links.get(link.id!);
|
|
19
|
+
if (prior && prior.open !== link.open) { links.delete(link.id!); conflicts.add(link.id!); return; }
|
|
20
|
+
links.set(link.id!, link as SettingsLink);
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
refresh() { links.clear(); conflicts.clear(); pi.events.emit("harness-settings:request", undefined); },
|
|
24
|
+
list() { return [...links.values()].sort((a, b) => a.label.localeCompare(b.label)); },
|
|
25
|
+
dispose() { stop(); links.clear(); conflicts.clear(); },
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -1,6 +1,126 @@
|
|
|
1
1
|
// Generated from packages/callback-batcher/index.ts. Do not edit directly.
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
2
7
|
export type CallbackSource = "subagent" | "background-task";
|
|
3
8
|
export type CallbackDetailTool = "subagent_result" | "bg_task_status";
|
|
9
|
+
export type CallbackDeliveryMode = "hold" | "steer";
|
|
10
|
+
|
|
11
|
+
export interface CallbackSettingsContext {
|
|
12
|
+
sessionManager?: { getBranch?(): readonly unknown[]; getSessionId?(): string };
|
|
13
|
+
ui?: { notify(message: string, type: "error"): void };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CallbackSettingsSeams {
|
|
17
|
+
agentDir?: () => string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CallbackSettings {
|
|
21
|
+
mode: CallbackDeliveryMode;
|
|
22
|
+
source: "session" | "default";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const CALLBACK_SETTINGS_ENTRY = "pi-better-callback-settings";
|
|
26
|
+
const CALLBACK_PREFERENCES_FILE = "pi-better-callback-preferences.json";
|
|
27
|
+
|
|
28
|
+
function isDeliveryMode(mode: unknown): mode is CallbackDeliveryMode {
|
|
29
|
+
return mode === "hold" || mode === "steer";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validCallbackPreferences(value: unknown): value is { version: 1; mode: CallbackDeliveryMode } {
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
34
|
+
const data = value as Record<string, unknown>;
|
|
35
|
+
return Object.keys(data).length === 2 && Object.hasOwn(data, "version") && Object.hasOwn(data, "mode")
|
|
36
|
+
&& data.version === 1 && isDeliveryMode(data.mode);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function callbackPreferencesPath(seams: CallbackSettingsSeams): string {
|
|
40
|
+
return join((seams.agentDir ?? getAgentDir)(), "extensions", CALLBACK_PREFERENCES_FILE);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Branch history is authoritative; abandoned branches and invalid entries are ignored. */
|
|
44
|
+
export function getCallbackSettings(
|
|
45
|
+
ctx: CallbackSettingsContext,
|
|
46
|
+
seams: CallbackSettingsSeams = {},
|
|
47
|
+
): CallbackSettings {
|
|
48
|
+
const branch = ctx.sessionManager?.getBranch?.() ?? [];
|
|
49
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
50
|
+
const entry = branch[i] as { type?: unknown; customType?: unknown; data?: unknown } | null;
|
|
51
|
+
if (entry?.type === "custom" && entry.customType === CALLBACK_SETTINGS_ENTRY && validCallbackPreferences(entry.data)) {
|
|
52
|
+
return { mode: entry.data.mode, source: "session" };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return getSessionDefault(ctx, seams);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getSessionDefault(ctx: CallbackSettingsContext, seams: CallbackSettingsSeams): CallbackSettings {
|
|
59
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
60
|
+
const defaults = globalState().sessionDefaults ??= new WeakMap<object, { sessionId?: string; mode: CallbackDeliveryMode }>();
|
|
61
|
+
const initial = ctx.sessionManager ? defaults.get(ctx.sessionManager) : undefined;
|
|
62
|
+
if (initial && initial.sessionId === sessionId) return { mode: initial.mode, source: "default" };
|
|
63
|
+
const snapshot = (mode: CallbackDeliveryMode): CallbackSettings => {
|
|
64
|
+
if (ctx.sessionManager) defaults.set(ctx.sessionManager, { sessionId, mode });
|
|
65
|
+
return { mode, source: "default" };
|
|
66
|
+
};
|
|
67
|
+
const path = callbackPreferencesPath(seams);
|
|
68
|
+
let text: string;
|
|
69
|
+
try {
|
|
70
|
+
text = readFileSync(path, "utf8");
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return snapshot("hold");
|
|
73
|
+
throw new Error(`Cannot read callback default at ${path}: ${String(error)}`, { cause: error });
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const data: unknown = JSON.parse(text);
|
|
77
|
+
if (!validCallbackPreferences(data)) throw new Error('Expected { version: 1, mode: "hold" | "steer" }');
|
|
78
|
+
return snapshot(data.mode);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new Error(`Invalid callback default at ${path}: ${String(error)}`, { cause: error });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Saves only the future-session default; never changes the active branch or batcher. */
|
|
85
|
+
export function saveCallbackDefault(mode: CallbackDeliveryMode, seams: CallbackSettingsSeams = {}): void {
|
|
86
|
+
if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing saved.");
|
|
87
|
+
const dir = join((seams.agentDir ?? getAgentDir)(), "extensions");
|
|
88
|
+
const path = join(dir, CALLBACK_PREFERENCES_FILE);
|
|
89
|
+
const tmp = join(dir, `.${CALLBACK_PREFERENCES_FILE}.${randomUUID()}.tmp`);
|
|
90
|
+
let created = false;
|
|
91
|
+
try {
|
|
92
|
+
mkdirSync(dir, { recursive: true });
|
|
93
|
+
const fd = openSync(tmp, "wx", 0o600);
|
|
94
|
+
created = true;
|
|
95
|
+
try { writeFileSync(fd, `${JSON.stringify({ version: 1, mode })}\n`); }
|
|
96
|
+
finally { closeSync(fd); }
|
|
97
|
+
renameSync(tmp, path);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new Error(`Cannot save callback default at ${path}: ${String(error)}`, { cause: error });
|
|
100
|
+
} finally {
|
|
101
|
+
if (created) rmSync(tmp, { force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Persist first so a failed append cannot become a transient delivery-mode change. */
|
|
106
|
+
export function changeCallbackSetting(
|
|
107
|
+
host: CallbackBatchHost & { appendEntry(customType: string, data: unknown): void },
|
|
108
|
+
ctx: CallbackSettingsContext & { isIdle(): boolean },
|
|
109
|
+
mode: CallbackDeliveryMode,
|
|
110
|
+
): void {
|
|
111
|
+
if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing changed.");
|
|
112
|
+
const data = { version: 1, mode };
|
|
113
|
+
try { host.appendEntry(CALLBACK_SETTINGS_ENTRY, data); }
|
|
114
|
+
catch (error) {
|
|
115
|
+
// Pi advances its in-memory branch before disk persistence. Invalidate
|
|
116
|
+
// our payload so that failed entry cannot become a restored setting.
|
|
117
|
+
data.version = 0;
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
const batcher = bindCallbackBatcher(host, ctx);
|
|
121
|
+
batcher.setWhileBusy(mode);
|
|
122
|
+
batcher.setAvailability(() => ctx.isIdle());
|
|
123
|
+
}
|
|
4
124
|
|
|
5
125
|
export interface CallbackBatchHost {
|
|
6
126
|
/** Older Pi shares this object; newer Pi supplies per-extension wrappers. */
|
|
@@ -70,6 +190,8 @@ export interface CallbackBatcherOptions {
|
|
|
70
190
|
retryMs?: number;
|
|
71
191
|
/** Ordinary callbacks stay here, not in Pi's follow-up queue, while unavailable. */
|
|
72
192
|
isAvailable?: () => boolean;
|
|
193
|
+
/** Internal initial mode. Session/default settings are restored with context. */
|
|
194
|
+
whileBusy?: CallbackDeliveryMode;
|
|
73
195
|
/** UTF-8 byte cap for one sendMessage payload. Defaults to 2 KiB. */
|
|
74
196
|
maxBytes?: number;
|
|
75
197
|
}
|
|
@@ -81,6 +203,14 @@ export interface CallbackBatcher {
|
|
|
81
203
|
setHost(host: CallbackBatchHost): void;
|
|
82
204
|
/** Update the session predicate and schedule an asynchronous drain when available. */
|
|
83
205
|
setAvailability(isAvailable: () => boolean): void;
|
|
206
|
+
/** Change delivery immediately without clearing pending callbacks or handoff receipts. */
|
|
207
|
+
setWhileBusy(mode: CallbackDeliveryMode): void;
|
|
208
|
+
/** Non-idle context operations are not foreground agent runs. */
|
|
209
|
+
setForegroundRunning(running: boolean): void;
|
|
210
|
+
/** Shared IDs make duplicate notifications from both extensions idempotent. */
|
|
211
|
+
toolStarted(toolCallId: string): void;
|
|
212
|
+
/** Await the final active tool's handoff before Pi checks its steering queue. */
|
|
213
|
+
toolEnded(toolCallId: string): Promise<boolean>;
|
|
84
214
|
deliverUrgent(event: UrgentCallbackEvent): boolean | Promise<boolean>;
|
|
85
215
|
cancel(): void;
|
|
86
216
|
pendingCount(): number;
|
|
@@ -104,6 +234,7 @@ interface PendingEvent {
|
|
|
104
234
|
interface SharedCallbackBatcherState {
|
|
105
235
|
byHost: WeakMap<object, CallbackBatcher>;
|
|
106
236
|
bySession?: WeakMap<object, CallbackBatcher>;
|
|
237
|
+
sessionDefaults?: WeakMap<object, { sessionId?: string; mode: CallbackDeliveryMode }>;
|
|
107
238
|
}
|
|
108
239
|
|
|
109
240
|
const GLOBAL_STATE_KEY = Symbol.for("@1aboveio/pi-better-harness/callback-batcher");
|
|
@@ -131,7 +262,6 @@ const RETRIEVAL_FOOTER =
|
|
|
131
262
|
|
|
132
263
|
export const CALLBACK_BATCH_WINDOW_ENV = "PI_BETTER_CALLBACK_BATCH_MS";
|
|
133
264
|
export const DEFAULT_CALLBACK_BATCH_WINDOW_MS = DEFAULT_WINDOW_MS;
|
|
134
|
-
|
|
135
265
|
export function resolveCallbackBatchWindowMs(
|
|
136
266
|
value: unknown = process.env[CALLBACK_BATCH_WINDOW_ENV],
|
|
137
267
|
): number {
|
|
@@ -436,26 +566,31 @@ export function createCallbackBatcher(
|
|
|
436
566
|
): CallbackBatcher {
|
|
437
567
|
const windowMs = options.windowMs ?? resolveCallbackBatchWindowMs();
|
|
438
568
|
const retryMs = Math.max(0, options.retryMs ?? DEFAULT_RETRY_MS);
|
|
569
|
+
let whileBusy = options.whileBusy ?? "hold";
|
|
439
570
|
const maxBytes = callbackBatchBudget(options.maxBytes);
|
|
440
571
|
const pending = new Map<string, PendingEvent>();
|
|
441
572
|
const inFlight = new Set<string>();
|
|
442
573
|
const urgentInFlight = new Set<string>();
|
|
443
574
|
const handedOff = new Map<string, number>();
|
|
575
|
+
const activeTools = new Set<string>();
|
|
444
576
|
let sequence = 0;
|
|
445
577
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
446
578
|
let flushPromise: Promise<boolean> | undefined;
|
|
447
579
|
let isAvailable = options.isAvailable ?? (() => true);
|
|
448
580
|
let gated = options.isAvailable !== undefined;
|
|
449
581
|
let awaitingRun = false;
|
|
582
|
+
let cancelled = false;
|
|
583
|
+
let foregroundRunning = false;
|
|
450
584
|
|
|
451
|
-
const
|
|
585
|
+
const deliveryMode = (): "followUp" | "steer" | undefined => {
|
|
586
|
+
if (cancelled) return undefined;
|
|
452
587
|
try {
|
|
453
588
|
if (!isAvailable()) {
|
|
454
589
|
awaitingRun = false;
|
|
455
|
-
return
|
|
590
|
+
return whileBusy === "steer" && foregroundRunning && activeTools.size === 0 ? "steer" : undefined;
|
|
456
591
|
}
|
|
457
|
-
return !awaitingRun;
|
|
458
|
-
} catch { return
|
|
592
|
+
return !awaitingRun ? "followUp" : undefined;
|
|
593
|
+
} catch { return undefined; }
|
|
459
594
|
};
|
|
460
595
|
|
|
461
596
|
const cancelTimer = (): void => {
|
|
@@ -466,7 +601,7 @@ export function createCallbackBatcher(
|
|
|
466
601
|
const schedule = (delayMs: number): void => {
|
|
467
602
|
if (timer || pending.size === 0) return;
|
|
468
603
|
// Some idle transitions (manual compaction) have no agent_settled event.
|
|
469
|
-
const delay =
|
|
604
|
+
const delay = deliveryMode() ? delayMs : retryMs;
|
|
470
605
|
timer = setTimeout(() => {
|
|
471
606
|
timer = undefined;
|
|
472
607
|
void api.flush();
|
|
@@ -485,7 +620,8 @@ export function createCallbackBatcher(
|
|
|
485
620
|
|
|
486
621
|
const performFlush = async (): Promise<boolean> => {
|
|
487
622
|
cancelTimer();
|
|
488
|
-
|
|
623
|
+
const mode = deliveryMode();
|
|
624
|
+
if (!mode) {
|
|
489
625
|
schedule(retryMs);
|
|
490
626
|
return false;
|
|
491
627
|
}
|
|
@@ -539,14 +675,16 @@ export function createCallbackBatcher(
|
|
|
539
675
|
try {
|
|
540
676
|
// Pi may defer the requested run while settled handlers finish, leaving
|
|
541
677
|
// isIdle() true. Permit no second handoff until a run starts.
|
|
542
|
-
awaitingRun = gated;
|
|
678
|
+
awaitingRun = mode === "followUp" && gated;
|
|
543
679
|
await host.sendMessage(
|
|
544
680
|
{
|
|
545
681
|
customType: "background-completion-batch",
|
|
546
682
|
content: packed.text,
|
|
547
683
|
display: true,
|
|
548
684
|
},
|
|
549
|
-
|
|
685
|
+
// Newer Pi requires triggerTurn for steering too; false only appends
|
|
686
|
+
// a custom message at the turn boundary without continuing the agent.
|
|
687
|
+
{ deliverAs: mode, triggerTurn: true },
|
|
550
688
|
);
|
|
551
689
|
} catch {
|
|
552
690
|
awaitingRun = false;
|
|
@@ -632,15 +770,42 @@ export function createCallbackBatcher(
|
|
|
632
770
|
setAvailability(check) {
|
|
633
771
|
gated = true;
|
|
634
772
|
isAvailable = check;
|
|
635
|
-
|
|
773
|
+
cancelled = false;
|
|
774
|
+
deliveryMode();
|
|
636
775
|
cancelTimer();
|
|
637
776
|
schedule(windowMs);
|
|
638
777
|
},
|
|
778
|
+
setWhileBusy(mode) {
|
|
779
|
+
if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing changed.");
|
|
780
|
+
whileBusy = mode;
|
|
781
|
+
cancelTimer();
|
|
782
|
+
schedule(windowMs);
|
|
783
|
+
},
|
|
784
|
+
setForegroundRunning(running) {
|
|
785
|
+
foregroundRunning = running;
|
|
786
|
+
cancelTimer();
|
|
787
|
+
schedule(windowMs);
|
|
788
|
+
},
|
|
789
|
+
toolStarted(toolCallId) {
|
|
790
|
+
activeTools.add(toolCallId);
|
|
791
|
+
},
|
|
792
|
+
async toolEnded(toolCallId) {
|
|
793
|
+
const ended = activeTools.delete(toolCallId);
|
|
794
|
+
if (activeTools.size > 0 || whileBusy !== "steer" || cancelled) return false;
|
|
795
|
+
if (!ended) return flushPromise ?? false;
|
|
796
|
+
// A timer may still be completing a deferred flush. Recheck after it so
|
|
797
|
+
// the tool boundary cannot lose the pending batch to that older attempt.
|
|
798
|
+
if (flushPromise) await flushPromise;
|
|
799
|
+
return pending.size > 0 ? api.flush() : true;
|
|
800
|
+
},
|
|
639
801
|
deliverUrgent,
|
|
640
802
|
cancel() {
|
|
641
803
|
cancelTimer();
|
|
642
804
|
pending.clear();
|
|
643
805
|
awaitingRun = false;
|
|
806
|
+
activeTools.clear();
|
|
807
|
+
foregroundRunning = false;
|
|
808
|
+
cancelled = true;
|
|
644
809
|
},
|
|
645
810
|
pendingCount() {
|
|
646
811
|
return pending.size;
|
|
@@ -671,11 +836,39 @@ export function cancelCallbackBatch(host: CallbackBatchHost): void {
|
|
|
671
836
|
batcher?.cancel();
|
|
672
837
|
}
|
|
673
838
|
|
|
674
|
-
/**
|
|
839
|
+
/** Restore current branch/default mode without resetting foreground tools or receipts. */
|
|
675
840
|
export function setCallbackBatchContext(
|
|
676
841
|
host: CallbackBatchHost,
|
|
677
|
-
ctx: { isIdle(): boolean
|
|
842
|
+
ctx: CallbackSettingsContext & { isIdle(): boolean },
|
|
843
|
+
seams: CallbackSettingsSeams = {},
|
|
678
844
|
): void {
|
|
845
|
+
const batcher = bindCallbackBatcher(host, ctx);
|
|
846
|
+
let mode: CallbackDeliveryMode = "hold";
|
|
847
|
+
try {
|
|
848
|
+
const settings = getCallbackSettings(ctx, seams);
|
|
849
|
+
if (settings.source === "session") {
|
|
850
|
+
// Capture the initial default even when an override currently hides it,
|
|
851
|
+
// so navigating to an unconfigured branch cannot inherit a later save.
|
|
852
|
+
try { getSessionDefault(ctx, seams); }
|
|
853
|
+
catch {
|
|
854
|
+
if (ctx.sessionManager) {
|
|
855
|
+
const defaults = globalState().sessionDefaults ??= new WeakMap();
|
|
856
|
+
defaults.set(ctx.sessionManager, { sessionId: ctx.sessionManager.getSessionId?.(), mode: "hold" });
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
mode = settings.mode;
|
|
861
|
+
} catch (error) {
|
|
862
|
+
try { ctx.ui?.notify(`Callback delivery restored to hold: ${String(error)}`, "error"); }
|
|
863
|
+
catch { /* A failing UI must not retain the prior session's delivery mode. */ }
|
|
864
|
+
}
|
|
865
|
+
batcher.setWhileBusy(mode);
|
|
866
|
+
// Schedule rather than await delivery inside agent_settled: its handlers are
|
|
867
|
+
// still settling the previous run, and sendMessage may start a new one.
|
|
868
|
+
batcher.setAvailability(() => ctx.isIdle());
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function bindCallbackBatcher(host: CallbackBatchHost, ctx: CallbackSettingsContext): CallbackBatcher {
|
|
679
872
|
const state = globalState();
|
|
680
873
|
const bySession = state.bySession ??= new WeakMap<object, CallbackBatcher>();
|
|
681
874
|
const shared = ctx.sessionManager ? bySession.get(ctx.sessionManager) : undefined;
|
|
@@ -683,9 +876,7 @@ export function setCallbackBatchContext(
|
|
|
683
876
|
if (ctx.sessionManager) bySession.set(ctx.sessionManager, batcher);
|
|
684
877
|
state.byHost.set(host.events ?? host, batcher);
|
|
685
878
|
batcher.setHost(host);
|
|
686
|
-
|
|
687
|
-
// still settling the previous run, and sendMessage may start a new one.
|
|
688
|
-
batcher.setAvailability(() => ctx.isIdle());
|
|
879
|
+
return batcher;
|
|
689
880
|
}
|
|
690
881
|
|
|
691
882
|
function globalState(): SharedCallbackBatcherState {
|