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 +6 -1
- package/package.json +1 -1
- package/src/hibernation-guard.js +202 -0
- package/src/keepalive-manager.js +79 -15
- package/src/public/app.js +56 -26
- package/src/public/index.html +23 -9
- package/src/public/service-worker.js +8 -2
- package/src/public/splits.js +82 -19
- package/src/public/terminal-wheel.js +188 -0
- package/src/public/vendor/xterm/addon-fit.js +2 -0
- package/src/public/vendor/xterm/addon-search.js +2 -0
- package/src/public/vendor/xterm/addon-serialize.js +2 -0
- package/src/public/vendor/xterm/addon-unicode11.js +2 -0
- package/src/public/vendor/xterm/addon-web-links.js +2 -0
- package/src/public/vendor/xterm/addon-webgl.js +2 -0
- package/src/public/vendor/xterm/xterm.css +285 -0
- package/src/public/vendor/xterm/xterm.js +2 -0
- package/src/public/ws-reconnect.js +33 -0
- package/src/server.js +40 -3
- package/src/usage-reader.js +11 -1
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
|
@@ -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;
|
package/src/keepalive-manager.js
CHANGED
|
@@ -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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
|
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
|
|
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 '
|
|
69
|
-
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
`if ([W.P]::SetThreadExecutionState([uint32]${
|
|
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.
|
|
210
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
3616
|
+
const addon = this.webglAddon;
|
|
3586
3617
|
this.webglAddon = null;
|
|
3587
|
-
|
|
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
|
-
|
|
3592
|
-
|
|
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]
|
|
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);
|
package/src/public/index.html
CHANGED
|
@@ -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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
<script src="
|
|
115
|
-
<script src="
|
|
116
|
-
<script src="
|
|
117
|
-
<
|
|
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. “Don't hijack” stops it from jumping menus; “Scroll” 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-
|
|
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
|