@narumitw/pi-caffeinate 0.31.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.31.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",
package/src/caffeinate.ts CHANGED
@@ -7,7 +7,13 @@ import type {
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { startInhibitorProcess, stopInhibitorProcess } from "./inhibitor-process.js";
9
9
  import { formatMode, getInhibitorCommand, type InhibitorCommand } from "./inhibitors.js";
10
- import { type CaffeinateMode, loadSettings, saveSettings, settingsFilePath } from "./settings.js";
10
+ import {
11
+ type CaffeinateMode,
12
+ type CaffeinateSettings,
13
+ loadSettings,
14
+ saveSettings,
15
+ settingsFilePath,
16
+ } from "./settings.js";
11
17
 
12
18
  const STATUS_KEY = "caffeinate";
13
19
  const DISABLED_VALUES = new Set(["1", "true", "yes", "on"]);
@@ -49,6 +55,7 @@ interface CaffeinateState {
49
55
  settingsError?: string;
50
56
  settingsNotice?: string;
51
57
  iconWarningShown: boolean;
58
+ sessionGeneration: number;
52
59
  }
53
60
 
54
61
  const state: CaffeinateState = {
@@ -59,19 +66,24 @@ const state: CaffeinateState = {
59
66
  quiet: false,
60
67
  settingsLoaded: false,
61
68
  iconWarningShown: false,
69
+ sessionGeneration: 0,
62
70
  };
63
71
 
64
72
  export default function caffeinate(pi: ExtensionAPI) {
65
73
  pi.on("session_start", async (_event, ctx) => {
74
+ const generation = ++state.sessionGeneration;
66
75
  state.iconWarningShown = false;
67
76
  state.settingsNotice = undefined;
68
77
  warnDeprecatedIcon(ctx);
69
- await loadSettingsIntoState(ctx);
78
+ await loadSettingsIntoState(ctx, generation);
79
+ if (generation !== state.sessionGeneration) return;
70
80
  updateStatus(ctx);
71
81
  });
72
82
 
73
83
  pi.on("agent_start", async (_event, ctx) => {
74
- await ensureSettingsLoaded(ctx);
84
+ const generation = state.sessionGeneration;
85
+ await ensureSettingsLoaded(ctx, generation);
86
+ if (generation !== state.sessionGeneration) return;
75
87
  state.activeTurns += 1;
76
88
  startInhibitor(ctx, { notify: !state.quiet });
77
89
  });
@@ -84,27 +96,36 @@ export default function caffeinate(pi: ExtensionAPI) {
84
96
  updateStatus(ctx);
85
97
  });
86
98
 
87
- pi.on("session_shutdown", (_event, ctx) => {
99
+ pi.on("session_shutdown", async (_event, ctx) => {
100
+ state.sessionGeneration += 1;
88
101
  state.activeTurns = 0;
89
102
  stopInhibitor(ctx, "session shutdown", { notify: false });
90
103
  ctx.ui.setStatus(STATUS_KEY, undefined);
104
+ await modeOperationQueue;
91
105
  });
92
106
 
93
107
  pi.registerCommand("caffeinate", {
94
108
  description: "Open pi-caffeinate keep-awake controls",
95
109
  getArgumentCompletions: (prefix) => commandCompletions(prefix),
96
110
  handler: async (args, ctx) => {
97
- await ensureSettingsLoaded(ctx);
98
- 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);
99
120
  },
100
121
  });
101
122
  }
102
123
 
103
- async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
124
+ async function handleCaffeinateCommand(args: string, ctx: CommandContext, generation: number) {
104
125
  const command = parseCommand(args);
105
126
  switch (command) {
106
127
  case "menu":
107
- await showMenu(ctx);
128
+ await showMenu(ctx, generation);
108
129
  return;
109
130
  case "help":
110
131
  ctx.ui.notify(buildCommandGuide(), "info");
@@ -113,13 +134,13 @@ async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
113
134
  showStatus(ctx);
114
135
  return;
115
136
  case "mode":
116
- await showModeSelector(ctx);
137
+ await showModeSelector(ctx, generation);
117
138
  return;
118
139
  case "sleep":
119
- await setMode(ctx, "sleep");
140
+ await setMode(ctx, "sleep", generation);
120
141
  return;
121
142
  case "display":
122
- await setMode(ctx, "display");
143
+ await setMode(ctx, "display", generation);
123
144
  return;
124
145
  case "stop":
125
146
  stopCaffeinate(ctx, "manual stop");
@@ -129,7 +150,7 @@ async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
129
150
  ctx.ui.notify(`Unknown /caffeinate command: ${args.trim()}\n\n${buildCommandGuide()}`, "warning");
130
151
  }
131
152
 
132
- async function showMenu(ctx: CommandContext) {
153
+ async function showMenu(ctx: CommandContext, generation: number) {
133
154
  if (!ctx.hasUI) {
134
155
  ctx.ui.notify(`${buildCommandGuide()}\n\n${describeState()}`, statusLevel());
135
156
  updateStatus(ctx);
@@ -137,15 +158,16 @@ async function showMenu(ctx: CommandContext) {
137
158
  }
138
159
 
139
160
  const choice = await ctx.ui.select("pi-caffeinate controls", Object.values(MENU_OPTIONS));
161
+ if (generation !== state.sessionGeneration) return;
140
162
  switch (choice) {
141
163
  case MENU_OPTIONS.status:
142
164
  showStatus(ctx);
143
165
  return;
144
166
  case MENU_OPTIONS.sleep:
145
- await setMode(ctx, "sleep");
167
+ await setMode(ctx, "sleep", generation);
146
168
  return;
147
169
  case MENU_OPTIONS.display:
148
- await setMode(ctx, "display");
170
+ await setMode(ctx, "display", generation);
149
171
  return;
150
172
  case MENU_OPTIONS.stop:
151
173
  stopCaffeinate(ctx, "manual stop");
@@ -156,7 +178,7 @@ async function showMenu(ctx: CommandContext) {
156
178
  }
157
179
  }
158
180
 
159
- async function showModeSelector(ctx: CommandContext) {
181
+ async function showModeSelector(ctx: CommandContext, generation: number) {
160
182
  if (!ctx.hasUI) {
161
183
  ctx.ui.notify(
162
184
  `Mode selection needs an interactive UI. Run /caffeinate sleep or /caffeinate display.\n\n${describeState()}`,
@@ -170,40 +192,95 @@ async function showModeSelector(ctx: CommandContext) {
170
192
  `pi-caffeinate mode (current: ${formatMode(state.mode)})`,
171
193
  Object.values(MODE_OPTIONS),
172
194
  );
195
+ if (generation !== state.sessionGeneration) return;
173
196
  if (choice === MODE_OPTIONS.sleep) {
174
- await setMode(ctx, "sleep");
197
+ await setMode(ctx, "sleep", generation);
175
198
  return;
176
199
  }
177
200
  if (choice === MODE_OPTIONS.display) {
178
- await setMode(ctx, "display");
201
+ await setMode(ctx, "display", generation);
179
202
  }
180
203
  }
181
204
 
182
- 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;
183
231
  const previousMode = state.mode;
184
- state.mode = mode;
232
+ const previousQuiet = state.quiet;
233
+ const restartRequired = Boolean(state.process && previousMode !== mode && !state.command?.custom);
185
234
  state.settingsError = undefined;
186
235
 
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");
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
+ }
194
254
  }
195
255
 
196
- if (state.process && previousMode !== mode && !state.command?.custom) {
197
- stopInhibitor(ctx, "mode changed", { notify: false });
198
- 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;
199
278
  }
200
279
 
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
- );
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");
207
284
  updateStatus(ctx);
208
285
  }
209
286
 
@@ -382,12 +459,12 @@ function statusModeLabel() {
382
459
  return formatMode(state.mode);
383
460
  }
384
461
 
385
- async function ensureSettingsLoaded(ctx: ExtensionContext) {
462
+ async function ensureSettingsLoaded(ctx: ExtensionContext, generation: number) {
386
463
  if (state.disabled || state.settingsLoaded) return;
387
- await loadSettingsIntoState(ctx);
464
+ await loadSettingsIntoState(ctx, generation);
388
465
  }
389
466
 
390
- async function loadSettingsIntoState(ctx: ExtensionContext) {
467
+ async function loadSettingsIntoState(ctx: ExtensionContext, generation: number) {
391
468
  if (state.disabled) {
392
469
  state.settingsLoaded = true;
393
470
  state.settingsError = undefined;
@@ -396,6 +473,7 @@ async function loadSettingsIntoState(ctx: ExtensionContext) {
396
473
  }
397
474
 
398
475
  const settings = await loadSettings();
476
+ if (generation !== state.sessionGeneration) return;
399
477
  state.settingsLoaded = true;
400
478
  state.settingsError = undefined;
401
479
  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 {