@narumitw/pi-caffeinate 0.49.3 → 0.49.4

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
@@ -17,6 +17,7 @@ It is designed for long-running coding, refactoring, debugging, web research, an
17
17
  - Persists the selected keep-awake mode and optional quiet mode in a small JSON settings file.
18
18
  - Allows a custom inhibitor command through environment configuration.
19
19
  - Emits plain status text; `@narumitw/pi-statusline` can add or suppress the status icon from JSON config.
20
+ - Uses the standard `org.freedesktop.ScreenSaver` D-Bus idle-inhibition service for Linux `display` mode.
20
21
  - Fails safely when no supported inhibitor is available.
21
22
 
22
23
  ## 📦 Install
@@ -34,7 +35,7 @@ pi -e npm:@narumitw/pi-caffeinate
34
35
  Try this package locally from the repository root:
35
36
 
36
37
  ```bash
37
- pi -e ./extensions/pi-caffeinate
38
+ pi -e ./packages/pi-caffeinate
38
39
  ```
39
40
 
40
41
  ## 🖥️ Supported platforms
@@ -48,8 +49,20 @@ Use `/caffeinate sleep` if you want to prevent system sleep while allowing norma
48
49
  | macOS | `caffeinate -ims` | `caffeinate -dimsu` |
49
50
  | Windows | PowerShell `SetThreadExecutionState(0x80000001)` | PowerShell `SetThreadExecutionState(0x80000003)` |
50
51
  | 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 |
52
+ | Linux with systemd | `systemd-inhibit --what=sleep ... sleep infinity` | D-Bus `org.freedesktop.ScreenSaver.Inhibit` + `systemd-inhibit --what=idle:sleep ... sleep infinity` |
53
+ | Linux without systemd | `caffeinate -ims` when available | D-Bus `org.freedesktop.ScreenSaver.Inhibit` + `caffeinate -dimsu` when available; D-Bus only otherwise |
54
+
55
+ On Linux, `display` mode requests idle inhibition through the standard `org.freedesktop.ScreenSaver`
56
+ D-Bus service, trying both `/org/freedesktop/ScreenSaver` and `/ScreenSaver` for desktop compatibility.
57
+ The session-bus connection stays open for the whole agent turn.
58
+ The inhibition ends when `UnInhibit` is called or the connection closes.
59
+ `systemd-inhibit --what=idle:sleep` runs alongside it to preserve logind idle and sleep inhibition.
60
+ If no ScreenSaver service is available, pi-caffeinate keeps the systemd blocker or `caffeinate`
61
+ fallback and reports a partial-activation warning.
62
+ If only D-Bus is available, pi-caffeinate reports partial activation because desktop idle is
63
+ inhibited but direct system suspend may remain possible.
64
+ D-Bus method calls use short deadlines, and stop or shutdown aborts an in-flight acquisition before
65
+ closing its session-bus connection.
53
66
 
54
67
  If no supported inhibitor is available, the extension stays loaded and reports that caffeinate is unavailable.
55
68
 
@@ -166,10 +179,15 @@ AI coding agents often run tool-heavy tasks that take several minutes. `pi-caffe
166
179
 
167
180
  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 `/caffeinate sleep` (shown as `system-awake` in status output) when you prefer normal screen power saving and your system does not need idle inhibition to keep Pi running.
168
181
 
182
+ ## 📦 Dependencies
183
+
184
+ On Linux, `display` mode uses the `dbus-native` package (pure JavaScript, no native build step) to
185
+ call `org.freedesktop.ScreenSaver` on the session bus.
186
+
169
187
  ## 🗂️ Package layout
170
188
 
171
189
  ```txt
172
- extensions/pi-caffeinate/
190
+ packages/pi-caffeinate/
173
191
  ├── src/
174
192
  │ ├── index.ts # Pi package entrypoint
175
193
  │ ├── caffeinate.ts # Extension registration and lifecycle orchestration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-caffeinate",
3
- "version": "0.49.3",
3
+ "version": "0.49.4",
4
4
  "description": "Pi extension that keeps the computer awake while the agent is running.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,6 +23,9 @@
23
23
  "./src/index.ts"
24
24
  ]
25
25
  },
26
+ "piExtension": {
27
+ "lifecycle": "stable"
28
+ },
26
29
  "scripts": {
27
30
  "check": "biome check . && npm run typecheck",
28
31
  "format": "biome check --write .",
@@ -33,16 +36,17 @@
33
36
  },
34
37
  "devDependencies": {
35
38
  "@biomejs/biome": "2.5.7",
36
- "@earendil-works/pi-coding-agent": "0.83.0",
39
+ "@earendil-works/pi-coding-agent": "0.84.1",
37
40
  "@types/node": "26.1.2",
38
41
  "typescript": "7.0.2"
39
42
  },
40
43
  "repository": {
41
44
  "type": "git",
42
45
  "url": "https://github.com/narumiruna/pi-extensions",
43
- "directory": "extensions/pi-caffeinate"
46
+ "directory": "packages/pi-caffeinate"
44
47
  },
45
48
  "dependencies": {
46
- "@narumitw/pi-tui-kit": "^0.49.1"
49
+ "@narumitw/pi-tui-kit": "^0.49.1",
50
+ "dbus-native": "^0.15.1"
47
51
  }
48
52
  }
package/src/caffeinate.ts CHANGED
@@ -5,6 +5,13 @@ import type {
5
5
  ExtensionCommandContext,
6
6
  ExtensionContext,
7
7
  } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ type DbusScreenSaverClient,
10
+ type DbusScreenSaverFactory,
11
+ defaultDbusScreenSaverFactory,
12
+ INHIBIT_REASON,
13
+ SCREENSAVER_BUS_NAME,
14
+ } from "./dbus-inhibit.js";
8
15
  import { startInhibitorProcess, stopInhibitorProcess } from "./inhibitor-process.js";
9
16
  import { formatMode, getInhibitorCommand, type InhibitorCommand } from "./inhibitors.js";
10
17
  import {
@@ -41,11 +48,27 @@ const MODE_OPTIONS = {
41
48
  type CommandAction = "menu" | "help" | "status" | "mode" | "sleep" | "display" | "stop";
42
49
  type CommandContext = ExtensionCommandContext;
43
50
 
51
+ interface CaffeinateOptions {
52
+ dbusFactory?: DbusScreenSaverFactory;
53
+ }
54
+
55
+ interface PendingDbusStart {
56
+ token: number;
57
+ controller: AbortController;
58
+ client?: DbusScreenSaverClient;
59
+ }
60
+
44
61
  interface CaffeinateState {
45
62
  process?: ChildProcess;
63
+ dbus?: DbusScreenSaverClient;
64
+ currentSession?: { generation: number; ctx: ExtensionContext };
65
+ dbusCleanup?: Promise<void>;
66
+ pendingDbus?: PendingDbusStart;
46
67
  startedAt?: number;
47
68
  command?: InhibitorCommand;
48
69
  lastError?: string;
70
+ inhibitWarning?: string;
71
+ inhibitorStarting: boolean;
49
72
  activeTurns: number;
50
73
  available: boolean;
51
74
  disabled: boolean;
@@ -62,6 +85,7 @@ interface CaffeinateState {
62
85
  const state: CaffeinateState = {
63
86
  activeTurns: 0,
64
87
  available: true,
88
+ inhibitorStarting: false,
65
89
  disabled: isDisabled(),
66
90
  mode: DEFAULT_MODE,
67
91
  quiet: false,
@@ -71,9 +95,14 @@ const state: CaffeinateState = {
71
95
  menuController: new AbortController(),
72
96
  };
73
97
 
74
- export default function caffeinate(pi: ExtensionAPI) {
98
+ let dbusFactory = defaultDbusScreenSaverFactory;
99
+ let inhibitSequence = 0;
100
+
101
+ export default function caffeinate(pi: ExtensionAPI, options: CaffeinateOptions = {}) {
102
+ dbusFactory = options.dbusFactory ?? defaultDbusScreenSaverFactory;
75
103
  pi.on("session_start", async (_event, ctx) => {
76
104
  const generation = ++state.sessionGeneration;
105
+ state.currentSession = { generation, ctx };
77
106
  replaceMenuController("Caffeinate session replaced");
78
107
  state.iconWarningShown = false;
79
108
  state.settingsNotice = undefined;
@@ -88,24 +117,28 @@ export default function caffeinate(pi: ExtensionAPI) {
88
117
  await ensureSettingsLoaded(ctx, generation);
89
118
  if (generation !== state.sessionGeneration) return;
90
119
  state.activeTurns += 1;
91
- startInhibitor(ctx, { notify: !state.quiet });
120
+ await startInhibitor(ctx, generation, { notify: !state.quiet });
92
121
  });
93
122
 
94
- pi.on("agent_end", (_event, ctx) => {
123
+ pi.on("agent_end", async (_event, ctx) => {
124
+ const generation = state.sessionGeneration;
95
125
  state.activeTurns = Math.max(0, state.activeTurns - 1);
96
126
  if (state.activeTurns === 0) {
97
- stopInhibitor(ctx, "agent finished", { notify: !state.quiet });
127
+ await stopInhibitor(ctx, "agent finished", { notify: !state.quiet });
98
128
  }
129
+ if (generation !== state.sessionGeneration) return;
99
130
  updateStatus(ctx);
100
131
  });
101
132
 
102
133
  pi.on("session_shutdown", async (_event, ctx) => {
103
- state.sessionGeneration += 1;
134
+ const generation = ++state.sessionGeneration;
135
+ state.currentSession = undefined;
104
136
  state.menuController.abort(new DOMException("Caffeinate session shut down", "AbortError"));
105
137
  state.activeTurns = 0;
106
- stopInhibitor(ctx, "session shutdown", { notify: false });
107
- ctx.ui.setStatus(STATUS_KEY, undefined);
138
+ await stopInhibitor(ctx, "session shutdown", { notify: false });
108
139
  await modeOperationQueue;
140
+ if (generation !== state.sessionGeneration) return;
141
+ ctx.ui.setStatus(STATUS_KEY, undefined);
109
142
  });
110
143
 
111
144
  pi.registerCommand("caffeinate", {
@@ -147,7 +180,7 @@ async function handleCaffeinateCommand(args: string, ctx: CommandContext, genera
147
180
  await setMode(ctx, "display", generation);
148
181
  return;
149
182
  case "stop":
150
- stopCaffeinate(ctx, "manual stop");
183
+ await stopCaffeinate(ctx, "manual stop", generation);
151
184
  return;
152
185
  }
153
186
 
@@ -215,7 +248,7 @@ async function runCaffeinateMenu(ctx: CommandContext, generation: number, start:
215
248
  return { kind: "close" };
216
249
  },
217
250
  stop: async () => {
218
- stopCaffeinate(ctx, "manual stop");
251
+ await stopCaffeinate(ctx, "manual stop", generation);
219
252
  return { kind: "close" };
220
253
  },
221
254
  help: async () => {
@@ -264,18 +297,23 @@ async function setModeNow(ctx: ExtensionContext, mode: CaffeinateMode, generatio
264
297
  if (generation !== state.sessionGeneration) return;
265
298
  const previousMode = state.mode;
266
299
  const previousQuiet = state.quiet;
267
- const restartRequired = Boolean(state.process && previousMode !== mode && !state.command?.custom);
300
+ const restartRequired = Boolean(
301
+ hasActiveInhibitor() && previousMode !== mode && !state.command?.custom,
302
+ );
268
303
  state.settingsError = undefined;
269
304
 
270
305
  if (restartRequired) {
271
306
  state.mode = mode;
272
- stopInhibitor(ctx, "mode changed", { notify: false });
273
- startInhibitor(ctx, { notify: false });
274
- if (!state.process) {
307
+ await stopInhibitor(ctx, "mode changed", { notify: false });
308
+ if (generation !== state.sessionGeneration) return;
309
+ await startInhibitor(ctx, generation, { notify: false });
310
+ if (generation !== state.sessionGeneration) return;
311
+ if (!hasActiveInhibitor()) {
275
312
  const applicationError = state.lastError ?? "the inhibitor could not be restarted";
276
313
  state.mode = previousMode;
277
- startInhibitor(ctx, { notify: false });
278
- const rollbackError = state.process
314
+ await startInhibitor(ctx, generation, { notify: false });
315
+ if (generation !== state.sessionGeneration) return;
316
+ const rollbackError = hasActiveInhibitor()
279
317
  ? undefined
280
318
  : (state.lastError ?? "the previous inhibitor could not be restored");
281
319
  state.settingsError = rollbackError
@@ -296,9 +334,13 @@ async function setModeNow(ctx: ExtensionContext, mode: CaffeinateMode, generatio
296
334
  if (restartRequired) {
297
335
  state.mode = previousMode;
298
336
  state.quiet = previousQuiet;
299
- stopInhibitor(ctx, "settings save failed", { notify: false });
300
- startInhibitor(ctx, { notify: false });
301
- if (!state.process) rollbackError = state.lastError ?? "the prior inhibitor was not restored";
337
+ await stopInhibitor(ctx, "settings save failed", { notify: false });
338
+ if (generation !== state.sessionGeneration) return;
339
+ await startInhibitor(ctx, generation, { notify: false });
340
+ if (generation !== state.sessionGeneration) return;
341
+ if (!hasActiveInhibitor()) {
342
+ rollbackError = state.lastError ?? "the prior inhibitor was not restored";
343
+ }
302
344
  }
303
345
  state.settingsError = rollbackError
304
346
  ? `settings save failed (${formatError(error)}); runtime rollback failed: ${rollbackError}`
@@ -323,9 +365,10 @@ function showStatus(ctx: ExtensionContext) {
323
365
  updateStatus(ctx);
324
366
  }
325
367
 
326
- function stopCaffeinate(ctx: ExtensionContext, reason: string) {
368
+ async function stopCaffeinate(ctx: ExtensionContext, reason: string, sessionGeneration: number) {
327
369
  state.activeTurns = 0;
328
- stopInhibitor(ctx, reason);
370
+ await stopInhibitor(ctx, reason);
371
+ if (sessionGeneration !== state.sessionGeneration) return;
329
372
  updateStatus(ctx);
330
373
  }
331
374
 
@@ -363,14 +406,24 @@ function buildCommandGuide() {
363
406
  ].join("\n");
364
407
  }
365
408
 
366
- function startInhibitor(ctx: ExtensionContext, options: { notify?: boolean } = {}) {
367
- if (state.disabled || state.process) {
409
+ async function startInhibitor(
410
+ ctx: ExtensionContext,
411
+ sessionGeneration: number,
412
+ options: { notify?: boolean } = {},
413
+ ) {
414
+ if (state.disabled || hasActiveInhibitor() || state.inhibitorStarting) {
368
415
  updateStatus(ctx);
369
416
  return;
370
417
  }
371
418
 
419
+ const token = ++inhibitSequence;
372
420
  const command = getInhibitorCommand(state.mode);
373
- if (!command) {
421
+ const wantsDbus =
422
+ !command?.custom &&
423
+ state.mode === "display" &&
424
+ process.platform === "linux" &&
425
+ (command === undefined || command.addDbusIdleInhibit === true);
426
+ if (!command && !wantsDbus) {
374
427
  state.available = false;
375
428
  state.lastError = `No supported sleep inhibitor found for ${process.platform}.`;
376
429
  ctx.ui.notify(state.lastError, "warning");
@@ -378,56 +431,242 @@ function startInhibitor(ctx: ExtensionContext, options: { notify?: boolean } = {
378
431
  return;
379
432
  }
380
433
 
381
- try {
382
- const child = startInhibitorProcess(
383
- command,
384
- (error) => {
385
- if (state.process === child) {
386
- state.process = undefined;
387
- state.startedAt = undefined;
388
- }
389
- state.available = false;
390
- state.lastError = `${command.description} failed: ${error.message}`;
391
- ctx.ui.notify(state.lastError, "warning");
392
- updateStatus(ctx);
393
- },
394
- (exit) => {
395
- if (state.process !== child) return;
396
- state.process = undefined;
397
- state.startedAt = undefined;
398
- state.available = false;
399
- state.lastError = `${command.description} exited unexpectedly (${exit}).`;
400
- ctx.ui.notify(state.lastError, "warning");
401
- updateStatus(ctx);
402
- },
403
- );
404
- state.process = child;
405
- state.startedAt = Date.now();
406
- state.command = command;
407
- state.available = true;
408
- state.lastError = undefined;
409
- if (options.notify !== false) {
410
- ctx.ui.notify(`Keeping computer awake (${statusModeLabel()}).`, "info");
411
- }
412
- updateStatus(ctx);
413
- } catch (error) {
434
+ state.inhibitorStarting = true;
435
+ state.inhibitWarning = undefined;
436
+ let child: ChildProcess | undefined;
437
+ let childError =
438
+ !command && wantsDbus
439
+ ? "No supported system sleep inhibitor is available; direct system suspend may remain possible."
440
+ : undefined;
441
+ let assembling = true;
442
+ const handleChildFailure = (message: string) => {
443
+ if (!child || token !== inhibitSequence || state.process !== child) return;
414
444
  state.process = undefined;
445
+ state.command = undefined;
446
+ if (assembling) {
447
+ childError = message;
448
+ return;
449
+ }
450
+ applyChildFailure(message);
451
+ };
452
+
453
+ if (command) {
454
+ try {
455
+ child = startInhibitorProcess(
456
+ command,
457
+ (error) => handleChildFailure(`${command.description} failed: ${error.message}`),
458
+ (exit) => handleChildFailure(`${command.description} exited unexpectedly (${exit}).`),
459
+ );
460
+ state.process = child;
461
+ state.command = command;
462
+ state.startedAt = Date.now();
463
+ } catch (error) {
464
+ childError = `${command.description}: ${formatError(error)}`;
465
+ }
466
+ }
467
+
468
+ let dbus: DbusScreenSaverClient | undefined;
469
+ let dbusError: string | undefined;
470
+ if (wantsDbus) {
471
+ const pending: PendingDbusStart = { token, controller: new AbortController() };
472
+ state.pendingDbus = pending;
473
+ try {
474
+ const client = await dbusFactory();
475
+ if (!pendingStartIsCurrent(pending, sessionGeneration)) {
476
+ await client.close().catch(() => undefined);
477
+ await cancelPendingStart(token, child, command);
478
+ return;
479
+ }
480
+ pending.client = client;
481
+ await client.inhibit(INHIBIT_REASON, pending.controller.signal);
482
+ if (!pendingStartIsCurrent(pending, sessionGeneration)) {
483
+ await cancelPendingStart(token, child, command);
484
+ return;
485
+ }
486
+ pending.client = undefined;
487
+ state.pendingDbus = undefined;
488
+ dbus = client;
489
+ } catch (error) {
490
+ const stale =
491
+ !pendingStartIsCurrent(pending, sessionGeneration) || pending.controller.signal.aborted;
492
+ const pendingClient = takePendingDbusClient(pending);
493
+ await pendingClient?.close().catch(() => undefined);
494
+ if (stale) {
495
+ await cancelPendingStart(token, child, command);
496
+ return;
497
+ }
498
+ dbusError = `D-Bus idle inhibit (${SCREENSAVER_BUS_NAME}): ${formatError(error)}`;
499
+ }
500
+ }
501
+
502
+ assembling = false;
503
+ if (startIsStale(token, sessionGeneration)) {
504
+ await cancelPendingStart(token, child, command);
505
+ await dbus?.close().catch(() => undefined);
506
+ return;
507
+ }
508
+
509
+ state.inhibitorStarting = false;
510
+ state.dbus = dbus;
511
+ if (dbus) {
512
+ dbus.setFailureHandler((error) => applyDbusFailure(token, dbus, error));
513
+ if (state.dbus !== dbus) return;
514
+ }
515
+ const failures = [childError, dbusError].filter((failure): failure is string => Boolean(failure));
516
+ if (!hasActiveInhibitor()) {
415
517
  state.startedAt = undefined;
518
+ state.command = undefined;
416
519
  state.available = false;
417
- state.lastError = error instanceof Error ? error.message : String(error);
418
- ctx.ui.notify(`Unable to start pi-caffeinate: ${state.lastError}`, "warning");
520
+ state.lastError =
521
+ failures.join("; ") || `No supported sleep inhibitor found for ${process.platform}.`;
522
+ ctx.ui.notify(state.lastError, "warning");
419
523
  updateStatus(ctx);
524
+ return;
525
+ }
526
+
527
+ state.startedAt ??= Date.now();
528
+ state.available = true;
529
+ state.lastError = undefined;
530
+ state.inhibitWarning = failures.length > 0 ? failures.join("; ") : undefined;
531
+ if (state.inhibitWarning) {
532
+ ctx.ui.notify(`pi-caffeinate is partially active: ${state.inhibitWarning}`, "warning");
533
+ } else if (options.notify !== false) {
534
+ ctx.ui.notify(`Keeping computer awake (${statusModeLabel()}).`, "info");
420
535
  }
536
+ updateStatus(ctx);
421
537
  }
422
538
 
423
- function stopInhibitor(ctx: ExtensionContext, reason: string, options: { notify?: boolean } = {}) {
539
+ async function stopInhibitor(
540
+ ctx: ExtensionContext,
541
+ reason: string,
542
+ options: { notify?: boolean } = {},
543
+ ) {
544
+ inhibitSequence += 1;
424
545
  const child = state.process;
425
- if (!child) return;
546
+ const dbus = state.dbus;
547
+ const dbusCleanup = state.dbusCleanup;
548
+ const pending = state.pendingDbus;
549
+ const command = state.command;
550
+ const wasStarting = state.inhibitorStarting;
426
551
  state.process = undefined;
427
- state.startedAt = undefined;
428
- stopInhibitorProcess(child, state.command);
552
+ state.dbus = undefined;
553
+ state.dbusCleanup = undefined;
554
+ state.pendingDbus = undefined;
429
555
  state.command = undefined;
556
+ state.startedAt = undefined;
557
+ state.inhibitWarning = undefined;
558
+ state.inhibitorStarting = false;
559
+ dbus?.setFailureHandler(undefined);
560
+ pending?.controller.abort(new DOMException(`Caffeinate stopped: ${reason}`, "AbortError"));
561
+ const pendingClient = pending ? takePendingDbusClient(pending) : undefined;
562
+ if (child) stopInhibitorProcess(child, command);
563
+ if (!child && !dbus && !pendingClient && !wasStarting) {
564
+ await dbusCleanup;
565
+ return;
566
+ }
430
567
  if (options.notify !== false) ctx.ui.notify(`Released pi-caffeinate (${reason}).`, "info");
568
+ await Promise.all([dbusCleanup, pendingClient?.close().catch(() => undefined)]);
569
+ if (!dbus) return;
570
+ try {
571
+ await dbus.uninhibit();
572
+ } catch {
573
+ // Closing the session-bus connection also releases its inhibition cookie.
574
+ }
575
+ await dbus.close().catch(() => undefined);
576
+ }
577
+
578
+ function applyChildFailure(message: string) {
579
+ if (state.dbus) {
580
+ state.available = true;
581
+ state.lastError = undefined;
582
+ state.inhibitWarning = message;
583
+ } else {
584
+ state.startedAt = undefined;
585
+ state.available = false;
586
+ state.lastError = message;
587
+ state.inhibitWarning = undefined;
588
+ }
589
+ const ctx = currentSessionContext();
590
+ if (!ctx) return;
591
+ ctx.ui.notify(message, "warning");
592
+ updateStatus(ctx);
593
+ }
594
+
595
+ function applyDbusFailure(token: number, client: DbusScreenSaverClient, error: Error) {
596
+ if (token !== inhibitSequence || state.dbus !== client) return;
597
+ client.setFailureHandler(undefined);
598
+ state.dbus = undefined;
599
+ const message = `D-Bus idle inhibit (${SCREENSAVER_BUS_NAME}) failed: ${formatError(error)}`;
600
+ const notification = state.process ? `pi-caffeinate is partially active: ${message}` : message;
601
+ if (state.process) {
602
+ state.available = true;
603
+ state.lastError = undefined;
604
+ state.inhibitWarning = message;
605
+ } else {
606
+ state.startedAt = undefined;
607
+ state.command = undefined;
608
+ state.available = false;
609
+ state.lastError = [state.inhibitWarning, message].filter(Boolean).join("; ");
610
+ state.inhibitWarning = undefined;
611
+ }
612
+ const cleanup = client.close().catch(() => undefined);
613
+ state.dbusCleanup = cleanup;
614
+ void cleanup.then(() => {
615
+ if (state.dbusCleanup === cleanup) state.dbusCleanup = undefined;
616
+ });
617
+ const ctx = currentSessionContext();
618
+ if (!ctx) return;
619
+ ctx.ui.notify(notification, "warning");
620
+ updateStatus(ctx);
621
+ }
622
+
623
+ function currentSessionContext() {
624
+ const session = state.currentSession;
625
+ return session?.generation === state.sessionGeneration ? session.ctx : undefined;
626
+ }
627
+
628
+ function hasActiveInhibitor() {
629
+ return Boolean(state.process || state.dbus);
630
+ }
631
+
632
+ function startIsStale(token: number, sessionGeneration: number) {
633
+ return token !== inhibitSequence || sessionGeneration !== state.sessionGeneration;
634
+ }
635
+
636
+ function pendingStartIsCurrent(pending: PendingDbusStart, sessionGeneration: number) {
637
+ return (
638
+ state.pendingDbus === pending &&
639
+ !pending.controller.signal.aborted &&
640
+ !startIsStale(pending.token, sessionGeneration)
641
+ );
642
+ }
643
+
644
+ function takePendingDbusClient(pending: PendingDbusStart) {
645
+ if (state.pendingDbus === pending) state.pendingDbus = undefined;
646
+ const client = pending.client;
647
+ pending.client = undefined;
648
+ return client;
649
+ }
650
+
651
+ async function cancelPendingStart(
652
+ token: number,
653
+ child: ChildProcess | undefined,
654
+ command: InhibitorCommand | undefined,
655
+ ) {
656
+ if (token !== inhibitSequence) return;
657
+ inhibitSequence += 1;
658
+ state.inhibitorStarting = false;
659
+ const pending = state.pendingDbus?.token === token ? state.pendingDbus : undefined;
660
+ state.pendingDbus = undefined;
661
+ pending?.controller.abort(new DOMException("Caffeinate start cancelled", "AbortError"));
662
+ const pendingClient = pending ? takePendingDbusClient(pending) : undefined;
663
+ if (child && state.process === child) {
664
+ state.process = undefined;
665
+ state.command = undefined;
666
+ state.startedAt = undefined;
667
+ stopInhibitorProcess(child, command);
668
+ }
669
+ await pendingClient?.close().catch(() => undefined);
431
670
  }
432
671
 
433
672
  function updateStatus(ctx: ExtensionContext) {
@@ -436,7 +675,7 @@ function updateStatus(ctx: ExtensionContext) {
436
675
  return;
437
676
  }
438
677
 
439
- if (state.process) {
678
+ if (hasActiveInhibitor()) {
440
679
  ctx.ui.setStatus(STATUS_KEY, withDeprecatedIcon(statusModeLabel()));
441
680
  return;
442
681
  }
@@ -465,11 +704,15 @@ function describeState() {
465
704
  return lines.join("\n");
466
705
  }
467
706
 
468
- if (state.process) {
707
+ if (hasActiveInhibitor()) {
469
708
  const seconds = state.startedAt ? Math.round((Date.now() - state.startedAt) / 1000) : 0;
709
+ const activeParts: string[] = [];
710
+ if (state.dbus) activeParts.push(`D-Bus idle inhibit (${SCREENSAVER_BUS_NAME})`);
711
+ if (state.process && state.command) activeParts.push(state.command.description);
470
712
  lines.unshift(
471
- `pi-caffeinate is active using ${state.command?.description ?? "an inhibitor"} for ${seconds}s.`,
713
+ `pi-caffeinate is active using ${activeParts.join(" + ") || "an inhibitor"} for ${seconds}s.`,
472
714
  );
715
+ if (state.inhibitWarning) lines.push(`Inhibitor warning: ${state.inhibitWarning}`);
473
716
  return lines.join("\n");
474
717
  }
475
718
 
@@ -485,7 +728,7 @@ function describeState() {
485
728
  }
486
729
 
487
730
  function statusLevel() {
488
- return state.available && !state.settingsError ? "info" : "warning";
731
+ return state.available && !state.settingsError && !state.inhibitWarning ? "info" : "warning";
489
732
  }
490
733
 
491
734
  function statusModeLabel() {
@@ -559,5 +802,10 @@ function warnDeprecatedIcon(ctx: ExtensionContext) {
559
802
  );
560
803
  }
561
804
 
562
- export { formatMode, splitCommand, windowsInhibitorScript } from "./inhibitors.js";
805
+ export {
806
+ formatMode,
807
+ getInhibitorCommand,
808
+ splitCommand,
809
+ windowsInhibitorScript,
810
+ } from "./inhibitors.js";
563
811
  export { normalizeCaffeinateSettings } from "./settings.js";
@@ -0,0 +1,152 @@
1
+ import { type Message, type MessageBus, sessionBus } from "dbus-native";
2
+
3
+ export const SCREENSAVER_BUS_NAME = "org.freedesktop.ScreenSaver";
4
+ export const SCREENSAVER_INTERFACE = "org.freedesktop.ScreenSaver";
5
+ export const INHIBIT_REASON = "Pi agent is running";
6
+
7
+ const INHIBIT_APPLICATION_NAME = "pi-caffeinate";
8
+ const SCREENSAVER_OBJECT_PATHS = ["/org/freedesktop/ScreenSaver", "/ScreenSaver"];
9
+ const DBUS_CALL_TIMEOUT_MS = 2_000;
10
+
11
+ type DbusFailureHandler = (error: Error) => void;
12
+
13
+ export interface DbusScreenSaverClient {
14
+ setFailureHandler(handler: DbusFailureHandler | undefined): void;
15
+ inhibit(reason: string, signal?: AbortSignal): Promise<void>;
16
+ uninhibit(): Promise<void>;
17
+ close(): Promise<void>;
18
+ }
19
+
20
+ export type DbusScreenSaverFactory = () => Promise<DbusScreenSaverClient>;
21
+
22
+ export async function defaultDbusScreenSaverFactory(): Promise<DbusScreenSaverClient> {
23
+ return new NativeScreenSaverClient(sessionBus());
24
+ }
25
+
26
+ class NativeScreenSaverClient implements DbusScreenSaverClient {
27
+ private cookie?: number;
28
+ private objectPath?: string;
29
+ private failureHandler?: DbusFailureHandler;
30
+ private pendingFailure?: Error;
31
+ private lastConnectionError?: Error;
32
+ private closing = false;
33
+
34
+ private readonly handleConnectionError = (error: unknown) => {
35
+ this.lastConnectionError = toError(error, "D-Bus session connection failed");
36
+ if (this.cookie !== undefined) this.reportFailure(this.lastConnectionError);
37
+ };
38
+
39
+ private readonly handleConnectionClose = (error?: unknown) => {
40
+ if (this.closing || this.cookie === undefined) return;
41
+ this.reportFailure(
42
+ toError(error, this.lastConnectionError?.message ?? "D-Bus session connection closed"),
43
+ );
44
+ };
45
+
46
+ constructor(private readonly bus: MessageBus) {
47
+ this.bus.connection.on("error", this.handleConnectionError);
48
+ this.bus.connection.on("close", this.handleConnectionClose);
49
+ }
50
+
51
+ setFailureHandler(handler: DbusFailureHandler | undefined): void {
52
+ this.failureHandler = handler;
53
+ if (!handler || !this.pendingFailure) return;
54
+ const failure = this.pendingFailure;
55
+ this.pendingFailure = undefined;
56
+ handler(failure);
57
+ }
58
+
59
+ async inhibit(reason: string, signal?: AbortSignal): Promise<void> {
60
+ let lastError: unknown;
61
+ for (const objectPath of SCREENSAVER_OBJECT_PATHS) {
62
+ if (signal?.aborted) throw abortReason(signal);
63
+ try {
64
+ this.cookie = await invoke<number>(
65
+ this.bus,
66
+ {
67
+ destination: SCREENSAVER_BUS_NAME,
68
+ path: objectPath,
69
+ interface: SCREENSAVER_INTERFACE,
70
+ member: "Inhibit",
71
+ signature: "ss",
72
+ body: [INHIBIT_APPLICATION_NAME, reason],
73
+ },
74
+ signal,
75
+ );
76
+ this.objectPath = objectPath;
77
+ return;
78
+ } catch (error) {
79
+ if (signal?.aborted) throw abortReason(signal);
80
+ lastError = error;
81
+ }
82
+ }
83
+ throw new Error(`D-Bus idle inhibit failed: ${formatError(lastError)}`, {
84
+ cause: lastError,
85
+ });
86
+ }
87
+
88
+ async uninhibit(): Promise<void> {
89
+ if (this.cookie === undefined || !this.objectPath) return;
90
+ const cookie = this.cookie;
91
+ this.cookie = undefined;
92
+ const objectPath = this.objectPath;
93
+ this.objectPath = undefined;
94
+ await invoke<void>(this.bus, {
95
+ destination: SCREENSAVER_BUS_NAME,
96
+ path: objectPath,
97
+ interface: SCREENSAVER_INTERFACE,
98
+ member: "UnInhibit",
99
+ signature: "u",
100
+ body: [cookie],
101
+ });
102
+ }
103
+
104
+ async close(): Promise<void> {
105
+ this.closing = true;
106
+ this.cookie = undefined;
107
+ this.objectPath = undefined;
108
+ this.failureHandler = undefined;
109
+ try {
110
+ await this.bus.close();
111
+ } finally {
112
+ this.bus.connection.off("error", this.handleConnectionError);
113
+ this.bus.connection.off("close", this.handleConnectionClose);
114
+ }
115
+ }
116
+
117
+ private reportFailure(error: Error): void {
118
+ if (this.cookie === undefined) return;
119
+ this.cookie = undefined;
120
+ this.objectPath = undefined;
121
+ if (this.failureHandler) this.failureHandler(error);
122
+ else this.pendingFailure = error;
123
+ }
124
+ }
125
+
126
+ function invoke<TResult>(
127
+ bus: MessageBus,
128
+ message: Message,
129
+ signal?: AbortSignal,
130
+ ): Promise<TResult> {
131
+ return new Promise<TResult>((resolve, reject) => {
132
+ bus.invoke(message, { signal, timeout: DBUS_CALL_TIMEOUT_MS }, (error, ...values) => {
133
+ if (error) reject(error);
134
+ else resolve(values[0] as TResult);
135
+ });
136
+ });
137
+ }
138
+
139
+ function abortReason(signal: AbortSignal): unknown {
140
+ return signal.reason ?? new DOMException("D-Bus idle inhibit cancelled", "AbortError");
141
+ }
142
+
143
+ function toError(error: unknown, fallback: string): Error {
144
+ return error instanceof Error ? error : new Error(fallback);
145
+ }
146
+
147
+ function formatError(error: unknown) {
148
+ if (error instanceof Error) {
149
+ return error.name === "Error" ? error.message : `${error.name}: ${error.message}`;
150
+ }
151
+ return String(error);
152
+ }
package/src/inhibitors.ts CHANGED
@@ -9,6 +9,8 @@ export interface InhibitorCommand {
9
9
  description: string;
10
10
  releaseOnStdinClose?: boolean;
11
11
  custom?: boolean;
12
+ /** Display mode also needs D-Bus because logind idle inhibition misses some compositors. */
13
+ addDbusIdleInhibit?: boolean;
12
14
  }
13
15
 
14
16
  export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | undefined {
@@ -29,7 +31,7 @@ export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | un
29
31
  return windowsPowerInhibitorCommand("powershell.exe", mode);
30
32
  }
31
33
  if (commandExists("systemd-inhibit")) {
32
- const what = mode === "sleep" ? "sleep" : "idle:sleep";
34
+ const what = mode === "display" ? "idle:sleep" : "sleep";
33
35
  return parentBoundUnixCommand(
34
36
  "systemd-inhibit",
35
37
  [
@@ -41,6 +43,7 @@ export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | un
41
43
  "infinity",
42
44
  ],
43
45
  `systemd-inhibit (${formatMode(mode)})`,
46
+ mode === "display",
44
47
  );
45
48
  }
46
49
  if (commandExists("caffeinate")) {
@@ -48,6 +51,7 @@ export function getInhibitorCommand(mode: CaffeinateMode): InhibitorCommand | un
48
51
  "caffeinate",
49
52
  macCaffeinateArgs(mode),
50
53
  caffeinateDescription(mode),
54
+ mode === "display",
51
55
  );
52
56
  }
53
57
  }
@@ -67,6 +71,7 @@ function parentBoundUnixCommand(
67
71
  command: string,
68
72
  args: string[],
69
73
  description: string,
74
+ addDbusIdleInhibit = false,
70
75
  ): InhibitorCommand {
71
76
  return {
72
77
  command: "sh",
@@ -79,6 +84,7 @@ function parentBoundUnixCommand(
79
84
  ...args,
80
85
  ],
81
86
  description,
87
+ ...(addDbusIdleInhibit ? { addDbusIdleInhibit: true } : {}),
82
88
  };
83
89
  }
84
90