pi-better-harness 0.24.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.
Files changed (31) hide show
  1. package/README.md +29 -7
  2. package/extensions/minimal-output/hook.ts +54 -2
  3. package/extensions/minimal-output/index.ts +26 -2
  4. package/extensions/settings/page.ts +3 -3
  5. package/extensions/shared-harness-settings.ts +114 -0
  6. package/node_modules/pi-better-background-tasks/package.json +4 -1
  7. package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +23 -30
  8. package/node_modules/pi-better-background-tasks/src/shared-harness-settings.ts +114 -0
  9. package/node_modules/pi-better-goal/README.md +14 -0
  10. package/node_modules/pi-better-goal/package.json +2 -1
  11. package/node_modules/pi-better-goal/src/preferences.ts +32 -23
  12. package/node_modules/pi-better-goal/src/shared-harness-settings.ts +114 -0
  13. package/node_modules/pi-better-plan/README.md +15 -2
  14. package/node_modules/pi-better-plan/package.json +1 -1
  15. package/node_modules/pi-better-plan/src/index.ts +49 -45
  16. package/node_modules/pi-better-sandbox/README.md +37 -17
  17. package/node_modules/pi-better-sandbox/deny-rules.ts +34 -37
  18. package/node_modules/pi-better-sandbox/package.json +7 -4
  19. package/node_modules/pi-better-sandbox/permission-settings.ts +14 -20
  20. package/node_modules/pi-better-sandbox/preferences.ts +14 -25
  21. package/node_modules/pi-better-sandbox/shared-harness-settings.ts +114 -0
  22. package/node_modules/pi-better-subagents/README.md +9 -1
  23. package/node_modules/pi-better-subagents/agent-commands.ts +13 -11
  24. package/node_modules/pi-better-subagents/agent-operations.ts +6 -1
  25. package/node_modules/pi-better-subagents/config.ts +53 -35
  26. package/node_modules/pi-better-subagents/docs/usage.md +13 -2
  27. package/node_modules/pi-better-subagents/index.ts +5 -1
  28. package/node_modules/pi-better-subagents/package.json +2 -1
  29. package/node_modules/pi-better-subagents/shared-callback-batcher.ts +23 -30
  30. package/node_modules/pi-better-subagents/shared-harness-settings.ts +114 -0
  31. package/package.json +8 -7
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
- Normal mode is the default. The preference is saved in the current session,
95
- including resume/reload. This is a version-sensitive internal TUI adapter,
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. `/sandbox`, `/subagents
112
- settings`, and `/goal settings` remain available in standalone installations.
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 to the current session and do not save a global
117
- default. The hub also owns **Completions while busy**, shared by Subagents and Background
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>/extensions/pi-better-callback-preferences.json` and also applies
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 = text.startsWith(`${component.toolName} `) ? text.slice(component.toolName.length).trimStart()
94
- : text === component.toolName ? "" : text;
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 = false;
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: "Session setting", values: control.values })),
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 session setting...");
44
+ report("Saving setting...");
45
45
  Promise.resolve().then(() => control.change(value, ctx)).then(() => {
46
46
  list.updateValue(id, control.get());
47
- report("Session setting saved.");
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.0",
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 interface CallbackSettingsSeams {
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 callbackPreferencesPath(seams: CallbackSettingsSeams): string {
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 = callbackPreferencesPath(seams);
68
- let text: string;
74
+ const path = harnessSettingsPath(seams);
69
75
  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);
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
- throw new Error(`Invalid callback default at ${path}: ${String(error)}`, { cause: error });
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 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;
90
+ const path = harnessSettingsPath(seams);
91
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);
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.1",
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
  }