pi-better-harness 0.25.0 → 0.26.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 +29 -7
- package/extensions/minimal-output/hook.ts +54 -2
- package/extensions/minimal-output/index.ts +26 -2
- 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
|
@@ -91,8 +91,17 @@ all original tool details in both modes and folds them again on the next toggle.
|
|
|
91
91
|
`/tool-output normal` restores ordinary rendering. Error result bodies are also
|
|
92
92
|
hidden in minimal mode and remain available when expanded.
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
Minimal mode also covers Harness tools: Subagents, Background Tasks, Goal, Plan,
|
|
95
|
+
catalog inspection, and SSH. Compact calls retain the run or task ID, launch name
|
|
96
|
+
and task, background command, goal action, or plan summary without showing result
|
|
97
|
+
bodies. Finished calls fold into the same disclosure blocks as built-in tools;
|
|
98
|
+
expanding them restores their original package renderers. Goal and Plan widgets
|
|
99
|
+
and the background-work navigator are independent UI surfaces and remain visible.
|
|
100
|
+
|
|
101
|
+
Normal mode is the initial default. Changing tool output saves the choice in
|
|
102
|
+
global `settings.json` under `piBetterHarness.toolOutput`, as well as the current
|
|
103
|
+
session. New sessions inherit it; resumed branches retain their own saved choice.
|
|
104
|
+
This is a version-sensitive internal TUI adapter,
|
|
96
105
|
tested with Pi 0.82.1, 0.99.1, and the bundled Pi 1.0.0 CLI; incompatible APIs
|
|
97
106
|
produce a warning and leave ordinary output enabled. Print/RPC output and
|
|
98
107
|
exported transcripts are unchanged.
|
|
@@ -108,13 +117,14 @@ pi -e ./packages/pi-better-harness/extensions/minimal-output/index.ts
|
|
|
108
117
|
|
|
109
118
|
The bundled harness provides `/harness-settings`, using Pi's native settings
|
|
110
119
|
list. It opens the settings screens of loaded Sandbox, Subagents, and Goal
|
|
111
|
-
packages without duplicating their configuration
|
|
112
|
-
|
|
120
|
+
packages without duplicating their configuration, plus the **Agents** shortcut
|
|
121
|
+
to the `/agents` catalog. `/sandbox`, `/subagents settings`, `/agents`, and
|
|
122
|
+
`/goal settings` remain available in standalone installations.
|
|
113
123
|
Packages without a settings screen are not listed. Pi's `/settings` is unchanged.
|
|
114
124
|
|
|
115
125
|
The hub includes **Tool output** when the bundled renderer extension is loaded.
|
|
116
|
-
Changes apply immediately
|
|
117
|
-
|
|
126
|
+
Changes apply immediately and also save the default for future sessions.
|
|
127
|
+
The hub also owns **Completions while busy**, shared by Subagents and Background
|
|
118
128
|
Tasks. Choose **Wait until idle** (the default) or **Steer active run**. Changes
|
|
119
129
|
apply immediately and autosave to the current session branch, including across
|
|
120
130
|
reloads. Press **Ctrl+S** to save the current choice as your default for future
|
|
@@ -122,10 +132,22 @@ Pi sessions; changing a session afterward does not change that saved default.
|
|
|
122
132
|
Saving a default leaves already-open sessions unchanged, including when
|
|
123
133
|
navigating to a branch without a session override.
|
|
124
134
|
The user default is stored in
|
|
125
|
-
`<agent-dir>/
|
|
135
|
+
`<agent-dir>/settings.json` under `piBetterHarness.callbacks` and also applies
|
|
126
136
|
when either callback package is loaded standalone. The former
|
|
127
137
|
`PI_BETTER_CALLBACK_WHILE_BUSY` environment variable is no longer supported.
|
|
128
138
|
|
|
139
|
+
All Harness-owned defaults use the `piBetterHarness` section of global
|
|
140
|
+
`~/.pi/agent/settings.json` (or `PI_CODING_AGENT_DIR/settings.json`): tool output,
|
|
141
|
+
Subagents configuration, callback delivery, Goal controls, and Sandbox activation,
|
|
142
|
+
permissions, and deny-rule templates. Existing preference files migrate on first
|
|
143
|
+
use after validation; global choices take precedence, and old files remain intact.
|
|
144
|
+
Updates preserve Pi's own settings and other packages' choices. SSH profiles,
|
|
145
|
+
plans, run records, and role/agent definitions remain in their existing stores:
|
|
146
|
+
they are session data or reusable definitions, not global UI defaults.
|
|
147
|
+
In `/agents`, model and effort edits are session-local until Ctrl+S saves them
|
|
148
|
+
to the user or project catalog definition. Saved catalog defaults also survive
|
|
149
|
+
sessions and upgrades; they are not duplicated in global `settings.json`.
|
|
150
|
+
|
|
129
151
|
Next-prompt inference is deferred pending safe public auth/header resolution in
|
|
130
152
|
Pi's SDK. It is not loaded by the bundle, has no toggle or preference store in
|
|
131
153
|
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 };
|
|
@@ -73,8 +74,58 @@ const TOOL_ICONS: Record<string, string> = {
|
|
|
73
74
|
grep: "\u2315",
|
|
74
75
|
find: "\u2316",
|
|
75
76
|
ls: "\u2261",
|
|
77
|
+
subagent_spawn: "\u25c7",
|
|
78
|
+
subagent_spawn_batch: "\u25c7",
|
|
79
|
+
bg_task_spawn: "\u2318",
|
|
80
|
+
bg_task_watch: "\u2318",
|
|
81
|
+
bg_task: "\u2318",
|
|
82
|
+
update_goal: "\u25ce",
|
|
83
|
+
get_goal: "\u25ce",
|
|
84
|
+
goal_resume: "\u25ce",
|
|
85
|
+
update_plan: "\u2261",
|
|
86
|
+
get_plan: "\u2261",
|
|
87
|
+
remote_bash: "\u2318",
|
|
76
88
|
};
|
|
77
89
|
|
|
90
|
+
function harnessCallDetail(name: string, input: unknown): string | undefined {
|
|
91
|
+
const args = input && typeof input === "object" ? input as Record<string, unknown> : {};
|
|
92
|
+
const text = (value: unknown): string => typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
93
|
+
const joinDetails = (...values: unknown[]) => values.map(text).filter(Boolean).join(" \u00b7 ");
|
|
94
|
+
const host = () => {
|
|
95
|
+
const ssh = args.ssh && typeof args.ssh === "object" ? args.ssh as Record<string, unknown> : args;
|
|
96
|
+
return text(ssh.host) ? `${text(ssh.user) ? `${text(ssh.user)}@` : ""}${text(ssh.host)}` : "";
|
|
97
|
+
};
|
|
98
|
+
switch (name) {
|
|
99
|
+
case "subagent_spawn":
|
|
100
|
+
return joinDetails(args.agent ?? args.role, args.alias ?? args.name, args.prompt);
|
|
101
|
+
case "subagent_spawn_batch":
|
|
102
|
+
return joinDetails(args.batchName, `${Array.isArray(args.jobs) ? args.jobs.length : 0} jobs`);
|
|
103
|
+
case "subagent_result": case "subagent_output": case "subagent_stop":
|
|
104
|
+
case "bg_task_status": case "bg_task_log": case "bg_task_stop":
|
|
105
|
+
return text(args.id);
|
|
106
|
+
case "subagent_list": case "bg_task_list":
|
|
107
|
+
return joinDetails(Array.isArray(args.status) ? args.status.map(text).filter(Boolean).join(", ") : args.status,
|
|
108
|
+
args.all === true ? "all sessions" : "");
|
|
109
|
+
case "agents_catalog":
|
|
110
|
+
return joinDetails(args.action, args.id);
|
|
111
|
+
case "bg_task_spawn": case "bg_task_watch": case "bg_task": case "bg_status":
|
|
112
|
+
return joinDetails(args.action, args.id, args.name, host(), args.command ??
|
|
113
|
+
(Array.isArray(args.argv) ? args.argv.map(text).filter(Boolean).join(" ") : undefined));
|
|
114
|
+
case "update_goal":
|
|
115
|
+
return text(args.status);
|
|
116
|
+
case "goal_resume":
|
|
117
|
+
return text(args.reason);
|
|
118
|
+
case "get_goal": case "get_background_activity": case "release_workflow": case "get_plan":
|
|
119
|
+
return "";
|
|
120
|
+
case "remote_bash":
|
|
121
|
+
return joinDetails(host(), args.command);
|
|
122
|
+
case "ssh_profile": case "ssh_mux":
|
|
123
|
+
return joinDetails(args.action, host(), args.all === true ? "all hosts" : "");
|
|
124
|
+
default:
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
78
129
|
function compactCall(component: ToolComponent, width: number, theme?: Theme, indent = 3): string[] {
|
|
79
130
|
if (width <= 0) return [];
|
|
80
131
|
// Render the call alone at a wider width so terminal wrapping becomes truncation.
|
|
@@ -90,8 +141,9 @@ function compactCall(component: ToolComponent, width: number, theme?: Theme, ind
|
|
|
90
141
|
}
|
|
91
142
|
const failed = component.result?.isError === true;
|
|
92
143
|
const running = !component.result || component.isPartial;
|
|
93
|
-
const detail =
|
|
94
|
-
|
|
144
|
+
const detail = (component.getCallRenderer?.() ? undefined : harnessCallDetail(component.toolName, component.args)) ??
|
|
145
|
+
(text.startsWith(`${component.toolName} `) ? text.slice(component.toolName.length).trimStart()
|
|
146
|
+
: text === component.toolName ? "" : text);
|
|
95
147
|
const label = component.toolName === "bash" ? "Shell"
|
|
96
148
|
: component.toolName.replace(/_/g, " ").replace(/^./, letter => letter.toUpperCase());
|
|
97
149
|
const suffix = failed ? " (failed)" : running ? " (running)" : "";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { installMinimalOutputHook, loadToolPrototype, 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);
|
|
@@ -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
|
|
|
@@ -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
|
+
}
|
|
@@ -21,6 +21,20 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
|
|
|
21
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.
|
|
22
22
|
- An observable-progress stall state for active goals.
|
|
23
23
|
|
|
24
|
+
## Settings Storage
|
|
25
|
+
|
|
26
|
+
Goal preferences are saved only in Pi's global `~/.pi/agent/settings.json` under
|
|
27
|
+
`piBetterHarness.goal` (or `$PI_CODING_AGENT_DIR/settings.json` when overridden).
|
|
28
|
+
The payload retains `version: 1` and the boolean `autoContinue`,
|
|
29
|
+
`conversationalResume`, and `pauseOnEscape` fields; omitted controls default to
|
|
30
|
+
`true`. Updates preserve other Pi settings and other `piBetterHarness` keys.
|
|
31
|
+
|
|
32
|
+
On first read, an existing `extensions/pi-better-goal-preferences.json` in the
|
|
33
|
+
agent directory is validated and migrated when the global Goal key is absent.
|
|
34
|
+
An existing global key is authoritative, and subsequent changes write only
|
|
35
|
+
`settings.json`. Invalid preferences report an error instead of being replaced
|
|
36
|
+
with defaults.
|
|
37
|
+
|
|
24
38
|
## Skill-Owned Workflows
|
|
25
39
|
|
|
26
40
|
Skills that own execution and their own task plan can opt in through `SKILL.md` frontmatter:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-better-goal",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "Pi extension for goal tracking with background-aware continuation.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"node": ">=22.0.0"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
|
+
"proper-lockfile": "^4.1.2",
|
|
72
73
|
"yaml": "^2.9.1"
|
|
73
74
|
}
|
|
74
75
|
}
|