@zigai/pi-mode 0.1.3 → 0.1.4

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
@@ -5,7 +5,7 @@ This Pi extension adds prompt modes for model and thinking-level switching.
5
5
  ## Install
6
6
 
7
7
  ```sh
8
- pi install git:github.com/zigai/pi-tweaks
8
+ pi install npm:@zigai/pi-mode
9
9
  ```
10
10
 
11
11
  ## Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zigai/pi-mode",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Pi package that adds prompt modes for model and thinking-level switching.",
5
5
  "keywords": [
6
6
  "mode",
package/src/mode-state.ts CHANGED
@@ -161,6 +161,22 @@ function normalizeThinkingLevel(level: ThinkingLevel | undefined): ThinkingLevel
161
161
  return undefined;
162
162
  }
163
163
 
164
+ function getLoadErrorCode(error: unknown): string | undefined {
165
+ if (!(error instanceof Error)) return undefined;
166
+ const code = (error as NodeJS.ErrnoException).code;
167
+ if (typeof code === "string") return code;
168
+ return undefined;
169
+ }
170
+
171
+ function errorMessage(error: unknown): string {
172
+ if (error instanceof Error) return error.message;
173
+ return String(error);
174
+ }
175
+
176
+ function throwLoadError(filePath: string, error: unknown): never {
177
+ throw new Error(`Failed to load ${filePath}: ${errorMessage(error)}`);
178
+ }
179
+
164
180
  function sanitizeModeSpec(spec: ModeSpecJson | undefined): ModeSpec {
165
181
  if (spec === undefined) return {};
166
182
 
@@ -225,6 +241,7 @@ async function loadModesFile(
225
241
  filePath: string,
226
242
  ctx: ExtensionContext,
227
243
  pi: ExtensionAPI,
244
+ options?: { throwOnInvalid?: boolean },
228
245
  ): Promise<ModesFile> {
229
246
  try {
230
247
  const raw = await fs.readFile(filePath, "utf8");
@@ -244,7 +261,9 @@ async function loadModesFile(
244
261
  };
245
262
  ensureDefaultModeEntries(file, ctx, pi);
246
263
  return file;
247
- } catch {
264
+ } catch (error: unknown) {
265
+ if (getLoadErrorCode(error) === "ENOENT") return createDefaultModes(ctx, pi);
266
+ if (options?.throwOnInvalid === true) throwLoadError(filePath, error);
248
267
  return createDefaultModes(ctx, pi);
249
268
  }
250
269
  }
@@ -404,16 +423,23 @@ async function persistRuntime(pi: ExtensionAPI, ctx: ExtensionContext): Promise<
404
423
  const patch = computeModesPatch(runtime.baseline, runtime.data, false);
405
424
  if (patch === null) return;
406
425
 
407
- await withFileLock(runtime.filePath, async () => {
408
- const latest = await loadModesFile(runtime.filePath, ctx, pi);
409
- applyModesPatch(latest, patch);
410
- ensureDefaultModeEntries(latest, ctx, pi);
411
- await saveModesFile(runtime.filePath, latest);
412
-
413
- runtime.data = latest;
414
- runtime.baseline = cloneModesFile(latest);
415
- runtime.fileMtimeMs = await getMtimeMs(runtime.filePath);
416
- });
426
+ try {
427
+ await withFileLock(runtime.filePath, async () => {
428
+ const latest = await loadModesFile(runtime.filePath, ctx, pi, { throwOnInvalid: true });
429
+ applyModesPatch(latest, patch);
430
+ ensureDefaultModeEntries(latest, ctx, pi);
431
+ await saveModesFile(runtime.filePath, latest);
432
+
433
+ runtime.data = latest;
434
+ runtime.baseline = cloneModesFile(latest);
435
+ runtime.fileMtimeMs = await getMtimeMs(runtime.filePath);
436
+ });
437
+ } catch (error: unknown) {
438
+ if (ctx.hasUI) {
439
+ ctx.ui.notify(`Mode settings were not saved: ${errorMessage(error)}`, "error");
440
+ }
441
+ throw error;
442
+ }
417
443
  }
418
444
 
419
445
  function getCurrentSelectionSpec(pi: ExtensionAPI): ModeSpec {
@@ -810,7 +836,12 @@ async function configureModesUI(pi: ExtensionAPI, ctx: ExtensionContext): Promis
810
836
 
811
837
  if (choice === MODE_UI_SHOW_NAME_ON || choice === MODE_UI_SHOW_NAME_OFF) {
812
838
  const next = !shouldShowModeName();
813
- setShowModeName(next);
839
+ try {
840
+ setShowModeName(next);
841
+ } catch (error: unknown) {
842
+ ctx.ui.notify(`Mode name display was not saved: ${errorMessage(error)}`, "error");
843
+ continue;
844
+ }
814
845
  requestEditorRender?.();
815
846
  let displayState = "disabled";
816
847
  if (next) {
package/src/settings.ts CHANGED
@@ -1,14 +1,131 @@
1
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ renameSync,
9
+ statSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from "node:fs";
3
13
  import { dirname, join } from "node:path";
4
14
 
5
15
  export const SHOW_MODE_NAME_SETTINGS_KEY = "modeShowName";
6
16
 
17
+ const SETTINGS_LOCK_TIMEOUT_MS = 5_000;
18
+ const STALE_SETTINGS_LOCK_MS = 30_000;
19
+
7
20
  let cachedShowModeName: boolean | undefined;
8
21
  let cachedSettingsMtimeMs: number | null | undefined;
9
22
 
10
23
  function getSettingsPath(): string {
11
- return join(homedir(), ".pi", "agent", "settings.json");
24
+ return join(getAgentDir(), "settings.json");
25
+ }
26
+
27
+ function getErrorCode(error: unknown): string | undefined {
28
+ if (!(error instanceof Error)) return undefined;
29
+ const code = (error as NodeJS.ErrnoException).code;
30
+ if (typeof code === "string") return code;
31
+ return undefined;
32
+ }
33
+
34
+ function throwError(error: unknown): never {
35
+ if (error instanceof Error) throw error;
36
+ throw new Error(String(error));
37
+ }
38
+
39
+ function sleepSync(ms: number): void {
40
+ const buffer = new SharedArrayBuffer(4);
41
+ Atomics.wait(new Int32Array(buffer), 0, 0, ms);
42
+ }
43
+
44
+ function withSettingsLock<T>(settingsPath: string, fn: () => T): T {
45
+ const lockPath = `${settingsPath}.lock`;
46
+ mkdirSync(dirname(lockPath), { recursive: true });
47
+
48
+ const start = Date.now();
49
+ while (true) {
50
+ try {
51
+ const fd = openSync(lockPath, "wx");
52
+ try {
53
+ writeFileSync(
54
+ fd,
55
+ `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`,
56
+ "utf8",
57
+ );
58
+ } catch {
59
+ // Ignore best-effort lock metadata.
60
+ }
61
+
62
+ try {
63
+ return fn();
64
+ } finally {
65
+ try {
66
+ closeSync(fd);
67
+ } catch {
68
+ // Ignore cleanup failures.
69
+ }
70
+ try {
71
+ unlinkSync(lockPath);
72
+ } catch {
73
+ // Ignore cleanup failures.
74
+ }
75
+ }
76
+ } catch (error: unknown) {
77
+ if (getErrorCode(error) !== "EEXIST") throwError(error);
78
+
79
+ try {
80
+ const stat = statSync(lockPath);
81
+ if (Date.now() - stat.mtimeMs > STALE_SETTINGS_LOCK_MS) {
82
+ unlinkSync(lockPath);
83
+ continue;
84
+ }
85
+ } catch {
86
+ // Ignore stale-lock checks.
87
+ }
88
+
89
+ if (Date.now() - start > SETTINGS_LOCK_TIMEOUT_MS) {
90
+ throw new Error(`Timed out waiting for lock: ${lockPath}`);
91
+ }
92
+ sleepSync(40 + Math.random() * 80);
93
+ }
94
+ }
95
+ }
96
+
97
+ function atomicWriteUtf8Sync(filePath: string, content: string): void {
98
+ mkdirSync(dirname(filePath), { recursive: true });
99
+
100
+ const tempPath = join(
101
+ dirname(filePath),
102
+ `.${filePath.split(/[\\/]/).pop() ?? "settings.json"}.tmp.${process.pid}.${Math.random()
103
+ .toString(16)
104
+ .slice(2)}`,
105
+ );
106
+
107
+ writeFileSync(tempPath, content, "utf8");
108
+
109
+ try {
110
+ renameSync(tempPath, filePath);
111
+ } catch (error: unknown) {
112
+ const code = getErrorCode(error);
113
+ if (code === "EEXIST" || code === "EPERM") {
114
+ try {
115
+ unlinkSync(filePath);
116
+ } catch {
117
+ // Ignore missing target before retrying the rename.
118
+ }
119
+ renameSync(tempPath, filePath);
120
+ return;
121
+ }
122
+ try {
123
+ unlinkSync(tempPath);
124
+ } catch {
125
+ // Ignore cleanup failures.
126
+ }
127
+ throwError(error);
128
+ }
12
129
  }
13
130
 
14
131
  function getSettingsMtimeMs(): number | null {
@@ -20,20 +137,35 @@ function getSettingsMtimeMs(): number | null {
20
137
  }
21
138
  }
22
139
 
23
- function readSettingsObject(): Record<string, unknown> {
140
+ function readSettingsObject(options?: { throwOnInvalid?: boolean }): Record<string, unknown> {
141
+ const settingsPath = getSettingsPath();
24
142
  try {
25
- const raw = readFileSync(getSettingsPath(), "utf8");
143
+ const raw = readFileSync(settingsPath, "utf8");
26
144
  const parsed = JSON.parse(raw) as unknown;
27
145
  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
28
146
  return { ...parsed };
29
147
  }
30
- } catch {
31
- // Ignore malformed or missing settings files and fall back to defaults.
148
+ if (options?.throwOnInvalid === true) {
149
+ throw new Error(`${settingsPath} must contain a JSON object.`);
150
+ }
151
+ } catch (error: unknown) {
152
+ if (getErrorCode(error) === "ENOENT") return {};
153
+ if (options?.throwOnInvalid === true) throwError(error);
154
+ // Ignore malformed settings files while reading and fall back to defaults.
32
155
  }
33
156
 
34
157
  return {};
35
158
  }
36
159
 
160
+ function updateSettingsObject(update: (settings: Record<string, unknown>) => void): void {
161
+ const settingsPath = getSettingsPath();
162
+ withSettingsLock(settingsPath, () => {
163
+ const settings = readSettingsObject({ throwOnInvalid: true });
164
+ update(settings);
165
+ atomicWriteUtf8Sync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
166
+ });
167
+ }
168
+
37
169
  export function shouldShowModeName(): boolean {
38
170
  const mtimeMs = getSettingsMtimeMs();
39
171
  if (cachedShowModeName !== undefined && cachedSettingsMtimeMs === mtimeMs) {
@@ -47,12 +179,9 @@ export function shouldShowModeName(): boolean {
47
179
  }
48
180
 
49
181
  export function setShowModeName(show: boolean): void {
50
- const settingsPath = getSettingsPath();
51
- const settings = readSettingsObject();
52
- settings[SHOW_MODE_NAME_SETTINGS_KEY] = show;
53
-
54
- mkdirSync(dirname(settingsPath), { recursive: true });
55
- writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
182
+ updateSettingsObject((settings) => {
183
+ settings[SHOW_MODE_NAME_SETTINGS_KEY] = show;
184
+ });
56
185
 
57
186
  cachedSettingsMtimeMs = getSettingsMtimeMs();
58
187
  cachedShowModeName = show;
package/src/storage.ts CHANGED
@@ -1,17 +1,9 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
1
2
  import fs from "node:fs/promises";
2
- import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
- export function expandUserPath(value: string): string {
6
- if (value === "~") return os.homedir();
7
- if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
8
- return value;
9
- }
10
-
11
5
  export function getGlobalAgentDir(): string {
12
- const env = process.env.PI_CODING_AGENT_DIR;
13
- if (env !== undefined && env.length > 0) return expandUserPath(env);
14
- return path.join(os.homedir(), ".pi", "agent");
6
+ return getAgentDir();
15
7
  }
16
8
 
17
9
  export function getGlobalModesPath(): string {