ai-or-die 0.1.74 → 0.1.75

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/bin/ai-or-die.js CHANGED
@@ -43,7 +43,9 @@ program
43
43
  .option('--no-sticky-notes', 'disable per-tab AI session summaries + auto tab titles (on by default)')
44
44
  .option('--sticky-notes-model-dir <path>', 'custom directory for the sticky-note model file')
45
45
  .option('--sticky-notes-model <url>', 'override the sticky-note model GGUF download URL')
46
- .option('--sticky-notes-threads <number>', 'CPU threads for sticky-note inference (default: auto, max 4)');
46
+ .option('--sticky-notes-threads <number>', 'CPU threads for sticky-note inference (default: auto, max 4)')
47
+ .option('--no-keepalive', 'disable keeping the machine awake while the server runs (Windows only; on by default)')
48
+ .option('--keepalive-display', 'also keep the display on (default keeps the system awake but lets the monitor sleep)');
47
49
 
48
50
  // Auto-open is OFF by default and opt-in via --open. Legacy callers may still pass
49
51
  // --no-open (the old opt-out flag); filter it out so it parses harmlessly as a no-op.
@@ -123,6 +125,12 @@ async function main() {
123
125
  stickyNotesModelDir: options.stickyNotesModelDir || process.env.STICKY_NOTES_MODEL_DIR,
124
126
  stickyNotesModel: options.stickyNotesModel || process.env.STICKY_NOTES_MODEL,
125
127
  stickyNotesThreads: options.stickyNotesThreads || process.env.STICKY_NOTES_THREADS,
128
+ // Keep the host awake while the server runs (Windows only; on by default;
129
+ // --no-keepalive / AIORDIE_DISABLE_KEEPALIVE=1 disables). System-awake by
130
+ // default; --keepalive-display / AIORDIE_KEEPALIVE_DISPLAY=1 also holds
131
+ // the display on.
132
+ keepalive: options.keepalive !== false && process.env.AIORDIE_DISABLE_KEEPALIVE !== '1',
133
+ keepaliveDisplay: options.keepaliveDisplay === true || process.env.AIORDIE_KEEPALIVE_DISPLAY === '1',
126
134
  };
127
135
 
128
136
  console.log('Starting ai-or-die...');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.74",
3
+ "version": "0.1.75",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -0,0 +1,251 @@
1
+ 'use strict';
2
+
3
+ const childProcess = require('child_process');
4
+ const path = require('path');
5
+
6
+ // Keep the host machine awake while the ai-or-die server runs (Windows 11).
7
+ //
8
+ // Mechanism: spawn ONE long-lived Windows PowerShell (5.1, in-box) helper that
9
+ // P/Invokes SetThreadExecutionState from kernel32.dll to hold a power
10
+ // assertion, then blocks on stdin. When the parent closes stdin (graceful
11
+ // release) OR dies (the OS tears down the pipe -> ReadLine() returns null), the
12
+ // helper clears the assertion and exits. Windows also drops a thread's
13
+ // execution-state flags when the holding process dies, so even taskkill /F
14
+ // cannot leak the assertion past reboot. No native deps, no powercfg.
15
+ //
16
+ // Windows-only by design; an instant no-op on macOS/Linux. See
17
+ // docs/specs/keepalive.md and docs/adrs/0028-windows-keepalive.md.
18
+
19
+ // SetThreadExecutionState flags as DECIMAL uint32 literals. PowerShell 5.1
20
+ // parses 0x80000001 as a negative Int32 and the [uint32] cast then throws, so
21
+ // the decimal forms are load-bearing (verified on a real Windows host):
22
+ // ES_CONTINUOUS 0x80000000 = 2147483648 (clear value, continuous alone)
23
+ // ES_SYSTEM_REQUIRED 0x00000001 -> system = 2147483649
24
+ // ES_DISPLAY_REQUIRED 0x00000002 -> +display = 2147483651
25
+ const ES_CONTINUOUS = 2147483648;
26
+ const ES_SYSTEM = 2147483649;
27
+ const ES_SYSTEM_DISPLAY = 2147483651;
28
+
29
+ const READY_TIMEOUT_MS = 5000;
30
+
31
+ class KeepaliveManager {
32
+ constructor(options = {}) {
33
+ this._enabled = !!options.enabled;
34
+ this._keepDisplayOn = !!options.keepDisplayOn;
35
+ this._platform = options.platform || process.platform;
36
+ this._spawn = options.spawn || childProcess.spawn;
37
+ this._logger = options.logger || console;
38
+ this._readyTimeoutMs = options.readyTimeoutMs || READY_TIMEOUT_MS;
39
+
40
+ this._started = false;
41
+ this._child = null;
42
+ this._readyTimer = null;
43
+ // Stable reference so we can both add and remove the process 'exit' hook
44
+ // (a fresh closure each time would leak listeners across start/release).
45
+ this._exitHandler = null;
46
+ // Resolves true once the assertion is confirmed held, false on any failure
47
+ // (spawn error, early exit, or readiness timeout). Used only for a status
48
+ // log line; the lifecycle never awaits it.
49
+ this.ready = Promise.resolve(false);
50
+ }
51
+
52
+ // ---- pure builders (static so tests assert them without spawning) ----
53
+
54
+ static buildScript(displayRequired, ppid = process.pid) {
55
+ const assert = displayRequired ? ES_SYSTEM_DISPLAY : ES_SYSTEM;
56
+ return [
57
+ // 'Stop' is load-bearing: under Constrained Language Mode / WDAC /
58
+ // AppLocker / EDR, Add-Type (which writes a .cs to %TEMP% and shells to
59
+ // csc.exe) is blocked. With the default 'Continue', a blocked Add-Type
60
+ // would NOT exit -- PowerShell would fall into the infinite stdin loop
61
+ // and leak an immortal ~30-50MB process on every restart. 'Stop' makes
62
+ // the child die immediately so our handler fires and ready -> false.
63
+ `$ErrorActionPreference = 'Stop'`,
64
+ // Tag the command line with the parent PID (a trusted integer, never user
65
+ // input) so a stale/orphaned helper is identifiable for triage:
66
+ // Get-CimInstance Win32_Process -Filter "Name='powershell.exe'"
67
+ `# aod-keepalive ppid=${ppid}`,
68
+ `Add-Type -Name P -Namespace W -MemberDefinition '[System.Runtime.InteropServices.DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint e);'`,
69
+ // exit 1 when the assertion is refused so the helper never blocks on
70
+ // stdin WITHOUT actually holding the assertion (which would otherwise
71
+ // latch us "started" while the machine can still sleep).
72
+ `if ([W.P]::SetThreadExecutionState([uint32]${assert}) -ne 0) { [Console]::Out.WriteLine('OK'); [Console]::Out.Flush() } else { [Console]::Error.WriteLine('SetThreadExecutionState returned 0'); exit 1 }`,
73
+ `while ($null -ne [Console]::In.ReadLine()) {}`,
74
+ `[void][W.P]::SetThreadExecutionState([uint32]${ES_CONTINUOUS})`,
75
+ ].join('\n');
76
+ }
77
+
78
+ static buildArgs(displayRequired, ppid = process.pid) {
79
+ return [
80
+ '-NoProfile',
81
+ '-NonInteractive',
82
+ // GPO machine-wide execution policy can otherwise block inline code /
83
+ // the Add-Type compilation step.
84
+ '-ExecutionPolicy', 'Bypass',
85
+ '-Command', KeepaliveManager.buildScript(displayRequired, ppid),
86
+ ];
87
+ }
88
+
89
+ // Absolute path to in-box Windows PowerShell 5.1 (PATH-hijack hardening --
90
+ // never resolve a bare "powershell.exe" off PATH).
91
+ static powershellPath() {
92
+ const root = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
93
+ return path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
94
+ }
95
+
96
+ // Acquire the wake assertion. No-op unless enabled on win32. Idempotent.
97
+ // Never throws -- keepalive must never break server startup.
98
+ start() {
99
+ if (this._started) return;
100
+ if (!this._enabled || this._platform !== 'win32') return;
101
+ this._started = true;
102
+
103
+ try {
104
+ const ps = KeepaliveManager.powershellPath();
105
+ const args = KeepaliveManager.buildArgs(this._keepDisplayOn);
106
+ const child = this._spawn(ps, args, {
107
+ stdio: ['pipe', 'pipe', 'pipe'],
108
+ windowsHide: true,
109
+ shell: false,
110
+ });
111
+ this._child = child;
112
+
113
+ // Never let the helper independently keep the parent's event loop alive
114
+ // (or revive the exit-134 native-teardown abort this repo has fought).
115
+ // unref() does NOT affect writability, so stdin stays usable by release()
116
+ // while unreferenced.
117
+ this._safe(() => child.unref());
118
+ this._safe(() => child.stdout && child.stdout.unref && child.stdout.unref());
119
+ this._safe(() => child.stderr && child.stderr.unref && child.stderr.unref());
120
+ this._safe(() => child.stdin && child.stdin.unref && child.stdin.unref());
121
+
122
+ // Capture the first stderr line so the failure warning can distinguish
123
+ // Constrained Language Mode (a PowerShell error) from an AV/EDR kill.
124
+ let firstErr = '';
125
+ if (child.stderr) {
126
+ this._safe(() => child.stderr.setEncoding && child.stderr.setEncoding('utf8'));
127
+ child.stderr.on('data', (d) => { if (!firstErr) firstErr = String(d).split('\n')[0].trim(); });
128
+ child.stderr.on('error', () => {});
129
+ }
130
+
131
+ // Per-start state, captured by the closures below so a stale child's
132
+ // late events can never mutate a newer run's state.
133
+ let settled = false;
134
+ let acquired = false;
135
+ let resolveReady;
136
+ this.ready = new Promise((res) => { resolveReady = res; });
137
+
138
+ // Declared with `let` so finishReady (defined next) can reference it and
139
+ // clear it; the timer is created after finishReady to keep ordering clear.
140
+ let timer = null;
141
+
142
+ const finishReady = (ok) => {
143
+ if (settled) return;
144
+ settled = true;
145
+ if (ok) acquired = true;
146
+ this._safe(() => clearTimeout(timer));
147
+ if (this._readyTimer === timer) this._readyTimer = null;
148
+ resolveReady(ok);
149
+ };
150
+
151
+ timer = setTimeout(() => finishReady(false), this._readyTimeoutMs);
152
+ if (timer.unref) timer.unref();
153
+ this._readyTimer = timer;
154
+
155
+ if (child.stdout) {
156
+ this._safe(() => child.stdout.setEncoding && child.stdout.setEncoding('utf8'));
157
+ let buf = '';
158
+ child.stdout.on('data', (d) => {
159
+ if (settled) return; // stop buffering once readiness is decided
160
+ buf += String(d);
161
+ if (/(^|\n)OK(\r?\n|$)/.test(buf)) finishReady(true);
162
+ });
163
+ child.stdout.on('error', () => {});
164
+ }
165
+
166
+ // Settle on 'close' (after all stdio has flushed) rather than 'exit' so
167
+ // firstErr is populated before the failure warning reads it. 'error'
168
+ // covers a spawn that never produced a process. The this._child === child
169
+ // guard makes a superseded/released child's late events a no-op.
170
+ const onGone = () => {
171
+ if (this._child !== child) return;
172
+ this._child = null;
173
+ this._started = false;
174
+ this._safe(() => clearTimeout(timer));
175
+ if (this._readyTimer === timer) this._readyTimer = null;
176
+ if (!settled) {
177
+ finishReady(false);
178
+ } else if (acquired) {
179
+ // Died AFTER holding the assertion (e.g. an AV/EDR kill hours in) and
180
+ // we did not initiate the release -> the machine can now sleep.
181
+ this._safe(() => this._logger.warn && this._logger.warn(
182
+ '⚠ keepalive: wake assertion lost — the helper exited; the machine may sleep. Restart ai-or-die or pass --no-keepalive.'));
183
+ }
184
+ };
185
+ child.on('error', onGone);
186
+ child.on('close', onGone);
187
+
188
+ // Guarantee release on EVERY exit path, including the ones that bypass
189
+ // close()/release(): uncaughtException and the bin outer-catch both call
190
+ // process.exit(1) without close(). A synchronous 'exit' hook, NOT a
191
+ // SIGINT/SIGTERM handler, so it cannot race the server's single
192
+ // handleShutdown owner -- it only closes a pipe. Re-registered idempotently
193
+ // (removeListener first) and removed in releaseSync to avoid leaking
194
+ // listeners across start/release cycles.
195
+ if (!this._exitHandler) {
196
+ this._exitHandler = () => { this._safe(() => this.releaseSync()); };
197
+ }
198
+ this._safe(() => process.removeListener('exit', this._exitHandler));
199
+ this._safe(() => process.once('exit', this._exitHandler));
200
+
201
+ this.ready.then((ok) => {
202
+ if (ok) {
203
+ this._safe(() => this._logger.log && this._logger.log(
204
+ 'keepalive: holding wake assertion (system sleep prevented)'));
205
+ } else {
206
+ const hint = firstErr || 'powershell.exe unavailable or blocked (Constrained Language Mode / WDAC / AV)';
207
+ this._safe(() => this._logger.warn && this._logger.warn(
208
+ `⚠ keepalive: could not hold the wake assertion; the machine may sleep (${hint}). Disable with --no-keepalive.`));
209
+ // Reap a helper that timed out without exiting (e.g. SetThreadExecutionState
210
+ // returned 0 and it is blocked on stdin) so it cannot leak.
211
+ if (this._child === child) this._safe(() => this.releaseSync());
212
+ }
213
+ }).catch(() => {});
214
+ } catch (err) {
215
+ this._started = false;
216
+ this._child = null;
217
+ this._safe(() => this._logger.debug && this._logger.debug(
218
+ 'keepalive: failed to start (continuing):', err && err.message));
219
+ }
220
+ }
221
+
222
+ // Async convenience wrapper; the real work is synchronous (just close a pipe).
223
+ release() {
224
+ this.releaseSync();
225
+ return Promise.resolve();
226
+ }
227
+
228
+ // Drop the assertion: closing stdin makes the helper hit EOF on ReadLine(),
229
+ // run its explicit clear, and exit; kill() is belt-and-suspenders. Idempotent
230
+ // and safe to call when never started or when the child is already dead (its
231
+ // body is fully guarded so it can never interrupt close()).
232
+ releaseSync() {
233
+ const child = this._child;
234
+ this._child = null;
235
+ this._started = false;
236
+ if (this._readyTimer) { this._safe(() => clearTimeout(this._readyTimer)); this._readyTimer = null; }
237
+ if (this._exitHandler) this._safe(() => process.removeListener('exit', this._exitHandler));
238
+ if (!child) return;
239
+ // end() sends a graceful EOF so the helper's ReadLine() returns null and the
240
+ // script runs its explicit ES_CONTINUOUS clear. Do NOT destroy() right after
241
+ // -- that would abort the pipe before the EOF flushes. kill() is the backstop.
242
+ this._safe(() => { if (child.stdin && !child.stdin.destroyed) child.stdin.end(); });
243
+ this._safe(() => child.kill());
244
+ }
245
+
246
+ _safe(fn) {
247
+ try { return fn(); } catch (_) { /* keepalive must never throw into the caller */ }
248
+ }
249
+ }
250
+
251
+ module.exports = KeepaliveManager;
package/src/server.js CHANGED
@@ -32,6 +32,7 @@ const { isBun } = require('./utils/runtime');
32
32
  const CircularBuffer = require('./utils/circular-buffer');
33
33
  const MinHeap = require('./utils/eviction-heap');
34
34
  const RestartManager = require('./restart-manager');
35
+ const KeepaliveManager = require('./keepalive-manager');
35
36
 
36
37
  // HOT-08: per-WebSocket-message size cap. Gates JSON.parse so a single
37
38
  // large frame can't block the event loop for tens-to-hundreds of ms.
@@ -153,6 +154,14 @@ class ClaudeCodeWebServer {
153
154
  const underTest =
154
155
  /^test/.test(process.env.npm_lifecycle_event || '') ||
155
156
  typeof global.it === 'function';
157
+ // CI runners must not be kept awake: the assertion can't hold in a headless
158
+ // CI session anyway, and spawning powershell.exe at startup races node-pty's
159
+ // ConPTY setup on Windows (it flaked the binary smoke test's terminal echo).
160
+ // GitHub Actions and most CIs set CI=true. Keep this OUT of KeepaliveManager
161
+ // so the unit tests (which construct it directly) still exercise win32 logic.
162
+ const ci = process.env.CI;
163
+ const isCI = (typeof ci === 'string' && ci !== '' && ci !== 'false' && ci !== '0') ||
164
+ !!process.env.GITHUB_ACTIONS;
156
165
  this.sttEngine = new SttEngine({
157
166
  // STT is ON by default (disable with --no-stt / STT_DISABLED=1, handled in
158
167
  // bin); an external endpoint always enables it.
@@ -222,6 +231,16 @@ class ClaudeCodeWebServer {
222
231
  onResult: (sessionId, payload) => this._onStickyNoteResult(sessionId, payload),
223
232
  });
224
233
 
234
+ // Keep the host machine awake for as long as the server runs (Windows 11
235
+ // only; instant no-op on macOS/Linux). Acquired in start() once listening,
236
+ // released in close() after the session-save flush. Gated on !underTest so
237
+ // mocha never spawns the PowerShell helper. See docs/specs/keepalive.md.
238
+ this.keepaliveManager = new KeepaliveManager({
239
+ enabled: options.keepalive !== false && !underTest && !isCI &&
240
+ process.env.AIORDIE_DISABLE_KEEPALIVE !== '1',
241
+ keepDisplayOn: !!options.keepaliveDisplay,
242
+ });
243
+
225
244
  this.sessionStore = new SessionStore(options.sessionStoreOptions);
226
245
  this.usageReader = new UsageReader(this.sessionDurationHours);
227
246
  this.usageAnalytics = new UsageAnalytics({
@@ -472,6 +491,8 @@ class ClaudeCodeWebServer {
472
491
  // Hard timeout: if close() hangs, force exit (protects unsupervised mode)
473
492
  const forceExitTimer = setTimeout(() => {
474
493
  console.error(`Shutdown timed out after 15s, forcing exit (code ${exitCode})`);
494
+ // Drop the keep-awake assertion before a hard exit in case close() hung.
495
+ try { this.keepaliveManager.releaseSync(); } catch (_) { /* ignore */ }
475
496
  process.exit(exitCode);
476
497
  }, 15000);
477
498
  forceExitTimer.unref();
@@ -3314,6 +3335,9 @@ class ClaudeCodeWebServer {
3314
3335
  reject(err);
3315
3336
  } else {
3316
3337
  this.server = server;
3338
+ // Now listening — hold the OS awake for the server's lifetime
3339
+ // (Windows only; no-op elsewhere; never throws).
3340
+ this.keepaliveManager.start();
3317
3341
  resolve(server);
3318
3342
  }
3319
3343
  });
@@ -5449,8 +5473,11 @@ class ClaudeCodeWebServer {
5449
5473
  }
5450
5474
 
5451
5475
  async close() {
5452
- // Save sessions before closing
5453
- await this.saveSessionsToDisk(true);
5476
+ // Save sessions before closing. Guarded so a save error can't abort close()
5477
+ // before the teardown below (incl. the keep-awake release at the end) runs;
5478
+ // handleShutdown already persisted sessions on the signal path, and wraps
5479
+ // its own save the same way.
5480
+ try { await this.saveSessionsToDisk(true); } catch (_) { /* ignore */ }
5454
5481
 
5455
5482
  // Tear down the STT (sherpa-onnx) native worker. close() is the cleanup path
5456
5483
  // shared by the signal handler (handleShutdown -> close) AND direct close()
@@ -5563,6 +5590,14 @@ class ClaudeCodeWebServer {
5563
5590
  }
5564
5591
  }
5565
5592
 
5593
+ // Release the Windows keep-awake assertion LAST. Held through the session
5594
+ // save + native-engine teardown above so an already-idle laptop cannot
5595
+ // sleep mid-flush (the exact data-loss window this feature prevents).
5596
+ // Idempotent and a no-op when keepalive was never started (non-win32 /
5597
+ // disabled / under test). The process.once('exit') hook + the force-exit
5598
+ // timer cover any path that skips close().
5599
+ try { this.keepaliveManager.releaseSync(); } catch (_) { /* ignore */ }
5600
+
5566
5601
  // Clear all data
5567
5602
  this.claudeSessions.clear();
5568
5603
  this.webSocketConnections.clear();