@narumitw/pi-caffeinate 0.13.0 → 0.14.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
@@ -115,8 +115,7 @@ Missing, invalid, or deleted settings default back to `display` mode on every su
115
115
  Compatibility: older versions used `pi-caffeinate-settings.json`. During the migration window, a
116
116
  legacy-only file is automatically migrated to `pi-caffeinate.json` with a warning. If both files
117
117
  exist, `pi-caffeinate.json` wins and the legacy file is ignored. The legacy filename is deprecated
118
- and planned for removal after at least one minor release cycle, preferably 4–8 weeks, or at the
119
- next major release.
118
+ and will be removed in a future major release.
120
119
 
121
120
  ### Environment variables
122
121
 
@@ -157,13 +156,16 @@ The default display-awake mode prioritizes uninterrupted long-running Pi work ac
157
156
  ```txt
158
157
  extensions/pi-caffeinate/
159
158
  ├── src/
160
- │ └── caffeinate.ts
159
+ │ ├── caffeinate.ts # Pi entrypoint and lifecycle orchestration
160
+ │ └── *.ts # Package-local inhibitor and settings modules
161
161
  ├── README.md
162
162
  ├── LICENSE
163
163
  ├── tsconfig.json
164
164
  └── package.json
165
165
  ```
166
166
 
167
+ Only `caffeinate.ts` is a Pi entrypoint; the other source modules are internal.
168
+
167
169
  ## 🔎 Keywords
168
170
 
169
171
  Pi extension, Pi coding agent, caffeinate, prevent sleep, keep awake, sleep inhibitor, AI agent automation, long-running coding task, TypeScript Pi package.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-caffeinate",
3
- "version": "0.13.0",
3
+ "version": "0.14.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,9 +29,9 @@
29
29
  "typecheck": "tsc --noEmit"
30
30
  },
31
31
  "devDependencies": {
32
- "@biomejs/biome": "2.5.2",
32
+ "@biomejs/biome": "2.5.3",
33
33
  "@earendil-works/pi-coding-agent": "0.80.3",
34
- "@types/node": "26.1.0",
34
+ "@types/node": "26.1.1",
35
35
  "typescript": "6.0.3"
36
36
  },
37
37
  "repository": {
package/src/caffeinate.ts CHANGED
@@ -1,10 +1,20 @@
1
- import { type ChildProcess, spawn } from "node:child_process";
2
- import { constants, existsSync } from "node:fs";
3
- import { access, link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
- import { homedir } from "node:os";
5
- import { dirname, join } from "node:path";
6
- import pathModule from "node:path";
1
+ import type { ChildProcess } from "node:child_process";
7
2
  import process from "node:process";
3
+ import { startInhibitorProcess, stopInhibitorProcess } from "./inhibitor-process.js";
4
+ import {
5
+ formatMode,
6
+ getInhibitorCommand,
7
+ type InhibitorCommand,
8
+ splitCommand,
9
+ windowsInhibitorScript,
10
+ } from "./inhibitors.js";
11
+ import {
12
+ type CaffeinateMode,
13
+ loadSettings,
14
+ normalizeCaffeinateSettings,
15
+ saveSettings,
16
+ settingsFilePath,
17
+ } from "./settings.js";
8
18
  import type {
9
19
  ExtensionAPI,
10
20
  ExtensionCommandContext,
@@ -14,8 +24,6 @@ import type {
14
24
  const STATUS_KEY = "caffeinate";
15
25
  const DISABLED_VALUES = new Set(["1", "true", "yes", "on"]);
16
26
  const DEFAULT_MODE = "display" satisfies CaffeinateMode;
17
- const NEW_SETTINGS_FILE = "pi-caffeinate.json";
18
- const LEGACY_SETTINGS_FILE = "pi-caffeinate-settings.json";
19
27
  const COMMAND_COMPLETIONS = [
20
28
  { value: "display", label: "display", description: "Keep system and display awake" },
21
29
  { value: "sleep", label: "sleep", description: "Keep system awake; allow display sleep" },
@@ -36,18 +44,9 @@ const MODE_OPTIONS = {
36
44
  sleep: "Keep system awake; allow display sleep",
37
45
  } as const;
38
46
 
39
- type CaffeinateMode = "sleep" | "display";
40
47
  type CommandAction = "menu" | "help" | "status" | "mode" | "sleep" | "display" | "stop";
41
48
  type CommandContext = ExtensionCommandContext;
42
49
 
43
- interface InhibitorCommand {
44
- command: string;
45
- args: string[];
46
- description: string;
47
- releaseOnStdinClose?: boolean;
48
- custom?: boolean;
49
- }
50
-
51
50
  interface CaffeinateState {
52
51
  process?: ChildProcess;
53
52
  startedAt?: number;
@@ -63,11 +62,6 @@ interface CaffeinateState {
63
62
  iconWarningShown: boolean;
64
63
  }
65
64
 
66
- interface CaffeinateSettings {
67
- mode: CaffeinateMode;
68
- updatedAt: number;
69
- }
70
-
71
65
  const state: CaffeinateState = {
72
66
  activeTurns: 0,
73
67
  available: true,
@@ -268,12 +262,7 @@ function buildCommandGuide() {
268
262
  }
269
263
 
270
264
  function startInhibitor(ctx: ExtensionContext) {
271
- if (state.disabled) {
272
- updateStatus(ctx);
273
- return;
274
- }
275
-
276
- if (state.process) {
265
+ if (state.disabled || state.process) {
277
266
  updateStatus(ctx);
278
267
  return;
279
268
  }
@@ -288,37 +277,32 @@ function startInhibitor(ctx: ExtensionContext) {
288
277
  }
289
278
 
290
279
  try {
291
- const child = spawn(command.command, command.args, {
292
- detached: false,
293
- stdio: [command.releaseOnStdinClose ? "pipe" : "ignore", "pipe", "pipe"],
294
- });
295
-
280
+ const child = startInhibitorProcess(
281
+ command,
282
+ (error) => {
283
+ if (state.process === child) {
284
+ state.process = undefined;
285
+ state.startedAt = undefined;
286
+ }
287
+ state.available = false;
288
+ state.lastError = `${command.description} failed: ${error.message}`;
289
+ ctx.ui.notify(state.lastError, "warning");
290
+ updateStatus(ctx);
291
+ },
292
+ (exit) => {
293
+ if (state.process !== child) return;
294
+ state.process = undefined;
295
+ state.startedAt = undefined;
296
+ state.lastError = `${command.description} exited unexpectedly (${exit}).`;
297
+ ctx.ui.notify(state.lastError, "warning");
298
+ updateStatus(ctx);
299
+ },
300
+ );
296
301
  state.process = child;
297
302
  state.startedAt = Date.now();
298
303
  state.command = command;
299
304
  state.available = true;
300
305
  state.lastError = undefined;
301
-
302
- child.once("error", (error) => {
303
- if (state.process === child) {
304
- state.process = undefined;
305
- state.startedAt = undefined;
306
- }
307
- state.available = false;
308
- state.lastError = `${command.description} failed: ${error.message}`;
309
- ctx.ui.notify(state.lastError, "warning");
310
- updateStatus(ctx);
311
- });
312
-
313
- child.once("exit", (code, signal) => {
314
- if (state.process !== child) return;
315
- state.process = undefined;
316
- state.startedAt = undefined;
317
- state.lastError = `${command.description} exited unexpectedly (${formatExit(code, signal)}).`;
318
- ctx.ui.notify(state.lastError, "warning");
319
- updateStatus(ctx);
320
- });
321
-
322
306
  ctx.ui.notify(`Keeping computer awake (${statusModeLabel()}).`, "info");
323
307
  updateStatus(ctx);
324
308
  } catch (error) {
@@ -334,194 +318,11 @@ function startInhibitor(ctx: ExtensionContext) {
334
318
  function stopInhibitor(ctx: ExtensionContext, reason: string, options: { notify?: boolean } = {}) {
335
319
  const child = state.process;
336
320
  if (!child) return;
337
-
338
321
  state.process = undefined;
339
322
  state.startedAt = undefined;
340
-
341
- child.removeAllListeners("exit");
342
- child.removeAllListeners("error");
343
-
344
- if (!child.killed) {
345
- if (state.command?.releaseOnStdinClose && child.stdin && !child.stdin.destroyed) {
346
- child.stdin.end();
347
- if (child.exitCode === null && child.signalCode === null) {
348
- const killTimer = setTimeout(() => {
349
- if (child.exitCode === null && child.signalCode === null && !child.killed) child.kill();
350
- }, 2000);
351
- killTimer.unref();
352
- child.once("exit", () => clearTimeout(killTimer));
353
- }
354
- } else if (process.platform === "win32") {
355
- child.kill();
356
- } else {
357
- child.kill("SIGTERM");
358
- }
359
- }
360
-
323
+ stopInhibitorProcess(child, state.command);
361
324
  state.command = undefined;
362
-
363
- if (options.notify !== false) {
364
- ctx.ui.notify(`Released pi-caffeinate (${reason}).`, "info");
365
- }
366
- }
367
-
368
- function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | undefined {
369
- const customCommand = process.env.PI_CAFFEINATE_COMMAND?.trim();
370
- if (customCommand) {
371
- const [command, ...args] = splitCommand(customCommand);
372
- if (command) return { command, args, description: `custom command (${command})`, custom: true };
373
- }
374
-
375
- if (process.platform === "darwin") {
376
- return parentBoundUnixCommand("caffeinate", macCaffeinateArgs(mode), caffeinateDescription(mode));
377
- }
378
-
379
- if (process.platform === "linux") {
380
- if (isWsl() && commandExists("powershell.exe")) {
381
- return windowsPowerInhibitorCommand("powershell.exe", mode);
382
- }
383
-
384
- if (commandExists("systemd-inhibit")) {
385
- const what = mode === "sleep" ? "sleep" : "idle:sleep";
386
- return parentBoundUnixCommand(
387
- "systemd-inhibit",
388
- [
389
- `--what=${what}`,
390
- "--who=pi-caffeinate",
391
- "--why=Pi agent is running",
392
- "--mode=block",
393
- "sleep",
394
- "infinity",
395
- ],
396
- `systemd-inhibit (${formatMode(mode)})`,
397
- );
398
- }
399
-
400
- if (commandExists("caffeinate")) {
401
- return parentBoundUnixCommand(
402
- "caffeinate",
403
- macCaffeinateArgs(mode),
404
- caffeinateDescription(mode),
405
- );
406
- }
407
- }
408
-
409
- if (process.platform === "win32") {
410
- return windowsPowerInhibitorCommand("powershell.exe", mode);
411
- }
412
-
413
- return undefined;
414
- }
415
-
416
- function macCaffeinateArgs(mode: CaffeinateMode) {
417
- return mode === "sleep" ? ["-ims"] : ["-dimsu"];
418
- }
419
-
420
- function caffeinateDescription(mode: CaffeinateMode) {
421
- return `caffeinate (${formatMode(mode)})`;
422
- }
423
-
424
- function parentBoundUnixCommand(
425
- command: string,
426
- args: string[],
427
- description: string,
428
- ): InhibitorCommand {
429
- return {
430
- command: "sh",
431
- args: [
432
- "-c",
433
- unixParentBoundScript(),
434
- "pi-caffeinate-watch",
435
- String(process.pid),
436
- command,
437
- ...args,
438
- ],
439
- description,
440
- };
441
- }
442
-
443
- function unixParentBoundScript() {
444
- return `parent=$1; shift; "$@" & child=$!; ( while kill -0 "$parent" 2>/dev/null; do sleep 5; done; kill "$child" 2>/dev/null ) & watcher=$!; cleanup() { kill "$watcher" 2>/dev/null; kill "$child" 2>/dev/null; wait "$child" 2>/dev/null; }; trap 'cleanup; exit 0' INT TERM HUP EXIT; wait "$child"; status=$?; kill "$watcher" 2>/dev/null; trap - EXIT; exit "$status"`;
445
- }
446
-
447
- function commandExists(command: string) {
448
- const path = process.env.PATH ?? "";
449
- const extensions = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
450
-
451
- for (const directory of path.split(process.platform === "win32" ? ";" : ":")) {
452
- if (!directory) continue;
453
- for (const extension of extensions) {
454
- const candidate = pathModule.join(directory, `${command}${extension}`);
455
- if (existsSync(candidate)) return true;
456
- }
457
- }
458
-
459
- return false;
460
- }
461
-
462
- export function splitCommand(input: string) {
463
- const parts: string[] = [];
464
- let current = "";
465
- let quote: '"' | "'" | undefined;
466
- let escaping = false;
467
-
468
- for (const char of input) {
469
- if (escaping) {
470
- current += char;
471
- escaping = false;
472
- continue;
473
- }
474
-
475
- if (char === "\\") {
476
- escaping = true;
477
- continue;
478
- }
479
-
480
- if ((char === '"' || char === "'") && !quote) {
481
- quote = char;
482
- continue;
483
- }
484
-
485
- if (char === quote) {
486
- quote = undefined;
487
- continue;
488
- }
489
-
490
- if (/\s/.test(char) && !quote) {
491
- if (current) {
492
- parts.push(current);
493
- current = "";
494
- }
495
- continue;
496
- }
497
-
498
- current += char;
499
- }
500
-
501
- if (current) parts.push(current);
502
- return parts;
503
- }
504
-
505
- function windowsPowerInhibitorCommand(command: string, mode: CaffeinateMode): InhibitorCommand {
506
- return {
507
- command,
508
- args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", windowsInhibitorScript(mode)],
509
- description: `PowerShell SetThreadExecutionState (${formatMode(mode)})`,
510
- releaseOnStdinClose: true,
511
- };
512
- }
513
-
514
- export function windowsInhibitorScript(mode: CaffeinateMode) {
515
- const flags = mode === "sleep" ? "0x80000001" : "0x80000003";
516
- 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 }`;
517
- }
518
-
519
- function isWsl() {
520
- try {
521
- return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop");
522
- } catch {
523
- return false;
524
- }
325
+ if (options.notify !== false) ctx.ui.notify(`Released pi-caffeinate (${reason}).`, "info");
525
326
  }
526
327
 
527
328
  function updateStatus(ctx: ExtensionContext) {
@@ -584,10 +385,6 @@ function statusModeLabel() {
584
385
  return formatMode(state.mode);
585
386
  }
586
387
 
587
- export function formatMode(mode: CaffeinateMode) {
588
- return mode === "sleep" ? "system-awake" : "display-awake";
589
- }
590
-
591
388
  async function ensureSettingsLoaded(ctx: ExtensionContext) {
592
389
  if (state.disabled || state.settingsLoaded) return;
593
390
  await loadSettingsIntoState(ctx);
@@ -623,158 +420,10 @@ async function loadSettingsIntoState(ctx: ExtensionContext) {
623
420
  }
624
421
  }
625
422
 
626
- type SettingsLoadResult =
627
- | { kind: "missing"; notice?: string }
628
- | { kind: "invalid"; reason: string; notice?: string }
629
- | { kind: "loaded"; settings: CaffeinateSettings; notice?: string };
630
-
631
- type SettingsMigrationResult = {
632
- kind: "migrated" | "failed";
633
- notice: string;
634
- };
635
-
636
- async function loadSettings(): Promise<SettingsLoadResult> {
637
- const newPath = settingsFilePath();
638
- const newSettings = await readSettingsFile(newPath);
639
- if (newSettings.kind !== "missing") {
640
- return withLegacyIgnoredNotice(newSettings);
641
- }
642
-
643
- const legacyPath = legacySettingsFilePath();
644
- const legacySettings = await readSettingsFile(legacyPath);
645
- const concurrentlyCreatedSettings = await readSettingsFile(newPath);
646
- if (concurrentlyCreatedSettings.kind !== "missing") {
647
- return withLegacyIgnoredNotice(concurrentlyCreatedSettings);
648
- }
649
- if (legacySettings.kind === "missing") return { kind: "missing" };
650
- if (legacySettings.kind === "invalid") return legacySettings;
651
-
652
- const migration = await migrateLegacySettings(legacyPath);
653
- if (migration.kind === "failed") {
654
- const settingsCreatedDuringMigration = await readSettingsFile(newPath);
655
- if (settingsCreatedDuringMigration.kind !== "missing") {
656
- return withLegacyIgnoredNotice(settingsCreatedDuringMigration);
657
- }
658
- }
659
-
660
- return { ...legacySettings, notice: migration.notice };
661
- }
662
-
663
- async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
664
- let text: string;
665
- try {
666
- text = await readFile(filePath, "utf8");
667
- } catch (error) {
668
- if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
669
- return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
670
- }
671
-
672
- try {
673
- const parsed = JSON.parse(text) as unknown;
674
- const settings = normalizeCaffeinateSettings(parsed);
675
- if (settings) return { kind: "loaded", settings };
676
- return {
677
- kind: "invalid",
678
- reason: `${filePath}: expected { "mode": "sleep" | "display" }`,
679
- };
680
- } catch (error) {
681
- return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
682
- }
683
- }
684
-
685
- async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<SettingsLoadResult> {
686
- if (!(await fileExists(legacySettingsFilePath()))) return settings;
687
- return {
688
- ...settings,
689
- notice: `pi-caffeinate legacy settings ignored: ${legacySettingsFilePath()} exists, but ${settingsFilePath()} takes precedence. Delete ${LEGACY_SETTINGS_FILE} after confirming your settings.`,
690
- };
691
- }
692
-
693
- async function migrateLegacySettings(legacyPath: string): Promise<SettingsMigrationResult> {
694
- const newPath = settingsFilePath();
695
- try {
696
- await link(legacyPath, newPath);
697
- } catch (error) {
698
- return {
699
- kind: "failed",
700
- 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}.`,
701
- };
702
- }
703
-
704
- try {
705
- await rm(legacyPath, { force: true });
706
- } catch (error) {
707
- return {
708
- kind: "migrated",
709
- 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.`,
710
- };
711
- }
712
-
713
- return {
714
- kind: "migrated",
715
- notice: `pi-caffeinate settings migrated from ${legacyPath} to ${newPath}. ${LEGACY_SETTINGS_FILE} is deprecated and will be removed in a future major release.`,
716
- };
717
- }
718
-
719
- async function fileExists(filePath: string) {
720
- try {
721
- await access(filePath, constants.F_OK);
722
- return true;
723
- } catch {
724
- return false;
725
- }
726
- }
727
-
728
- export function normalizeCaffeinateSettings(value: unknown): CaffeinateSettings | undefined {
729
- if (!value || typeof value !== "object") return undefined;
730
- const settings = value as { mode?: unknown; updatedAt?: unknown };
731
- if (!isCaffeinateMode(settings.mode)) return undefined;
732
- if (settings.updatedAt !== undefined && typeof settings.updatedAt !== "number") return undefined;
733
- return { mode: settings.mode, updatedAt: settings.updatedAt ?? 0 };
734
- }
735
-
736
- function isCaffeinateMode(value: unknown): value is CaffeinateMode {
737
- return value === "sleep" || value === "display";
738
- }
739
-
740
- async function saveSettings(settings: CaffeinateSettings) {
741
- const filePath = settingsFilePath();
742
- await mkdir(dirname(filePath), { recursive: true });
743
- const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
744
- try {
745
- await writeFile(tempFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
746
- await rename(tempFile, filePath);
747
- } catch (error) {
748
- await rm(tempFile, { force: true }).catch(() => undefined);
749
- throw error;
750
- }
751
- }
752
-
753
- function settingsFilePath() {
754
- return join(agentDir(), NEW_SETTINGS_FILE);
755
- }
756
-
757
- function legacySettingsFilePath() {
758
- return join(agentDir(), LEGACY_SETTINGS_FILE);
759
- }
760
-
761
- function agentDir() {
762
- return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
763
- }
764
-
765
- function isNodeError(error: unknown): error is NodeJS.ErrnoException {
766
- return error instanceof Error && "code" in error;
767
- }
768
-
769
423
  function formatError(error: unknown) {
770
424
  return error instanceof Error ? error.message : String(error);
771
425
  }
772
426
 
773
- function formatExit(code: number | null, signal: NodeJS.Signals | null) {
774
- if (signal) return `signal ${signal}`;
775
- return `code ${code ?? "unknown"}`;
776
- }
777
-
778
427
  function isDisabled() {
779
428
  const value = process.env.PI_CAFFEINATE_DISABLED?.trim().toLowerCase();
780
429
  return value ? DISABLED_VALUES.has(value) : false;
@@ -797,3 +446,6 @@ function warnDeprecatedIcon(ctx: ExtensionContext) {
797
446
  "warning",
798
447
  );
799
448
  }
449
+
450
+ export { formatMode, splitCommand, windowsInhibitorScript } from "./inhibitors.js";
451
+ export { normalizeCaffeinateSettings } from "./settings.js";
@@ -0,0 +1,43 @@
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
+ import process from "node:process";
3
+ import type { InhibitorCommand } from "./inhibitors.js";
4
+
5
+ export function startInhibitorProcess(
6
+ command: InhibitorCommand,
7
+ onError: (error: Error) => void,
8
+ onUnexpectedExit: (description: string) => void,
9
+ ): ChildProcess {
10
+ const child = spawn(command.command, command.args, {
11
+ detached: false,
12
+ stdio: [command.releaseOnStdinClose ? "pipe" : "ignore", "pipe", "pipe"],
13
+ });
14
+ child.once("error", onError);
15
+ child.once("exit", (code, signal) => onUnexpectedExit(formatExit(code, signal)));
16
+ return child;
17
+ }
18
+
19
+ export function stopInhibitorProcess(child: ChildProcess, command: InhibitorCommand | undefined) {
20
+ child.removeAllListeners("exit");
21
+ child.removeAllListeners("error");
22
+ if (child.killed) return;
23
+
24
+ if (command?.releaseOnStdinClose && child.stdin && !child.stdin.destroyed) {
25
+ child.stdin.end();
26
+ if (child.exitCode === null && child.signalCode === null) {
27
+ const killTimer = setTimeout(() => {
28
+ if (child.exitCode === null && child.signalCode === null && !child.killed) child.kill();
29
+ }, 2000);
30
+ killTimer.unref();
31
+ child.once("exit", () => clearTimeout(killTimer));
32
+ }
33
+ } else if (process.platform === "win32") {
34
+ child.kill();
35
+ } else {
36
+ child.kill("SIGTERM");
37
+ }
38
+ }
39
+
40
+ function formatExit(code: number | null, signal: NodeJS.Signals | null) {
41
+ if (signal) return `signal ${signal}`;
42
+ return `code ${code ?? "unknown"}`;
43
+ }
@@ -0,0 +1,142 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import type { CaffeinateMode } from "./settings.js";
5
+
6
+ export interface InhibitorCommand {
7
+ command: string;
8
+ args: string[];
9
+ description: string;
10
+ releaseOnStdinClose?: boolean;
11
+ custom?: boolean;
12
+ }
13
+
14
+ export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | undefined {
15
+ const customCommand = process.env.PI_CAFFEINATE_COMMAND?.trim();
16
+ if (customCommand) {
17
+ const [command, ...args] = splitCommand(customCommand);
18
+ if (command) return { command, args, description: `custom command (${command})`, custom: true };
19
+ }
20
+ if (process.platform === "darwin") {
21
+ return parentBoundUnixCommand("caffeinate", macCaffeinateArgs(mode), caffeinateDescription(mode));
22
+ }
23
+ if (process.platform === "linux") {
24
+ if (isWsl() && commandExists("powershell.exe")) {
25
+ return windowsPowerInhibitorCommand("powershell.exe", mode);
26
+ }
27
+ if (commandExists("systemd-inhibit")) {
28
+ const what = mode === "sleep" ? "sleep" : "idle:sleep";
29
+ return parentBoundUnixCommand(
30
+ "systemd-inhibit",
31
+ [
32
+ `--what=${what}`,
33
+ "--who=pi-caffeinate",
34
+ "--why=Pi agent is running",
35
+ "--mode=block",
36
+ "sleep",
37
+ "infinity",
38
+ ],
39
+ `systemd-inhibit (${formatMode(mode)})`,
40
+ );
41
+ }
42
+ if (commandExists("caffeinate")) {
43
+ return parentBoundUnixCommand("caffeinate", macCaffeinateArgs(mode), caffeinateDescription(mode));
44
+ }
45
+ }
46
+ if (process.platform === "win32") return windowsPowerInhibitorCommand("powershell.exe", mode);
47
+ return undefined;
48
+ }
49
+
50
+ function macCaffeinateArgs(mode: CaffeinateMode) {
51
+ return mode === "sleep" ? ["-ims"] : ["-dimsu"];
52
+ }
53
+
54
+ function caffeinateDescription(mode: CaffeinateMode) {
55
+ return `caffeinate (${formatMode(mode)})`;
56
+ }
57
+
58
+ function parentBoundUnixCommand(command: string, args: string[], description: string): InhibitorCommand {
59
+ return {
60
+ command: "sh",
61
+ args: ["-c", unixParentBoundScript(), "pi-caffeinate-watch", String(process.pid), command, ...args],
62
+ description,
63
+ };
64
+ }
65
+
66
+ function unixParentBoundScript() {
67
+ return `parent=$1; shift; "$@" & child=$!; ( while kill -0 "$parent" 2>/dev/null; do sleep 5; done; kill "$child" 2>/dev/null ) & watcher=$!; cleanup() { kill "$watcher" 2>/dev/null; kill "$child" 2>/dev/null; wait "$child" 2>/dev/null; }; trap 'cleanup; exit 0' INT TERM HUP EXIT; wait "$child"; status=$?; kill "$watcher" 2>/dev/null; trap - EXIT; exit "$status"`;
68
+ }
69
+
70
+ function commandExists(command: string) {
71
+ const searchPath = process.env.PATH ?? "";
72
+ const extensions = process.platform === "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
73
+ for (const directory of searchPath.split(process.platform === "win32" ? ";" : ":")) {
74
+ if (!directory) continue;
75
+ for (const extension of extensions) {
76
+ if (existsSync(path.join(directory, `${command}${extension}`))) return true;
77
+ }
78
+ }
79
+ return false;
80
+ }
81
+
82
+ export function splitCommand(input: string) {
83
+ const parts: string[] = [];
84
+ let current = "";
85
+ let quote: '"' | "'" | undefined;
86
+ let escaping = false;
87
+ for (const char of input) {
88
+ if (escaping) {
89
+ current += char;
90
+ escaping = false;
91
+ continue;
92
+ }
93
+ if (char === "\\") {
94
+ escaping = true;
95
+ continue;
96
+ }
97
+ if ((char === '"' || char === "'") && !quote) {
98
+ quote = char;
99
+ continue;
100
+ }
101
+ if (char === quote) {
102
+ quote = undefined;
103
+ continue;
104
+ }
105
+ if (/\s/.test(char) && !quote) {
106
+ if (current) {
107
+ parts.push(current);
108
+ current = "";
109
+ }
110
+ continue;
111
+ }
112
+ current += char;
113
+ }
114
+ if (current) parts.push(current);
115
+ return parts;
116
+ }
117
+
118
+ function windowsPowerInhibitorCommand(command: string, mode: CaffeinateMode): InhibitorCommand {
119
+ return {
120
+ command,
121
+ args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", windowsInhibitorScript(mode)],
122
+ description: `PowerShell SetThreadExecutionState (${formatMode(mode)})`,
123
+ releaseOnStdinClose: true,
124
+ };
125
+ }
126
+
127
+ export function windowsInhibitorScript(mode: CaffeinateMode) {
128
+ const flags = mode === "sleep" ? "0x80000001" : "0x80000003";
129
+ 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 }`;
130
+ }
131
+
132
+ function isWsl() {
133
+ try {
134
+ return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop");
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ export function formatMode(mode: CaffeinateMode) {
141
+ return mode === "sleep" ? "system-awake" : "display-awake";
142
+ }
@@ -0,0 +1,152 @@
1
+ import { constants } from "node:fs";
2
+ import { access, link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import process from "node:process";
6
+
7
+ const NEW_SETTINGS_FILE = "pi-caffeinate.json";
8
+ const LEGACY_SETTINGS_FILE = "pi-caffeinate-settings.json";
9
+
10
+ export type CaffeinateMode = "sleep" | "display";
11
+
12
+ export interface CaffeinateSettings {
13
+ mode: CaffeinateMode;
14
+ updatedAt: number;
15
+ }
16
+
17
+ export type SettingsLoadResult =
18
+ | { kind: "missing"; notice?: string }
19
+ | { kind: "invalid"; reason: string; notice?: string }
20
+ | { kind: "loaded"; settings: CaffeinateSettings; notice?: string };
21
+
22
+ type SettingsMigrationResult = {
23
+ kind: "migrated" | "failed";
24
+ notice: string;
25
+ };
26
+
27
+ export async function loadSettings(): Promise<SettingsLoadResult> {
28
+ const newPath = settingsFilePath();
29
+ const newSettings = await readSettingsFile(newPath);
30
+ if (newSettings.kind !== "missing") return withLegacyIgnoredNotice(newSettings);
31
+
32
+ const legacyPath = legacySettingsFilePath();
33
+ const legacySettings = await readSettingsFile(legacyPath);
34
+ const concurrentlyCreatedSettings = await readSettingsFile(newPath);
35
+ if (concurrentlyCreatedSettings.kind !== "missing") {
36
+ return withLegacyIgnoredNotice(concurrentlyCreatedSettings);
37
+ }
38
+ if (legacySettings.kind === "missing") return { kind: "missing" };
39
+ if (legacySettings.kind === "invalid") return legacySettings;
40
+
41
+ const migration = await migrateLegacySettings(legacyPath);
42
+ if (migration.kind === "failed") {
43
+ const settingsCreatedDuringMigration = await readSettingsFile(newPath);
44
+ if (settingsCreatedDuringMigration.kind !== "missing") {
45
+ return withLegacyIgnoredNotice(settingsCreatedDuringMigration);
46
+ }
47
+ }
48
+ return { ...legacySettings, notice: migration.notice };
49
+ }
50
+
51
+ async function readSettingsFile(filePath: string): Promise<SettingsLoadResult> {
52
+ let text: string;
53
+ try {
54
+ text = await readFile(filePath, "utf8");
55
+ } catch (error) {
56
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
57
+ return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
58
+ }
59
+ try {
60
+ const settings = normalizeCaffeinateSettings(JSON.parse(text) as unknown);
61
+ if (settings) return { kind: "loaded", settings };
62
+ return { kind: "invalid", reason: `${filePath}: expected { "mode": "sleep" | "display" }` };
63
+ } catch (error) {
64
+ return { kind: "invalid", reason: `${filePath}: ${formatError(error)}` };
65
+ }
66
+ }
67
+
68
+ async function withLegacyIgnoredNotice(settings: SettingsLoadResult): Promise<SettingsLoadResult> {
69
+ if (!(await fileExists(legacySettingsFilePath()))) return settings;
70
+ return {
71
+ ...settings,
72
+ notice: `pi-caffeinate legacy settings ignored: ${legacySettingsFilePath()} exists, but ${settingsFilePath()} takes precedence. Delete ${LEGACY_SETTINGS_FILE} after confirming your settings.`,
73
+ };
74
+ }
75
+
76
+ async function migrateLegacySettings(legacyPath: string): Promise<SettingsMigrationResult> {
77
+ const newPath = settingsFilePath();
78
+ try {
79
+ await link(legacyPath, newPath);
80
+ } catch (error) {
81
+ return {
82
+ kind: "failed",
83
+ 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}.`,
84
+ };
85
+ }
86
+ try {
87
+ await rm(legacyPath, { force: true });
88
+ } catch (error) {
89
+ return {
90
+ kind: "migrated",
91
+ 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.`,
92
+ };
93
+ }
94
+ return {
95
+ kind: "migrated",
96
+ notice: `pi-caffeinate settings migrated from ${legacyPath} to ${newPath}. ${LEGACY_SETTINGS_FILE} is deprecated and will be removed in a future major release.`,
97
+ };
98
+ }
99
+
100
+ async function fileExists(filePath: string) {
101
+ try {
102
+ await access(filePath, constants.F_OK);
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ export function normalizeCaffeinateSettings(value: unknown): CaffeinateSettings | undefined {
110
+ if (!value || typeof value !== "object") return undefined;
111
+ const settings = value as { mode?: unknown; updatedAt?: unknown };
112
+ if (!isCaffeinateMode(settings.mode)) return undefined;
113
+ if (settings.updatedAt !== undefined && typeof settings.updatedAt !== "number") return undefined;
114
+ return { mode: settings.mode, updatedAt: settings.updatedAt ?? 0 };
115
+ }
116
+
117
+ function isCaffeinateMode(value: unknown): value is CaffeinateMode {
118
+ return value === "sleep" || value === "display";
119
+ }
120
+
121
+ export async function saveSettings(settings: CaffeinateSettings) {
122
+ const filePath = settingsFilePath();
123
+ await mkdir(dirname(filePath), { recursive: true });
124
+ const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
125
+ try {
126
+ await writeFile(tempFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
127
+ await rename(tempFile, filePath);
128
+ } catch (error) {
129
+ await rm(tempFile, { force: true }).catch(() => undefined);
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ export function settingsFilePath() {
135
+ return join(agentDir(), NEW_SETTINGS_FILE);
136
+ }
137
+
138
+ function legacySettingsFilePath() {
139
+ return join(agentDir(), LEGACY_SETTINGS_FILE);
140
+ }
141
+
142
+ function agentDir() {
143
+ return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
144
+ }
145
+
146
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
147
+ return error instanceof Error && "code" in error;
148
+ }
149
+
150
+ function formatError(error: unknown) {
151
+ return error instanceof Error ? error.message : String(error);
152
+ }