ai-or-die 0.1.73 → 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 +9 -1
- package/package.json +1 -1
- package/src/base-bridge.js +4 -2
- package/src/keepalive-manager.js +251 -0
- package/src/server.js +193 -10
- package/src/sticky-note-jsonl.js +14 -4
- package/src/utils/session-store.js +5 -0
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
package/src/base-bridge.js
CHANGED
|
@@ -262,7 +262,8 @@ class BaseBridge {
|
|
|
262
262
|
onExit = () => {},
|
|
263
263
|
onError = () => {},
|
|
264
264
|
cols = 80,
|
|
265
|
-
rows = 24
|
|
265
|
+
rows = 24,
|
|
266
|
+
extraEnv = null
|
|
266
267
|
} = options;
|
|
267
268
|
|
|
268
269
|
try {
|
|
@@ -280,7 +281,8 @@ class BaseBridge {
|
|
|
280
281
|
...process.env,
|
|
281
282
|
TERM: 'xterm-256color',
|
|
282
283
|
FORCE_COLOR: '1',
|
|
283
|
-
COLORTERM: 'truecolor'
|
|
284
|
+
COLORTERM: 'truecolor',
|
|
285
|
+
...((extraEnv && typeof extraEnv === 'object') ? extraEnv : {})
|
|
284
286
|
};
|
|
285
287
|
|
|
286
288
|
const ptyProcess = spawn(this.command, args, {
|
|
@@ -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({
|
|
@@ -277,6 +296,8 @@ class ClaudeCodeWebServer {
|
|
|
277
296
|
}
|
|
278
297
|
}
|
|
279
298
|
this._capClaudeNotes();
|
|
299
|
+
// Remove orphan claude-bind sidecars whose tab no longer exists.
|
|
300
|
+
this._sweepClaudeBindSidecars();
|
|
280
301
|
if (sessions.size > 0) {
|
|
281
302
|
console.log(`Loaded ${sessions.size} persisted sessions`);
|
|
282
303
|
}
|
|
@@ -470,6 +491,8 @@ class ClaudeCodeWebServer {
|
|
|
470
491
|
// Hard timeout: if close() hangs, force exit (protects unsupervised mode)
|
|
471
492
|
const forceExitTimer = setTimeout(() => {
|
|
472
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 */ }
|
|
473
496
|
process.exit(exitCode);
|
|
474
497
|
}, 15000);
|
|
475
498
|
forceExitTimer.unref();
|
|
@@ -1351,6 +1374,7 @@ class ClaudeCodeWebServer {
|
|
|
1351
1374
|
// Stop + tear down the summariser so an in-flight inference is discarded.
|
|
1352
1375
|
this.stickyNoteSummarizer.cancel(sessionId);
|
|
1353
1376
|
this._stickyJsonl.delete(sessionId);
|
|
1377
|
+
this._removeClaudeBindSidecar(session);
|
|
1354
1378
|
if (this._foregroundSessionId === sessionId) this._foregroundSessionId = null;
|
|
1355
1379
|
|
|
1356
1380
|
this.claudeSessions.delete(sessionId);
|
|
@@ -3311,6 +3335,9 @@ class ClaudeCodeWebServer {
|
|
|
3311
3335
|
reject(err);
|
|
3312
3336
|
} else {
|
|
3313
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();
|
|
3314
3341
|
resolve(server);
|
|
3315
3342
|
}
|
|
3316
3343
|
});
|
|
@@ -3963,6 +3990,12 @@ class ClaudeCodeWebServer {
|
|
|
3963
3990
|
// process; their session.liveCwd stays null. We pass the OSC 7
|
|
3964
3991
|
// hooks only when starting a Terminal session so the other bridges
|
|
3965
3992
|
// remain a true no-op.
|
|
3993
|
+
const terminalExtraEnv = {};
|
|
3994
|
+
if (toolName === 'terminal') {
|
|
3995
|
+
const sidecarPath = this._prepareClaudeBindSidecar(sessionId, session);
|
|
3996
|
+
if (sidecarPath) terminalExtraEnv.AIORDIE_CLAUDE_BIND = sidecarPath;
|
|
3997
|
+
}
|
|
3998
|
+
|
|
3966
3999
|
const osc7Hooks = (toolName === 'terminal') ? {
|
|
3967
4000
|
validatePath: (p) => this.validatePath(p),
|
|
3968
4001
|
onCwdChange: (cwd, prev) => {
|
|
@@ -4039,7 +4072,13 @@ class ClaudeCodeWebServer {
|
|
|
4039
4072
|
this.broadcastToSession(sessionId, { type: 'error', message: error.message });
|
|
4040
4073
|
this.broadcastSessionActivity(sessionId, 'session_error');
|
|
4041
4074
|
},
|
|
4042
|
-
...options
|
|
4075
|
+
...options,
|
|
4076
|
+
extraEnv: toolName === 'terminal'
|
|
4077
|
+
? {
|
|
4078
|
+
...((options.extraEnv && typeof options.extraEnv === 'object') ? options.extraEnv : {}),
|
|
4079
|
+
...terminalExtraEnv,
|
|
4080
|
+
}
|
|
4081
|
+
: options.extraEnv
|
|
4043
4082
|
});
|
|
4044
4083
|
|
|
4045
4084
|
session.lastActivity = new Date();
|
|
@@ -4256,13 +4295,57 @@ class ClaudeCodeWebServer {
|
|
|
4256
4295
|
async _pumpStickyJsonl(sessionId, cwd) {
|
|
4257
4296
|
let binding = this._stickyJsonl.get(sessionId);
|
|
4258
4297
|
binding && (binding._ticks = (binding._ticks || 0) + 1);
|
|
4298
|
+
const session = this.claudeSessions.get(sessionId);
|
|
4259
4299
|
|
|
4260
|
-
//
|
|
4261
|
-
//
|
|
4262
|
-
//
|
|
4263
|
-
//
|
|
4264
|
-
//
|
|
4265
|
-
|
|
4300
|
+
// DETERMINISTIC SIDECAR BINDING (terminal tabs launched via github-router).
|
|
4301
|
+
// ai-or-die set AIORDIE_CLAUDE_BIND=<sidecar> on the shell; github-router's
|
|
4302
|
+
// SessionStart/SessionEnd hook writes the ACTIVE claude session id +
|
|
4303
|
+
// transcript path there on every startup / resume / clear / compact. When a
|
|
4304
|
+
// sidecar exists it is AUTHORITATIVE: bind directly to that transcript by
|
|
4305
|
+
// exact path and skip the cwd+mtime inference entirely. Survives in-session
|
|
4306
|
+
// /resume, /clear and exit→relaunch, and works even when liveCwd is null
|
|
4307
|
+
// (cmd.exe / no OSC 7) — the case the inference path gets wrong.
|
|
4308
|
+
const sidecar = session ? await this._readClaudeBindSidecar(session) : null;
|
|
4309
|
+
if (sidecar) {
|
|
4310
|
+
session._sidecarSeen = true;
|
|
4311
|
+
// A SessionEnd record is intentionally NOT acted on: an in-session /resume
|
|
4312
|
+
// or /clear writes end-then-start, and the following start drives the
|
|
4313
|
+
// rebind; a terminal end (logout / prompt_input_exit) means claude exited,
|
|
4314
|
+
// and the PTY onExit flips session.active=false so _pollStickyJsonl stops
|
|
4315
|
+
// pumping this tab. So we keep the current binding (note frozen at its last
|
|
4316
|
+
// state) and only (re)bind on a start with a NEW claude session id.
|
|
4317
|
+
if (
|
|
4318
|
+
sidecar.event !== 'end' &&
|
|
4319
|
+
sidecar.claudeSessionId &&
|
|
4320
|
+
sidecar.transcriptPath &&
|
|
4321
|
+
(!binding || binding.claudeSessionId !== sidecar.claudeSessionId)
|
|
4322
|
+
) {
|
|
4323
|
+
const stp = await this._statQuiet(sidecar.transcriptPath);
|
|
4324
|
+
if (stp) {
|
|
4325
|
+
this._bindStickyJsonl(sessionId, {
|
|
4326
|
+
file: sidecar.transcriptPath,
|
|
4327
|
+
sessionId: sidecar.claudeSessionId,
|
|
4328
|
+
mtimeMs: stp.mtimeMs,
|
|
4329
|
+
size: stp.size,
|
|
4330
|
+
});
|
|
4331
|
+
binding = this._stickyJsonl.get(sessionId);
|
|
4332
|
+
session.claudePinnedSessionId = sidecar.claudeSessionId;
|
|
4333
|
+
this.sessionStore.markDirty();
|
|
4334
|
+
}
|
|
4335
|
+
// transcript not created yet → wait for a later tick (never inference).
|
|
4336
|
+
}
|
|
4337
|
+
// Pinned tabs never run the mtime inference / resume-follow below.
|
|
4338
|
+
} else if (session && session._sidecarSeen) {
|
|
4339
|
+
// Previously sidecar-managed but the file is momentarily absent/unreadable
|
|
4340
|
+
// → keep the current binding; do NOT fall back to mtime inference (which
|
|
4341
|
+
// could grab a stranger session). Wait for the next sidecar write.
|
|
4342
|
+
} else if (!binding || binding._ticks % 5 === 0) {
|
|
4343
|
+
// INFERENCE FALLBACK (no sidecar — e.g. claude launched without
|
|
4344
|
+
// github-router). Periodically (or while unbound) reconcile the binding. A
|
|
4345
|
+
// tab STAYS on its bound session while that file is alive and not owned by
|
|
4346
|
+
// another tab; it only moves to a newer unowned session once its own has
|
|
4347
|
+
// gone quiet (an in-session /resume) — so a third, unrelated session can't
|
|
4348
|
+
// steal an active tab. agent-*.jsonl is excluded by findActiveSessions.
|
|
4266
4349
|
const candidates = await StickyNoteJsonl.findActiveSessions(cwd, { projectsDir: this._stickyProjectsDir });
|
|
4267
4350
|
const ownedByOthers = this._ownedClaudeSessions(sessionId);
|
|
4268
4351
|
// Only (re)bind to a session being ACTIVELY written (recent mtime). A fresh
|
|
@@ -4275,7 +4358,6 @@ class ClaudeCodeWebServer {
|
|
|
4275
4358
|
// the recency gate, so a restart / lost binding can re-resume an idle-but-
|
|
4276
4359
|
// live session. A FRESH tab has no own-session, so it still won't adopt a
|
|
4277
4360
|
// stale stranger session in the project.
|
|
4278
|
-
const session = this.claudeSessions.get(sessionId);
|
|
4279
4361
|
const ownClaudeSession = session && session.stickyClaudeSessionId;
|
|
4280
4362
|
const eligible = (c) => !ownedByOthers.has(c.sessionId) && (freshlyActive(c) || c.sessionId === ownClaudeSession);
|
|
4281
4363
|
const currentValid =
|
|
@@ -4384,9 +4466,99 @@ class ClaudeCodeWebServer {
|
|
|
4384
4466
|
for (const [sid, b] of this._stickyJsonl) {
|
|
4385
4467
|
if (sid !== exceptSessionId && b && b.claudeSessionId) owned.add(b.claudeSessionId);
|
|
4386
4468
|
}
|
|
4469
|
+
// Also reserve every OTHER tab's pinned (sidecar) claude session, so an
|
|
4470
|
+
// unpinned tab's inference fallback can never adopt a pinned tab's session
|
|
4471
|
+
// even in the window before that tab has finished binding.
|
|
4472
|
+
for (const [sid, s] of this.claudeSessions) {
|
|
4473
|
+
if (sid !== exceptSessionId && s && s.claudePinnedSessionId) owned.add(s.claudePinnedSessionId);
|
|
4474
|
+
}
|
|
4387
4475
|
return owned;
|
|
4388
4476
|
}
|
|
4389
4477
|
|
|
4478
|
+
/** Absolute path to this server's per-tab claude-bind sidecar directory. */
|
|
4479
|
+
_claudeBindSidecarDir() {
|
|
4480
|
+
const base = (this.sessionStore && this.sessionStore.storageDir) || path.join(os.homedir(), '.ai-or-die');
|
|
4481
|
+
return path.join(base, 'claude-bindings');
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4484
|
+
/**
|
|
4485
|
+
* Allocate (and record on the session) the per-tab sidecar path that
|
|
4486
|
+
* github-router's SessionStart/SessionEnd hook writes the active claude
|
|
4487
|
+
* session id + transcript path into. Returns the absolute path, or null on
|
|
4488
|
+
* failure (the tab then degrades to the inference fallback). Best-effort mkdir.
|
|
4489
|
+
*/
|
|
4490
|
+
_prepareClaudeBindSidecar(sessionId, session) {
|
|
4491
|
+
try {
|
|
4492
|
+
const dir = this._claudeBindSidecarDir();
|
|
4493
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch (_) { /* best-effort */ }
|
|
4494
|
+
const file = path.join(dir, `${sessionId}.json`);
|
|
4495
|
+
// Clear any stale sidecar from a previous run/launch so this fresh terminal
|
|
4496
|
+
// start waits for github-router's next SessionStart write instead of binding
|
|
4497
|
+
// to a dead session's transcript. (Runs before any github-router launch in
|
|
4498
|
+
// this shell, so it can't race the hook.)
|
|
4499
|
+
try { fs.unlinkSync(file); } catch (_) { /* none / best-effort */ }
|
|
4500
|
+
if (session) {
|
|
4501
|
+
session.claudeBindSidecar = file;
|
|
4502
|
+
session._sidecarSeen = false;
|
|
4503
|
+
}
|
|
4504
|
+
return file;
|
|
4505
|
+
} catch (_) {
|
|
4506
|
+
return null;
|
|
4507
|
+
}
|
|
4508
|
+
}
|
|
4509
|
+
|
|
4510
|
+
/**
|
|
4511
|
+
* Read + parse the tab's sidecar (written atomically by github-router's hook).
|
|
4512
|
+
* Returns the parsed record `{claudeSessionId, transcriptPath, cwd, event,
|
|
4513
|
+
* source?, reason?}` or null (no file / unreadable / malformed). The file is
|
|
4514
|
+
* tiny, so we re-read every tick (no mtime cache: a SessionEnd→SessionStart
|
|
4515
|
+
* rewrite on /resume can land in the same mtime tick, and a cache keyed on
|
|
4516
|
+
* mtime would then serve the stale record and never rebind). Never throws — any
|
|
4517
|
+
* error yields null so the poll is unaffected.
|
|
4518
|
+
*/
|
|
4519
|
+
async _readClaudeBindSidecar(session) {
|
|
4520
|
+
if (!session || !session.claudeBindSidecar) return null;
|
|
4521
|
+
let raw;
|
|
4522
|
+
try {
|
|
4523
|
+
raw = await fs.promises.readFile(session.claudeBindSidecar, 'utf8');
|
|
4524
|
+
} catch (_) {
|
|
4525
|
+
return null; // no sidecar yet (claude not launched via github-router, or pending)
|
|
4526
|
+
}
|
|
4527
|
+
try {
|
|
4528
|
+
const obj = JSON.parse(raw);
|
|
4529
|
+
if (!obj || typeof obj !== 'object' || typeof obj.claudeSessionId !== 'string') return null;
|
|
4530
|
+
return obj;
|
|
4531
|
+
} catch (_) {
|
|
4532
|
+
return null; // mid-write / malformed → skip this tick
|
|
4533
|
+
}
|
|
4534
|
+
}
|
|
4535
|
+
|
|
4536
|
+
/** Best-effort: delete a tab's sidecar file (on session close). */
|
|
4537
|
+
_removeClaudeBindSidecar(session) {
|
|
4538
|
+
const file = session && session.claudeBindSidecar;
|
|
4539
|
+
if (!file) return;
|
|
4540
|
+
try { fs.unlinkSync(file); } catch (_) { /* already gone / best-effort */ }
|
|
4541
|
+
}
|
|
4542
|
+
|
|
4543
|
+
/**
|
|
4544
|
+
* Startup sweep: remove orphan sidecar files (`<sessionId>.json`) whose tab is
|
|
4545
|
+
* no longer in the active session set. Best-effort, bounded, never fatal.
|
|
4546
|
+
*/
|
|
4547
|
+
_sweepClaudeBindSidecars() {
|
|
4548
|
+
try {
|
|
4549
|
+
const dir = this._claudeBindSidecarDir();
|
|
4550
|
+
let entries;
|
|
4551
|
+
try { entries = fs.readdirSync(dir); } catch (_) { return; } // no dir → nothing to sweep
|
|
4552
|
+
for (const name of entries) {
|
|
4553
|
+
if (!name.endsWith('.json')) continue;
|
|
4554
|
+
const sid = name.slice(0, -'.json'.length);
|
|
4555
|
+
if (this.claudeSessions.has(sid)) continue; // live tab → keep
|
|
4556
|
+
try { fs.unlinkSync(path.join(dir, name)); } catch (_) { /* best-effort */ }
|
|
4557
|
+
}
|
|
4558
|
+
} catch (_) { /* never fatal */ }
|
|
4559
|
+
}
|
|
4560
|
+
|
|
4561
|
+
|
|
4390
4562
|
/**
|
|
4391
4563
|
* Bind a tab to a claude transcript and resume its durable note. Binds near the
|
|
4392
4564
|
* end of the file so the first summary uses recent context, not the whole
|
|
@@ -5301,8 +5473,11 @@ class ClaudeCodeWebServer {
|
|
|
5301
5473
|
}
|
|
5302
5474
|
|
|
5303
5475
|
async close() {
|
|
5304
|
-
// Save sessions before closing
|
|
5305
|
-
|
|
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 */ }
|
|
5306
5481
|
|
|
5307
5482
|
// Tear down the STT (sherpa-onnx) native worker. close() is the cleanup path
|
|
5308
5483
|
// shared by the signal handler (handleShutdown -> close) AND direct close()
|
|
@@ -5415,6 +5590,14 @@ class ClaudeCodeWebServer {
|
|
|
5415
5590
|
}
|
|
5416
5591
|
}
|
|
5417
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
|
+
|
|
5418
5601
|
// Clear all data
|
|
5419
5602
|
this.claudeSessions.clear();
|
|
5420
5603
|
this.webSocketConnections.clear();
|
package/src/sticky-note-jsonl.js
CHANGED
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
// Read clean conversation turns from a Claude Code session JSONL transcript.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// claude writes ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl — a complete,
|
|
6
|
+
// structured log (the same source claude uses for --resume). We summarise THIS
|
|
7
|
+
// instead of scraping the Ink TUI (which repaints in place and can't be scraped).
|
|
8
|
+
// When launched under github-router, CLAUDE_CONFIG_DIR points at a per-launch
|
|
9
|
+
// mirror whose `projects` subdir is a junction back to the real ~/.claude/projects,
|
|
10
|
+
// so transcripts still land here.
|
|
11
|
+
//
|
|
12
|
+
// Binding a tab to ITS transcript is done two ways (see src/server.js
|
|
13
|
+
// `_pumpStickyJsonl`): (1) PRIMARY — a per-tab sidecar written by github-router's
|
|
14
|
+
// SessionStart/SessionEnd hook names the exact active session id + transcript
|
|
15
|
+
// path (deterministic; survives /resume, /clear, relaunch); (2) FALLBACK — when
|
|
16
|
+
// no sidecar exists (claude launched without github-router), newest-mtime
|
|
17
|
+
// inference over the cwd's project dir. This module only READS a resolved file;
|
|
18
|
+
// `findActiveSessions`/`findActiveSession` serve the fallback path.
|
|
9
19
|
//
|
|
10
20
|
// Signal we keep: user `string`/`text` prompts, assistant `text` replies, and the NAMES
|
|
11
21
|
// of tools the assistant ran. We skip `thinking`, `tool_result`, metadata line types, and
|
|
@@ -232,6 +232,11 @@ class SessionStore {
|
|
|
232
232
|
// the durable per-claude-session note store can be rebuilt after a
|
|
233
233
|
// restart and resume when that session reopens.
|
|
234
234
|
stickyClaudeSessionId: session.stickyClaudeSessionId || null,
|
|
235
|
+
// The claude sessionId pinned via the github-router SessionStart
|
|
236
|
+
// hook sidecar (terminal tabs). Persisted so the ownership
|
|
237
|
+
// reservation (_ownedClaudeSessions) survives a restart; the
|
|
238
|
+
// durable note itself resumes via stickyClaudeSessionId above.
|
|
239
|
+
claudePinnedSessionId: session.claudePinnedSessionId || null,
|
|
235
240
|
autoTitle: session.autoTitle || null,
|
|
236
241
|
nameIsUserSet: session.nameIsUserSet || false,
|
|
237
242
|
stickyNotesEnabled: session.stickyNotesEnabled !== false
|