@narumitw/pi-caffeinate 0.31.0 → 0.38.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
@@ -59,7 +59,8 @@ If no supported inhibitor is available, the extension stays loaded and reports t
59
59
  /caffeinate
60
60
  ```
61
61
 
62
- Opens keep-awake controls. In non-interactive sessions, it prints command usage and status.
62
+ Opens standard keep-awake controls in TUI or RPC mode. Print and JSON modes reject the interactive
63
+ menu observably; use the direct `status`, `sleep`, `display`, `stop`, or `help` routes instead.
63
64
 
64
65
  ```text
65
66
  /caffeinate display
@@ -83,7 +84,7 @@ Shows whether an inhibitor is active, unavailable, disabled, or idle. The status
83
84
  /caffeinate mode
84
85
  ```
85
86
 
86
- Opens an interactive selector for the keep-awake mode.
87
+ Opens the standard keep-awake mode selector in TUI or RPC mode. Escape closes the selector.
87
88
 
88
89
  ```text
89
90
  /caffeinate stop
@@ -119,11 +120,16 @@ when omitted. The file is read at startup and on `/reload`; run `/reload` after
119
120
  running Pi session before using mode commands.
120
121
 
121
122
  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
123
+ 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
124
+ JSON or an invalid recognized field blocks mode saves until repaired instead of being overwritten.
125
+ A failed save keeps the prior runtime mode; if restarting an active inhibitor fails after publication,
126
+ the extension restores the prior saved mode and inhibitor behavior or reports an explicit rollback
127
+ failure.
128
+
129
+ Compatibility: older versions used `pi-caffeinate-settings.json`. A legacy-only file remains
130
+ readable with a warning and is never modified automatically; rename it to `pi-caffeinate.json`.
131
+ The first subsequent settings save writes the canonical file. If both files exist,
132
+ `pi-caffeinate.json` wins and the legacy file is ignored. The legacy filename is deprecated
127
133
  and will be removed in a future major release.
128
134
 
129
135
  ### Environment variables
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-caffeinate",
3
- "version": "0.31.0",
3
+ "version": "0.38.0",
4
4
  "description": "Pi extension that keeps the computer awake while the agent is running.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,5 +38,8 @@
38
38
  "type": "git",
39
39
  "url": "https://github.com/narumiruna/pi-extensions",
40
40
  "directory": "extensions/pi-caffeinate"
41
+ },
42
+ "dependencies": {
43
+ "@narumitw/pi-tui-kit": "<1"
41
44
  }
42
45
  }
package/src/caffeinate.ts CHANGED
@@ -5,9 +5,16 @@ import type {
5
5
  ExtensionCommandContext,
6
6
  ExtensionContext,
7
7
  } from "@earendil-works/pi-coding-agent";
8
+ import { defineMenu, runMenu } from "@narumitw/pi-tui-kit";
8
9
  import { startInhibitorProcess, stopInhibitorProcess } from "./inhibitor-process.js";
9
10
  import { formatMode, getInhibitorCommand, type InhibitorCommand } from "./inhibitors.js";
10
- import { type CaffeinateMode, loadSettings, saveSettings, settingsFilePath } from "./settings.js";
11
+ import {
12
+ type CaffeinateMode,
13
+ type CaffeinateSettings,
14
+ loadSettings,
15
+ saveSettings,
16
+ settingsFilePath,
17
+ } from "./settings.js";
11
18
 
12
19
  const STATUS_KEY = "caffeinate";
13
20
  const DISABLED_VALUES = new Set(["1", "true", "yes", "on"]);
@@ -49,6 +56,8 @@ interface CaffeinateState {
49
56
  settingsError?: string;
50
57
  settingsNotice?: string;
51
58
  iconWarningShown: boolean;
59
+ sessionGeneration: number;
60
+ menuController: AbortController;
52
61
  }
53
62
 
54
63
  const state: CaffeinateState = {
@@ -59,19 +68,26 @@ const state: CaffeinateState = {
59
68
  quiet: false,
60
69
  settingsLoaded: false,
61
70
  iconWarningShown: false,
71
+ sessionGeneration: 0,
72
+ menuController: new AbortController(),
62
73
  };
63
74
 
64
75
  export default function caffeinate(pi: ExtensionAPI) {
65
76
  pi.on("session_start", async (_event, ctx) => {
77
+ const generation = ++state.sessionGeneration;
78
+ replaceMenuController("Caffeinate session replaced");
66
79
  state.iconWarningShown = false;
67
80
  state.settingsNotice = undefined;
68
81
  warnDeprecatedIcon(ctx);
69
- await loadSettingsIntoState(ctx);
82
+ await loadSettingsIntoState(ctx, generation);
83
+ if (generation !== state.sessionGeneration) return;
70
84
  updateStatus(ctx);
71
85
  });
72
86
 
73
87
  pi.on("agent_start", async (_event, ctx) => {
74
- await ensureSettingsLoaded(ctx);
88
+ const generation = state.sessionGeneration;
89
+ await ensureSettingsLoaded(ctx, generation);
90
+ if (generation !== state.sessionGeneration) return;
75
91
  state.activeTurns += 1;
76
92
  startInhibitor(ctx, { notify: !state.quiet });
77
93
  });
@@ -84,27 +100,37 @@ export default function caffeinate(pi: ExtensionAPI) {
84
100
  updateStatus(ctx);
85
101
  });
86
102
 
87
- pi.on("session_shutdown", (_event, ctx) => {
103
+ pi.on("session_shutdown", async (_event, ctx) => {
104
+ state.sessionGeneration += 1;
105
+ state.menuController.abort(new DOMException("Caffeinate session shut down", "AbortError"));
88
106
  state.activeTurns = 0;
89
107
  stopInhibitor(ctx, "session shutdown", { notify: false });
90
108
  ctx.ui.setStatus(STATUS_KEY, undefined);
109
+ await modeOperationQueue;
91
110
  });
92
111
 
93
112
  pi.registerCommand("caffeinate", {
94
113
  description: "Open pi-caffeinate keep-awake controls",
95
114
  getArgumentCompletions: (prefix) => commandCompletions(prefix),
96
115
  handler: async (args, ctx) => {
97
- await ensureSettingsLoaded(ctx);
98
- await handleCaffeinateCommand(args, ctx);
116
+ const generation = state.sessionGeneration;
117
+ const command = parseCommand(args);
118
+ if (command === "sleep" || command === "display") {
119
+ await setModeAfterSettingsLoad(ctx, command, generation);
120
+ return;
121
+ }
122
+ await ensureSettingsLoaded(ctx, generation);
123
+ if (generation !== state.sessionGeneration) return;
124
+ await handleCaffeinateCommand(args, ctx, generation);
99
125
  },
100
126
  });
101
127
  }
102
128
 
103
- async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
129
+ async function handleCaffeinateCommand(args: string, ctx: CommandContext, generation: number) {
104
130
  const command = parseCommand(args);
105
131
  switch (command) {
106
132
  case "menu":
107
- await showMenu(ctx);
133
+ await showMenu(ctx, generation);
108
134
  return;
109
135
  case "help":
110
136
  ctx.ui.notify(buildCommandGuide(), "info");
@@ -113,13 +139,13 @@ async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
113
139
  showStatus(ctx);
114
140
  return;
115
141
  case "mode":
116
- await showModeSelector(ctx);
142
+ await showModeSelector(ctx, generation);
117
143
  return;
118
144
  case "sleep":
119
- await setMode(ctx, "sleep");
145
+ await setMode(ctx, "sleep", generation);
120
146
  return;
121
147
  case "display":
122
- await setMode(ctx, "display");
148
+ await setMode(ctx, "display", generation);
123
149
  return;
124
150
  case "stop":
125
151
  stopCaffeinate(ctx, "manual stop");
@@ -129,81 +155,163 @@ async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
129
155
  ctx.ui.notify(`Unknown /caffeinate command: ${args.trim()}\n\n${buildCommandGuide()}`, "warning");
130
156
  }
131
157
 
132
- async function showMenu(ctx: CommandContext) {
133
- if (!ctx.hasUI) {
134
- ctx.ui.notify(`${buildCommandGuide()}\n\n${describeState()}`, statusLevel());
135
- updateStatus(ctx);
136
- return;
137
- }
158
+ async function showMenu(ctx: CommandContext, generation: number) {
159
+ await runCaffeinateMenu(ctx, generation, "main");
160
+ }
138
161
 
139
- const choice = await ctx.ui.select("pi-caffeinate controls", Object.values(MENU_OPTIONS));
140
- switch (choice) {
141
- case MENU_OPTIONS.status:
142
- showStatus(ctx);
143
- return;
144
- case MENU_OPTIONS.sleep:
145
- await setMode(ctx, "sleep");
146
- return;
147
- case MENU_OPTIONS.display:
148
- await setMode(ctx, "display");
149
- return;
150
- case MENU_OPTIONS.stop:
151
- stopCaffeinate(ctx, "manual stop");
152
- return;
153
- case MENU_OPTIONS.help:
154
- ctx.ui.notify(buildCommandGuide(), "info");
155
- return;
156
- }
162
+ async function showModeSelector(ctx: CommandContext, generation: number) {
163
+ await runCaffeinateMenu(ctx, generation, "mode");
157
164
  }
158
165
 
159
- async function showModeSelector(ctx: CommandContext) {
166
+ async function runCaffeinateMenu(ctx: CommandContext, generation: number, start: "main" | "mode") {
160
167
  if (!ctx.hasUI) {
161
- ctx.ui.notify(
162
- `Mode selection needs an interactive UI. Run /caffeinate sleep or /caffeinate display.\n\n${describeState()}`,
163
- statusLevel(),
168
+ throw new Error(
169
+ start === "mode"
170
+ ? "Mode selection requires TUI or RPC mode. Use /caffeinate sleep or /caffeinate display."
171
+ : `The pi-caffeinate menu requires TUI or RPC mode.\n\n${buildCommandGuide()}\n\n${describeState()}`,
164
172
  );
165
- updateStatus(ctx);
166
- return;
167
173
  }
174
+ type Screen = "main" | "mode";
175
+ type Action = "display" | "sleep" | "status" | "stop" | "help";
176
+ const menu = defineMenu<undefined, Screen, Action>({
177
+ start,
178
+ screens: {
179
+ main: () => ({
180
+ kind: "actions",
181
+ title: "pi-caffeinate controls",
182
+ lines: describeState().split("\n"),
183
+ items: Object.entries(MENU_OPTIONS).map(([id, label]) => ({
184
+ id,
185
+ label,
186
+ action: id as Action,
187
+ })),
188
+ hint: "close",
189
+ }),
190
+ mode: () => ({
191
+ kind: "actions",
192
+ title: `pi-caffeinate mode (current: ${formatMode(state.mode)})`,
193
+ items: Object.entries(MODE_OPTIONS).map(([id, label]) => ({
194
+ id,
195
+ label,
196
+ action: id as Action,
197
+ })),
198
+ hint: "close",
199
+ }),
200
+ },
201
+ actions: {
202
+ display: async () => {
203
+ await setMode(ctx, "display", generation);
204
+ return { kind: "close" };
205
+ },
206
+ sleep: async () => {
207
+ await setMode(ctx, "sleep", generation);
208
+ return { kind: "close" };
209
+ },
210
+ status: async () => {
211
+ showStatus(ctx);
212
+ return { kind: "close" };
213
+ },
214
+ stop: async () => {
215
+ stopCaffeinate(ctx, "manual stop");
216
+ return { kind: "close" };
217
+ },
218
+ help: async () => {
219
+ ctx.ui.notify(buildCommandGuide(), "info");
220
+ return { kind: "close" };
221
+ },
222
+ },
223
+ });
224
+ await runMenu(ctx, menu, {
225
+ getState: () => undefined,
226
+ signal: state.menuController.signal,
227
+ isCurrent: () => generation === state.sessionGeneration,
228
+ });
229
+ }
168
230
 
169
- const choice = await ctx.ui.select(
170
- `pi-caffeinate mode (current: ${formatMode(state.mode)})`,
171
- Object.values(MODE_OPTIONS),
172
- );
173
- if (choice === MODE_OPTIONS.sleep) {
174
- await setMode(ctx, "sleep");
175
- return;
176
- }
177
- if (choice === MODE_OPTIONS.display) {
178
- await setMode(ctx, "display");
179
- }
231
+ function replaceMenuController(reason: string) {
232
+ state.menuController.abort(new DOMException(reason, "AbortError"));
233
+ state.menuController = new AbortController();
234
+ }
235
+
236
+ let modeOperationQueue = Promise.resolve();
237
+
238
+ function setModeAfterSettingsLoad(
239
+ ctx: ExtensionContext,
240
+ mode: CaffeinateMode,
241
+ generation: number,
242
+ ): Promise<void> {
243
+ return enqueueModeOperation(async () => {
244
+ await ensureSettingsLoaded(ctx, generation);
245
+ if (generation !== state.sessionGeneration) return;
246
+ await setModeNow(ctx, mode, generation);
247
+ });
248
+ }
249
+
250
+ function setMode(ctx: ExtensionContext, mode: CaffeinateMode, generation: number): Promise<void> {
251
+ return enqueueModeOperation(() => setModeNow(ctx, mode, generation));
180
252
  }
181
253
 
182
- async function setMode(ctx: ExtensionContext, mode: CaffeinateMode) {
254
+ function enqueueModeOperation(operation: () => Promise<void>): Promise<void> {
255
+ const queued = modeOperationQueue.then(operation);
256
+ modeOperationQueue = queued.catch(() => undefined);
257
+ return queued;
258
+ }
259
+
260
+ async function setModeNow(ctx: ExtensionContext, mode: CaffeinateMode, generation: number) {
261
+ if (generation !== state.sessionGeneration) return;
183
262
  const previousMode = state.mode;
184
- state.mode = mode;
263
+ const previousQuiet = state.quiet;
264
+ const restartRequired = Boolean(state.process && previousMode !== mode && !state.command?.custom);
185
265
  state.settingsError = undefined;
186
266
 
187
- let saved = true;
188
- try {
189
- await saveSettings({ mode, quiet: state.quiet, updatedAt: Date.now() });
190
- } catch (error) {
191
- saved = false;
192
- state.settingsError = `settings save failed: ${formatError(error)}`;
193
- ctx.ui.notify(`pi-caffeinate settings save failed: ${formatError(error)}`, "warning");
267
+ if (restartRequired) {
268
+ state.mode = mode;
269
+ stopInhibitor(ctx, "mode changed", { notify: false });
270
+ startInhibitor(ctx, { notify: false });
271
+ if (!state.process) {
272
+ const applicationError = state.lastError ?? "the inhibitor could not be restarted";
273
+ state.mode = previousMode;
274
+ startInhibitor(ctx, { notify: false });
275
+ const rollbackError = state.process
276
+ ? undefined
277
+ : (state.lastError ?? "the previous inhibitor could not be restored");
278
+ state.settingsError = rollbackError
279
+ ? `mode application failed (${applicationError}); runtime rollback failed: ${rollbackError}`
280
+ : `mode application failed and was rolled back: ${applicationError}`;
281
+ ctx.ui.notify(`pi-caffeinate ${state.settingsError}`, "warning");
282
+ updateStatus(ctx);
283
+ return;
284
+ }
194
285
  }
195
286
 
196
- if (state.process && previousMode !== mode && !state.command?.custom) {
197
- stopInhibitor(ctx, "mode changed", { notify: false });
198
- startInhibitor(ctx, { notify: !state.quiet });
287
+ let savedSettings: CaffeinateSettings;
288
+ try {
289
+ savedSettings = await saveSettings({ mode, updatedAt: Date.now() });
290
+ } catch (error) {
291
+ if (generation !== state.sessionGeneration) return;
292
+ let rollbackError: string | undefined;
293
+ if (restartRequired) {
294
+ state.mode = previousMode;
295
+ state.quiet = previousQuiet;
296
+ stopInhibitor(ctx, "settings save failed", { notify: false });
297
+ startInhibitor(ctx, { notify: false });
298
+ if (!state.process) rollbackError = state.lastError ?? "the prior inhibitor was not restored";
299
+ }
300
+ state.settingsError = rollbackError
301
+ ? `settings save failed (${formatError(error)}); runtime rollback failed: ${rollbackError}`
302
+ : `settings save failed: ${formatError(error)}`;
303
+ ctx.ui.notify(
304
+ `pi-caffeinate mode remains ${formatMode(previousMode)}; settings were not saved: ${state.settingsError}`,
305
+ "warning",
306
+ );
307
+ updateStatus(ctx);
308
+ return;
199
309
  }
200
310
 
201
- ctx.ui.notify(
202
- saved
203
- ? `pi-caffeinate mode set to ${formatMode(mode)} and saved.`
204
- : `pi-caffeinate mode set to ${formatMode(mode)} for this session, but settings were not saved.`,
205
- saved ? "info" : "warning",
206
- );
311
+ if (generation !== state.sessionGeneration) return;
312
+ state.mode = mode;
313
+ state.quiet = savedSettings.quiet;
314
+ ctx.ui.notify(`pi-caffeinate mode set to ${formatMode(mode)} and saved.`, "info");
207
315
  updateStatus(ctx);
208
316
  }
209
317
 
@@ -382,12 +490,12 @@ function statusModeLabel() {
382
490
  return formatMode(state.mode);
383
491
  }
384
492
 
385
- async function ensureSettingsLoaded(ctx: ExtensionContext) {
493
+ async function ensureSettingsLoaded(ctx: ExtensionContext, generation: number) {
386
494
  if (state.disabled || state.settingsLoaded) return;
387
- await loadSettingsIntoState(ctx);
495
+ await loadSettingsIntoState(ctx, generation);
388
496
  }
389
497
 
390
- async function loadSettingsIntoState(ctx: ExtensionContext) {
498
+ async function loadSettingsIntoState(ctx: ExtensionContext, generation: number) {
391
499
  if (state.disabled) {
392
500
  state.settingsLoaded = true;
393
501
  state.settingsError = undefined;
@@ -396,6 +504,7 @@ async function loadSettingsIntoState(ctx: ExtensionContext) {
396
504
  }
397
505
 
398
506
  const settings = await loadSettings();
507
+ if (generation !== state.sessionGeneration) return;
399
508
  state.settingsLoaded = true;
400
509
  state.settingsError = undefined;
401
510
  if (settings.notice) {
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,6 +106,16 @@ 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 };
@@ -127,13 +133,48 @@ function isCaffeinateMode(value: unknown): value is CaffeinateMode {
127
133
  return value === "sleep" || value === "display";
128
134
  }
129
135
 
130
- 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> {
131
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
+ };
132
166
  await mkdir(dirname(filePath), { recursive: true });
133
- const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
167
+ const tempFile = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
134
168
  try {
135
- await writeFile(tempFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
136
- 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;
137
178
  } catch (error) {
138
179
  await rm(tempFile, { force: true }).catch(() => undefined);
139
180
  throw error;
@@ -149,7 +190,7 @@ function legacySettingsFilePath() {
149
190
  }
150
191
 
151
192
  function agentDir() {
152
- return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
193
+ return getAgentDir();
153
194
  }
154
195
 
155
196
  function isNodeError(error: unknown): error is NodeJS.ErrnoException {