pi-better-background-tasks 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -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
+ }