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 +10 -12
- package/package.json +1 -1
- package/src/base-bridge.js +4 -2
- package/src/server.js +189 -9
- package/src/sticky-note-engine.js +75 -32
- package/src/sticky-note-jsonl.js +23 -6
- package/src/sticky-note-worker.js +7 -0
- package/src/stt-engine.js +97 -9
- package/src/stt-worker.js +14 -0
- package/src/usage-reader.js +5 -2
- package/src/utils/session-store.js +5 -0
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
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
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, {
|
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
|
-
|
|
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
|
-
//
|
|
4243
|
-
//
|
|
4244
|
-
//
|
|
4245
|
-
//
|
|
4246
|
-
//
|
|
4247
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
}
|
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
|
|
@@ -39,9 +49,16 @@ function cleanProse(s) {
|
|
|
39
49
|
.trim();
|
|
40
50
|
}
|
|
41
51
|
|
|
42
|
-
/**
|
|
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(/[
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
315
|
-
|
|
344
|
+
detach();
|
|
345
|
+
clearPending();
|
|
316
346
|
reject(new Error(msg.message));
|
|
317
347
|
}
|
|
318
348
|
};
|
|
319
349
|
|
|
320
350
|
const onError = (err) => {
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
package/src/usage-reader.js
CHANGED
|
@@ -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
|
-
|
|
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
|