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.
Files changed (28) hide show
  1. package/README.md +33 -8
  2. package/extensions/minimal-output/hook.ts +106 -4
  3. package/extensions/minimal-output/index.ts +28 -4
  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-sandbox/README.md +37 -17
  14. package/node_modules/pi-better-sandbox/deny-rules.ts +34 -37
  15. package/node_modules/pi-better-sandbox/package.json +7 -4
  16. package/node_modules/pi-better-sandbox/permission-settings.ts +14 -20
  17. package/node_modules/pi-better-sandbox/preferences.ts +14 -25
  18. package/node_modules/pi-better-sandbox/shared-harness-settings.ts +114 -0
  19. package/node_modules/pi-better-subagents/README.md +9 -1
  20. package/node_modules/pi-better-subagents/agent-commands.ts +13 -11
  21. package/node_modules/pi-better-subagents/agent-operations.ts +6 -1
  22. package/node_modules/pi-better-subagents/config.ts +53 -35
  23. package/node_modules/pi-better-subagents/docs/usage.md +13 -2
  24. package/node_modules/pi-better-subagents/index.ts +5 -1
  25. package/node_modules/pi-better-subagents/package.json +2 -1
  26. package/node_modules/pi-better-subagents/shared-callback-batcher.ts +23 -30
  27. package/node_modules/pi-better-subagents/shared-harness-settings.ts +114 -0
  28. 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. Execution, sandboxing, and agent-facing payloads are unchanged.
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
- 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,
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. `/sandbox`, `/subagents
112
- settings`, and `/goal settings` remain available in standalone installations.
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 to the current session and do not save a global
117
- default. The hub also owns **Completions while busy**, shared by Subagents and Background
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>/extensions/pi-better-callback-preferences.json` and also applies
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 = text.startsWith(`${component.toolName} `) ? text.slice(component.toolName.length).trimStart()
94
- : text === component.toolName ? "" : text;
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 = 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