@narumitw/pi-caffeinate 0.2.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +67 -12
  2. package/package.json +1 -1
  3. package/src/caffeinate.ts +366 -36
package/README.md CHANGED
@@ -10,9 +10,11 @@ It is designed for long-running coding, refactoring, debugging, web research, an
10
10
 
11
11
  - Starts an OS sleep inhibitor when Pi begins processing (`agent_start`).
12
12
  - Releases the inhibitor when processing ends (`agent_end`) or the session shuts down.
13
- - Publishes an `awake` status only while an inhibitor is active.
13
+ - Publishes the active keep-awake mode as status while an inhibitor is active.
14
14
  - Supports macOS, Windows, WSL, and Linux.
15
- - Provides `/caffeinate-status` and `/caffeinate-stop` commands.
15
+ - Defaults to display-awake mode on every supported OS: prevent system sleep and keep the screen/display awake.
16
+ - Provides a single `/caffeinate` command with menu-based controls and direct subcommands.
17
+ - Persists the selected keep-awake mode in a small JSON settings file.
16
18
  - Allows a custom inhibitor command through environment configuration.
17
19
  - Allows a custom status icon through environment configuration.
18
20
  - Fails safely when no supported inhibitor is available.
@@ -37,30 +39,81 @@ pi -e ./extensions/pi-caffeinate
37
39
 
38
40
  ## 🖥️ Supported platforms
39
41
 
40
- - macOS: uses `caffeinate -dimsu`.
41
- - Windows: uses PowerShell `SetThreadExecutionState`.
42
- - WSL: uses Windows `powershell.exe` with `SetThreadExecutionState`.
43
- - Linux: uses `systemd-inhibit` with `sleep infinity`.
44
- - Linux fallback: uses `caffeinate -dimsu` when available.
42
+ The default mode is `display` on every supported OS. That means pi-caffeinate prevents system sleep, suspend, or hibernate and keeps the screen/display awake.
43
+
44
+ Use `/caffeinate sleep` if you want to prevent system sleep while allowing normal display idle behavior such as screen blanking or monitor power-off.
45
+
46
+ | Platform | `sleep` mode | `display` mode, default |
47
+ | --- | --- | --- |
48
+ | macOS | `caffeinate -ims` | `caffeinate -dimsu` |
49
+ | Windows | PowerShell `SetThreadExecutionState(0x80000001)` | PowerShell `SetThreadExecutionState(0x80000003)` |
50
+ | WSL | Windows `powershell.exe` with `SetThreadExecutionState(0x80000001)` | Windows `powershell.exe` with `SetThreadExecutionState(0x80000003)` |
51
+ | Linux with systemd | `systemd-inhibit --what=sleep ... sleep infinity` | `systemd-inhibit --what=idle:sleep ... sleep infinity` |
52
+ | Linux fallback | `caffeinate -ims` when available | `caffeinate -dimsu` when available |
45
53
 
46
54
  If no supported inhibitor is available, the extension stays loaded and reports that caffeinate is unavailable.
47
55
 
48
56
  ## 🚀 Commands
49
57
 
50
58
  ```text
51
- /caffeinate-status
59
+ /caffeinate
60
+ ```
61
+
62
+ Opens keep-awake controls. In non-interactive sessions, it prints command usage and status.
63
+
64
+ ```text
65
+ /caffeinate display
52
66
  ```
53
67
 
54
- Shows whether an inhibitor is active, unavailable, or disabled.
68
+ Keeps the system and screen/display awake. If an inhibitor is currently active, it is restarted so the new mode applies immediately.
55
69
 
56
70
  ```text
57
- /caffeinate-stop
71
+ /caffeinate sleep
58
72
  ```
59
73
 
60
- Manually releases any active inhibitor for the current session.
74
+ Keeps the system awake while allowing normal display sleep. If an inhibitor is currently active, it is restarted so the new mode applies immediately.
75
+
76
+ ```text
77
+ /caffeinate status
78
+ ```
79
+
80
+ Shows whether an inhibitor is active, unavailable, disabled, or idle. The status includes the current mode and settings file path.
81
+
82
+ ```text
83
+ /caffeinate mode
84
+ ```
85
+
86
+ Opens an interactive selector for the keep-awake mode.
87
+
88
+ ```text
89
+ /caffeinate stop
90
+ ```
91
+
92
+ Releases any active inhibitor until Pi starts another agent run.
61
93
 
62
94
  ## ⚙️ Configuration
63
95
 
96
+ ### Persisted mode
97
+
98
+ `/caffeinate sleep` and `/caffeinate display` save the selected mode to:
99
+
100
+ ```text
101
+ ${PI_CODING_AGENT_DIR:-~/.pi/agent}/pi-caffeinate-settings.json
102
+ ```
103
+
104
+ Example:
105
+
106
+ ```json
107
+ {
108
+ "mode": "display",
109
+ "updatedAt": 1791763200000
110
+ }
111
+ ```
112
+
113
+ Missing, invalid, or deleted settings default back to `display` mode on every supported OS.
114
+
115
+ ### Environment variables
116
+
64
117
  Disable the extension:
65
118
 
66
119
  ```bash
@@ -73,7 +126,7 @@ Use a custom inhibitor command:
73
126
  PI_CAFFEINATE_COMMAND='systemd-inhibit --what=idle:sleep --why="pi running" --mode=block sleep infinity' pi
74
127
  ```
75
128
 
76
- The custom command is parsed with shell-like quoting and is run directly without a shell.
129
+ The custom command is parsed with shell-like quoting and is run directly without a shell. `PI_CAFFEINATE_COMMAND` takes precedence over the saved mode; `/caffeinate status` reports when a custom command is active.
77
130
 
78
131
  Customise the status bar icon (default: `💊`):
79
132
 
@@ -85,6 +138,8 @@ PI_CAFFEINATE_ICON='☕️' pi
85
138
 
86
139
  AI coding agents often run tool-heavy tasks that take several minutes. `pi-caffeinate` keeps your machine awake during active Pi work, helping browser automation, local builds, test runs, code generation, and long prompts finish reliably.
87
140
 
141
+ The default display-awake mode prioritizes uninterrupted long-running Pi work across platforms, including Linux desktops that require idle inhibition to prevent automatic suspend. Use sleep-only mode when you prefer normal screen power saving and your system does not need idle inhibition to keep Pi running.
142
+
88
143
  ## 🗂️ Package layout
89
144
 
90
145
  ```txt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-caffeinate",
3
- "version": "0.2.0",
3
+ "version": "0.3.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
@@ -1,17 +1,53 @@
1
1
  import { type ChildProcess, spawn } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
3
6
  import pathModule from "node:path";
4
7
  import process from "node:process";
5
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
8
+ import type {
9
+ ExtensionAPI,
10
+ ExtensionCommandContext,
11
+ ExtensionContext,
12
+ } from "@mariozechner/pi-coding-agent";
6
13
 
7
14
  const STATUS_KEY = "caffeinate";
8
15
  const DISABLED_VALUES = new Set(["1", "true", "yes", "on"]);
16
+ const DEFAULT_MODE = "display" satisfies CaffeinateMode;
17
+ const SETTINGS_FILE = join(
18
+ process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"),
19
+ "pi-caffeinate-settings.json",
20
+ );
21
+ const COMMAND_COMPLETIONS = [
22
+ { value: "display", label: "Keep system and display awake" },
23
+ { value: "sleep", label: "Keep system awake; allow display sleep" },
24
+ { value: "status", label: "Show current status" },
25
+ { value: "mode", label: "Choose keep-awake mode" },
26
+ { value: "stop", label: "Release inhibitor for now" },
27
+ { value: "help", label: "Show command help" },
28
+ ];
29
+ const MENU_OPTIONS = {
30
+ display: "Keep system and display awake",
31
+ sleep: "Keep system awake; allow display sleep",
32
+ status: "Show current status",
33
+ stop: "Release inhibitor for now",
34
+ help: "Show command help",
35
+ } as const;
36
+ const MODE_OPTIONS = {
37
+ display: "Keep system and display awake",
38
+ sleep: "Keep system awake; allow display sleep",
39
+ } as const;
40
+
41
+ type CaffeinateMode = "sleep" | "display";
42
+ type CommandAction = "menu" | "help" | "status" | "mode" | "sleep" | "display" | "stop";
43
+ type CommandContext = ExtensionCommandContext;
9
44
 
10
45
  interface InhibitorCommand {
11
46
  command: string;
12
47
  args: string[];
13
48
  description: string;
14
49
  releaseOnStdinClose?: boolean;
50
+ custom?: boolean;
15
51
  }
16
52
 
17
53
  interface CaffeinateState {
@@ -22,20 +58,32 @@ interface CaffeinateState {
22
58
  activeTurns: number;
23
59
  available: boolean;
24
60
  disabled: boolean;
61
+ mode: CaffeinateMode;
62
+ settingsLoaded: boolean;
63
+ settingsError?: string;
64
+ }
65
+
66
+ interface CaffeinateSettings {
67
+ mode: CaffeinateMode;
68
+ updatedAt: number;
25
69
  }
26
70
 
27
71
  const state: CaffeinateState = {
28
72
  activeTurns: 0,
29
73
  available: true,
30
74
  disabled: isDisabled(),
75
+ mode: DEFAULT_MODE,
76
+ settingsLoaded: false,
31
77
  };
32
78
 
33
79
  export default function caffeinate(pi: ExtensionAPI) {
34
- pi.on("session_start", (_event, ctx) => {
80
+ pi.on("session_start", async (_event, ctx) => {
81
+ await loadSettingsIntoState(ctx);
35
82
  updateStatus(ctx);
36
83
  });
37
84
 
38
- pi.on("agent_start", (_event, ctx) => {
85
+ pi.on("agent_start", async (_event, ctx) => {
86
+ await ensureSettingsLoaded(ctx);
39
87
  state.activeTurns += 1;
40
88
  startInhibitor(ctx);
41
89
  });
@@ -52,22 +100,167 @@ export default function caffeinate(pi: ExtensionAPI) {
52
100
  ctx.ui.setStatus(STATUS_KEY, undefined);
53
101
  });
54
102
 
55
- pi.registerCommand("caffeinate-status", {
56
- description: "Show whether pi-caffeinate is currently keeping the computer awake",
57
- handler: async (_args, ctx) => {
58
- ctx.ui.notify(describeState(), state.process ? "info" : state.available ? "info" : "warning");
59
- updateStatus(ctx);
103
+ pi.registerCommand("caffeinate", {
104
+ description: "Open pi-caffeinate keep-awake controls",
105
+ getArgumentCompletions: (prefix) => commandCompletions(prefix),
106
+ handler: async (args, ctx) => {
107
+ await ensureSettingsLoaded(ctx);
108
+ await handleCaffeinateCommand(args, ctx);
60
109
  },
61
110
  });
62
111
 
63
- pi.registerCommand("caffeinate-stop", {
64
- description: "Release the active pi-caffeinate sleep inhibitor",
65
- handler: async (_args, ctx) => {
66
- state.activeTurns = 0;
67
- stopInhibitor(ctx, "manual stop");
68
- updateStatus(ctx);
69
- },
70
- });
112
+ }
113
+
114
+ async function handleCaffeinateCommand(args: string, ctx: CommandContext) {
115
+ const command = parseCommand(args);
116
+ switch (command) {
117
+ case "menu":
118
+ await showMenu(ctx);
119
+ return;
120
+ case "help":
121
+ ctx.ui.notify(buildCommandGuide(), "info");
122
+ return;
123
+ case "status":
124
+ showStatus(ctx);
125
+ return;
126
+ case "mode":
127
+ await showModeSelector(ctx);
128
+ return;
129
+ case "sleep":
130
+ await setMode(ctx, "sleep");
131
+ return;
132
+ case "display":
133
+ await setMode(ctx, "display");
134
+ return;
135
+ case "stop":
136
+ stopCaffeinate(ctx, "manual stop");
137
+ return;
138
+ }
139
+
140
+ ctx.ui.notify(`Unknown /caffeinate command: ${args.trim()}\n\n${buildCommandGuide()}`, "warning");
141
+ }
142
+
143
+ async function showMenu(ctx: CommandContext) {
144
+ if (!ctx.hasUI) {
145
+ ctx.ui.notify(`${buildCommandGuide()}\n\n${describeState()}`, statusLevel());
146
+ updateStatus(ctx);
147
+ return;
148
+ }
149
+
150
+ const choice = await ctx.ui.select("pi-caffeinate controls", Object.values(MENU_OPTIONS));
151
+ switch (choice) {
152
+ case MENU_OPTIONS.status:
153
+ showStatus(ctx);
154
+ return;
155
+ case MENU_OPTIONS.sleep:
156
+ await setMode(ctx, "sleep");
157
+ return;
158
+ case MENU_OPTIONS.display:
159
+ await setMode(ctx, "display");
160
+ return;
161
+ case MENU_OPTIONS.stop:
162
+ stopCaffeinate(ctx, "manual stop");
163
+ return;
164
+ case MENU_OPTIONS.help:
165
+ ctx.ui.notify(buildCommandGuide(), "info");
166
+ return;
167
+ }
168
+ }
169
+
170
+ async function showModeSelector(ctx: CommandContext) {
171
+ if (!ctx.hasUI) {
172
+ ctx.ui.notify(
173
+ `Mode selection needs an interactive UI. Run /caffeinate sleep or /caffeinate display.\n\n${describeState()}`,
174
+ statusLevel(),
175
+ );
176
+ updateStatus(ctx);
177
+ return;
178
+ }
179
+
180
+ const choice = await ctx.ui.select(
181
+ `pi-caffeinate mode (current: ${formatMode(state.mode)})`,
182
+ Object.values(MODE_OPTIONS),
183
+ );
184
+ if (choice === MODE_OPTIONS.sleep) {
185
+ await setMode(ctx, "sleep");
186
+ return;
187
+ }
188
+ if (choice === MODE_OPTIONS.display) {
189
+ await setMode(ctx, "display");
190
+ }
191
+ }
192
+
193
+ async function setMode(ctx: ExtensionContext, mode: CaffeinateMode) {
194
+ const previousMode = state.mode;
195
+ state.mode = mode;
196
+ state.settingsError = undefined;
197
+
198
+ let saved = true;
199
+ try {
200
+ await saveSettings({ mode, updatedAt: Date.now() });
201
+ } catch (error) {
202
+ saved = false;
203
+ state.settingsError = `settings save failed: ${formatError(error)}`;
204
+ ctx.ui.notify(`pi-caffeinate settings save failed: ${formatError(error)}`, "warning");
205
+ }
206
+
207
+ if (state.process && previousMode !== mode && !state.command?.custom) {
208
+ stopInhibitor(ctx, "mode changed", { notify: false });
209
+ startInhibitor(ctx);
210
+ }
211
+
212
+ ctx.ui.notify(
213
+ saved
214
+ ? `pi-caffeinate mode set to ${formatMode(mode)} and saved.`
215
+ : `pi-caffeinate mode set to ${formatMode(mode)} for this session, but settings were not saved.`,
216
+ saved ? "info" : "warning",
217
+ );
218
+ updateStatus(ctx);
219
+ }
220
+
221
+ function showStatus(ctx: ExtensionContext) {
222
+ ctx.ui.notify(describeState(), statusLevel());
223
+ updateStatus(ctx);
224
+ }
225
+
226
+ function stopCaffeinate(ctx: ExtensionContext, reason: string) {
227
+ state.activeTurns = 0;
228
+ stopInhibitor(ctx, reason);
229
+ updateStatus(ctx);
230
+ }
231
+
232
+ function parseCommand(args: string): CommandAction | "unknown" {
233
+ const command = args.trim().toLowerCase();
234
+ if (!command) return "menu";
235
+ if (command === "help") return "help";
236
+ if (command === "status") return "status";
237
+ if (command === "mode" || command === "config" || command === "settings") return "mode";
238
+ if (command === "sleep" || command === "system") return "sleep";
239
+ if (command === "display" || command === "screen") return "display";
240
+ if (command === "stop" || command === "off") return "stop";
241
+ return "unknown";
242
+ }
243
+
244
+ function commandCompletions(prefix: string) {
245
+ const normalized = prefix.trim().toLowerCase();
246
+ if (normalized.includes(" ")) return null;
247
+
248
+ const matches = COMMAND_COMPLETIONS.filter((completion) =>
249
+ completion.value.startsWith(normalized),
250
+ );
251
+ return matches.length > 0 ? matches : null;
252
+ }
253
+
254
+ function buildCommandGuide() {
255
+ return [
256
+ "pi-caffeinate commands:",
257
+ "/caffeinate — open keep-awake controls",
258
+ "/caffeinate display — keep the system and display awake",
259
+ "/caffeinate sleep — keep the system awake while allowing display sleep",
260
+ "/caffeinate status — show current mode, settings, and inhibitor state",
261
+ "/caffeinate mode — choose a keep-awake mode",
262
+ "/caffeinate stop — release the active inhibitor until the next agent run",
263
+ ].join("\n");
71
264
  }
72
265
 
73
266
  function startInhibitor(ctx: ExtensionContext) {
@@ -81,7 +274,7 @@ function startInhibitor(ctx: ExtensionContext) {
81
274
  return;
82
275
  }
83
276
 
84
- const command = getInhibitorCommand();
277
+ const command = getInhibitorCommand(state.mode);
85
278
  if (!command) {
86
279
  state.available = false;
87
280
  state.lastError = `No supported sleep inhibitor found for ${process.platform}.`;
@@ -168,49 +361,62 @@ function stopInhibitor(ctx: ExtensionContext, reason: string, options: { notify?
168
361
  }
169
362
  }
170
363
 
171
- function getInhibitorCommand(): InhibitorCommand | undefined {
364
+ function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | undefined {
172
365
  const customCommand = process.env.PI_CAFFEINATE_COMMAND?.trim();
173
366
  if (customCommand) {
174
367
  const [command, ...args] = splitCommand(customCommand);
175
- if (command) return { command, args, description: command };
368
+ if (command) return { command, args, description: `custom command (${command})`, custom: true };
176
369
  }
177
370
 
178
371
  if (process.platform === "darwin") {
179
- return parentBoundUnixCommand("caffeinate", ["-dimsu"], "caffeinate");
372
+ return parentBoundUnixCommand("caffeinate", macCaffeinateArgs(mode), caffeinateDescription(mode));
180
373
  }
181
374
 
182
375
  if (process.platform === "linux") {
183
376
  if (isWsl() && commandExists("powershell.exe")) {
184
- return windowsPowerInhibitorCommand("powershell.exe");
377
+ return windowsPowerInhibitorCommand("powershell.exe", mode);
185
378
  }
186
379
 
187
380
  if (commandExists("systemd-inhibit")) {
381
+ const what = mode === "sleep" ? "sleep" : "idle:sleep";
188
382
  return parentBoundUnixCommand(
189
383
  "systemd-inhibit",
190
384
  [
191
- "--what=idle:sleep",
385
+ `--what=${what}`,
192
386
  "--who=pi-caffeinate",
193
387
  "--why=Pi agent is running",
194
388
  "--mode=block",
195
389
  "sleep",
196
390
  "infinity",
197
391
  ],
198
- "systemd-inhibit",
392
+ `systemd-inhibit (${formatMode(mode)})`,
199
393
  );
200
394
  }
201
395
 
202
396
  if (commandExists("caffeinate")) {
203
- return parentBoundUnixCommand("caffeinate", ["-dimsu"], "caffeinate");
397
+ return parentBoundUnixCommand(
398
+ "caffeinate",
399
+ macCaffeinateArgs(mode),
400
+ caffeinateDescription(mode),
401
+ );
204
402
  }
205
403
  }
206
404
 
207
405
  if (process.platform === "win32") {
208
- return windowsPowerInhibitorCommand("powershell.exe");
406
+ return windowsPowerInhibitorCommand("powershell.exe", mode);
209
407
  }
210
408
 
211
409
  return undefined;
212
410
  }
213
411
 
412
+ function macCaffeinateArgs(mode: CaffeinateMode) {
413
+ return mode === "sleep" ? ["-ims"] : ["-dimsu"];
414
+ }
415
+
416
+ function caffeinateDescription(mode: CaffeinateMode) {
417
+ return `caffeinate (${formatMode(mode)})`;
418
+ }
419
+
214
420
  function parentBoundUnixCommand(
215
421
  command: string,
216
422
  args: string[],
@@ -292,17 +498,18 @@ function splitCommand(input: string) {
292
498
  return parts;
293
499
  }
294
500
 
295
- function windowsPowerInhibitorCommand(command: string): InhibitorCommand {
501
+ function windowsPowerInhibitorCommand(command: string, mode: CaffeinateMode): InhibitorCommand {
296
502
  return {
297
503
  command,
298
- args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", windowsInhibitorScript()],
299
- description: "PowerShell SetThreadExecutionState",
504
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", windowsInhibitorScript(mode)],
505
+ description: `PowerShell SetThreadExecutionState (${formatMode(mode)})`,
300
506
  releaseOnStdinClose: true,
301
507
  };
302
508
  }
303
509
 
304
- function windowsInhibitorScript() {
305
- return `$ErrorActionPreference = 'Stop'; Add-Type -Namespace Native -Name Power -MemberDefinition '[DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint esFlags);'; $flags = [uint32]'0x80000003'; $release = [uint32]'0x80000000'; $stdin = [Console]::OpenStandardInput(); $buffer = New-Object byte[] 1; $readTask = $stdin.ReadAsync($buffer, 0, 1); try { while ($true) { [Native.Power]::SetThreadExecutionState($flags) | Out-Null; if ($readTask.Wait(30000)) { break } } } finally { [Native.Power]::SetThreadExecutionState($release) | Out-Null }`;
510
+ function windowsInhibitorScript(mode: CaffeinateMode) {
511
+ const flags = mode === "sleep" ? "0x80000001" : "0x80000003";
512
+ return `$ErrorActionPreference = 'Stop'; Add-Type -Namespace Native -Name Power -MemberDefinition '[DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint esFlags);'; $flags = [uint32]'${flags}'; $release = [uint32]'0x80000000'; $stdin = [Console]::OpenStandardInput(); $buffer = New-Object byte[] 1; $readTask = $stdin.ReadAsync($buffer, 0, 1); try { while ($true) { [Native.Power]::SetThreadExecutionState($flags) | Out-Null; if ($readTask.Wait(30000)) { break } } } finally { [Native.Power]::SetThreadExecutionState($release) | Out-Null }`;
306
513
  }
307
514
 
308
515
  function isWsl() {
@@ -320,7 +527,7 @@ function updateStatus(ctx: ExtensionContext) {
320
527
  }
321
528
 
322
529
  if (state.process) {
323
- ctx.ui.setStatus(STATUS_KEY, `${getIcon()} awake`);
530
+ ctx.ui.setStatus(STATUS_KEY, `${getIcon()} ${statusModeLabel()}`);
324
531
  return;
325
532
  }
326
533
 
@@ -333,14 +540,133 @@ function updateStatus(ctx: ExtensionContext) {
333
540
  }
334
541
 
335
542
  function describeState() {
336
- if (state.disabled) return "pi-caffeinate is disabled by PI_CAFFEINATE_DISABLED.";
543
+ const customCommand = hasCustomCommand();
544
+ const lines = [
545
+ `Mode: ${formatMode(state.mode)}${customCommand ? " (overridden by custom command)" : ""}`,
546
+ `Settings: ${SETTINGS_FILE}`,
547
+ ];
548
+
549
+ if (customCommand) lines.push("Custom command: PI_CAFFEINATE_COMMAND overrides the saved mode.");
550
+ if (state.settingsError) lines.push(`Settings warning: ${state.settingsError}`);
551
+ if (state.disabled) {
552
+ lines.unshift("pi-caffeinate is disabled by PI_CAFFEINATE_DISABLED.");
553
+ return lines.join("\n");
554
+ }
555
+
337
556
  if (state.process) {
338
557
  const seconds = state.startedAt ? Math.round((Date.now() - state.startedAt) / 1000) : 0;
339
- return `pi-caffeinate is active using ${state.command?.description ?? "an inhibitor"} for ${seconds}s.`;
558
+ lines.unshift(
559
+ `pi-caffeinate is active using ${state.command?.description ?? "an inhibitor"} for ${seconds}s.`,
560
+ );
561
+ return lines.join("\n");
562
+ }
563
+
564
+ if (!state.available) {
565
+ lines.unshift(`pi-caffeinate is unavailable: ${state.lastError ?? "unknown reason"}`);
566
+ return lines.join("\n");
567
+ }
568
+
569
+ lines.unshift("pi-caffeinate is idle and will keep the computer awake during the next agent run.");
570
+ return lines.join("\n");
571
+ }
572
+
573
+ function statusLevel() {
574
+ return state.available && !state.settingsError ? "info" : "warning";
575
+ }
576
+
577
+ function statusModeLabel() {
578
+ if (state.command?.custom) return "custom";
579
+ return state.mode === "sleep" ? "sleep" : "display";
580
+ }
581
+
582
+ function formatMode(mode: CaffeinateMode) {
583
+ return mode === "sleep" ? "sleep-only" : "display-awake";
584
+ }
585
+
586
+ async function ensureSettingsLoaded(ctx: ExtensionContext) {
587
+ if (state.disabled || state.settingsLoaded) return;
588
+ await loadSettingsIntoState(ctx);
589
+ }
590
+
591
+ async function loadSettingsIntoState(ctx: ExtensionContext) {
592
+ if (state.disabled) {
593
+ state.settingsLoaded = true;
594
+ state.settingsError = undefined;
595
+ return;
596
+ }
597
+
598
+ const settings = await loadSettings();
599
+ state.settingsLoaded = true;
600
+ state.settingsError = undefined;
601
+
602
+ if (settings.kind === "loaded") {
603
+ state.mode = settings.settings.mode;
604
+ return;
340
605
  }
341
- if (!state.available)
342
- return `pi-caffeinate is unavailable: ${state.lastError ?? "unknown reason"}`;
343
- return "pi-caffeinate is idle and will keep the computer awake during the next agent run.";
606
+
607
+ state.mode = DEFAULT_MODE;
608
+ if (settings.kind === "invalid") {
609
+ state.settingsError = settings.reason;
610
+ ctx.ui.notify(
611
+ `pi-caffeinate settings ignored: ${settings.reason}; using ${formatMode(DEFAULT_MODE)} mode.`,
612
+ "warning",
613
+ );
614
+ }
615
+ }
616
+
617
+ async function loadSettings(): Promise<
618
+ | { kind: "missing" }
619
+ | { kind: "invalid"; reason: string }
620
+ | { kind: "loaded"; settings: CaffeinateSettings }
621
+ > {
622
+ let text: string;
623
+ try {
624
+ text = await readFile(SETTINGS_FILE, "utf8");
625
+ } catch (error) {
626
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
627
+ return { kind: "invalid", reason: formatError(error) };
628
+ }
629
+
630
+ try {
631
+ const parsed = JSON.parse(text) as unknown;
632
+ const settings = normalizeCaffeinateSettings(parsed);
633
+ if (settings) return { kind: "loaded", settings };
634
+ return { kind: "invalid", reason: 'expected { "mode": "sleep" | "display" }' };
635
+ } catch (error) {
636
+ return { kind: "invalid", reason: formatError(error) };
637
+ }
638
+ }
639
+
640
+ function normalizeCaffeinateSettings(value: unknown): CaffeinateSettings | undefined {
641
+ if (!value || typeof value !== "object") return undefined;
642
+ const settings = value as { mode?: unknown; updatedAt?: unknown };
643
+ if (!isCaffeinateMode(settings.mode)) return undefined;
644
+ if (settings.updatedAt !== undefined && typeof settings.updatedAt !== "number") return undefined;
645
+ return { mode: settings.mode, updatedAt: settings.updatedAt ?? 0 };
646
+ }
647
+
648
+ function isCaffeinateMode(value: unknown): value is CaffeinateMode {
649
+ return value === "sleep" || value === "display";
650
+ }
651
+
652
+ async function saveSettings(settings: CaffeinateSettings) {
653
+ await mkdir(dirname(SETTINGS_FILE), { recursive: true });
654
+ const tempFile = `${SETTINGS_FILE}.${process.pid}.${Date.now()}.tmp`;
655
+ try {
656
+ await writeFile(tempFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
657
+ await rename(tempFile, SETTINGS_FILE);
658
+ } catch (error) {
659
+ await rm(tempFile, { force: true }).catch(() => undefined);
660
+ throw error;
661
+ }
662
+ }
663
+
664
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
665
+ return error instanceof Error && "code" in error;
666
+ }
667
+
668
+ function formatError(error: unknown) {
669
+ return error instanceof Error ? error.message : String(error);
344
670
  }
345
671
 
346
672
  function formatExit(code: number | null, signal: NodeJS.Signals | null) {
@@ -353,6 +679,10 @@ function isDisabled() {
353
679
  return value ? DISABLED_VALUES.has(value) : false;
354
680
  }
355
681
 
682
+ function hasCustomCommand() {
683
+ return Boolean(process.env.PI_CAFFEINATE_COMMAND?.trim());
684
+ }
685
+
356
686
  function getIcon() {
357
687
  return process.env.PI_CAFFEINATE_ICON?.trim() ?? "💊";
358
688
  }