pi-better-harness 0.25.0 → 0.26.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 +33 -8
- package/extensions/minimal-output/hook.ts +106 -4
- package/extensions/minimal-output/index.ts +28 -4
- package/extensions/settings/page.ts +3 -3
- package/extensions/shared-harness-settings.ts +114 -0
- package/node_modules/pi-better-background-tasks/package.json +4 -1
- package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +23 -30
- package/node_modules/pi-better-background-tasks/src/shared-harness-settings.ts +114 -0
- package/node_modules/pi-better-goal/README.md +14 -0
- package/node_modules/pi-better-goal/package.json +2 -1
- package/node_modules/pi-better-goal/src/preferences.ts +32 -23
- package/node_modules/pi-better-goal/src/shared-harness-settings.ts +114 -0
- package/node_modules/pi-better-sandbox/README.md +37 -17
- package/node_modules/pi-better-sandbox/deny-rules.ts +34 -37
- package/node_modules/pi-better-sandbox/package.json +7 -4
- package/node_modules/pi-better-sandbox/permission-settings.ts +14 -20
- package/node_modules/pi-better-sandbox/preferences.ts +14 -25
- package/node_modules/pi-better-sandbox/shared-harness-settings.ts +114 -0
- package/node_modules/pi-better-subagents/README.md +9 -1
- package/node_modules/pi-better-subagents/agent-commands.ts +13 -11
- package/node_modules/pi-better-subagents/agent-operations.ts +6 -1
- package/node_modules/pi-better-subagents/config.ts +53 -35
- package/node_modules/pi-better-subagents/docs/usage.md +13 -2
- package/node_modules/pi-better-subagents/index.ts +5 -1
- package/node_modules/pi-better-subagents/package.json +2 -1
- package/node_modules/pi-better-subagents/shared-callback-batcher.ts +23 -30
- package/node_modules/pi-better-subagents/shared-harness-settings.ts +114 -0
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -76,7 +76,10 @@ stays quieter than conversation text. Tool rows are indented two columns past
|
|
|
76
76
|
the assistant text padding. Long
|
|
77
77
|
headers are truncated to the terminal width; result bodies,
|
|
78
78
|
images, boxes, and tool spacers are hidden. Built-in, extension, and MCP calls
|
|
79
|
-
are included.
|
|
79
|
+
are included. Tools without a custom call renderer use a generic icon and a
|
|
80
|
+
compact argument hint. Compaction summaries also collapse to one quiet row with
|
|
81
|
+
the pre-compaction token count; Ctrl+O reveals their original summary.
|
|
82
|
+
Execution, sandboxing, and agent-facing payloads are unchanged.
|
|
80
83
|
When a foreground run ends, each consecutive block of tool calls folds further
|
|
81
84
|
into one disclosure row showing the call count and any failures. Only the failure
|
|
82
85
|
count uses the error color; the disclosure and total stay muted. Assistant text
|
|
@@ -91,8 +94,17 @@ all original tool details in both modes and folds them again on the next toggle.
|
|
|
91
94
|
`/tool-output normal` restores ordinary rendering. Error result bodies are also
|
|
92
95
|
hidden in minimal mode and remain available when expanded.
|
|
93
96
|
|
|
94
|
-
|
|
95
|
-
|
|
97
|
+
Minimal mode also covers Harness tools: Subagents, Background Tasks, Goal, Plan,
|
|
98
|
+
catalog inspection, and SSH. Compact calls retain the run or task ID, launch name
|
|
99
|
+
and task, background command, goal action, or plan summary without showing result
|
|
100
|
+
bodies. Finished calls fold into the same disclosure blocks as built-in tools;
|
|
101
|
+
expanding them restores their original package renderers. Goal and Plan widgets
|
|
102
|
+
and the background-work navigator are independent UI surfaces and remain visible.
|
|
103
|
+
|
|
104
|
+
Normal mode is the initial default. Changing tool output saves the choice in
|
|
105
|
+
global `settings.json` under `piBetterHarness.toolOutput`, as well as the current
|
|
106
|
+
session. New sessions inherit it; resumed branches retain their own saved choice.
|
|
107
|
+
This is a version-sensitive internal TUI adapter,
|
|
96
108
|
tested with Pi 0.82.1, 0.99.1, and the bundled Pi 1.0.0 CLI; incompatible APIs
|
|
97
109
|
produce a warning and leave ordinary output enabled. Print/RPC output and
|
|
98
110
|
exported transcripts are unchanged.
|
|
@@ -108,13 +120,14 @@ pi -e ./packages/pi-better-harness/extensions/minimal-output/index.ts
|
|
|
108
120
|
|
|
109
121
|
The bundled harness provides `/harness-settings`, using Pi's native settings
|
|
110
122
|
list. It opens the settings screens of loaded Sandbox, Subagents, and Goal
|
|
111
|
-
packages without duplicating their configuration
|
|
112
|
-
|
|
123
|
+
packages without duplicating their configuration, plus the **Agents** shortcut
|
|
124
|
+
to the `/agents` catalog. `/sandbox`, `/subagents settings`, `/agents`, and
|
|
125
|
+
`/goal settings` remain available in standalone installations.
|
|
113
126
|
Packages without a settings screen are not listed. Pi's `/settings` is unchanged.
|
|
114
127
|
|
|
115
128
|
The hub includes **Tool output** when the bundled renderer extension is loaded.
|
|
116
|
-
Changes apply immediately
|
|
117
|
-
|
|
129
|
+
Changes apply immediately and also save the default for future sessions.
|
|
130
|
+
The hub also owns **Completions while busy**, shared by Subagents and Background
|
|
118
131
|
Tasks. Choose **Wait until idle** (the default) or **Steer active run**. Changes
|
|
119
132
|
apply immediately and autosave to the current session branch, including across
|
|
120
133
|
reloads. Press **Ctrl+S** to save the current choice as your default for future
|
|
@@ -122,10 +135,22 @@ Pi sessions; changing a session afterward does not change that saved default.
|
|
|
122
135
|
Saving a default leaves already-open sessions unchanged, including when
|
|
123
136
|
navigating to a branch without a session override.
|
|
124
137
|
The user default is stored in
|
|
125
|
-
`<agent-dir>/
|
|
138
|
+
`<agent-dir>/settings.json` under `piBetterHarness.callbacks` and also applies
|
|
126
139
|
when either callback package is loaded standalone. The former
|
|
127
140
|
`PI_BETTER_CALLBACK_WHILE_BUSY` environment variable is no longer supported.
|
|
128
141
|
|
|
142
|
+
All Harness-owned defaults use the `piBetterHarness` section of global
|
|
143
|
+
`~/.pi/agent/settings.json` (or `PI_CODING_AGENT_DIR/settings.json`): tool output,
|
|
144
|
+
Subagents configuration, callback delivery, Goal controls, and Sandbox activation,
|
|
145
|
+
permissions, and deny-rule templates. Existing preference files migrate on first
|
|
146
|
+
use after validation; global choices take precedence, and old files remain intact.
|
|
147
|
+
Updates preserve Pi's own settings and other packages' choices. SSH profiles,
|
|
148
|
+
plans, run records, and role/agent definitions remain in their existing stores:
|
|
149
|
+
they are session data or reusable definitions, not global UI defaults.
|
|
150
|
+
In `/agents`, model and effort edits are session-local until Ctrl+S saves them
|
|
151
|
+
to the user or project catalog definition. Saved catalog defaults also survive
|
|
152
|
+
sessions and upgrades; they are not duplicated in global `settings.json`.
|
|
153
|
+
|
|
129
154
|
Next-prompt inference is deferred pending safe public auth/header resolution in
|
|
130
155
|
Pi's SDK. It is not loaded by the bundle, has no toggle or preference store in
|
|
131
156
|
this version, and makes no auxiliary model requests. The integration blocker is
|
|
@@ -16,6 +16,7 @@ interface ToolComponent {
|
|
|
16
16
|
toolCallId: string;
|
|
17
17
|
args: unknown;
|
|
18
18
|
callRendererComponent?: Component;
|
|
19
|
+
getCallRenderer?(): unknown;
|
|
19
20
|
expanded: boolean;
|
|
20
21
|
isPartial?: boolean;
|
|
21
22
|
result?: { isError?: boolean };
|
|
@@ -33,6 +34,14 @@ interface TranscriptComponent extends Component {
|
|
|
33
34
|
lastMessage?: { content: { type: string; text?: string; thinking?: string }[]; stopReason?: string };
|
|
34
35
|
}
|
|
35
36
|
interface ToolGroup { members: ToolComponent[]; open: boolean; expanded: number }
|
|
37
|
+
interface CompactionComponent extends Component {
|
|
38
|
+
expanded: boolean;
|
|
39
|
+
message: { tokensBefore: number };
|
|
40
|
+
setExpanded(expanded: boolean): void;
|
|
41
|
+
}
|
|
42
|
+
interface CompactionPrototype {
|
|
43
|
+
render: CompactionComponent["render"];
|
|
44
|
+
}
|
|
36
45
|
|
|
37
46
|
type Method = (this: ToolComponent, ...args: unknown[]) => unknown;
|
|
38
47
|
interface ToolMouseEvent { type: string; button: string; x: number; y: number; screenX: number; screenY: number; width: number; shift?: boolean; alt?: boolean; ctrl?: boolean }
|
|
@@ -73,12 +82,62 @@ const TOOL_ICONS: Record<string, string> = {
|
|
|
73
82
|
grep: "\u2315",
|
|
74
83
|
find: "\u2316",
|
|
75
84
|
ls: "\u2261",
|
|
85
|
+
subagent_spawn: "\u25c7",
|
|
86
|
+
subagent_spawn_batch: "\u25c7",
|
|
87
|
+
bg_task_spawn: "\u2318",
|
|
88
|
+
bg_task_watch: "\u2318",
|
|
89
|
+
bg_task: "\u2318",
|
|
90
|
+
update_goal: "\u25ce",
|
|
91
|
+
get_goal: "\u25ce",
|
|
92
|
+
goal_resume: "\u25ce",
|
|
93
|
+
update_plan: "\u2261",
|
|
94
|
+
get_plan: "\u2261",
|
|
95
|
+
remote_bash: "\u2318",
|
|
76
96
|
};
|
|
77
97
|
|
|
98
|
+
function harnessCallDetail(name: string, input: unknown): string | undefined {
|
|
99
|
+
const args = input && typeof input === "object" ? input as Record<string, unknown> : {};
|
|
100
|
+
const text = (value: unknown): string => typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
101
|
+
const joinDetails = (...values: unknown[]) => values.map(text).filter(Boolean).join(" \u00b7 ");
|
|
102
|
+
const host = () => {
|
|
103
|
+
const ssh = args.ssh && typeof args.ssh === "object" ? args.ssh as Record<string, unknown> : args;
|
|
104
|
+
return text(ssh.host) ? `${text(ssh.user) ? `${text(ssh.user)}@` : ""}${text(ssh.host)}` : "";
|
|
105
|
+
};
|
|
106
|
+
switch (name) {
|
|
107
|
+
case "subagent_spawn":
|
|
108
|
+
return joinDetails(args.agent ?? args.role, args.alias ?? args.name, args.prompt);
|
|
109
|
+
case "subagent_spawn_batch":
|
|
110
|
+
return joinDetails(args.batchName, `${Array.isArray(args.jobs) ? args.jobs.length : 0} jobs`);
|
|
111
|
+
case "subagent_result": case "subagent_output": case "subagent_stop":
|
|
112
|
+
case "bg_task_status": case "bg_task_log": case "bg_task_stop":
|
|
113
|
+
return text(args.id);
|
|
114
|
+
case "subagent_list": case "bg_task_list":
|
|
115
|
+
return joinDetails(Array.isArray(args.status) ? args.status.map(text).filter(Boolean).join(", ") : args.status,
|
|
116
|
+
args.all === true ? "all sessions" : "");
|
|
117
|
+
case "agents_catalog":
|
|
118
|
+
return joinDetails(args.action, args.id);
|
|
119
|
+
case "bg_task_spawn": case "bg_task_watch": case "bg_task": case "bg_status":
|
|
120
|
+
return joinDetails(args.action, args.id, args.name, host(), args.command ??
|
|
121
|
+
(Array.isArray(args.argv) ? args.argv.map(text).filter(Boolean).join(" ") : undefined));
|
|
122
|
+
case "update_goal":
|
|
123
|
+
return text(args.status);
|
|
124
|
+
case "goal_resume":
|
|
125
|
+
return text(args.reason);
|
|
126
|
+
case "get_goal": case "get_background_activity": case "release_workflow": case "get_plan":
|
|
127
|
+
return "";
|
|
128
|
+
case "remote_bash":
|
|
129
|
+
return joinDetails(host(), args.command);
|
|
130
|
+
case "ssh_profile": case "ssh_mux":
|
|
131
|
+
return joinDetails(args.action, host(), args.all === true ? "all hosts" : "");
|
|
132
|
+
default:
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
78
137
|
function compactCall(component: ToolComponent, width: number, theme?: Theme, indent = 3): string[] {
|
|
79
138
|
if (width <= 0) return [];
|
|
80
139
|
// Render the call alone at a wider width so terminal wrapping becomes truncation.
|
|
81
|
-
const header = component.callRendererComponent?.render(Math.max(4096, width + 1))
|
|
140
|
+
const header = Boolean(component.getCallRenderer?.()) && component.callRendererComponent?.render(Math.max(4096, width + 1))
|
|
82
141
|
.map((line) => stripVTControlCharacters(line).trim())
|
|
83
142
|
.find((line) => line.length > 0);
|
|
84
143
|
let text = header;
|
|
@@ -90,8 +149,9 @@ function compactCall(component: ToolComponent, width: number, theme?: Theme, ind
|
|
|
90
149
|
}
|
|
91
150
|
const failed = component.result?.isError === true;
|
|
92
151
|
const running = !component.result || component.isPartial;
|
|
93
|
-
const detail =
|
|
94
|
-
|
|
152
|
+
const detail = (component.getCallRenderer?.() ? undefined : harnessCallDetail(component.toolName, component.args)) ??
|
|
153
|
+
(text.startsWith(`${component.toolName} `) ? text.slice(component.toolName.length).trimStart()
|
|
154
|
+
: text === component.toolName ? "" : text);
|
|
95
155
|
const label = component.toolName === "bash" ? "Shell"
|
|
96
156
|
: component.toolName.replace(/_/g, " ").replace(/^./, letter => letter.toUpperCase());
|
|
97
157
|
const suffix = failed ? " (failed)" : running ? " (running)" : "";
|
|
@@ -159,6 +219,16 @@ export async function loadToolPrototype(): Promise<ToolPrototype> {
|
|
|
159
219
|
return module.ToolExecutionComponent.prototype;
|
|
160
220
|
}
|
|
161
221
|
|
|
222
|
+
export async function loadCompactionPrototype(): Promise<CompactionPrototype> {
|
|
223
|
+
const entry = sdkEntry(hostRequire());
|
|
224
|
+
const path = bundledHostEntry(entry) ?? join(dirname(entry), "modes/interactive/components/compaction-summary-message.js");
|
|
225
|
+
const module = await import(pathToFileURL(path).href);
|
|
226
|
+
if (!module.CompactionSummaryMessageComponent?.prototype) {
|
|
227
|
+
throw new Error("Pi's runtime does not expose its compaction display class.");
|
|
228
|
+
}
|
|
229
|
+
return module.CompactionSummaryMessageComponent.prototype;
|
|
230
|
+
}
|
|
231
|
+
|
|
162
232
|
// Pi's ESM-only export cannot be resolved with require.resolve on older SDKs.
|
|
163
233
|
function sdkEntry(require: ReturnType<typeof createRequire>): string {
|
|
164
234
|
for (const directory of require.resolve.paths("@earendil-works/pi-coding-agent") ?? []) {
|
|
@@ -173,7 +243,7 @@ function sdkEntry(require: ReturnType<typeof createRequire>): string {
|
|
|
173
243
|
}
|
|
174
244
|
|
|
175
245
|
/** Internal TUI adapter: no tools are replaced and no result content is changed. */
|
|
176
|
-
export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: () => Theme): MinimalOutputHook {
|
|
246
|
+
export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: () => Theme, compactionPrototype?: CompactionPrototype): MinimalOutputHook {
|
|
177
247
|
for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "invalidate", "render"] as const) {
|
|
178
248
|
if (typeof prototype[name] !== "function") {
|
|
179
249
|
throw new Error(`Pi's tool display API is incompatible: missing ${name}. Normal output remains enabled.`);
|
|
@@ -307,6 +377,38 @@ export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: ()
|
|
|
307
377
|
},
|
|
308
378
|
};
|
|
309
379
|
const current = state;
|
|
380
|
+
const compactions = new Set<WeakRef<CompactionComponent>>();
|
|
381
|
+
const seenCompactions = new WeakSet<CompactionComponent>();
|
|
382
|
+
if (compactionPrototype) {
|
|
383
|
+
const originalRender = compactionPrototype.render;
|
|
384
|
+
const render = function(this: CompactionComponent, width: number): string[] {
|
|
385
|
+
if (!seenCompactions.has(this)) { seenCompactions.add(this); compactions.add(new WeakRef(this)); }
|
|
386
|
+
if (!current.enabled || this.expanded) return originalRender.call(this, width);
|
|
387
|
+
const detail = `from ${this.message.tokensBefore.toLocaleString()} tokens`;
|
|
388
|
+
const theme = getTheme?.();
|
|
389
|
+
const text = theme
|
|
390
|
+
? ` ${theme.fg("muted", "\u25c7")} ${theme.fg("muted", "Compaction")} ${theme.fg("dim", detail)}`
|
|
391
|
+
: ` \u25c7 Compaction ${detail}`;
|
|
392
|
+
return width > 0 ? [truncateToWidth(`${" ".repeat(Math.min(3, width - 1))}${text}`, width)] : [];
|
|
393
|
+
};
|
|
394
|
+
compactionPrototype.render = render;
|
|
395
|
+
const redraw = current.redraw;
|
|
396
|
+
current.redraw = function(collapse) {
|
|
397
|
+
for (const reference of compactions) {
|
|
398
|
+
const component = reference.deref();
|
|
399
|
+
if (!component) { compactions.delete(reference); continue; }
|
|
400
|
+
if (collapse) component.setExpanded(false);
|
|
401
|
+
else component.invalidate();
|
|
402
|
+
}
|
|
403
|
+
redraw.call(this, collapse);
|
|
404
|
+
};
|
|
405
|
+
const restore = current.restore;
|
|
406
|
+
current.restore = function() {
|
|
407
|
+
if (compactionPrototype.render === render) compactionPrototype.render = originalRender;
|
|
408
|
+
compactions.clear();
|
|
409
|
+
restore.call(this);
|
|
410
|
+
};
|
|
411
|
+
}
|
|
310
412
|
const wrappers = {
|
|
311
413
|
updateDisplay(this: ToolComponent, ...args: unknown[]) {
|
|
312
414
|
if (typeof this.ui.handleViewportInput === "function" && !inputWrappers.has(this.ui)) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { installMinimalOutputHook, loadToolPrototype, type MinimalOutputHook } from "./hook.ts";
|
|
2
|
+
import { installMinimalOutputHook, loadToolPrototype, loadCompactionPrototype, type MinimalOutputHook } from "./hook.ts";
|
|
3
3
|
import type { SettingsControl } from "../settings/registry.ts";
|
|
4
|
+
import { readHarnessSetting, updateHarnessSetting } from "../shared-harness-settings.ts";
|
|
4
5
|
|
|
5
6
|
const ENTRY = "pi-better-harness-tool-output";
|
|
6
7
|
|
|
@@ -8,6 +9,16 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
8
9
|
let hook: MinimalOutputHook | undefined;
|
|
9
10
|
let enabled = false;
|
|
10
11
|
let running = false;
|
|
12
|
+
let initialDefault: boolean | undefined;
|
|
13
|
+
|
|
14
|
+
function defaultEnabled(): boolean {
|
|
15
|
+
if (initialDefault !== undefined) return initialDefault;
|
|
16
|
+
const value = readHarnessSetting<{ version?: unknown; enabled?: unknown }>("toolOutput");
|
|
17
|
+
if (value !== undefined && (value?.version !== 1 || typeof value.enabled !== "boolean")) {
|
|
18
|
+
throw new Error("Invalid tool-output default in global settings.json.");
|
|
19
|
+
}
|
|
20
|
+
return initialDefault = value?.enabled === true;
|
|
21
|
+
}
|
|
11
22
|
|
|
12
23
|
function status(ctx: ExtensionContext): void {
|
|
13
24
|
ctx.ui.setStatus(ENTRY, enabled ? "tools: minimal" : undefined);
|
|
@@ -26,7 +37,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
26
37
|
}
|
|
27
38
|
if (hook) return true;
|
|
28
39
|
try {
|
|
29
|
-
hook = installMinimalOutputHook(await loadToolPrototype(), () => ctx.ui.theme);
|
|
40
|
+
hook = installMinimalOutputHook(await loadToolPrototype(), () => ctx.ui.theme, await loadCompactionPrototype());
|
|
30
41
|
return true;
|
|
31
42
|
} catch (error) {
|
|
32
43
|
ctx.ui.notify(`Minimal tool output is unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
@@ -36,7 +47,11 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
36
47
|
|
|
37
48
|
async function restore(ctx: ExtensionContext): Promise<void> {
|
|
38
49
|
if (ctx.mode !== "tui") return;
|
|
39
|
-
enabled =
|
|
50
|
+
try { enabled = defaultEnabled(); }
|
|
51
|
+
catch (error) {
|
|
52
|
+
enabled = false;
|
|
53
|
+
ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
54
|
+
}
|
|
40
55
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
41
56
|
if (entry.type === "custom" && entry.customType === ENTRY) {
|
|
42
57
|
const data = entry.data as { version?: unknown; enabled?: unknown } | undefined;
|
|
@@ -59,6 +74,14 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
59
74
|
const data = { version: 1, enabled: value === "Minimal" };
|
|
60
75
|
try { pi.appendEntry(ENTRY, data); }
|
|
61
76
|
catch (error) { data.version = 0; data.enabled = enabled; throw error; }
|
|
77
|
+
try { updateHarnessSetting("toolOutput", () => ({ version: 1, enabled: data.enabled })); }
|
|
78
|
+
catch (error) {
|
|
79
|
+
data.version = 0;
|
|
80
|
+
data.enabled = enabled;
|
|
81
|
+
// The session entry may already be on disk; restore its prior effective value too.
|
|
82
|
+
pi.appendEntry(ENTRY, { version: 1, enabled });
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
62
85
|
enabled = data.enabled;
|
|
63
86
|
if (enabled) collapseTools(ctx);
|
|
64
87
|
hook!.setEnabled(enabled);
|
|
@@ -75,7 +98,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
75
98
|
const subscribe = () => { stopSettings ??= pi.events.on("harness-settings:request", register); };
|
|
76
99
|
subscribe();
|
|
77
100
|
register();
|
|
78
|
-
pi.on("session_start", async (_event, ctx) => { subscribe(); await restore(ctx); register(); });
|
|
101
|
+
pi.on("session_start", async (_event, ctx) => { initialDefault = undefined; subscribe(); await restore(ctx); register(); });
|
|
79
102
|
pi.on("session_tree", async (_event, ctx) => { await restore(ctx); });
|
|
80
103
|
pi.on("agent_start", () => { running = true; });
|
|
81
104
|
pi.on("agent_settled", (_event, ctx) => {
|
|
@@ -91,6 +114,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
91
114
|
hook = undefined;
|
|
92
115
|
enabled = false;
|
|
93
116
|
running = false;
|
|
117
|
+
initialDefault = undefined;
|
|
94
118
|
});
|
|
95
119
|
|
|
96
120
|
pi.registerCommand("tool-output", {
|
|
@@ -23,7 +23,7 @@ export async function chooseHarnessSetting(ctx: any, links: SettingsLink[], sele
|
|
|
23
23
|
} : undefined;
|
|
24
24
|
const items: SettingItem[] = [
|
|
25
25
|
...(callbackSetting ? [callbackSetting] : []),
|
|
26
|
-
...controls.map(control => ({ id: `setting:${control.id}`, label: control.label, currentValue: control.get(), description: "
|
|
26
|
+
...controls.map(control => ({ id: `setting:${control.id}`, label: control.label, currentValue: control.get(), description: "User default", values: control.values })),
|
|
27
27
|
...links.map(link => ({ id: `link:${link.id}`, label: link.label, currentValue: link.command, description: "Open package settings", values: [link.command] })),
|
|
28
28
|
];
|
|
29
29
|
const feedback = new Text("", 0, 0);
|
|
@@ -41,10 +41,10 @@ export async function chooseHarnessSetting(ctx: any, links: SettingsLink[], sele
|
|
|
41
41
|
if (control) {
|
|
42
42
|
const previous = control.get();
|
|
43
43
|
changing = true;
|
|
44
|
-
report("Saving
|
|
44
|
+
report("Saving setting...");
|
|
45
45
|
Promise.resolve().then(() => control.change(value, ctx)).then(() => {
|
|
46
46
|
list.updateValue(id, control.get());
|
|
47
|
-
report("
|
|
47
|
+
report("Setting saved.");
|
|
48
48
|
}).catch(error => {
|
|
49
49
|
list.updateValue(id, previous);
|
|
50
50
|
report(error instanceof Error ? error.message : String(error), true);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Generated from packages/harness-settings/index.ts. Do not edit directly.
|
|
2
|
+
import { existsSync, lstatSync, realpathSync, readFileSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
// Match Pi's settings lock without depending on a private SDK import.
|
|
9
|
+
const lockfile = createRequire(import.meta.url)("proper-lockfile") as {
|
|
10
|
+
lockSync(path: string, options: { realpath: boolean }): () => void;
|
|
11
|
+
};
|
|
12
|
+
const NAMESPACE = "piBetterHarness";
|
|
13
|
+
export interface HarnessSettingsSeams { agentDir?: () => string }
|
|
14
|
+
|
|
15
|
+
export function harnessSettingsPath(seams: HarnessSettingsSeams = {}): string {
|
|
16
|
+
return join((seams.agentDir ?? getAgentDir)(), "settings.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function object(value: unknown, label: string): Record<string, unknown> {
|
|
20
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be a JSON object.`);
|
|
21
|
+
return value as Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readSettings(path: string): Record<string, unknown> {
|
|
25
|
+
try { return object(JSON.parse(readFileSync(path, "utf8")), `Settings at ${path}`); }
|
|
26
|
+
catch (error) {
|
|
27
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function section(settings: Record<string, unknown>): Record<string, unknown> {
|
|
33
|
+
return Object.hasOwn(settings, NAMESPACE) ? object(settings[NAMESPACE], NAMESPACE) : {};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function acquire(path: string): () => void {
|
|
37
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
38
|
+
try { return lockfile.lockSync(path, { realpath: false }); }
|
|
39
|
+
catch (error) {
|
|
40
|
+
if ((error as NodeJS.ErrnoException).code !== "ELOCKED" || attempt === 9) throw error;
|
|
41
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`Cannot lock settings at ${path}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function lockedRead(path: string): Record<string, unknown> {
|
|
48
|
+
if (!existsSync(path)) return {};
|
|
49
|
+
const release = acquire(path);
|
|
50
|
+
try { return readSettings(path); } finally { release(); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function writeDestination(path: string): string {
|
|
54
|
+
let stat;
|
|
55
|
+
try { stat = lstatSync(path); }
|
|
56
|
+
catch (error) {
|
|
57
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return path;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
// Keep managed dotfiles links intact. A dangling link fails rather than being replaced.
|
|
61
|
+
return stat.isSymbolicLink() ? realpathSync(path) : path;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function mutate<T>(seams: HarnessSettingsSeams, change: (values: Record<string, unknown>) => T): T {
|
|
65
|
+
const path = harnessSettingsPath(seams);
|
|
66
|
+
mkdirSync((seams.agentDir ?? getAgentDir)(), { recursive: true });
|
|
67
|
+
const release = acquire(path);
|
|
68
|
+
let pending: string | undefined;
|
|
69
|
+
try {
|
|
70
|
+
// Read inside the lock so another session's settings are never replaced by a stale snapshot.
|
|
71
|
+
const settings = readSettings(path);
|
|
72
|
+
const values = section(settings);
|
|
73
|
+
const result = change(values);
|
|
74
|
+
settings[NAMESPACE] = values;
|
|
75
|
+
const destination = writeDestination(path);
|
|
76
|
+
pending = `${destination}.${randomUUID()}.tmp`;
|
|
77
|
+
writeFileSync(pending, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
78
|
+
renameSync(pending, destination);
|
|
79
|
+
return result;
|
|
80
|
+
} finally {
|
|
81
|
+
try { if (pending) rmSync(pending, { force: true }); } finally { release(); }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function readHarnessSetting<T = unknown>(
|
|
86
|
+
key: string,
|
|
87
|
+
seams: HarnessSettingsSeams = {},
|
|
88
|
+
legacy?: { path: string; parse: (value: unknown) => T },
|
|
89
|
+
): T | undefined {
|
|
90
|
+
const values = section(lockedRead(harnessSettingsPath(seams)));
|
|
91
|
+
if (Object.hasOwn(values, key)) return values[key] as T;
|
|
92
|
+
if (!legacy) return undefined;
|
|
93
|
+
let raw: string;
|
|
94
|
+
try { raw = readFileSync(legacy.path, "utf8"); }
|
|
95
|
+
catch (error) {
|
|
96
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
const migrated = legacy.parse(JSON.parse(raw));
|
|
100
|
+
return mutate(seams, current => {
|
|
101
|
+
if (!Object.hasOwn(current, key)) current[key] = migrated;
|
|
102
|
+
return current[key] as T;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function updateHarnessSetting<T>(
|
|
107
|
+
key: string, update: (current: unknown) => T, seams: HarnessSettingsSeams = {},
|
|
108
|
+
): T {
|
|
109
|
+
return mutate(seams, values => {
|
|
110
|
+
const next = update(values[key]);
|
|
111
|
+
values[key] = next;
|
|
112
|
+
return next;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-background-tasks",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -58,5 +58,8 @@
|
|
|
58
58
|
"typebox": "^1.1.38",
|
|
59
59
|
"typescript": "^5.7.0",
|
|
60
60
|
"vitest": "^3.0.0"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"proper-lockfile": "^4.1.2"
|
|
61
64
|
}
|
|
62
65
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
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
2
|
import { join } from "node:path";
|
|
5
3
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
harnessSettingsPath,
|
|
6
|
+
readHarnessSetting,
|
|
7
|
+
updateHarnessSetting,
|
|
8
|
+
type HarnessSettingsSeams,
|
|
9
|
+
} from "./shared-harness-settings.ts";
|
|
6
10
|
|
|
7
11
|
export type CallbackSource = "subagent" | "background-task";
|
|
8
12
|
export type CallbackDetailTool = "subagent_result" | "bg_task_status";
|
|
@@ -13,9 +17,7 @@ export interface CallbackSettingsContext {
|
|
|
13
17
|
ui?: { notify(message: string, type: "error"): void };
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
export
|
|
17
|
-
agentDir?: () => string;
|
|
18
|
-
}
|
|
20
|
+
export type CallbackSettingsSeams = HarnessSettingsSeams;
|
|
19
21
|
|
|
20
22
|
export interface CallbackSettings {
|
|
21
23
|
mode: CallbackDeliveryMode;
|
|
@@ -36,7 +38,12 @@ function validCallbackPreferences(value: unknown): value is { version: 1; mode:
|
|
|
36
38
|
&& data.version === 1 && isDeliveryMode(data.mode);
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
function
|
|
41
|
+
function parseCallbackPreferences(value: unknown): { version: 1; mode: CallbackDeliveryMode } {
|
|
42
|
+
if (!validCallbackPreferences(value)) throw new Error('Expected { version: 1, mode: "hold" | "steer" }');
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function legacyCallbackPreferencesPath(seams: CallbackSettingsSeams): string {
|
|
40
47
|
return join((seams.agentDir ?? getAgentDir)(), "extensions", CALLBACK_PREFERENCES_FILE);
|
|
41
48
|
}
|
|
42
49
|
|
|
@@ -64,41 +71,27 @@ function getSessionDefault(ctx: CallbackSettingsContext, seams: CallbackSettings
|
|
|
64
71
|
if (ctx.sessionManager) defaults.set(ctx.sessionManager, { sessionId, mode });
|
|
65
72
|
return { mode, source: "default" };
|
|
66
73
|
};
|
|
67
|
-
const path =
|
|
68
|
-
let text: string;
|
|
74
|
+
const path = harnessSettingsPath(seams);
|
|
69
75
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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);
|
|
76
|
+
const data = readHarnessSetting<{ version: 1; mode: CallbackDeliveryMode }>("callbacks", seams, {
|
|
77
|
+
path: legacyCallbackPreferencesPath(seams),
|
|
78
|
+
parse: parseCallbackPreferences,
|
|
79
|
+
});
|
|
80
|
+
return snapshot(data === undefined ? "hold" : parseCallbackPreferences(data).mode);
|
|
79
81
|
} catch (error) {
|
|
80
|
-
|
|
82
|
+
const prefix = (error as NodeJS.ErrnoException).code ? "Cannot read" : "Invalid";
|
|
83
|
+
throw new Error(`${prefix} callback default at ${path}: ${String(error)}`, { cause: error });
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/** Saves only the future-session default; never changes the active branch or batcher. */
|
|
85
88
|
export function saveCallbackDefault(mode: CallbackDeliveryMode, seams: CallbackSettingsSeams = {}): void {
|
|
86
89
|
if (!isDeliveryMode(mode)) throw new Error("Invalid callback delivery mode. Nothing saved.");
|
|
87
|
-
const
|
|
88
|
-
const path = join(dir, CALLBACK_PREFERENCES_FILE);
|
|
89
|
-
const tmp = join(dir, `.${CALLBACK_PREFERENCES_FILE}.${randomUUID()}.tmp`);
|
|
90
|
-
let created = false;
|
|
90
|
+
const path = harnessSettingsPath(seams);
|
|
91
91
|
try {
|
|
92
|
-
|
|
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);
|
|
92
|
+
updateHarnessSetting("callbacks", () => ({ version: 1, mode }), seams);
|
|
98
93
|
} catch (error) {
|
|
99
94
|
throw new Error(`Cannot save callback default at ${path}: ${String(error)}`, { cause: error });
|
|
100
|
-
} finally {
|
|
101
|
-
if (created) rmSync(tmp, { force: true });
|
|
102
95
|
}
|
|
103
96
|
}
|
|
104
97
|
|