pi-better-goal 0.8.1 → 0.8.2

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 CHANGED
@@ -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:
package/package.json CHANGED
@@ -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
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { CustomEditor, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { CustomEditor, ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
3
  import { isKeyRelease, matchesKey } from "@earendil-works/pi-tui";
4
4
  import { readFileSync } from "node:fs";
@@ -842,6 +842,36 @@ export default function (pi: ExtensionAPI): void {
842
842
  if (latestSnapshot) collectIfPossible();
843
843
  });
844
844
 
845
+ const changePreference = async (ctx: ExtensionCommandContext, key: keyof GoalPreferences, enabled: boolean): Promise<void> => {
846
+ const previous = preferences;
847
+ preferences = writeGoalPreference(key, enabled);
848
+ if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
849
+ syncResumeTool(getGoal(ctx));
850
+ applyStatus(ctx);
851
+ if (!previous.autoContinue && preferences.autoContinue) {
852
+ const snapshot = await publishSnapshot(ctx);
853
+ const goal = getGoal(ctx);
854
+ if (snapshot && isPokeable(goal) && !isForegroundBusy(ctx) && !snapshot.backgroundRunning) {
855
+ scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
856
+ }
857
+ }
858
+ };
859
+ const openSettings = async (ctx: ExtensionCommandContext): Promise<void> => {
860
+ currentCtx = ctx;
861
+ if (ctx.hasUI && ctx.mode !== "rpc" && typeof ctx.ui.custom === "function") {
862
+ await ctx.ui.custom<void>((tui, theme, _kb, done) => createGoalSettingsPage(theme, {
863
+ get: () => ({ ...preferences }),
864
+ change: (key, enabled) => changePreference(ctx, key, enabled),
865
+ }, () => tui.requestRender(), () => done()));
866
+ } else {
867
+ ctx.ui.notify(`${formatPreferences(preferences)}\nPreferences: ${goalPreferencesPath()}`, "info");
868
+ }
869
+ };
870
+ const contribution = { id: "goal", label: "Goal", command: "/goal settings", open: openSettings };
871
+ const registerSettings = () => pi.events.emit("harness-settings:register", contribution);
872
+ const unsubscribeSettingsRequest = pi.events.on("harness-settings:request", registerSettings);
873
+ registerSettings();
874
+
845
875
  pi.registerCommand("goal", {
846
876
  description: "Create, inspect, pause, resume, clear, or complete the active goal; configure persistent goal settings",
847
877
  getArgumentCompletions: goalArgumentCompletions,
@@ -851,25 +881,8 @@ export default function (pi: ExtensionAPI): void {
851
881
  const current = getGoal(ctx);
852
882
 
853
883
  if (trimmed === "settings" || trimmed.startsWith("settings ")) {
854
- const changePreference = async (key: keyof GoalPreferences, enabled: boolean): Promise<void> => {
855
- const previous = preferences;
856
- preferences = writeGoalPreference(key, enabled);
857
- if (previous.autoContinue !== preferences.autoContinue) clearIdleContinuation();
858
- syncResumeTool(getGoal(ctx));
859
- applyStatus(ctx);
860
- if (!previous.autoContinue && preferences.autoContinue) {
861
- const snapshot = await publishSnapshot(ctx);
862
- const goal = getGoal(ctx);
863
- if (snapshot && isPokeable(goal) && !isForegroundBusy(ctx) && !snapshot.backgroundRunning) {
864
- scheduleIdleContinuation(goal, ctx, "continuation", snapshot);
865
- }
866
- }
867
- };
868
- if (trimmed === "settings" && ctx.hasUI && ctx.mode !== "rpc" && typeof ctx.ui.custom === "function") {
869
- await ctx.ui.custom<void>((tui, theme, _kb, done) => createGoalSettingsPage(theme, {
870
- get: () => ({ ...preferences }),
871
- change: changePreference,
872
- }, () => tui.requestRender(), () => done()));
884
+ if (trimmed === "settings") {
885
+ await openSettings(ctx);
873
886
  return;
874
887
  }
875
888
  const [, setting, mode, ...extra] = trimmed.split(/\s+/);
@@ -882,7 +895,7 @@ export default function (pi: ExtensionAPI): void {
882
895
  try {
883
896
  const key = setting === "auto-continue" ? "autoContinue"
884
897
  : setting === "conversational-resume" ? "conversationalResume" : "pauseOnEscape";
885
- await changePreference(key, mode === "on");
898
+ await changePreference(ctx, key, mode === "on");
886
899
  } catch (error) {
887
900
  notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
888
901
  return;
@@ -1425,6 +1438,8 @@ export default function (pi: ExtensionAPI): void {
1425
1438
  });
1426
1439
 
1427
1440
  pi.on("session_shutdown", async (_event, ctx) => {
1441
+ if (typeof unsubscribeSettingsRequest === "function") unsubscribeSettingsRequest();
1442
+
1428
1443
  executionGeneration += 1;
1429
1444
  sessionGeneration += 1;
1430
1445
  escapeAbortSuppression = undefined;
@@ -1,6 +1,11 @@
1
1
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
2
+ import { join } from "node:path";
3
+ import {
4
+ harnessSettingsPath,
5
+ readHarnessSetting,
6
+ updateHarnessSetting,
7
+ type HarnessSettingsSeams,
8
+ } from "./shared-harness-settings.js";
4
9
 
5
10
  export interface GoalPreferences {
6
11
  autoContinue: boolean;
@@ -8,30 +13,33 @@ export interface GoalPreferences {
8
13
  pauseOnEscape: boolean;
9
14
  }
10
15
 
11
- export interface GoalPreferenceSeams {
12
- agentDir?: () => string;
13
- }
16
+ export interface GoalPreferenceSeams extends HarnessSettingsSeams {}
14
17
 
15
18
  export function goalPreferencesPath(seams: GoalPreferenceSeams = {}): string {
16
- return join((seams.agentDir ?? getAgentDir)(), "extensions", "pi-better-goal-preferences.json");
19
+ return harnessSettingsPath(seams);
17
20
  }
18
21
 
19
22
  export function readGoalPreferences(seams: GoalPreferenceSeams = {}): GoalPreferences {
20
23
  const path = goalPreferencesPath(seams);
21
- let raw: string;
24
+ const legacyPath = join((seams.agentDir ?? getAgentDir)(), "extensions", "pi-better-goal-preferences.json");
22
25
  try {
23
- raw = readFileSync(path, "utf8");
26
+ const value = readHarnessSetting<unknown>("goal", seams, {
27
+ path: legacyPath,
28
+ parse: (value) => ({ version: 1, ...parseGoalPreferences(value, legacyPath) }),
29
+ });
30
+ return parseGoalPreferences(value, path);
24
31
  } catch (error) {
25
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
26
- return { autoContinue: true, conversationalResume: true, pauseOnEscape: true };
32
+ if (messageOf(error).startsWith("Goal preferences at ")) throw error;
33
+ if (error instanceof SyntaxError || /not valid JSON/.test(messageOf(error))) {
34
+ throw new Error(`Goal preferences at ${path} are not valid JSON: ${messageOf(error)}`);
27
35
  }
28
36
  throw new Error(`Goal preferences at ${path} could not be read: ${messageOf(error)}`);
29
37
  }
30
- let parsed: unknown;
31
- try {
32
- parsed = JSON.parse(raw);
33
- } catch (error) {
34
- throw new Error(`Goal preferences at ${path} are not valid JSON: ${messageOf(error)}`);
38
+ }
39
+
40
+ function parseGoalPreferences(parsed: unknown, path: string): GoalPreferences {
41
+ if (parsed === undefined) {
42
+ return { autoContinue: true, conversationalResume: true, pauseOnEscape: true };
35
43
  }
36
44
  const value = parsed as Partial<GoalPreferences> & { version?: unknown } | null;
37
45
  if (value?.version !== 1 ||
@@ -53,19 +61,20 @@ export function writeGoalPreference(
53
61
  enabled: boolean,
54
62
  seams: GoalPreferenceSeams = {},
55
63
  ): GoalPreferences {
56
- const preferences = { ...readGoalPreferences(seams), [key]: enabled };
64
+ // Migrate legacy preferences before updating the authoritative value under lock.
65
+ readGoalPreferences(seams);
57
66
  const path = goalPreferencesPath(seams);
58
- const pending = `${path}.${process.pid}.tmp`;
59
67
  try {
60
- mkdirSync(dirname(path), { recursive: true });
61
- writeFileSync(pending, `${JSON.stringify({ version: 1, ...preferences }, null, 2)}\n`, "utf8");
62
- renameSync(pending, path);
68
+ const { version: _version, ...preferences } = updateHarnessSetting("goal", (current) => ({
69
+ version: 1,
70
+ ...parseGoalPreferences(current, path),
71
+ [key]: enabled,
72
+ }), seams);
73
+ return preferences;
63
74
  } catch (error) {
75
+ if (messageOf(error).startsWith("Goal preferences at ")) throw error;
64
76
  throw new Error(`Goal preferences at ${path} could not be written: ${messageOf(error)}`);
65
- } finally {
66
- rmSync(pending, { force: true });
67
77
  }
68
- return preferences;
69
78
  }
70
79
 
71
80
  function messageOf(error: unknown): string {
@@ -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
+ }