ai-or-die 0.1.73 → 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/package.json +1 -1
- package/src/base-bridge.js +4 -2
- package/src/server.js +156 -8
- package/src/sticky-note-jsonl.js +14 -4
- package/src/utils/session-store.js +5 -0
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
|
}
|
|
@@ -1351,6 +1353,7 @@ class ClaudeCodeWebServer {
|
|
|
1351
1353
|
// Stop + tear down the summariser so an in-flight inference is discarded.
|
|
1352
1354
|
this.stickyNoteSummarizer.cancel(sessionId);
|
|
1353
1355
|
this._stickyJsonl.delete(sessionId);
|
|
1356
|
+
this._removeClaudeBindSidecar(session);
|
|
1354
1357
|
if (this._foregroundSessionId === sessionId) this._foregroundSessionId = null;
|
|
1355
1358
|
|
|
1356
1359
|
this.claudeSessions.delete(sessionId);
|
|
@@ -3963,6 +3966,12 @@ class ClaudeCodeWebServer {
|
|
|
3963
3966
|
// process; their session.liveCwd stays null. We pass the OSC 7
|
|
3964
3967
|
// hooks only when starting a Terminal session so the other bridges
|
|
3965
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
|
+
|
|
3966
3975
|
const osc7Hooks = (toolName === 'terminal') ? {
|
|
3967
3976
|
validatePath: (p) => this.validatePath(p),
|
|
3968
3977
|
onCwdChange: (cwd, prev) => {
|
|
@@ -4039,7 +4048,13 @@ class ClaudeCodeWebServer {
|
|
|
4039
4048
|
this.broadcastToSession(sessionId, { type: 'error', message: error.message });
|
|
4040
4049
|
this.broadcastSessionActivity(sessionId, 'session_error');
|
|
4041
4050
|
},
|
|
4042
|
-
...options
|
|
4051
|
+
...options,
|
|
4052
|
+
extraEnv: toolName === 'terminal'
|
|
4053
|
+
? {
|
|
4054
|
+
...((options.extraEnv && typeof options.extraEnv === 'object') ? options.extraEnv : {}),
|
|
4055
|
+
...terminalExtraEnv,
|
|
4056
|
+
}
|
|
4057
|
+
: options.extraEnv
|
|
4043
4058
|
});
|
|
4044
4059
|
|
|
4045
4060
|
session.lastActivity = new Date();
|
|
@@ -4256,13 +4271,57 @@ class ClaudeCodeWebServer {
|
|
|
4256
4271
|
async _pumpStickyJsonl(sessionId, cwd) {
|
|
4257
4272
|
let binding = this._stickyJsonl.get(sessionId);
|
|
4258
4273
|
binding && (binding._ticks = (binding._ticks || 0) + 1);
|
|
4274
|
+
const session = this.claudeSessions.get(sessionId);
|
|
4259
4275
|
|
|
4260
|
-
//
|
|
4261
|
-
//
|
|
4262
|
-
//
|
|
4263
|
-
//
|
|
4264
|
-
//
|
|
4265
|
-
|
|
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.
|
|
4266
4325
|
const candidates = await StickyNoteJsonl.findActiveSessions(cwd, { projectsDir: this._stickyProjectsDir });
|
|
4267
4326
|
const ownedByOthers = this._ownedClaudeSessions(sessionId);
|
|
4268
4327
|
// Only (re)bind to a session being ACTIVELY written (recent mtime). A fresh
|
|
@@ -4275,7 +4334,6 @@ class ClaudeCodeWebServer {
|
|
|
4275
4334
|
// the recency gate, so a restart / lost binding can re-resume an idle-but-
|
|
4276
4335
|
// live session. A FRESH tab has no own-session, so it still won't adopt a
|
|
4277
4336
|
// stale stranger session in the project.
|
|
4278
|
-
const session = this.claudeSessions.get(sessionId);
|
|
4279
4337
|
const ownClaudeSession = session && session.stickyClaudeSessionId;
|
|
4280
4338
|
const eligible = (c) => !ownedByOthers.has(c.sessionId) && (freshlyActive(c) || c.sessionId === ownClaudeSession);
|
|
4281
4339
|
const currentValid =
|
|
@@ -4384,9 +4442,99 @@ class ClaudeCodeWebServer {
|
|
|
4384
4442
|
for (const [sid, b] of this._stickyJsonl) {
|
|
4385
4443
|
if (sid !== exceptSessionId && b && b.claudeSessionId) owned.add(b.claudeSessionId);
|
|
4386
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
|
+
}
|
|
4387
4451
|
return owned;
|
|
4388
4452
|
}
|
|
4389
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
|
+
|
|
4390
4538
|
/**
|
|
4391
4539
|
* Bind a tab to a claude transcript and resume its durable note. Binds near the
|
|
4392
4540
|
* end of the file so the first summary uses recent context, not the whole
|
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
|