ai-or-die 0.1.94 → 0.1.96

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
@@ -46,7 +46,8 @@ program
46
46
  .option('--sticky-notes-model <url>', 'override the sticky-note model GGUF download URL')
47
47
  .option('--sticky-notes-threads <number>', 'CPU threads for sticky-note inference (default: auto — three-quarters of the cores on CPU, gentle on GPU)')
48
48
  .option('--no-keepalive', 'disable keeping the machine awake while the server runs (Windows only; on by default)')
49
- .option('--keepalive-display', 'also keep the display on (default keeps the system awake but lets the monitor sleep)');
49
+ .option('--keepalive-display', 'also keep the display on (default keeps the system awake but lets the monitor sleep)')
50
+ .option('--disable-hibernation', 'disable OS hibernation + set sleep/hibernate timeouts to Never via elevated powercfg (Windows only; off by default; prompts for UAC; needed to stop host-initiated hibernation on Hyper-V guests)');
50
51
 
51
52
  // Auto-open is OFF by default and opt-in via --open. Legacy callers may still pass
52
53
  // --no-open (the old opt-out flag); filter it out so it parses harmlessly as a no-op.
@@ -138,6 +139,10 @@ async function main() {
138
139
  // the display on.
139
140
  keepalive: options.keepalive !== false && process.env.AIORDIE_DISABLE_KEEPALIVE !== '1',
140
141
  keepaliveDisplay: options.keepaliveDisplay === true || process.env.AIORDIE_KEEPALIVE_DISPLAY === '1',
142
+ // Opt-in: disable OS hibernation (+ sleep/hibernate timeouts -> Never) via
143
+ // an elevated powercfg. Off by default. The wake assertion can't block a
144
+ // host-initiated hibernate (Hyper-V vmicshutdown); this removes the target.
145
+ disableHibernation: options.disableHibernation === true || process.env.AIORDIE_DISABLE_HIBERNATION === '1',
141
146
  // Mesh binds loopback-only always: the tailnet `serve` proxy reaches the
142
147
  // port, the LAN never does (even with --https). Other modes: all interfaces.
143
148
  bindHost: options.mesh ? '127.0.0.1' : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.94",
3
+ "version": "0.1.96",
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,202 @@
1
+ 'use strict';
2
+
3
+ const childProcess = require('child_process');
4
+ const KeepaliveManager = require('./keepalive-manager');
5
+
6
+ // Opt-in remediation for HOST-INITIATED sleep on Windows (primarily Hyper-V
7
+ // guests). The keepalive wake assertion (SetThreadExecutionState /
8
+ // PowerRequestSystemRequired) only suppresses the IDLE timer; Windows documents
9
+ // that it "cannot be used to prevent ... standby or hibernate" when the suspend
10
+ // is requested programmatically. On a Hyper-V guest, the host's Guest Shutdown
11
+ // Service (`vmicshutdown`) requests hibernation through the Application API,
12
+ // which the assertion cannot block. The only reliable fix is to remove the sleep
13
+ // target itself: `powercfg /hibernate off` (+ sleep/hibernate timeouts -> Never).
14
+ // On a Hyper-V guest S3 standby is typically unavailable, so disabling S4
15
+ // hibernate eliminates the vector entirely.
16
+ //
17
+ // That is a PRIVILEGED, PERSISTENT machine change (survives our process exit;
18
+ // reversible with `powercfg /hibernate on`), so it is OFF by default and gated
19
+ // behind --disable-hibernation / AIORDIE_DISABLE_HIBERNATION=1. When enabled, one
20
+ // long-lived in-box `powershell.exe` (spawned like the keepalive helper) runs
21
+ // ONCE at startup:
22
+ // 1. Reads HKLM\...\Power\HibernateEnabled (non-privileged). If already 0 it
23
+ // prints SKIPPED and exits -- no UAC prompt, so a machine that is already
24
+ // configured never prompts again.
25
+ // 2. Otherwise it launches an ELEVATED cmd.exe via `Start-Process -Verb RunAs`
26
+ // (UAC is the OS approve/deny; the flag is the user's pre-approval) that runs
27
+ // the powercfg remediation, then prints APPLIED / DENIED / ERROR.
28
+ // Best-effort and non-fatal: a denied UAC prompt, a headless/remote session with
29
+ // no interactive desktop, or any spawn failure logs a warning and the server
30
+ // continues normally. Never throws into startup. Windows-only; instant no-op on
31
+ // macOS/Linux. See docs/specs/keepalive.md and docs/adrs/0030-hibernation-guard.md.
32
+
33
+ // The remediation, chained under ONE elevation (one UAC prompt) via `cmd /c a & b`.
34
+ // All constants (no user input), so there is no command-injection surface. `& `
35
+ // runs every command regardless of a prior one's exit; success is judged by
36
+ // re-reading HibernateEnabled afterward (not the chain's exit code, which only
37
+ // reflects the last command), so an early failure can't be masked.
38
+ const REMEDIATION = [
39
+ 'powercfg /hibernate off',
40
+ 'powercfg /change standby-timeout-ac 0',
41
+ 'powercfg /change standby-timeout-dc 0',
42
+ 'powercfg /change hibernate-timeout-ac 0',
43
+ 'powercfg /change hibernate-timeout-dc 0',
44
+ ];
45
+
46
+ const READY_TIMEOUT_MS = 60000;
47
+
48
+ class HibernationGuard {
49
+ constructor(options = {}) {
50
+ this._enabled = !!options.enabled;
51
+ this._platform = options.platform || process.platform;
52
+ this._spawn = options.spawn || childProcess.spawn;
53
+ this._logger = options.logger || console;
54
+ this._readyTimeoutMs = options.readyTimeoutMs || READY_TIMEOUT_MS;
55
+
56
+ this._ran = false;
57
+ this._child = null;
58
+ }
59
+
60
+ // ---- pure builders (static so tests assert them without spawning) ----
61
+
62
+ static buildScript(ppid = process.pid) {
63
+ const argline = '/c ' + REMEDIATION.join(' & ');
64
+ return [
65
+ // 'Stop' turns a blocked/failed cmdlet into a caught terminating error
66
+ // rather than a silent partial run.
67
+ `$ErrorActionPreference = 'Stop'`,
68
+ // Trusted-integer parent-PID tag for triage (never user input).
69
+ `# aod-hibernation ppid=${ppid}`,
70
+ // (1) Non-privileged detect. Skip (no UAC) when hibernate is already off.
71
+ `$he = $null`,
72
+ `try { $he = (Get-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Power' -Name 'HibernateEnabled' -ErrorAction Stop).HibernateEnabled } catch { $he = $null }`,
73
+ `if ($he -eq 0) { [Console]::Out.WriteLine('SKIPPED'); exit 0 }`,
74
+ // (2) One elevated cmd.exe runs the whole powercfg chain under a single UAC
75
+ // prompt. Absolute cmd path (PATH-hijack hardening). WaitForExit + ExitCode
76
+ // is more reliable than -Wait with -Verb RunAs for reading the result.
77
+ `$cmd = Join-Path $env:SystemRoot 'System32\\cmd.exe'`,
78
+ `$argline = '${argline}'`,
79
+ `try {`,
80
+ ` $p = Start-Process -FilePath $cmd -Verb RunAs -WindowStyle Hidden -PassThru -ArgumentList $argline`,
81
+ ` $p.WaitForExit()`,
82
+ // Verify the critical end-state directly rather than trusting the exit code:
83
+ // `cmd /c a & b & c` reports only the LAST command's exit, so an early
84
+ // failure could be masked. APPLIED iff hibernation is now actually off.
85
+ ` $after = $null`,
86
+ ` try { $after = (Get-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Power' -Name 'HibernateEnabled' -ErrorAction Stop).HibernateEnabled } catch { $after = $null }`,
87
+ ` if ($after -eq 0) { [Console]::Out.WriteLine('APPLIED') } else { [Console]::Out.WriteLine('ERROR'); [Console]::Error.WriteLine('hibernate still enabled after powercfg (exit=' + $p.ExitCode + ')') }`,
88
+ `} catch {`,
89
+ // Detect a declined UAC by the Win32 error code (1223 = ERROR_CANCELLED),
90
+ // locale-independent; walk the inner-exception chain, then fall back to text.
91
+ ` $cancelled = $false`,
92
+ ` $e = $_.Exception`,
93
+ ` while ($null -ne $e) { if (($e -is [System.ComponentModel.Win32Exception]) -and ($e.NativeErrorCode -eq 1223)) { $cancelled = $true; break }; $e = $e.InnerException }`,
94
+ ` if (-not $cancelled -and $_.Exception.Message -match 'cancel') { $cancelled = $true }`,
95
+ ` if ($cancelled) { [Console]::Out.WriteLine('DENIED') } else { [Console]::Out.WriteLine('ERROR'); [Console]::Error.WriteLine($_.Exception.Message) }`,
96
+ `}`,
97
+ ].join('\n');
98
+ }
99
+
100
+ static buildArgs(ppid = process.pid) {
101
+ return [
102
+ '-NoProfile',
103
+ '-NonInteractive',
104
+ '-ExecutionPolicy', 'Bypass',
105
+ '-Command', HibernationGuard.buildScript(ppid),
106
+ ];
107
+ }
108
+
109
+ // Attempt the remediation once. No-op unless enabled on win32. Idempotent.
110
+ // Fire-and-forget: the server never awaits this (the UAC prompt is interactive
111
+ // and may take a while). Never throws.
112
+ run() {
113
+ if (this._ran) return;
114
+ if (!this._enabled || this._platform !== 'win32') return;
115
+ this._ran = true;
116
+
117
+ try {
118
+ const ps = KeepaliveManager.powershellPath();
119
+ const args = HibernationGuard.buildArgs();
120
+ const child = this._spawn(ps, args, {
121
+ stdio: ['ignore', 'pipe', 'pipe'],
122
+ windowsHide: true,
123
+ shell: false,
124
+ });
125
+ this._child = child;
126
+
127
+ // Never let the helper pin the parent's event loop.
128
+ this._safe(() => child.unref());
129
+ this._safe(() => child.stdout && child.stdout.unref && child.stdout.unref());
130
+ this._safe(() => child.stderr && child.stderr.unref && child.stderr.unref());
131
+
132
+ let out = '';
133
+ let err = '';
134
+ if (child.stdout) {
135
+ this._safe(() => child.stdout.setEncoding && child.stdout.setEncoding('utf8'));
136
+ child.stdout.on('data', (d) => { out += String(d); });
137
+ child.stdout.on('error', () => {});
138
+ }
139
+ if (child.stderr) {
140
+ this._safe(() => child.stderr.setEncoding && child.stderr.setEncoding('utf8'));
141
+ child.stderr.on('data', (d) => { err += String(d); });
142
+ child.stderr.on('error', () => {});
143
+ }
144
+
145
+ let settled = false;
146
+ const finish = (status, hint) => {
147
+ if (settled) return;
148
+ settled = true;
149
+ this._safe(() => clearTimeout(timer));
150
+ if (this._child === child) this._child = null;
151
+ this._logOutcome(status, hint);
152
+ };
153
+
154
+ // The UAC prompt blocks the helper until the user responds; only reap after
155
+ // a generous timeout so a genuinely stuck helper cannot linger forever.
156
+ const timer = setTimeout(() => {
157
+ finish('ERROR', 'timed out waiting for the elevation prompt');
158
+ this._safe(() => child.kill && child.kill());
159
+ }, this._readyTimeoutMs);
160
+ if (timer.unref) timer.unref();
161
+
162
+ child.on('error', (e) => finish('ERROR', (e && e.message) || 'spawn error'));
163
+ child.on('close', () => {
164
+ const m = out.match(/\b(APPLIED|SKIPPED|DENIED|ERROR)\b/);
165
+ finish(m ? m[1] : 'ERROR', err.trim());
166
+ });
167
+ } catch (err) {
168
+ this._child = null;
169
+ this._safe(() => this._logger.debug && this._logger.debug(
170
+ 'hibernation-guard: failed to start (continuing):', err && err.message));
171
+ }
172
+ }
173
+
174
+ _logOutcome(status, hint) {
175
+ const L = this._logger;
176
+ switch (status) {
177
+ case 'APPLIED':
178
+ this._safe(() => L.log && L.log(
179
+ 'hibernation guard: hibernation disabled + sleep/hibernate timeouts set to Never (host-initiated hibernation suppressed)'));
180
+ break;
181
+ case 'SKIPPED':
182
+ this._safe(() => L.log && L.log(
183
+ 'hibernation guard: hibernation already disabled — nothing to do'));
184
+ break;
185
+ case 'DENIED':
186
+ this._safe(() => L.warn && L.warn(
187
+ '⚠ hibernation guard: elevation declined — hibernation left enabled. A Hyper-V host can still hibernate this machine. Run `powercfg /hibernate off` as admin, or relaunch without --disable-hibernation.'));
188
+ break;
189
+ default:
190
+ this._safe(() => L.warn && L.warn(
191
+ `⚠ hibernation guard: could not disable hibernation${hint ? ` (${hint})` : ''}; the host may still hibernate this machine. Run \`powercfg /hibernate off\` as admin.`));
192
+ break;
193
+ }
194
+ }
195
+
196
+ _safe(fn) {
197
+ try { return fn(); } catch (_) { /* the guard must never throw into the caller */ }
198
+ }
199
+ }
200
+
201
+ module.exports = HibernationGuard;
202
+ module.exports.REMEDIATION = REMEDIATION;
@@ -6,15 +6,26 @@ const path = require('path');
6
6
  // Keep the host machine awake while the ai-or-die server runs (Windows 11).
7
7
  //
8
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.
9
+ // holds TWO redundant kernel32 power assertions, then blocks on stdin:
10
+ // 1. SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED) -- the legacy
11
+ // per-thread assertion (shows in `powercfg /requests` with no reason text).
12
+ // 2. PowerCreateRequest + PowerSetRequest(PowerRequestSystemRequired) with a
13
+ // REASON_CONTEXT SimpleReasonString of "GitHub Copilot CLI session active".
14
+ // This is the exact API + reason string the GitHub Copilot CLI /keep-alive
15
+ // registers (its bundled cli-native.node calls the same kernel32 Power*
16
+ // functions with that wide string), so `powercfg /requests` shows a
17
+ // byte-identical "GitHub Copilot CLI session active" line.
18
+ // Holding both is deliberate belt-and-suspenders (chosen over Power*-only): if
19
+ // either API is refused at runtime the other still keeps the machine awake. The
20
+ // helper prints OK once AT LEAST ONE assertion is held. When the parent closes
21
+ // stdin (graceful release) OR dies (the OS tears down the pipe -> ReadLine()
22
+ // returns null), the helper clears both and exits. Windows also drops a thread's
23
+ // execution-state flags AND releases a process's power requests when the holding
24
+ // process dies, so even taskkill /F cannot leak past reboot. No native deps.
15
25
  //
16
26
  // Windows-only by design; an instant no-op on macOS/Linux. See
17
- // docs/specs/keepalive.md and docs/adrs/0028-windows-keepalive.md.
27
+ // docs/specs/keepalive.md and
28
+ // docs/adrs/0029-windows-keepalive-power-request.md (supersedes ADR-0028).
18
29
 
19
30
  // SetThreadExecutionState flags as DECIMAL uint32 literals. PowerShell 5.1
20
31
  // parses 0x80000001 as a negative Int32 and the [uint32] cast then throws, so
@@ -26,6 +37,15 @@ const ES_CONTINUOUS = 2147483648;
26
37
  const ES_SYSTEM = 2147483649;
27
38
  const ES_SYSTEM_DISPLAY = 2147483651;
28
39
 
40
+ // POWER_REQUEST_TYPE enum values passed to PowerSetRequest / PowerClearRequest.
41
+ // Small ints, so no PS 5.1 hex/Int32 parsing hazard (unlike the ES_ flags).
42
+ const PR_DISPLAY = 0; // PowerRequestDisplayRequired
43
+ const PR_SYSTEM = 1; // PowerRequestSystemRequired
44
+
45
+ // REASON_CONTEXT.SimpleReasonString shown by `powercfg /requests`. Byte-for-byte
46
+ // the string the GitHub Copilot CLI registers, so the entry is indistinguishable.
47
+ const KEEPALIVE_REASON = 'GitHub Copilot CLI session active';
48
+
29
49
  const READY_TIMEOUT_MS = 5000;
30
50
 
31
51
  class KeepaliveManager {
@@ -52,7 +72,25 @@ class KeepaliveManager {
52
72
  // ---- pure builders (static so tests assert them without spawning) ----
53
73
 
54
74
  static buildScript(displayRequired, ppid = process.pid) {
55
- const assert = displayRequired ? ES_SYSTEM_DISPLAY : ES_SYSTEM;
75
+ const stesAssert = displayRequired ? ES_SYSTEM_DISPLAY : ES_SYSTEM;
76
+ // One Add-Type carrying the REASON_CONTEXT struct (SIMPLE_STRING layout:
77
+ // Version + Flags + a single LPWStr) and the four kernel32 P/Invokes.
78
+ // Attributes are fully qualified so the compile never depends on Add-Type's
79
+ // default `using`s. The native REASON_CONTEXT is a union (SimpleReasonString
80
+ // XOR a larger Detailed struct); we only ever use SIMPLE_STRING, but two
81
+ // long pad fields make the marshalled struct >= the native union size
82
+ // (32 bytes x64 / 28 x86) so PowerCreateRequest can never read past the [In]
83
+ // buffer. Marshalling verified on a real Windows host.
84
+ const members = [
85
+ '[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential, CharSet = System.Runtime.InteropServices.CharSet.Unicode)]',
86
+ 'public struct REASON_CONTEXT { public uint Version; public uint Flags; [System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] public string Reason; public long Pad0; public long Pad1; }',
87
+ '[System.Runtime.InteropServices.DllImport("kernel32.dll")] public static extern uint SetThreadExecutionState(uint e);',
88
+ '[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] public static extern System.IntPtr PowerCreateRequest(ref REASON_CONTEXT Context);',
89
+ '[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] public static extern bool PowerSetRequest(System.IntPtr PowerRequest, int RequestType);',
90
+ '[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] public static extern bool PowerClearRequest(System.IntPtr PowerRequest, int RequestType);',
91
+ '[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] public static extern bool CloseHandle(System.IntPtr hObject);',
92
+ ].join('\n');
93
+
56
94
  return [
57
95
  // 'Stop' is load-bearing: under Constrained Language Mode / WDAC /
58
96
  // AppLocker / EDR, Add-Type (which writes a .cs to %TEMP% and shells to
@@ -65,13 +103,39 @@ class KeepaliveManager {
65
103
  // input) so a stale/orphaned helper is identifiable for triage:
66
104
  // Get-CimInstance Win32_Process -Filter "Name='powershell.exe'"
67
105
  `# 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 }`,
106
+ `Add-Type -Name P -Namespace W -MemberDefinition '${members}'`,
107
+ `$held = $false`,
108
+ // (1) Legacy per-thread assertion. Redundant safety net; carries no name in
109
+ // `powercfg /requests`.
110
+ `if ([W.P]::SetThreadExecutionState([uint32]${stesAssert}) -ne 0) { $held = $true }`,
111
+ // (2) Named power request -- this is the "GitHub Copilot CLI session active"
112
+ // line. Flags=1 is POWER_REQUEST_CONTEXT_SIMPLE_STRING; Version=0.
113
+ `$ctx = New-Object 'W.P+REASON_CONTEXT'`,
114
+ `$ctx.Version = 0`,
115
+ `$ctx.Flags = 1`,
116
+ `$ctx.Reason = '${KEEPALIVE_REASON}'`,
117
+ `$h = [W.P]::PowerCreateRequest([ref]$ctx)`,
118
+ // PowerCreateRequest returns NULL or INVALID_HANDLE_VALUE (-1) on failure.
119
+ `$hv = $h.ToInt64()`,
120
+ `$powerSys = $false`,
121
+ ...(displayRequired ? [`$powerDisp = $false`] : []),
122
+ // Set each request type independently so "at least one held" counts a
123
+ // display-only success too, and each is cleared only if it was set.
124
+ `if ($hv -ne 0 -and $hv -ne -1) {`,
125
+ ` if ([W.P]::PowerSetRequest($h, ${PR_SYSTEM})) { $held = $true; $powerSys = $true }`,
126
+ ...(displayRequired ? [` if ([W.P]::PowerSetRequest($h, ${PR_DISPLAY})) { $held = $true; $powerDisp = $true }`] : []),
127
+ `}`,
128
+ // Only latch "started" (block on stdin) when SOMETHING is actually held, so
129
+ // a total failure surfaces as ready=false instead of a silent awake-less
130
+ // block. Close a valid-but-unused handle before exiting so nothing lingers.
131
+ `if (-not $held) { if ($hv -ne 0 -and $hv -ne -1) { [void][W.P]::CloseHandle($h) } [Console]::Error.WriteLine('SetThreadExecutionState and PowerSetRequest both failed'); exit 1 }`,
132
+ `[Console]::Out.WriteLine('OK'); [Console]::Out.Flush()`,
73
133
  `while ($null -ne [Console]::In.ReadLine()) {}`,
134
+ // Release each held assertion on EOF; process death also drops all of them.
74
135
  `[void][W.P]::SetThreadExecutionState([uint32]${ES_CONTINUOUS})`,
136
+ `if ($powerSys) { [void][W.P]::PowerClearRequest($h, ${PR_SYSTEM}) }`,
137
+ ...(displayRequired ? [`if ($powerDisp) { [void][W.P]::PowerClearRequest($h, ${PR_DISPLAY}) }`] : []),
138
+ `if ($hv -ne 0 -and $hv -ne -1) { [void][W.P]::CloseHandle($h) }`,
75
139
  ].join('\n');
76
140
  }
77
141
 
@@ -206,8 +270,8 @@ class KeepaliveManager {
206
270
  const hint = firstErr || 'powershell.exe unavailable or blocked (Constrained Language Mode / WDAC / AV)';
207
271
  this._safe(() => this._logger.warn && this._logger.warn(
208
272
  `⚠ 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.
273
+ // Reap a helper that timed out without exiting (e.g. both assertions were
274
+ // refused yet it is somehow blocked on stdin) so it cannot leak.
211
275
  if (this._child === child) this._safe(() => this.releaseSync());
212
276
  }
213
277
  }).catch(() => {});
package/src/public/app.js CHANGED
@@ -692,7 +692,7 @@ class ClaudeCodeWebInterface {
692
692
  foreground: '#f0f6fc',
693
693
  cursor: '#ff6b00',
694
694
  cursorAccent: '#0d1117',
695
- selection: 'rgba(255, 107, 0, 0.2)',
695
+ selectionBackground: 'rgba(255, 107, 0, 0.2)',
696
696
  black: '#484f58',
697
697
  red: '#ff7b72',
698
698
  green: '#7ee787',
@@ -741,6 +741,20 @@ class ClaudeCodeWebInterface {
741
741
 
742
742
  this.terminal.open(document.getElementById('terminal'));
743
743
 
744
+ // Trackpad/mouse-wheel policy: preempt xterm's alt-buffer wheel->arrow
745
+ // translation so scrolling doesn't hijack the Claude Code TUI. Reads the
746
+ // live setting via the cached `_wheelScrollMode` (default 'dontHijack').
747
+ if (typeof window.attachTerminalWheel === 'function') {
748
+ if (this._wheelHandler && this._wheelHandler.dispose) {
749
+ try { this._wheelHandler.dispose(); } catch (_) {}
750
+ }
751
+ this._wheelHandler = window.attachTerminalWheel(
752
+ this.terminal,
753
+ document.getElementById('terminal'),
754
+ () => this._wheelScrollMode || 'dontHijack'
755
+ );
756
+ }
757
+
744
758
  // Faithful per-tab snapshot cache for instant tab-switch repaint.
745
759
  // Guarded: if the serialize addon or the cache class failed to load
746
760
  // (e.g. CDN blocked), snapshotCache stays null and every call site is
@@ -2293,7 +2307,17 @@ class ClaudeCodeWebInterface {
2293
2307
  if (restartGen !== this._socketGeneration) return;
2294
2308
  this.reconnect();
2295
2309
  }, restartBackoff);
2296
- } else if ((!event.wasClean || voiceClose.rejected) && this.reconnectAttempts < this.maxReconnectAttempts) {
2310
+ // Reconnect decision extracted to WsReconnect.isReconnectableClose
2311
+ // (unit-tested): abnormal close, our own heartbeat pong-timeout
2312
+ // (code 4000 — a clean close that MUST still reconnect, or a single
2313
+ // transient pong-timeout strands the client on "Disconnected"), or a
2314
+ // server frame-rejection (1009/1003). Inline fallback keeps reconnect
2315
+ // working even if the tiny module failed to load.
2316
+ } else if (
2317
+ ((window.WsReconnect && window.WsReconnect.isReconnectableClose)
2318
+ ? window.WsReconnect.isReconnectableClose(event, voiceClose.rejected)
2319
+ : (!event.wasClean || event.code === 4000 || voiceClose.rejected))
2320
+ && this.reconnectAttempts < this.maxReconnectAttempts) {
2297
2321
  this.updateStatus('Reconnecting (' + (this.reconnectAttempts + 1) + '/' + this.maxReconnectAttempts + ')...');
2298
2322
  // First attempt is fast (250ms covers a server-process restart window);
2299
2323
  // subsequent attempts use exponential backoff with jitter.
@@ -2315,7 +2339,11 @@ class ClaudeCodeWebInterface {
2315
2339
  this.reconnectAttempts++;
2316
2340
  } else {
2317
2341
  this.updateStatus('Disconnected');
2318
- this.showError(`Connection lost after ${this.maxReconnectAttempts} attempts.\n\nYour session data is preserved on the server.\n\u2022 Check your network connection\n\u2022 The server may have restarted \u2014 try refreshing the page`);
2342
+ const n = this.reconnectAttempts;
2343
+ const lead = n > 0
2344
+ ? `Connection lost after ${n} reconnect attempt${n === 1 ? '' : 's'}.`
2345
+ : 'Disconnected from the server.';
2346
+ this.showError(`${lead}\n\nYour session data is preserved on the server.\n\u2022 Check your network connection\n\u2022 The server may have restarted \u2014 try refreshing the page`);
2319
2347
  }
2320
2348
  };
2321
2349
 
@@ -3573,40 +3601,30 @@ class ClaudeCodeWebInterface {
3573
3601
  }
3574
3602
 
3575
3603
  _loadGpuRenderer() {
3604
+ // The Canvas renderer was removed in xterm 6.0. Mobile keeps using the
3605
+ // reliable default DOM renderer (WebGL was historically avoided there for
3606
+ // context-loss/memory reasons); desktop uses WebGL and falls back to the
3607
+ // DOM renderer on failure or context loss.
3576
3608
  if (this.isMobile) {
3577
- console.log('[Renderer] Mobile detected, using Canvas renderer for reliability');
3578
- this._loadCanvasAddon();
3609
+ console.log('[Renderer] Mobile detected, using DOM renderer for reliability');
3579
3610
  return;
3580
3611
  }
3581
3612
  if (typeof WebglAddon !== 'undefined') {
3582
3613
  try {
3583
3614
  this.webglAddon = new WebglAddon.WebglAddon();
3584
3615
  this.webglAddon.onContextLoss(() => {
3585
- this.webglAddon.dispose();
3616
+ const addon = this.webglAddon;
3586
3617
  this.webglAddon = null;
3587
- this._loadCanvasAddon();
3618
+ try { if (addon) addon.dispose(); } catch (_) {}
3619
+ console.log('[Renderer] WebGL context lost, using DOM renderer');
3588
3620
  });
3589
3621
  this.terminal.loadAddon(this.webglAddon);
3590
3622
  } catch (e) {
3591
- console.log('[Renderer] WebGL unavailable, using Canvas renderer');
3592
- this._loadCanvasAddon();
3593
- }
3594
- } else {
3595
- console.log('[Renderer] WebGL unavailable, using Canvas renderer');
3596
- this._loadCanvasAddon();
3597
- }
3598
- }
3599
-
3600
- _loadCanvasAddon() {
3601
- if (typeof CanvasAddon !== 'undefined') {
3602
- try {
3603
- this.canvasAddon = new CanvasAddon.CanvasAddon();
3604
- this.terminal.loadAddon(this.canvasAddon);
3605
- } catch (e) {
3606
- console.log('[Renderer] Canvas unavailable, using DOM renderer (slower)');
3623
+ this.webglAddon = null;
3624
+ console.log('[Renderer] WebGL unavailable, using DOM renderer');
3607
3625
  }
3608
3626
  } else {
3609
- console.log('[Renderer] Canvas unavailable, using DOM renderer (slower)');
3627
+ console.log('[Renderer] WebGL unavailable, using DOM renderer');
3610
3628
  }
3611
3629
  }
3612
3630
 
@@ -4259,6 +4277,9 @@ class ClaudeCodeWebInterface {
4259
4277
  const enableSessionStickyNotes = document.getElementById('enableSessionStickyNotes');
4260
4278
  if (enableSessionStickyNotes) enableSessionStickyNotes.checked = settings.enableSessionStickyNotes ?? true;
4261
4279
 
4280
+ const wheelScrollMode = document.getElementById('wheelScrollMode');
4281
+ if (wheelScrollMode) wheelScrollMode.value = settings.wheelScrollMode || 'dontHijack';
4282
+
4262
4283
  // Update install section
4263
4284
  this._updateInstallSection();
4264
4285
  }
@@ -4559,7 +4580,11 @@ class ClaudeCodeWebInterface {
4559
4580
  notifVolume: 30,
4560
4581
  notifDesktop: true,
4561
4582
  enableSessionStickyNotes: true,
4562
- tabSnapshotLines: 500
4583
+ tabSnapshotLines: 500,
4584
+ // Trackpad/mouse-wheel behaviour inside full-screen (alt-buffer) apps:
4585
+ // 'dontHijack' - wheel does nothing there (no menu hijack in the Claude Code TUI)
4586
+ // 'altScroll' - wheel sends Up/Down arrows (pagers like `less` scroll)
4587
+ wheelScrollMode: 'dontHijack'
4563
4588
  };
4564
4589
  }
4565
4590
 
@@ -4602,7 +4627,8 @@ class ClaudeCodeWebInterface {
4602
4627
  notifVolume: parseInt(document.getElementById('notifVolume')?.value || '30'),
4603
4628
  notifDesktop: document.getElementById('notifDesktop')?.checked ?? true,
4604
4629
  enableSessionStickyNotes: document.getElementById('enableSessionStickyNotes')?.checked ?? true,
4605
- tabSnapshotLines: parseInt(document.getElementById('tabSnapshotLines')?.value || '500', 10)
4630
+ tabSnapshotLines: parseInt(document.getElementById('tabSnapshotLines')?.value || '500', 10),
4631
+ wheelScrollMode: document.getElementById('wheelScrollMode')?.value || 'dontHijack'
4606
4632
  };
4607
4633
 
4608
4634
  try {
@@ -4651,6 +4677,10 @@ class ClaudeCodeWebInterface {
4651
4677
  this.terminal.options.cursorBlink = settings.cursorBlink ?? true;
4652
4678
  if (settings.scrollback) this.terminal.options.scrollback = settings.scrollback;
4653
4679
 
4680
+ // Cache the wheel-scroll policy for the capture-phase wheel handler
4681
+ // (read live per wheel event without touching localStorage each notch).
4682
+ this._wheelScrollMode = settings.wheelScrollMode || 'dontHijack';
4683
+
4654
4684
  // Push the instant-snapshot line cap to the cache (0 disables capture+paint).
4655
4685
  if (this.snapshotCache && settings.tabSnapshotLines !== undefined) {
4656
4686
  this.snapshotCache.setMaxLines(settings.tabSnapshotLines);
@@ -106,15 +106,19 @@
106
106
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
107
107
  <link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
108
108
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
109
- <script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
110
- <script src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
111
- <script src="https://unpkg.com/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.js"></script>
112
- <script src="https://unpkg.com/xterm-addon-search@0.13.0/lib/xterm-addon-search.js"></script>
113
- <script src="https://unpkg.com/xterm-addon-unicode11@0.6.0/lib/xterm-addon-unicode11.js"></script>
114
- <script src="https://unpkg.com/xterm-addon-webgl@0.16.0/lib/xterm-addon-webgl.js"></script>
115
- <script src="https://unpkg.com/xterm-addon-canvas@0.5.0/lib/xterm-addon-canvas.js"></script>
116
- <script src="https://unpkg.com/xterm-addon-serialize@0.11.0/lib/xterm-addon-serialize.js"></script>
117
- <link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css" />
109
+ <!-- xterm.js self-hosted under vendor/xterm/ (not a CDN) for deterministic,
110
+ offline-capable, fast loads — same rationale as the self-hosted fonts.
111
+ Versions: xterm 6.0.0, addon-fit 0.11.0, addon-web-links 0.12.0,
112
+ addon-search 0.16.0, addon-unicode11 0.9.0, addon-webgl 0.19.0,
113
+ addon-serialize 0.14.0. -->
114
+ <script src="/vendor/xterm/xterm.js"></script>
115
+ <script src="/vendor/xterm/addon-fit.js"></script>
116
+ <script src="/vendor/xterm/addon-web-links.js"></script>
117
+ <script src="/vendor/xterm/addon-search.js"></script>
118
+ <script src="/vendor/xterm/addon-unicode11.js"></script>
119
+ <script src="/vendor/xterm/addon-webgl.js"></script>
120
+ <script src="/vendor/xterm/addon-serialize.js"></script>
121
+ <link rel="stylesheet" href="/vendor/xterm/xterm.css" />
118
122
  <!-- Monaco editor is loaded lazily on first file preview/editor open via
119
123
  file-viewer-monaco.js (see ADR-0016). Preconnect above warms the
120
124
  CDN handshake; we don't preload loader.js to avoid charging users
@@ -493,6 +497,14 @@
493
497
  <span class="range-value" id="terminalPaddingValue">8px</span>
494
498
  </span>
495
499
  </div>
500
+ <div class="setting-group">
501
+ <label for="wheelScrollMode">Trackpad scroll in full-screen apps</label>
502
+ <select id="wheelScrollMode">
503
+ <option value="dontHijack">Don't hijack menus</option>
504
+ <option value="altScroll">Scroll (send arrows)</option>
505
+ </select>
506
+ </div>
507
+ <div class="setting-hint">Inside full-screen apps like the Claude Code TUI, the wheel can't both scroll and drive menus. &ldquo;Don't hijack&rdquo; stops it from jumping menus; &ldquo;Scroll&rdquo; makes it scroll pagers like <code>less</code>. Normal shell scrolling is unaffected.</div>
496
508
  </section>
497
509
 
498
510
  <section class="settings-pane" id="settingsPane-voice" role="tabpanel" aria-labelledby="settingsTab-voice" tabindex="0" hidden>
@@ -841,6 +853,7 @@
841
853
  <script src="clipboard-handler.js"></script>
842
854
  <script src="image-handler.js"></script>
843
855
  <script src="generic-drop-handler.js"></script>
856
+ <script src="terminal-wheel.js"></script>
844
857
  <script src="auth.js"></script>
845
858
  <script src="feedback-manager.js"></script>
846
859
  <!-- marked.min.js and purify.min.js lazy-loaded on first plan viewer open -->
@@ -849,6 +862,7 @@
849
862
  <script src="sticky-note-card.js"></script>
850
863
  <script src="artifact-panel.js"></script>
851
864
  <script src="heartbeat-watchdog.js"></script>
865
+ <script src="ws-reconnect.js"></script>
852
866
  <script src="splits.js"></script>
853
867
  <script src="icons.js"></script>
854
868
  <script src="file-viewer-monaco.js"></script>
@@ -1,6 +1,6 @@
1
1
  // Bump this version when urlsToCache entries are added or removed.
2
2
  // Content changes to existing files are handled by the network-first fetch strategy.
3
- const CACHE_NAME = 'ai-or-die-v11';
3
+ const CACHE_NAME = 'ai-or-die-v13';
4
4
  const urlsToCache = [
5
5
  '/',
6
6
  '/index.html',
@@ -49,7 +49,13 @@ const urlsToCache = [
49
49
  '/voice-handler.js',
50
50
  '/image-handler.js',
51
51
  '/input-overlay.js',
52
- '/feedback-manager.js'
52
+ '/feedback-manager.js',
53
+ '/terminal-wheel.js'
54
+ // xterm.js is self-hosted under /vendor/xterm/ (served locally, fast) but is
55
+ // intentionally NOT precached on install: ~900KB of addons would bloat the
56
+ // install step (it churns on every fresh page load, and is pathologically slow
57
+ // under the WebKit-on-Windows CI runner). The runtime fetch handler caches it
58
+ // on first load, so offline still works after the first online visit.
53
59
  ];
54
60
 
55
61
  // Install event - cache resources