ai-or-die 0.1.72 → 0.1.74

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
@@ -142,7 +142,7 @@ async function main() {
142
142
  }
143
143
 
144
144
  const app = new ClaudeCodeWebServer(serverOptions);
145
- const httpServer = await app.start();
145
+ await app.start();
146
146
 
147
147
  const protocol = options.https ? 'https' : 'http';
148
148
  const baseUrl = `${protocol}://localhost:${port}`;
@@ -187,17 +187,15 @@ async function main() {
187
187
 
188
188
  console.log('\nPress Ctrl+C to stop the server\n');
189
189
 
190
- const shutdown = async () => {
191
- console.log('\nShutting down server...');
192
- if (tunnel) await tunnel.stop();
193
- httpServer.close(() => {
194
- console.log('Server closed');
195
- process.exit(0);
196
- });
197
- };
198
-
199
- process.on('SIGINT', () => { shutdown(); });
200
- process.on('SIGTERM', () => { shutdown(); });
190
+ // Shutdown is owned by the server's single SIGINT/SIGTERM handler
191
+ // (ClaudeCodeWebServer.handleShutdown), which performs the ordered graceful
192
+ // teardown: cooperative disposal of the local-LLM (sticky-note) and STT
193
+ // native worker threads, tunnel stop, session save, then server close.
194
+ // A second handler here used to race it — its httpServer.close() callback
195
+ // fires immediately when there are no open connections and called
196
+ // process.exit(0) before the worker threads could dispose their ggml-based
197
+ // native models, which aborted the process (SIGABRT / exit 134) on Ctrl+C.
198
+ // So we deliberately do NOT register a SIGINT/SIGTERM handler here.
201
199
 
202
200
  } catch (error) {
203
201
  console.error('Error starting server:', error.message);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-or-die",
3
- "version": "0.1.72",
3
+ "version": "0.1.74",
4
4
  "description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -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, {
package/src/server.js CHANGED
@@ -277,6 +277,8 @@ class ClaudeCodeWebServer {
277
277
  }
278
278
  }
279
279
  this._capClaudeNotes();
280
+ // Remove orphan claude-bind sidecars whose tab no longer exists.
281
+ this._sweepClaudeBindSidecars();
280
282
  if (sessions.size > 0) {
281
283
  console.log(`Loaded ${sessions.size} persisted sessions`);
282
284
  }
@@ -475,13 +477,31 @@ class ClaudeCodeWebServer {
475
477
  forceExitTimer.unref();
476
478
 
477
479
  console.log(`\nGracefully shutting down (exit code: ${exitCode})...`);
480
+ // Persist sessions FIRST, before the (bounded but potentially multi-second)
481
+ // native-engine teardown below. If a pathological native teardown ever blew
482
+ // the 15s force-exit budget, sessions would already be safe on disk. close()
483
+ // saves again at the end of a normal shutdown.
484
+ try { await this.saveSessionsToDisk(true); } catch (_) { /* ignore */ }
478
485
  // Tear down the local-LLM summariser + worker so the model/worker thread
479
486
  // don't keep the process alive (and don't hold a GGUF file lock on Windows).
480
487
  if (this._stickyInitTimer) { clearTimeout(this._stickyInitTimer); this._stickyInitTimer = null; }
481
488
  if (this._stickyJsonlPoll) { clearInterval(this._stickyJsonlPoll); this._stickyJsonlPoll = null; }
482
489
  this._stickyJsonl.clear();
483
490
  try { this.stickyNoteSummarizer.shutdown(); } catch (_) { /* ignore */ }
484
- try { await this.stickyNoteEngine.shutdown(); } catch (_) { /* ignore */ }
491
+ // Tear down both local native worker engines (sticky-note = node-llama-cpp,
492
+ // STT = sherpa-onnx) concurrently. Each disposes its loaded model/recognizer
493
+ // on its worker thread before exiting; force-tearing them down via
494
+ // process.exit() while a model is loaded/loading aborts the process (SIGABRT)
495
+ // during native cleanup. Running them in parallel keeps total shutdown well
496
+ // inside the 15s force-exit budget above. STT shutdown was previously missing
497
+ // entirely. The CLI dev tunnel (only set in --tunnel mode) used to be stopped
498
+ // by a second SIGINT handler in bin/ai-or-die.js, now removed to avoid a
499
+ // shutdown race; its stop moves here onto the single graceful path.
500
+ await Promise.allSettled([
501
+ Promise.resolve().then(() => this.stickyNoteEngine.shutdown()),
502
+ Promise.resolve().then(() => this.sttEngine.shutdown()),
503
+ Promise.resolve().then(() => (this.tunnelManager ? this.tunnelManager.stop() : undefined)),
504
+ ]);
485
505
  await this.close();
486
506
  clearTimeout(forceExitTimer);
487
507
  process.exit(exitCode);
@@ -1333,6 +1353,7 @@ class ClaudeCodeWebServer {
1333
1353
  // Stop + tear down the summariser so an in-flight inference is discarded.
1334
1354
  this.stickyNoteSummarizer.cancel(sessionId);
1335
1355
  this._stickyJsonl.delete(sessionId);
1356
+ this._removeClaudeBindSidecar(session);
1336
1357
  if (this._foregroundSessionId === sessionId) this._foregroundSessionId = null;
1337
1358
 
1338
1359
  this.claudeSessions.delete(sessionId);
@@ -3945,6 +3966,12 @@ class ClaudeCodeWebServer {
3945
3966
  // process; their session.liveCwd stays null. We pass the OSC 7
3946
3967
  // hooks only when starting a Terminal session so the other bridges
3947
3968
  // remain a true no-op.
3969
+ const terminalExtraEnv = {};
3970
+ if (toolName === 'terminal') {
3971
+ const sidecarPath = this._prepareClaudeBindSidecar(sessionId, session);
3972
+ if (sidecarPath) terminalExtraEnv.AIORDIE_CLAUDE_BIND = sidecarPath;
3973
+ }
3974
+
3948
3975
  const osc7Hooks = (toolName === 'terminal') ? {
3949
3976
  validatePath: (p) => this.validatePath(p),
3950
3977
  onCwdChange: (cwd, prev) => {
@@ -4021,7 +4048,13 @@ class ClaudeCodeWebServer {
4021
4048
  this.broadcastToSession(sessionId, { type: 'error', message: error.message });
4022
4049
  this.broadcastSessionActivity(sessionId, 'session_error');
4023
4050
  },
4024
- ...options
4051
+ ...options,
4052
+ extraEnv: toolName === 'terminal'
4053
+ ? {
4054
+ ...((options.extraEnv && typeof options.extraEnv === 'object') ? options.extraEnv : {}),
4055
+ ...terminalExtraEnv,
4056
+ }
4057
+ : options.extraEnv
4025
4058
  });
4026
4059
 
4027
4060
  session.lastActivity = new Date();
@@ -4238,13 +4271,57 @@ class ClaudeCodeWebServer {
4238
4271
  async _pumpStickyJsonl(sessionId, cwd) {
4239
4272
  let binding = this._stickyJsonl.get(sessionId);
4240
4273
  binding && (binding._ticks = (binding._ticks || 0) + 1);
4274
+ const session = this.claudeSessions.get(sessionId);
4241
4275
 
4242
- // Periodically (or while unbound) reconcile the binding. A tab STAYS on its
4243
- // bound session while that file is alive and not owned by another tab; it
4244
- // only moves to a newer unowned session once its own has gone quiet (an
4245
- // in-session /resume) so a third, unrelated session can't steal an active
4246
- // tab. agent-*.jsonl is excluded by findActiveSessions.
4247
- if (!binding || binding._ticks % 5 === 0) {
4276
+ // DETERMINISTIC SIDECAR BINDING (terminal tabs launched via github-router).
4277
+ // ai-or-die set AIORDIE_CLAUDE_BIND=<sidecar> on the shell; github-router's
4278
+ // SessionStart/SessionEnd hook writes the ACTIVE claude session id +
4279
+ // transcript path there on every startup / resume / clear / compact. When a
4280
+ // sidecar exists it is AUTHORITATIVE: bind directly to that transcript by
4281
+ // exact path and skip the cwd+mtime inference entirely. Survives in-session
4282
+ // /resume, /clear and exit→relaunch, and works even when liveCwd is null
4283
+ // (cmd.exe / no OSC 7) — the case the inference path gets wrong.
4284
+ const sidecar = session ? await this._readClaudeBindSidecar(session) : null;
4285
+ if (sidecar) {
4286
+ session._sidecarSeen = true;
4287
+ // A SessionEnd record is intentionally NOT acted on: an in-session /resume
4288
+ // or /clear writes end-then-start, and the following start drives the
4289
+ // rebind; a terminal end (logout / prompt_input_exit) means claude exited,
4290
+ // and the PTY onExit flips session.active=false so _pollStickyJsonl stops
4291
+ // pumping this tab. So we keep the current binding (note frozen at its last
4292
+ // state) and only (re)bind on a start with a NEW claude session id.
4293
+ if (
4294
+ sidecar.event !== 'end' &&
4295
+ sidecar.claudeSessionId &&
4296
+ sidecar.transcriptPath &&
4297
+ (!binding || binding.claudeSessionId !== sidecar.claudeSessionId)
4298
+ ) {
4299
+ const stp = await this._statQuiet(sidecar.transcriptPath);
4300
+ if (stp) {
4301
+ this._bindStickyJsonl(sessionId, {
4302
+ file: sidecar.transcriptPath,
4303
+ sessionId: sidecar.claudeSessionId,
4304
+ mtimeMs: stp.mtimeMs,
4305
+ size: stp.size,
4306
+ });
4307
+ binding = this._stickyJsonl.get(sessionId);
4308
+ session.claudePinnedSessionId = sidecar.claudeSessionId;
4309
+ this.sessionStore.markDirty();
4310
+ }
4311
+ // transcript not created yet → wait for a later tick (never inference).
4312
+ }
4313
+ // Pinned tabs never run the mtime inference / resume-follow below.
4314
+ } else if (session && session._sidecarSeen) {
4315
+ // Previously sidecar-managed but the file is momentarily absent/unreadable
4316
+ // → keep the current binding; do NOT fall back to mtime inference (which
4317
+ // could grab a stranger session). Wait for the next sidecar write.
4318
+ } else if (!binding || binding._ticks % 5 === 0) {
4319
+ // INFERENCE FALLBACK (no sidecar — e.g. claude launched without
4320
+ // github-router). Periodically (or while unbound) reconcile the binding. A
4321
+ // tab STAYS on its bound session while that file is alive and not owned by
4322
+ // another tab; it only moves to a newer unowned session once its own has
4323
+ // gone quiet (an in-session /resume) — so a third, unrelated session can't
4324
+ // steal an active tab. agent-*.jsonl is excluded by findActiveSessions.
4248
4325
  const candidates = await StickyNoteJsonl.findActiveSessions(cwd, { projectsDir: this._stickyProjectsDir });
4249
4326
  const ownedByOthers = this._ownedClaudeSessions(sessionId);
4250
4327
  // Only (re)bind to a session being ACTIVELY written (recent mtime). A fresh
@@ -4257,7 +4334,6 @@ class ClaudeCodeWebServer {
4257
4334
  // the recency gate, so a restart / lost binding can re-resume an idle-but-
4258
4335
  // live session. A FRESH tab has no own-session, so it still won't adopt a
4259
4336
  // stale stranger session in the project.
4260
- const session = this.claudeSessions.get(sessionId);
4261
4337
  const ownClaudeSession = session && session.stickyClaudeSessionId;
4262
4338
  const eligible = (c) => !ownedByOthers.has(c.sessionId) && (freshlyActive(c) || c.sessionId === ownClaudeSession);
4263
4339
  const currentValid =
@@ -4366,9 +4442,99 @@ class ClaudeCodeWebServer {
4366
4442
  for (const [sid, b] of this._stickyJsonl) {
4367
4443
  if (sid !== exceptSessionId && b && b.claudeSessionId) owned.add(b.claudeSessionId);
4368
4444
  }
4445
+ // Also reserve every OTHER tab's pinned (sidecar) claude session, so an
4446
+ // unpinned tab's inference fallback can never adopt a pinned tab's session
4447
+ // even in the window before that tab has finished binding.
4448
+ for (const [sid, s] of this.claudeSessions) {
4449
+ if (sid !== exceptSessionId && s && s.claudePinnedSessionId) owned.add(s.claudePinnedSessionId);
4450
+ }
4369
4451
  return owned;
4370
4452
  }
4371
4453
 
4454
+ /** Absolute path to this server's per-tab claude-bind sidecar directory. */
4455
+ _claudeBindSidecarDir() {
4456
+ const base = (this.sessionStore && this.sessionStore.storageDir) || path.join(os.homedir(), '.ai-or-die');
4457
+ return path.join(base, 'claude-bindings');
4458
+ }
4459
+
4460
+ /**
4461
+ * Allocate (and record on the session) the per-tab sidecar path that
4462
+ * github-router's SessionStart/SessionEnd hook writes the active claude
4463
+ * session id + transcript path into. Returns the absolute path, or null on
4464
+ * failure (the tab then degrades to the inference fallback). Best-effort mkdir.
4465
+ */
4466
+ _prepareClaudeBindSidecar(sessionId, session) {
4467
+ try {
4468
+ const dir = this._claudeBindSidecarDir();
4469
+ try { fs.mkdirSync(dir, { recursive: true }); } catch (_) { /* best-effort */ }
4470
+ const file = path.join(dir, `${sessionId}.json`);
4471
+ // Clear any stale sidecar from a previous run/launch so this fresh terminal
4472
+ // start waits for github-router's next SessionStart write instead of binding
4473
+ // to a dead session's transcript. (Runs before any github-router launch in
4474
+ // this shell, so it can't race the hook.)
4475
+ try { fs.unlinkSync(file); } catch (_) { /* none / best-effort */ }
4476
+ if (session) {
4477
+ session.claudeBindSidecar = file;
4478
+ session._sidecarSeen = false;
4479
+ }
4480
+ return file;
4481
+ } catch (_) {
4482
+ return null;
4483
+ }
4484
+ }
4485
+
4486
+ /**
4487
+ * Read + parse the tab's sidecar (written atomically by github-router's hook).
4488
+ * Returns the parsed record `{claudeSessionId, transcriptPath, cwd, event,
4489
+ * source?, reason?}` or null (no file / unreadable / malformed). The file is
4490
+ * tiny, so we re-read every tick (no mtime cache: a SessionEnd→SessionStart
4491
+ * rewrite on /resume can land in the same mtime tick, and a cache keyed on
4492
+ * mtime would then serve the stale record and never rebind). Never throws — any
4493
+ * error yields null so the poll is unaffected.
4494
+ */
4495
+ async _readClaudeBindSidecar(session) {
4496
+ if (!session || !session.claudeBindSidecar) return null;
4497
+ let raw;
4498
+ try {
4499
+ raw = await fs.promises.readFile(session.claudeBindSidecar, 'utf8');
4500
+ } catch (_) {
4501
+ return null; // no sidecar yet (claude not launched via github-router, or pending)
4502
+ }
4503
+ try {
4504
+ const obj = JSON.parse(raw);
4505
+ if (!obj || typeof obj !== 'object' || typeof obj.claudeSessionId !== 'string') return null;
4506
+ return obj;
4507
+ } catch (_) {
4508
+ return null; // mid-write / malformed → skip this tick
4509
+ }
4510
+ }
4511
+
4512
+ /** Best-effort: delete a tab's sidecar file (on session close). */
4513
+ _removeClaudeBindSidecar(session) {
4514
+ const file = session && session.claudeBindSidecar;
4515
+ if (!file) return;
4516
+ try { fs.unlinkSync(file); } catch (_) { /* already gone / best-effort */ }
4517
+ }
4518
+
4519
+ /**
4520
+ * Startup sweep: remove orphan sidecar files (`<sessionId>.json`) whose tab is
4521
+ * no longer in the active session set. Best-effort, bounded, never fatal.
4522
+ */
4523
+ _sweepClaudeBindSidecars() {
4524
+ try {
4525
+ const dir = this._claudeBindSidecarDir();
4526
+ let entries;
4527
+ try { entries = fs.readdirSync(dir); } catch (_) { return; } // no dir → nothing to sweep
4528
+ for (const name of entries) {
4529
+ if (!name.endsWith('.json')) continue;
4530
+ const sid = name.slice(0, -'.json'.length);
4531
+ if (this.claudeSessions.has(sid)) continue; // live tab → keep
4532
+ try { fs.unlinkSync(path.join(dir, name)); } catch (_) { /* best-effort */ }
4533
+ }
4534
+ } catch (_) { /* never fatal */ }
4535
+ }
4536
+
4537
+
4372
4538
  /**
4373
4539
  * Bind a tab to a claude transcript and resume its durable note. Binds near the
4374
4540
  * end of the file so the first summary uses recent context, not the whole
@@ -5286,6 +5452,20 @@ class ClaudeCodeWebServer {
5286
5452
  // Save sessions before closing
5287
5453
  await this.saveSessionsToDisk(true);
5288
5454
 
5455
+ // Tear down the STT (sherpa-onnx) native worker. close() is the cleanup path
5456
+ // shared by the signal handler (handleShutdown -> close) AND direct close()
5457
+ // callers (e.g. the e2e test servers, which construct a ClaudeCodeWebServer
5458
+ // and call server.close()). Without this, a server that has loaded the STT
5459
+ // model leaks its worker thread past close() and keeps the process alive —
5460
+ // this hung the Windows e2e jobs once the model was cached/present. The
5461
+ // shutdown is cooperative (graceful message, no terminate()) so native
5462
+ // teardown can't abort the process, and idempotent (handleShutdown already
5463
+ // ran it on the signal path, so this is then a no-op). The sticky-note engine
5464
+ // is torn down by handleShutdown only: it is disabled in the e2e test
5465
+ // servers, and its teardown must precede close()'s session-output flush to
5466
+ // avoid re-triggering a summary, so it stays out of this shared path.
5467
+ try { await this.sttEngine.shutdown(); } catch (_) { /* ignore */ }
5468
+
5289
5469
  // Clear all intervals
5290
5470
  if (this.autoSaveInterval) {
5291
5471
  clearInterval(this.autoSaveInterval);
@@ -30,6 +30,7 @@ class StickyNoteEngine {
30
30
 
31
31
  this._status = 'unavailable';
32
32
  this._worker = null;
33
+ this._spawningWorker = null;
33
34
  this._queue = [];
34
35
  this._currentRequest = null;
35
36
  this._requestIdCounter = 0;
@@ -88,6 +89,11 @@ class StickyNoteEngine {
88
89
  });
89
90
  }
90
91
  this._status = 'loading';
92
+ // If shutdown began while we were checking/downloading the model, do NOT
93
+ // spawn a worker we'd immediately have to kill mid-native-load (which aborts
94
+ // the process). shutdown() awaits this in-flight init, so bailing here lets
95
+ // it complete cleanly with no worker.
96
+ if (this._stopping) return;
91
97
  await this._spawnWorker();
92
98
  }
93
99
 
@@ -198,6 +204,11 @@ class StickyNoteEngine {
198
204
  _spawnWorker() {
199
205
  return new Promise((resolve, reject) => {
200
206
  const worker = this._createWorker();
207
+ // Track the worker from the moment it's created (not just after 'ready')
208
+ // so shutdown() can stop it cooperatively even while it's still loading the
209
+ // model. Cleared once it becomes this._worker or fails to boot.
210
+ this._spawningWorker = worker;
211
+ const clearPending = () => { if (this._spawningWorker === worker) this._spawningWorker = null; };
201
212
 
202
213
  const onReady = (msg) => {
203
214
  if (!msg) return;
@@ -205,6 +216,16 @@ class StickyNoteEngine {
205
216
  worker.off('message', onReady);
206
217
  worker.off('error', onError);
207
218
  worker.off('exit', onBootExit);
219
+ clearPending();
220
+ // If shutdown started while this worker was still loading, do NOT
221
+ // promote it to the active worker — that would resurrect a torn-down
222
+ // engine. Ask it to dispose + exit and resolve init as cancelled.
223
+ if (this._stopping) {
224
+ this._status = 'unavailable';
225
+ try { worker.postMessage({ type: 'shutdown' }); } catch { /* ignore */ }
226
+ resolve();
227
+ return;
228
+ }
208
229
  this._worker = worker;
209
230
  this._status = 'ready';
210
231
  this._restartAttempts = 0;
@@ -217,6 +238,7 @@ class StickyNoteEngine {
217
238
  worker.off('message', onReady);
218
239
  worker.off('error', onError);
219
240
  worker.off('exit', onBootExit);
241
+ clearPending();
220
242
  if (msg.code === 'MODULE_NOT_FOUND') this._lastSpawnError = 'MODULE_NOT_FOUND';
221
243
  this._status = 'unavailable';
222
244
  reject(new Error(msg.message || 'worker error'));
@@ -226,6 +248,7 @@ class StickyNoteEngine {
226
248
  worker.off('message', onReady);
227
249
  worker.off('error', onError);
228
250
  worker.off('exit', onBootExit);
251
+ clearPending();
229
252
  if (err && (err.code === 'MODULE_NOT_FOUND' || (err.message && err.message.includes('node-llama-cpp')))) {
230
253
  this._lastSpawnError = 'MODULE_NOT_FOUND';
231
254
  }
@@ -238,6 +261,7 @@ class StickyNoteEngine {
238
261
  worker.off('message', onReady);
239
262
  worker.off('error', onError);
240
263
  worker.off('exit', onBootExit);
264
+ clearPending();
241
265
  this._status = 'unavailable';
242
266
  reject(new Error(`sticky-note worker exited during init (code ${code})`));
243
267
  };
@@ -263,6 +287,32 @@ class StickyNoteEngine {
263
287
 
264
288
  async shutdown() {
265
289
  this._stopping = true;
290
+
291
+ // Shared time budget: the init-wait + cooperative-exit waits below together
292
+ // stay within this window so the whole engine teardown (run concurrently with
293
+ // the STT engine by handleShutdown) finishes inside handleShutdown's 15s
294
+ // force-exit budget, leaving room for close(). Realistic teardown is a few
295
+ // hundred ms; this only caps pathological hangs.
296
+ const deadline = Date.now() + 10000;
297
+ const remaining = () => Math.max(0, deadline - Date.now());
298
+
299
+ // If a worker is mid model-LOAD (status 'loading' = the native model is being
300
+ // constructed in the worker thread), wait — bounded — for it to settle so we
301
+ // can dispose it cooperatively; a worker killed mid-native-load aborts the
302
+ // process (SIGABRT / exit 134). We do NOT wait during 'downloading' (no native
303
+ // worker is loaded yet, so process.exit can't abort, and the download can take
304
+ // minutes — _doInitialize bails before spawning once _stopping is set, so a
305
+ // Ctrl+C during a first-run download still exits promptly).
306
+ if (this._initPromise && this._status === 'loading') {
307
+ await Promise.race([
308
+ Promise.resolve(this._initPromise).catch(() => {}),
309
+ new Promise((resolve) => {
310
+ const t = setTimeout(resolve, remaining());
311
+ if (t.unref) t.unref();
312
+ }),
313
+ ]);
314
+ }
315
+
266
316
  if (this._restartTimer) {
267
317
  clearTimeout(this._restartTimer);
268
318
  this._restartTimer = null;
@@ -273,38 +323,31 @@ class StickyNoteEngine {
273
323
  }
274
324
  this._queue = [];
275
325
  this._currentRequest = null;
276
- if (this._worker) {
277
- const w = this._worker;
278
- this._worker = null;
279
- try {
280
- // Ask the worker to dispose the native model/context cleanly, then
281
- // terminate only if it didn't exit on its own. A bare terminate() with
282
- // the model loaded can abort the process during native cleanup.
283
- let exited = false;
284
- await new Promise((resolve) => {
285
- let done = false;
286
- const finish = () => {
287
- if (!done) {
288
- done = true;
289
- resolve();
290
- }
291
- };
292
- w.once('exit', () => {
293
- exited = true;
294
- finish();
295
- });
296
- try {
297
- w.postMessage({ type: 'shutdown' });
298
- } catch {
299
- finish();
300
- }
301
- const t = setTimeout(finish, 3000);
302
- if (t.unref) t.unref();
303
- });
304
- if (!exited) await w.terminate();
305
- } catch {
306
- /* ignore */
307
- }
326
+ // Cooperatively stop the worker — the live one, or one still booting (tracked
327
+ // from creation in _spawnWorker). Ask it to dispose its native model/context
328
+ // and exit on its own. We deliberately do NOT call worker.terminate():
329
+ // force-killing a thread that is inside native ggml code (mid model-load or
330
+ // mid-inference) throws an uncaught Napi error during worker-env teardown and
331
+ // ggml's set_terminate aborts the whole process (SIGABRT / exit 134) the
332
+ // bug this fixes. The wait is bounded (shared deadline) so handleShutdown can
333
+ // still save sessions + close(); a worker that never exits is reaped by
334
+ // handleShutdown's 15s force-exit backstop.
335
+ const w = this._worker || this._spawningWorker;
336
+ this._worker = null;
337
+ this._spawningWorker = null;
338
+ if (w) {
339
+ await new Promise((resolve) => {
340
+ let done = false;
341
+ const finish = () => { if (!done) { done = true; resolve(); } };
342
+ w.once('exit', finish);
343
+ try {
344
+ w.postMessage({ type: 'shutdown' });
345
+ } catch {
346
+ finish();
347
+ }
348
+ const t = setTimeout(finish, Math.max(1000, remaining()));
349
+ if (t.unref) t.unref();
350
+ });
308
351
  }
309
352
  this._status = 'unavailable';
310
353
  }
@@ -2,10 +2,20 @@
2
2
 
3
3
  // Read clean conversation turns from a Claude Code session JSONL transcript.
4
4
  //
5
- // `github-router claude` runs the normal claude CLI with CLAUDE_CONFIG_DIR=$HOME/.claude,
6
- // so each session writes ~/.claude/projects/<cwd-slug>/<sessionId>.jsonl a complete,
7
- // structured log (the same source claude uses for --resume). We summarise THIS instead
8
- // of scraping the Ink TUI (which repaints in place and can't be scraped).
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
@@ -39,9 +49,16 @@ function cleanProse(s) {
39
49
  .trim();
40
50
  }
41
51
 
42
- /** Claude's project-dir slug for a cwd: all path separators → '-'. */
52
+ /**
53
+ * Claude's project-dir slug for a cwd: every non-alphanumeric char → '-'
54
+ * (drive-letter colon, path separators, spaces, dots, etc.). Matches the folder
55
+ * claude writes under ~/.claude/projects/. A separator-only replace leaves the
56
+ * Windows drive-letter colon in place (`C:-Users-...`) and never matches claude's
57
+ * `C--Users-...`, so the transcript binding — and with it the tab title and the
58
+ * sticky note — silently fail on Windows.
59
+ */
43
60
  function slugForCwd(cwd) {
44
- return String(cwd || '').replace(/[\\/]/g, '-');
61
+ return String(cwd || '').replace(/[^a-zA-Z0-9]/g, '-');
45
62
  }
46
63
 
47
64
  /** The claude session id is the JSONL basename (the --resume key). */
@@ -90,8 +90,15 @@ parentPort.on('message', (msg) => {
90
90
  _inferChain
91
91
  .catch(() => {})
92
92
  .then(async () => {
93
+ // Dispose in dependency order: context + model, then the top-level
94
+ // llama backend. Disposing the backend (await llama.dispose()) is what
95
+ // actually drains node-llama-cpp's native async work; without it the
96
+ // worker-thread env teardown that follows process.exit() can hit a
97
+ // pending Napi completion and ggml's set_terminate aborts the whole
98
+ // process (SIGABRT / exit 134) on Ctrl+C.
93
99
  try { if (context) await context.dispose(); } catch { /* ignore */ }
94
100
  try { if (model) await model.dispose(); } catch { /* ignore */ }
101
+ try { if (llama) await llama.dispose(); } catch { /* ignore */ }
95
102
  })
96
103
  .finally(() => process.exit(0));
97
104
  }
package/src/stt-engine.js CHANGED
@@ -17,11 +17,13 @@ class SttEngine {
17
17
  this._numThreads = options.numThreads || Math.min(4, os.cpus().length);
18
18
  this._status = 'unavailable';
19
19
  this._worker = null;
20
+ this._spawningWorker = null;
20
21
  this._queue = [];
21
22
  this._currentRequest = null;
22
23
  this._requestIdCounter = 0;
23
24
  this._restartAttempts = 0;
24
25
  this._lastSpawnError = null;
26
+ this._stopping = false;
25
27
  this._initPromise = null;
26
28
  this._modelManager = new ModelManager({
27
29
  modelsDir: options.modelsDir
@@ -62,6 +64,11 @@ class SttEngine {
62
64
  }
63
65
 
64
66
  this._status = 'loading';
67
+ // If shutdown began while we were checking/downloading the model, do NOT
68
+ // spawn a worker we'd immediately have to kill mid-native-load (which aborts
69
+ // the process). shutdown() awaits this in-flight init, so bailing here lets
70
+ // it complete cleanly with no worker.
71
+ if (this._stopping) return;
65
72
  await this._spawnWorker();
66
73
  }
67
74
 
@@ -275,6 +282,11 @@ class SttEngine {
275
282
 
276
283
  _restartWorker(delay) {
277
284
  setTimeout(async () => {
285
+ // Don't respawn if shutdown started after this restart was scheduled.
286
+ if (this._stopping) {
287
+ this._status = 'unavailable';
288
+ return;
289
+ }
278
290
  try {
279
291
  await this._spawnWorker();
280
292
  } catch (err) {
@@ -294,11 +306,29 @@ class SttEngine {
294
306
  nodeModulesDir: path.resolve(__dirname, '..', 'node_modules')
295
307
  }
296
308
  });
309
+ // Track the worker from creation (not just after 'ready') so shutdown() can
310
+ // stop it cooperatively even while it is still loading the recognizer.
311
+ this._spawningWorker = worker;
312
+ const clearPending = () => { if (this._spawningWorker === worker) this._spawningWorker = null; };
313
+ const detach = () => {
314
+ worker.off('message', onReady);
315
+ worker.off('error', onError);
316
+ worker.off('exit', onBootExit);
317
+ };
297
318
 
298
319
  const onReady = (msg) => {
299
320
  if (msg.type === 'ready') {
300
- worker.off('message', onReady);
301
- worker.off('error', onError);
321
+ detach();
322
+ clearPending();
323
+ // If shutdown started while this worker was still loading, do NOT
324
+ // promote it to the active worker — that would resurrect a torn-down
325
+ // engine. Ask it to exit and resolve init as cancelled.
326
+ if (this._stopping) {
327
+ this._status = 'unavailable';
328
+ try { worker.postMessage({ type: 'shutdown' }); } catch { /* ignore */ }
329
+ resolve();
330
+ return;
331
+ }
302
332
  this._worker = worker;
303
333
  this._status = 'ready';
304
334
  this._restartAttempts = 0;
@@ -311,15 +341,15 @@ class SttEngine {
311
341
  this._processQueue();
312
342
  resolve();
313
343
  } else if (msg.type === 'error') {
314
- worker.off('message', onReady);
315
- worker.off('error', onError);
344
+ detach();
345
+ clearPending();
316
346
  reject(new Error(msg.message));
317
347
  }
318
348
  };
319
349
 
320
350
  const onError = (err) => {
321
- worker.off('message', onReady);
322
- worker.off('error', onError);
351
+ detach();
352
+ clearPending();
323
353
  // Tag dependency errors so _onWorkerExit can skip futile retries
324
354
  if (err.code === 'MODULE_NOT_FOUND' || (err.message && err.message.includes('sherpa-onnx-node'))) {
325
355
  this._lastSpawnError = 'MODULE_NOT_FOUND';
@@ -327,8 +357,19 @@ class SttEngine {
327
357
  reject(err);
328
358
  };
329
359
 
360
+ // If the worker dies before emitting ready/error, neither listener above
361
+ // fires — without this the init Promise would hang forever (and shutdown
362
+ // would burn its full bounded wait on it).
363
+ const onBootExit = (code) => {
364
+ detach();
365
+ clearPending();
366
+ this._status = 'unavailable';
367
+ reject(new Error(`STT worker exited during init (code ${code})`));
368
+ };
369
+
330
370
  worker.on('message', onReady);
331
371
  worker.on('error', onError);
372
+ worker.on('exit', onBootExit);
332
373
  });
333
374
  }
334
375
 
@@ -421,6 +462,31 @@ class SttEngine {
421
462
  // See docs/audits/proc-child-processes.md gap 1.
422
463
  this._stopping = true;
423
464
 
465
+ // Shared time budget for the init-wait + cooperative-exit waits below, so the
466
+ // whole engine teardown (run concurrently with the sticky-note engine by
467
+ // handleShutdown) finishes inside handleShutdown's 15s force-exit budget,
468
+ // leaving room for close(). Realistic teardown is sub-second; this only caps
469
+ // pathological hangs.
470
+ const deadline = Date.now() + 10000;
471
+ const remaining = () => Math.max(0, deadline - Date.now());
472
+
473
+ // If a worker is mid model-LOAD (status 'loading' = the recognizer is being
474
+ // constructed in the worker thread), wait — bounded — for it to settle so we
475
+ // can tear it down cooperatively; a worker killed mid-native-load aborts the
476
+ // process (SIGABRT / exit 134). We do NOT wait during 'downloading' (no native
477
+ // worker is loaded yet, and the download can take minutes — _doInitialize
478
+ // bails before spawning once _stopping is set, so a Ctrl+C during a first-run
479
+ // download still exits promptly).
480
+ if (this._initPromise && this._status === 'loading') {
481
+ await Promise.race([
482
+ Promise.resolve(this._initPromise).catch(() => {}),
483
+ new Promise((resolve) => {
484
+ const t = setTimeout(resolve, remaining());
485
+ if (t.unref) t.unref();
486
+ }),
487
+ ]);
488
+ }
489
+
424
490
  // Reject all queued requests
425
491
  for (const req of this._queue) {
426
492
  clearTimeout(req.timer);
@@ -429,9 +495,31 @@ class SttEngine {
429
495
  this._queue = [];
430
496
  this._currentRequest = null;
431
497
 
432
- if (this._worker) {
433
- await this._worker.terminate();
434
- this._worker = null;
498
+ // Cooperatively stop the worker — the live one, or one still booting (tracked
499
+ // from creation in _spawnWorker). Ask it to exit on its own. We deliberately
500
+ // do NOT call worker.terminate(): force-killing a thread inside native
501
+ // sherpa-onnx code (mid-load or mid-transcribe) throws an uncaught Napi error
502
+ // during worker-env teardown and ggml's set_terminate aborts the whole
503
+ // process (SIGABRT / exit 134) — the bug this fixes. The wait is bounded
504
+ // (shared deadline) so handleShutdown can still save sessions + close(); a
505
+ // worker that never exits is reaped by handleShutdown's 15s force-exit
506
+ // backstop.
507
+ const w = this._worker || this._spawningWorker;
508
+ this._worker = null;
509
+ this._spawningWorker = null;
510
+ if (w) {
511
+ await new Promise((resolve) => {
512
+ let done = false;
513
+ const finish = () => { if (!done) { done = true; resolve(); } };
514
+ w.once('exit', finish);
515
+ try {
516
+ w.postMessage({ type: 'shutdown' });
517
+ } catch {
518
+ finish();
519
+ }
520
+ const t = setTimeout(finish, Math.max(1000, remaining()));
521
+ if (t.unref) t.unref();
522
+ });
435
523
  }
436
524
 
437
525
  this._status = 'unavailable';
package/src/stt-worker.js CHANGED
@@ -72,8 +72,22 @@ try {
72
72
  process.exit(1);
73
73
  }
74
74
 
75
+ let _shuttingDown = false;
75
76
  parentPort.on('message', (msg) => {
77
+ if (!msg) return;
78
+ if (msg.type === 'shutdown') {
79
+ // Graceful teardown. sherpa-onnx-node exposes no dispose API (the recognizer
80
+ // is GC/finalizer-managed), and transcribe runs synchronously here, so when
81
+ // this message is processed nothing is in flight. Exit cleanly while idle so
82
+ // the worker-env teardown doesn't race a pending native op — a bare
83
+ // terminate() with the recognizer loaded can abort the process during native
84
+ // cleanup (SIGABRT / exit 134) on Ctrl+C.
85
+ _shuttingDown = true;
86
+ process.exit(0);
87
+ return;
88
+ }
76
89
  if (msg.type === 'transcribe') {
90
+ if (_shuttingDown) return;
77
91
  try {
78
92
  // Two input shapes:
79
93
  // - msg.pcm16: raw 16-bit PCM (Int16Array). Conversion to Float32 runs
@@ -321,8 +321,11 @@ class UsageReader {
321
321
  try {
322
322
  // Get the current working directory to find the right project folder
323
323
  const cwd = process.cwd();
324
- // Claude uses format: -home-user-Development-project
325
- const projectDirName = cwd.replace(/[\\/]/g, '-'); // Handle both Unix / and Windows \ separators
324
+ // Claude uses format: -home-user-Development-project. It replaces EVERY
325
+ // non-alphanumeric char with '-', so on Windows the drive-letter colon
326
+ // becomes a dash too (`C:\Users\me` -> `C--Users-me`). A separator-only
327
+ // replace leaves the colon and never matches the real folder.
328
+ const projectDirName = cwd.replace(/[^a-zA-Z0-9]/g, '-');
326
329
  let projectPath = path.join(this.claudeProjectsPath, projectDirName);
327
330
 
328
331
  // Check if the project directory exists
@@ -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