@narumitw/pi-caffeinate 0.28.0 → 0.35.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.
package/README.md CHANGED
@@ -119,11 +119,16 @@ when omitted. The file is read at startup and on `/reload`; run `/reload` after
119
119
  running Pi session before using mode commands.
120
120
 
121
121
  Missing, invalid, or deleted settings default back to `display` mode with quiet mode disabled on
122
- every supported OS.
123
-
124
- Compatibility: older versions used `pi-caffeinate-settings.json`. During the migration window, a
125
- legacy-only file is automatically migrated to `pi-caffeinate.json` with a warning. If both files
126
- exist, `pi-caffeinate.json` wins and the legacy file is ignored. The legacy filename is deprecated
122
+ every supported OS. A missing file stays absent until the first successful mode change. Within one Pi process, mode saves run in invocation order, reread the latest valid document, and preserve unknown fields. Malformed
123
+ JSON or an invalid recognized field blocks mode saves until repaired instead of being overwritten.
124
+ A failed save keeps the prior runtime mode; if restarting an active inhibitor fails after publication,
125
+ the extension restores the prior saved mode and inhibitor behavior or reports an explicit rollback
126
+ failure.
127
+
128
+ Compatibility: older versions used `pi-caffeinate-settings.json`. A legacy-only file remains
129
+ readable with a warning and is never modified automatically; rename it to `pi-caffeinate.json`.
130
+ The first subsequent settings save writes the canonical file. If both files exist,
131
+ `pi-caffeinate.json` wins and the legacy file is ignored. The legacy filename is deprecated
127
132
  and will be removed in a future major release.
128
133
 
129
134
  ### Environment variables
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-caffeinate",
3
- "version": "0.28.0",
3
+ "version": "0.35.0",
4
4
  "description": "Pi extension that keeps the computer awake while the agent is running.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,8 +29,8 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "devDependencies": {
32
- "@biomejs/biome": "2.5.3",
33
- "@earendil-works/pi-coding-agent": "0.80.10",
32
+ "@biomejs/biome": "2.5.5",
33
+ "@earendil-works/pi-coding-agent": "0.82.1",
34
34
  "@types/node": "26.1.1",
35
35
  "typescript": "7.0.2"
36
36
  },
package/src/caffeinate.ts CHANGED
@@ -1,25 +1,19 @@
1
1
  import type { ChildProcess } from "node:child_process";
2
2
  import process from "node:process";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionCommandContext,
6
+ ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
3
8
  import { startInhibitorProcess, stopInhibitorProcess } from "./inhibitor-process.js";
4
- import {
5
- formatMode,
6
- getInhibitorCommand,
7
- type InhibitorCommand,
8
- splitCommand,
9
- windowsInhibitorScript,
10
- } from "./inhibitors.js";
9
+ import { formatMode, getInhibitorCommand, type InhibitorCommand } from "./inhibitors.js";
11
10
  import {
12
11
  type CaffeinateMode,
12
+ type CaffeinateSettings,
13
13
  loadSettings,
14
- normalizeCaffeinateSettings,
15
14
  saveSettings,
16
15
  settingsFilePath,
17
16
  } from "./settings.js";
18
- import type {
19
- ExtensionAPI,
20
- ExtensionCommandContext,
21
- ExtensionContext,
22
- } from "@earendil-works/pi-coding-agent";
23
17
 
24
18
  const STATUS_KEY = "caffeinate";
25
19
  const DISABLED_VALUES = new Set(["1", "true", "yes", "on"]);
@@ -61,6 +55,7 @@ interface CaffeinateState {
61
55
  settingsError?: string;
62
56
  settingsNotice?: string;
63
57
  iconWarningShown: boolean;
58
+ sessionGeneration: number;
64
59
  }
65
60
 
66
61
  const state: CaffeinateState = {
@@ -71,19 +66,24 @@ const state: CaffeinateState = {
71
66
  quiet: false,
72
67
  settingsLoaded: false,
73
68
  iconWarningShown: false,
69
+ sessionGeneration: 0,
74
70
  };
75
71
 
76
72
  export default function caffeinate(pi: ExtensionAPI) {
77
73
  pi.on("session_start", async (_event, ctx) => {
74
+ const generation = ++state.sessionGeneration;
78
75
  state.iconWarningShown = false;
79
76
  state.settingsNotice = undefined;
80
77
  warnDeprecatedIcon(ctx);
81
- await loadSettingsIntoState(ctx);
78
+ await loadSettingsIntoState(ctx, generation);
79
+ if (generation !== state.sessionGeneration) return;
82
80
  updateStatus(ctx);
83
81
  });
84
82
 
85
83
  pi.on("agent_start", async (_event, ctx) => {
86
- await ensureSettingsLoaded(ctx);
84
+ const generation = state.sessionGeneration;
85
+ await ensureSettingsLoaded(ctx, generation);
86
+ if (generation !== state.sessionGeneration) return;
87
87
  state.activeTurns += 1;
88
88
  startInhibitor(ctx, { notify: !state.quiet });
89
89
  });
@@ -96,28 +96,36 @@ export default function caffeinate(pi: ExtensionAPI) {
96
96
  updateStatus(ctx);
97
97
  });
98
98
 
99
- pi.on("session_shutdown", (_event, ctx) => {
99
+ pi.on("session_shutdown", async (_event, ctx) => {
100
+ state.sessionGeneration += 1;
100
101
  state.activeTurns = 0;
101
102
  stopInhibitor(ctx, "session shutdown", { notify: false });
102
103
  ctx.ui.setStatus(STATUS_KEY, undefined);
104
+ await modeOperationQueue;
103
105
  });
104
106
 
105
107
  pi.registerCommand("caffeinate", {
106
108
  description: "Open pi-caffeinate keep-awake controls",
107
109
  getArgumentCompletions: (prefix) => commandCompletions(prefix),
108
110
  handler: async (args, ctx) => {
109
- await ensureSettingsLoaded(ctx);
110
- await handleCaffeinateCommand(args, ctx);
111
+ const generation = state.sessionGeneration;
112
+ const command = parseCommand(args);
113
+ if (command === "sleep" || command === "display") {
114
+ await setModeAfterSettingsLoad(ctx, command, generation);
115
+ return;
116
+ }
117
+ await ensureSettingsLoaded(ctx, generation);
118
+ if (generation !== state.sessionGeneration) return;
119
+ await handleCaffeinateCommand(args, ctx, generation);
111
120
  },
112
121
  });
113
-
114
122
  }
115
123
 
116
- async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
124
+ async function handleCaffeinateCommand(args: string, ctx: CommandContext, generation: number) {
117
125
  const command = parseCommand(args);
118
126
  switch (command) {
119
127
  case "menu":
120
- await showMenu(ctx);
128
+ await showMenu(ctx, generation);
121
129
  return;
122
130
  case "help":
123
131
  ctx.ui.notify(buildCommandGuide(), "info");
@@ -126,13 +134,13 @@ async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
126
134
  showStatus(ctx);
127
135
  return;
128
136
  case "mode":
129
- await showModeSelector(ctx);
137
+ await showModeSelector(ctx, generation);
130
138
  return;
131
139
  case "sleep":
132
- await setMode(ctx, "sleep");
140
+ await setMode(ctx, "sleep", generation);
133
141
  return;
134
142
  case "display":
135
- await setMode(ctx, "display");
143
+ await setMode(ctx, "display", generation);
136
144
  return;
137
145
  case "stop":
138
146
  stopCaffeinate(ctx, "manual stop");
@@ -142,7 +150,7 @@ async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
142
150
  ctx.ui.notify(`Unknown /caffeinate command: ${args.trim()}\n\n${buildCommandGuide()}`, "warning");
143
151
  }
144
152
 
145
- async function showMenu(ctx: CommandContext) {
153
+ async function showMenu(ctx: CommandContext, generation: number) {
146
154
  if (!ctx.hasUI) {
147
155
  ctx.ui.notify(`${buildCommandGuide()}\n\n${describeState()}`, statusLevel());
148
156
  updateStatus(ctx);
@@ -150,15 +158,16 @@ async function showMenu(ctx: CommandContext) {
150
158
  }
151
159
 
152
160
  const choice = await ctx.ui.select("pi-caffeinate controls", Object.values(MENU_OPTIONS));
161
+ if (generation !== state.sessionGeneration) return;
153
162
  switch (choice) {
154
163
  case MENU_OPTIONS.status:
155
164
  showStatus(ctx);
156
165
  return;
157
166
  case MENU_OPTIONS.sleep:
158
- await setMode(ctx, "sleep");
167
+ await setMode(ctx, "sleep", generation);
159
168
  return;
160
169
  case MENU_OPTIONS.display:
161
- await setMode(ctx, "display");
170
+ await setMode(ctx, "display", generation);
162
171
  return;
163
172
  case MENU_OPTIONS.stop:
164
173
  stopCaffeinate(ctx, "manual stop");
@@ -169,7 +178,7 @@ async function showMenu(ctx: CommandContext) {
169
178
  }
170
179
  }
171
180
 
172
- async function showModeSelector(ctx: CommandContext) {
181
+ async function showModeSelector(ctx: CommandContext, generation: number) {
173
182
  if (!ctx.hasUI) {
174
183
  ctx.ui.notify(
175
184
  `Mode selection needs an interactive UI. Run /caffeinate sleep or /caffeinate display.\n\n${describeState()}`,
@@ -183,40 +192,95 @@ async function showModeSelector(ctx: CommandContext) {
183
192
  `pi-caffeinate mode (current: ${formatMode(state.mode)})`,
184
193
  Object.values(MODE_OPTIONS),
185
194
  );
195
+ if (generation !== state.sessionGeneration) return;
186
196
  if (choice === MODE_OPTIONS.sleep) {
187
- await setMode(ctx, "sleep");
197
+ await setMode(ctx, "sleep", generation);
188
198
  return;
189
199
  }
190
200
  if (choice === MODE_OPTIONS.display) {
191
- await setMode(ctx, "display");
201
+ await setMode(ctx, "display", generation);
192
202
  }
193
203
  }
194
204
 
195
- async function setMode(ctx: ExtensionContext, mode: CaffeinateMode) {
205
+ let modeOperationQueue = Promise.resolve();
206
+
207
+ function setModeAfterSettingsLoad(
208
+ ctx: ExtensionContext,
209
+ mode: CaffeinateMode,
210
+ generation: number,
211
+ ): Promise<void> {
212
+ return enqueueModeOperation(async () => {
213
+ await ensureSettingsLoaded(ctx, generation);
214
+ if (generation !== state.sessionGeneration) return;
215
+ await setModeNow(ctx, mode, generation);
216
+ });
217
+ }
218
+
219
+ function setMode(ctx: ExtensionContext, mode: CaffeinateMode, generation: number): Promise<void> {
220
+ return enqueueModeOperation(() => setModeNow(ctx, mode, generation));
221
+ }
222
+
223
+ function enqueueModeOperation(operation: () => Promise<void>): Promise<void> {
224
+ const queued = modeOperationQueue.then(operation);
225
+ modeOperationQueue = queued.catch(() => undefined);
226
+ return queued;
227
+ }
228
+
229
+ async function setModeNow(ctx: ExtensionContext, mode: CaffeinateMode, generation: number) {
230
+ if (generation !== state.sessionGeneration) return;
196
231
  const previousMode = state.mode;
197
- state.mode = mode;
232
+ const previousQuiet = state.quiet;
233
+ const restartRequired = Boolean(state.process && previousMode !== mode && !state.command?.custom);
198
234
  state.settingsError = undefined;
199
235
 
200
- let saved = true;
201
- try {
202
- await saveSettings({ mode, quiet: state.quiet, updatedAt: Date.now() });
203
- } catch (error) {
204
- saved = false;
205
- state.settingsError = `settings save failed: ${formatError(error)}`;
206
- ctx.ui.notify(`pi-caffeinate settings save failed: ${formatError(error)}`, "warning");
236
+ if (restartRequired) {
237
+ state.mode = mode;
238
+ stopInhibitor(ctx, "mode changed", { notify: false });
239
+ startInhibitor(ctx, { notify: false });
240
+ if (!state.process) {
241
+ const applicationError = state.lastError ?? "the inhibitor could not be restarted";
242
+ state.mode = previousMode;
243
+ startInhibitor(ctx, { notify: false });
244
+ const rollbackError = state.process
245
+ ? undefined
246
+ : (state.lastError ?? "the previous inhibitor could not be restored");
247
+ state.settingsError = rollbackError
248
+ ? `mode application failed (${applicationError}); runtime rollback failed: ${rollbackError}`
249
+ : `mode application failed and was rolled back: ${applicationError}`;
250
+ ctx.ui.notify(`pi-caffeinate ${state.settingsError}`, "warning");
251
+ updateStatus(ctx);
252
+ return;
253
+ }
207
254
  }
208
255
 
209
- if (state.process && previousMode !== mode && !state.command?.custom) {
210
- stopInhibitor(ctx, "mode changed", { notify: false });
211
- startInhibitor(ctx, { notify: !state.quiet });
256
+ let savedSettings: CaffeinateSettings;
257
+ try {
258
+ savedSettings = await saveSettings({ mode, updatedAt: Date.now() });
259
+ } catch (error) {
260
+ if (generation !== state.sessionGeneration) return;
261
+ let rollbackError: string | undefined;
262
+ if (restartRequired) {
263
+ state.mode = previousMode;
264
+ state.quiet = previousQuiet;
265
+ stopInhibitor(ctx, "settings save failed", { notify: false });
266
+ startInhibitor(ctx, { notify: false });
267
+ if (!state.process) rollbackError = state.lastError ?? "the prior inhibitor was not restored";
268
+ }
269
+ state.settingsError = rollbackError
270
+ ? `settings save failed (${formatError(error)}); runtime rollback failed: ${rollbackError}`
271
+ : `settings save failed: ${formatError(error)}`;
272
+ ctx.ui.notify(
273
+ `pi-caffeinate mode remains ${formatMode(previousMode)}; settings were not saved: ${state.settingsError}`,
274
+ "warning",
275
+ );
276
+ updateStatus(ctx);
277
+ return;
212
278
  }
213
279
 
214
- ctx.ui.notify(
215
- saved
216
- ? `pi-caffeinate mode set to ${formatMode(mode)} and saved.`
217
- : `pi-caffeinate mode set to ${formatMode(mode)} for this session, but settings were not saved.`,
218
- saved ? "info" : "warning",
219
- );
280
+ if (generation !== state.sessionGeneration) return;
281
+ state.mode = mode;
282
+ state.quiet = savedSettings.quiet;
283
+ ctx.ui.notify(`pi-caffeinate mode set to ${formatMode(mode)} and saved.`, "info");
220
284
  updateStatus(ctx);
221
285
  }
222
286
 
@@ -380,7 +444,9 @@ function describeState() {
380
444
  return lines.join("\n");
381
445
  }
382
446
 
383
- lines.unshift("pi-caffeinate is idle and will keep the computer awake during the next agent run.");
447
+ lines.unshift(
448
+ "pi-caffeinate is idle and will keep the computer awake during the next agent run.",
449
+ );
384
450
  return lines.join("\n");
385
451
  }
386
452
 
@@ -393,12 +459,12 @@ function statusModeLabel() {
393
459
  return formatMode(state.mode);
394
460
  }
395
461
 
396
- async function ensureSettingsLoaded(ctx: ExtensionContext) {
462
+ async function ensureSettingsLoaded(ctx: ExtensionContext, generation: number) {
397
463
  if (state.disabled || state.settingsLoaded) return;
398
- await loadSettingsIntoState(ctx);
464
+ await loadSettingsIntoState(ctx, generation);
399
465
  }
400
466
 
401
- async function loadSettingsIntoState(ctx: ExtensionContext) {
467
+ async function loadSettingsIntoState(ctx: ExtensionContext, generation: number) {
402
468
  if (state.disabled) {
403
469
  state.settingsLoaded = true;
404
470
  state.settingsError = undefined;
@@ -407,6 +473,7 @@ async function loadSettingsIntoState(ctx: ExtensionContext) {
407
473
  }
408
474
 
409
475
  const settings = await loadSettings();
476
+ if (generation !== state.sessionGeneration) return;
410
477
  state.settingsLoaded = true;
411
478
  state.settingsError = undefined;
412
479
  if (settings.notice) {
package/src/inhibitors.ts CHANGED
@@ -18,7 +18,11 @@ export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | un
18
18
  if (command) return { command, args, description: `custom command (${command})`, custom: true };
19
19
  }
20
20
  if (process.platform === "darwin") {
21
- return parentBoundUnixCommand("caffeinate", macCaffeinateArgs(mode), caffeinateDescription(mode));
21
+ return parentBoundUnixCommand(
22
+ "caffeinate",
23
+ macCaffeinateArgs(mode),
24
+ caffeinateDescription(mode),
25
+ );
22
26
  }
23
27
  if (process.platform === "linux") {
24
28
  if (isWsl() && commandExists("powershell.exe")) {
@@ -40,7 +44,11 @@ export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | un
40
44
  );
41
45
  }
42
46
  if (commandExists("caffeinate")) {
43
- return parentBoundUnixCommand("caffeinate", macCaffeinateArgs(mode), caffeinateDescription(mode));
47
+ return parentBoundUnixCommand(
48
+ "caffeinate",
49
+ macCaffeinateArgs(mode),
50
+ caffeinateDescription(mode),
51
+ );
44
52
  }
45
53
  }
46
54
  if (process.platform === "win32") return windowsPowerInhibitorCommand("powershell.exe", mode);
@@ -55,10 +63,21 @@ function caffeinateDescription(mode: CaffeinateMode) {
55
63
  return `caffeinate (${formatMode(mode)})`;
56
64
  }
57
65
 
58
- function parentBoundUnixCommand(command: string, args: string[], description: string): InhibitorCommand {
66
+ function parentBoundUnixCommand(
67
+ command: string,
68
+ args: string[],
69
+ description: string,
70
+ ): InhibitorCommand {
59
71
  return {
60
72
  command: "sh",
61
- args: ["-c", unixParentBoundScript(), "pi-caffeinate-watch", String(process.pid), command, ...args],
73
+ args: [
74
+ "-c",
75
+ unixParentBoundScript(),
76
+ "pi-caffeinate-watch",
77
+ String(process.pid),
78
+ command,
79
+ ...args,
80
+ ],
62
81
  description,
63
82
  };
64
83
  }
package/src/settings.ts CHANGED
@@ -1,8 +1,9 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { constants } from "node:fs";
2
- import { access, link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
3
+ import { access, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
5
  import process from "node:process";
6
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
7
 
7
8
  const NEW_SETTINGS_FILE = "pi-caffeinate.json";
8
9
  const LEGACY_SETTINGS_FILE = "pi-caffeinate-settings.json";
@@ -15,17 +16,23 @@ export interface CaffeinateSettings {
15
16
  updatedAt: number;
16
17
  }
17
18
 
19
+ export interface SettingsFileOperations {
20
+ write(path: string, data: string): Promise<void>;
21
+ rename(source: string, destination: string): Promise<void>;
22
+ }
23
+
24
+ const DEFAULT_FILE_OPERATIONS: SettingsFileOperations = {
25
+ write: (path, data) => writeFile(path, data, "utf8").then(() => undefined),
26
+ rename,
27
+ };
28
+
18
29
  export type SettingsLoadResult =
19
30
  | { kind: "missing"; notice?: string }
20
31
  | { kind: "invalid"; reason: string; notice?: string }
21
32
  | { kind: "loaded"; settings: CaffeinateSettings; notice?: string };
22
33
 
23
- type SettingsMigrationResult = {
24
- kind: "migrated" | "failed";
25
- notice: string;
26
- };
27
-
28
34
  export async function loadSettings(): Promise<SettingsLoadResult> {
35
+ await settingsSaveQueue;
29
36
  const newPath = settingsFilePath();
30
37
  const newSettings = await readSettingsFile(newPath);
31
38
  if (newSettings.kind !== "missing") return withLegacyIgnoredNotice(newSettings);
@@ -39,36 +46,49 @@ export async function loadSettings(): Promise<SettingsLoadResult> {
39
46
  if (legacySettings.kind === "missing") return { kind: "missing" };
40
47
  if (legacySettings.kind === "invalid") return legacySettings;
41
48
 
42
- const migration = await migrateLegacySettings(legacyPath);
43
- if (migration.kind === "failed") {
44
- const settingsCreatedDuringMigration = await readSettingsFile(newPath);
45
- if (settingsCreatedDuringMigration.kind !== "missing") {
46
- return withLegacyIgnoredNotice(settingsCreatedDuringMigration);
47
- }
48
- }
49
- return { ...legacySettings, notice: migration.notice };
49
+ return {
50
+ ...legacySettings,
51
+ notice: `Using legacy ${LEGACY_SETTINGS_FILE}; rename it to ${NEW_SETTINGS_FILE}. Future saves write ${NEW_SETTINGS_FILE} without modifying the legacy file.`,
52
+ };
50
53
  }
51
54
 
52
- async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
55
+ interface SettingsDocumentResult {
56
+ result: SettingsLoadResult;
57
+ document?: Record<string, unknown>;
58
+ }
59
+
60
+ async function readSettingsDocument(filePath: string): Promise<SettingsDocumentResult> {
53
61
  let text: string;
54
62
  try {
55
63
  text = await readFile(filePath, "utf8");
56
64
  } catch (error) {
57
- if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
58
- return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
65
+ if (isNodeError(error) && error.code === "ENOENT") return { result: { kind: "missing" } };
66
+ return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
59
67
  }
60
68
  try {
61
- const settings = normalizeCaffeinateSettings(JSON.parse(text) as unknown);
62
- if (settings) return { kind: "loaded", settings };
69
+ const document = JSON.parse(text) as unknown;
70
+ const settings = normalizeCaffeinateSettings(document);
71
+ if (settings) {
72
+ return {
73
+ result: { kind: "loaded", settings },
74
+ document: { ...(document as Record<string, unknown>) },
75
+ };
76
+ }
63
77
  return {
64
- kind: "invalid",
65
- reason: `${filePath}: expected { "mode": "sleep" | "display", optional "quiet": boolean }`,
78
+ result: {
79
+ kind: "invalid",
80
+ reason: `${filePath}: expected { "mode": "sleep" | "display", optional "quiet": boolean }`,
81
+ },
66
82
  };
67
83
  } catch (error) {
68
- return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
84
+ return { result: { kind: "invalid", reason: `${filePath}: ${formatError(error)}` } };
69
85
  }
70
86
  }
71
87
 
88
+ async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
89
+ return (await readSettingsDocument(filePath)).result;
90
+ }
91
+
72
92
  async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<SettingsLoadResult> {
73
93
  if (!(await fileExists(legacySettingsFilePath()))) return settings;
74
94
  return {
@@ -77,30 +97,6 @@ async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<Se
77
97
  };
78
98
  }
79
99
 
80
- async function migrateLegacySettings(legacyPath: string): Promise<SettingsMigrationResult> {
81
- const newPath = settingsFilePath();
82
- try {
83
- await link(legacyPath, newPath);
84
- } catch (error) {
85
- return {
86
- kind: "failed",
87
- notice: `pi-caffeinate legacy settings migration failed: could not migrate ${legacyPath} to ${newPath}: ${formatError(error)}. The legacy file was used for this session; future saves will write ${NEW_SETTINGS_FILE}.`,
88
- };
89
- }
90
- try {
91
- await rm(legacyPath, { force: true });
92
- } catch (error) {
93
- return {
94
- kind: "migrated",
95
- notice: `pi-caffeinate settings migrated from ${legacyPath} to ${newPath}, but the legacy file could not be removed: ${formatError(error)}. Delete ${LEGACY_SETTINGS_FILE} after confirming your settings.`,
96
- };
97
- }
98
- return {
99
- kind: "migrated",
100
- notice: `pi-caffeinate settings migrated from ${legacyPath} to ${newPath}. ${LEGACY_SETTINGS_FILE} is deprecated and will be removed in a future major release.`,
101
- };
102
- }
103
-
104
100
  async function fileExists(filePath: string) {
105
101
  try {
106
102
  await access(filePath, constants.F_OK);
@@ -110,26 +106,75 @@ async function fileExists(filePath: string) {
110
106
  }
111
107
  }
112
108
 
109
+ async function pathEntryExists(filePath: string) {
110
+ try {
111
+ await lstat(filePath);
112
+ return true;
113
+ } catch (error) {
114
+ if (isNodeError(error) && error.code === "ENOENT") return false;
115
+ throw error;
116
+ }
117
+ }
118
+
113
119
  export function normalizeCaffeinateSettings(value: unknown): CaffeinateSettings | undefined {
114
120
  if (!value || typeof value !== "object") return undefined;
115
121
  const settings = value as { mode?: unknown; quiet?: unknown; updatedAt?: unknown };
116
122
  if (!isCaffeinateMode(settings.mode)) return undefined;
117
123
  if (settings.quiet !== undefined && typeof settings.quiet !== "boolean") return undefined;
118
124
  if (settings.updatedAt !== undefined && typeof settings.updatedAt !== "number") return undefined;
119
- return { mode: settings.mode, quiet: settings.quiet ?? false, updatedAt: settings.updatedAt ?? 0 };
125
+ return {
126
+ mode: settings.mode,
127
+ quiet: settings.quiet ?? false,
128
+ updatedAt: settings.updatedAt ?? 0,
129
+ };
120
130
  }
121
131
 
122
132
  function isCaffeinateMode(value: unknown): value is CaffeinateMode {
123
133
  return value === "sleep" || value === "display";
124
134
  }
125
135
 
126
- export async function saveSettings(settings: CaffeinateSettings) {
136
+ let settingsSaveQueue: Promise<unknown> = Promise.resolve();
137
+
138
+ export function saveSettings(
139
+ settings: Omit<CaffeinateSettings, "quiet"> & { quiet?: boolean },
140
+ operations: Partial<SettingsFileOperations> = {},
141
+ ): Promise<CaffeinateSettings> {
142
+ const operation = settingsSaveQueue.then(() => saveSettingsNow(settings, operations));
143
+ settingsSaveQueue = operation.catch(() => undefined);
144
+ return operation;
145
+ }
146
+
147
+ async function saveSettingsNow(
148
+ settings: Omit<CaffeinateSettings, "quiet"> & { quiet?: boolean },
149
+ operations: Partial<SettingsFileOperations>,
150
+ ): Promise<CaffeinateSettings> {
127
151
  const filePath = settingsFilePath();
152
+ let current = await readSettingsDocument(filePath);
153
+ const replaceCanonical = current.result.kind !== "missing";
154
+ if (!replaceCanonical) current = await readSettingsDocument(legacySettingsFilePath());
155
+ if (current.result.kind === "invalid") {
156
+ throw new Error(`Cannot save pi-caffeinate settings until you repair ${current.result.reason}`);
157
+ }
158
+ const document = current.document ?? {};
159
+ const quiet =
160
+ settings.quiet ?? (current.result.kind === "loaded" ? current.result.settings.quiet : false);
161
+ const nextSettings = { mode: settings.mode, quiet, updatedAt: settings.updatedAt };
162
+ const nextDocument = {
163
+ ...document,
164
+ ...nextSettings,
165
+ };
128
166
  await mkdir(dirname(filePath), { recursive: true });
129
- const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
167
+ const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
130
168
  try {
131
- await writeFile(tempFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
132
- await rename(tempFile, filePath);
169
+ await (operations.write ?? DEFAULT_FILE_OPERATIONS.write)(
170
+ tempFile,
171
+ `${JSON.stringify(nextDocument, null, 2)}\n`,
172
+ );
173
+ if (!replaceCanonical && (await pathEntryExists(filePath))) {
174
+ throw new Error(`${NEW_SETTINGS_FILE} was created concurrently; reopen settings and retry.`);
175
+ }
176
+ await (operations.rename ?? DEFAULT_FILE_OPERATIONS.rename)(tempFile, filePath);
177
+ return nextSettings;
133
178
  } catch (error) {
134
179
  await rm(tempFile, { force: true }).catch(() => undefined);
135
180
  throw error;
@@ -145,7 +190,7 @@ function legacySettingsFilePath() {
145
190
  }
146
191
 
147
192
  function agentDir() {
148
- return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
193
+ return getAgentDir();
149
194
  }
150
195
 
151
196
  function isNodeError(error: unknown): error is NodeJS.ErrnoException {