baychat 0.11.2 → 0.11.4

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.
@@ -0,0 +1,615 @@
1
+ "use strict";
2
+ /**
3
+ * Resume-id discovery.
4
+ *
5
+ * A detached session with no resume id is the relay's worst failure mode: the
6
+ * message reaches the box, `canResume` says no, and the delivery is recorded
7
+ * `pending` until a human re-attaches by hand. Every other delay in the system
8
+ * is measured in seconds; that one is unbounded.
9
+ *
10
+ * This module answers one question — *which runtime session is this BayChat
11
+ * session?* — and it is allowed to answer "I don't know". Resuming the wrong
12
+ * session is strictly worse than reporting pending: it answers a room from a
13
+ * process with no memory of it, or hijacks an unrelated piece of work. So
14
+ * every method here either **identifies** a session or refuses.
15
+ *
16
+ * Three methods, in descending order of trust:
17
+ *
18
+ * 1. `flag` — a human passed `--resume-id`. We take their word for it.
19
+ * 2. `session-env` — the attaching session read its OWN id out of the
20
+ * environment its runtime gave it. Self-identification, so
21
+ * it cannot name someone else's session.
22
+ * 3. `transcript` — the relay found, in the runtime's own on-disk state, a
23
+ * session whose transcript records *this BayChat session
24
+ * name being attached*. That is evidence of identity, not a
25
+ * heuristic about recency.
26
+ *
27
+ * Deliberately NOT implemented: `claude --continue` and `codex exec resume
28
+ * --last`. Both mean "the most recent session in this directory", which is a
29
+ * statement about who ran last, not about who attached. On the machine this was
30
+ * written on, `/var/www/baychat` holds ten Claude transcripts and five Codex
31
+ * conversations, two of the latter started two seconds apart in the same
32
+ * directory — and Codex's newest rollout there is a *subagent* thread. A
33
+ * `--last` wake would have resumed a stranger. See ADR note in
34
+ * docs/features/AGENT_RELAY.md.
35
+ */
36
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
37
+ if (k2 === undefined) k2 = k;
38
+ var desc = Object.getOwnPropertyDescriptor(m, k);
39
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
40
+ desc = { enumerable: true, get: function() { return m[k]; } };
41
+ }
42
+ Object.defineProperty(o, k2, desc);
43
+ }) : (function(o, m, k, k2) {
44
+ if (k2 === undefined) k2 = k;
45
+ o[k2] = m[k];
46
+ }));
47
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
48
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
49
+ }) : function(o, v) {
50
+ o["default"] = v;
51
+ });
52
+ var __importStar = (this && this.__importStar) || (function () {
53
+ var ownKeys = function(o) {
54
+ ownKeys = Object.getOwnPropertyNames || function (o) {
55
+ var ar = [];
56
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
57
+ return ar;
58
+ };
59
+ return ownKeys(o);
60
+ };
61
+ return function (mod) {
62
+ if (mod && mod.__esModule) return mod;
63
+ var result = {};
64
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
65
+ __setModuleDefault(result, mod);
66
+ return result;
67
+ };
68
+ })();
69
+ Object.defineProperty(exports, "__esModule", { value: true });
70
+ exports.SESSION_ID_ENV = void 0;
71
+ exports.isUuid = isUuid;
72
+ exports.attachMarker = attachMarker;
73
+ exports.commandAttaches = commandAttaches;
74
+ exports.resumeIdFromSessionEnv = resumeIdFromSessionEnv;
75
+ exports.discoverClaudeResume = discoverClaudeResume;
76
+ exports.discoverCodexResume = discoverCodexResume;
77
+ exports.discoverResume = discoverResume;
78
+ const fs = __importStar(require("fs"));
79
+ const os = __importStar(require("os"));
80
+ const path = __importStar(require("path"));
81
+ const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60_000;
82
+ const DEFAULT_MAX_FILES = 200;
83
+ const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
84
+ /**
85
+ * The env var each runtime exports to processes it spawns, if any.
86
+ *
87
+ * `CLAUDE_CODE_SESSION_ID` is verified: it is present in the environment of
88
+ * every Bash tool call and equals the id of the transcript the session is
89
+ * writing (a subagent inherits its parent's, which is the id you want — the
90
+ * parent is what `--resume` reopens).
91
+ *
92
+ * `CODEX_THREAD_ID` is a name found in the Codex binary next to its
93
+ * `exec_command` plumbing and is NOT verified to be exported. It is read
94
+ * opportunistically and then **corroborated against on-disk rollouts** before
95
+ * being trusted, because an unverified variable could name a subagent thread —
96
+ * exactly the kind of wrong session this module exists to refuse.
97
+ *
98
+ * Cursor and Hermes export nothing we can verify, and neither can be resumed
99
+ * headlessly anyway: for them "no session id" is the permanent, correct answer
100
+ * rather than a gap to be closed. They are woken while attached instead.
101
+ */
102
+ exports.SESSION_ID_ENV = {
103
+ claude: "CLAUDE_CODE_SESSION_ID",
104
+ codex: "CODEX_THREAD_ID",
105
+ cursor: null,
106
+ hermes: null,
107
+ };
108
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
109
+ function isUuid(value) {
110
+ return UUID_RE.test(value);
111
+ }
112
+ /**
113
+ * Build the matcher for "this command attached THIS BayChat session".
114
+ *
115
+ * Three things have to be true at once, and each one closes a real false
116
+ * positive found while writing this:
117
+ *
118
+ * - the **BayChat CLI** is what runs, not any command containing the words.
119
+ * `grep -r 'relay attach --session codex-vps' src/` is a session reading
120
+ * about another session, and the machine this was written on is full of them.
121
+ * - the session name follows `--session` **directly**, so the usage line
122
+ * (`--session <name>`) matches nothing and `codex-vps` never claims
123
+ * `codex-vps-2`.
124
+ * - the invocation is not itself inside a quoted argument — see
125
+ * `commandAttaches`, which is the predicate you should actually call.
126
+ */
127
+ function attachMarker(sessionName) {
128
+ const name = sessionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
129
+ return new RegExp(
130
+ // an argv-position CLI token: `baychat`, `npx baychat`, `node dist/index.js`
131
+ `(?:^|[\\s;&|(])(?:[^\\s'"]*[\\\\/])?(?:baychat|index\\.js)\\s+relay\\s+attach\\b` +
132
+ `[^\\n]{0,400}?--session[=\\s]+(?:["'])?${name}(?:["']|\\s|$)`);
133
+ }
134
+ /**
135
+ * Did this recorded command actually attach that session?
136
+ *
137
+ * The quoting check is the last gate. A session that greps for
138
+ * `'baychat relay attach --session codex-vps'` runs a command that contains a
139
+ * perfectly good invocation — inside single quotes, as data. Resuming on that
140
+ * evidence would wake the session that read about the room instead of the one
141
+ * that joined it.
142
+ */
143
+ function commandAttaches(command, sessionName) {
144
+ for (const text of [command, ...unwrapShellC(command)]) {
145
+ const match = attachMarker(sessionName).exec(text);
146
+ if (match && !isQuotedAt(text, match.index))
147
+ return true;
148
+ }
149
+ return false;
150
+ }
151
+ /**
152
+ * `bash -lc "…"` one level down — there the quoted argument IS the command.
153
+ *
154
+ * Without this, an agent that wraps its attach in a login shell (a common habit)
155
+ * would look exactly like one grepping for the string, and its session would
156
+ * stay undiscoverable. Inside the unwrapped text the quoting rule still applies,
157
+ * so `bash -lc "grep 'baychat relay attach …' ."` is still refused.
158
+ */
159
+ function unwrapShellC(command) {
160
+ const m = /^\s*(?:\S*\/)?(?:ba|z|da|k)?sh\s+(?:-\S+\s+)*?-\S*c\S*\s+(['"])([\s\S]*)\1\s*$/.exec(command);
161
+ if (!m)
162
+ return [];
163
+ return [m[2].replace(/\\(["'\\$`])/g, "$1")];
164
+ }
165
+ /** Is offset `index` inside a single- or double-quoted run of shell text? */
166
+ function isQuotedAt(text, index) {
167
+ let single = false;
168
+ let double = false;
169
+ for (let i = 0; i < index; i++) {
170
+ const ch = text[i];
171
+ if (ch === "\\") {
172
+ i += 1;
173
+ continue;
174
+ }
175
+ if (ch === "'" && !double)
176
+ single = !single;
177
+ else if (ch === '"' && !single)
178
+ double = !double;
179
+ }
180
+ return single || double;
181
+ }
182
+ // ---------------------------------------------------------------------------
183
+ // (2) self-identification from the runtime's environment
184
+ // ---------------------------------------------------------------------------
185
+ /**
186
+ * Read this session's own id out of the environment its runtime gave it.
187
+ *
188
+ * Called by `relay attach`, in the session's own process tree, so the answer
189
+ * can only ever be *this* session. That is what makes it the preferred method:
190
+ * no search, no ranking, nothing to mistake for a sibling.
191
+ */
192
+ async function resumeIdFromSessionEnv(runtime, opts = {}) {
193
+ const env = opts.env ?? process.env;
194
+ const varName = exports.SESSION_ID_ENV[runtime];
195
+ if (!varName) {
196
+ return { ok: false, reason: `${runtime} exports no session id to the processes it spawns` };
197
+ }
198
+ const raw = (env[varName] ?? "").trim();
199
+ if (!raw) {
200
+ return { ok: false, reason: `$${varName} is not set — this process was not spawned by a ${runtime} session` };
201
+ }
202
+ if (!isUuid(raw)) {
203
+ // A non-uuid here means the variable does not mean what we think it means.
204
+ // Recording it would produce a resume that fails, or worse, one that hits
205
+ // something else entirely.
206
+ return { ok: false, reason: `$${varName} is not a session uuid (${truncate(raw, 40)}) — refusing to record it` };
207
+ }
208
+ if (runtime === "claude") {
209
+ // The transcript is not required — the variable is authoritative — but
210
+ // finding it gives us the session's own cwd for the headless spawn.
211
+ const found = await claudeTranscriptById(raw, opts);
212
+ return {
213
+ ok: true,
214
+ resumeId: raw,
215
+ source: "session-env",
216
+ evidence: `recorded at attach from $${varName}`,
217
+ cwd: found?.cwd,
218
+ };
219
+ }
220
+ // Codex: corroborate before trusting. `CODEX_THREAD_ID` is undocumented, and
221
+ // a value naming a subagent thread would resume a helper, not the session
222
+ // that joined the room.
223
+ const roll = await codexRolloutById(raw, opts);
224
+ if (!roll) {
225
+ return {
226
+ ok: false,
227
+ reason: `$${varName} names ${truncate(raw, 40)} but no top-level Codex rollout under ${codexRoot(opts)} has that id — refusing to record an id we cannot corroborate`,
228
+ };
229
+ }
230
+ return {
231
+ ok: true,
232
+ resumeId: roll.resumeId,
233
+ source: "session-env",
234
+ evidence: `recorded at attach from $${varName}, corroborated by ${short(roll.file)}`,
235
+ cwd: roll.cwd,
236
+ };
237
+ }
238
+ // ---------------------------------------------------------------------------
239
+ // (3) discovery from the runtime's on-disk state
240
+ // ---------------------------------------------------------------------------
241
+ /**
242
+ * Find the Claude Code session that attached under this BayChat session name.
243
+ *
244
+ * Transcripts live at `~/.claude/projects/<slug>/<session-uuid>.jsonl`, so the
245
+ * file name IS the id `--resume` wants. We do not rank by recency: we look for
246
+ * the transcript that records the attach command naming this session, which is
247
+ * proof of identity rather than a guess. Sub-agent transcripts
248
+ * (`<uuid>/subagents/agent-*.jsonl`) are skipped — they are not resumable and
249
+ * they mirror the parent's shell activity, so they would double every match.
250
+ */
251
+ async function discoverClaudeResume(sessionName, opts = {}) {
252
+ const root = claudeRoot(opts);
253
+ const files = listClaudeTranscripts(root, opts);
254
+ if (files.length === 0) {
255
+ return { ok: false, reason: `no Claude Code transcripts under ${short(root)} to search` };
256
+ }
257
+ const hits = [];
258
+ for (const { file, id } of files) {
259
+ const scan = await scanTranscript(file, sessionName, opts, (line) => claudeEvidence(line, sessionName));
260
+ if (scan.evidence)
261
+ hits.push({ id, file, cwd: scan.cwd, command: scan.evidence });
262
+ }
263
+ return oneOrRefuse(hits.map((h) => ({ resumeId: h.id, file: h.file, cwd: h.cwd })), sessionName, "Claude Code", root);
264
+ }
265
+ /**
266
+ * Find the Codex conversation that attached under this BayChat session name.
267
+ *
268
+ * Codex writes `~/.codex/sessions/<y>/<m>/<d>/rollout-<ts>-<id>.jsonl`, whose
269
+ * first line is a `session_meta` carrying `session_id` (the conversation),
270
+ * `id` (this rollout), `cwd` and `source`.
271
+ *
272
+ * Two filters matter, and both were found by looking at real files:
273
+ * - `source` distinguishes a real session (`"cli"`) from a **subagent** thread
274
+ * (`{subagent:{…}}`). The subagent rollout mirrors the parent's shell calls,
275
+ * so it carries the same attach command — resuming it would resume a helper.
276
+ * - resuming a conversation appends a NEW rollout that keeps the original
277
+ * `session_id`, so candidates are collapsed on `session_id`. Without that a
278
+ * session resumed twice looks like three different sessions and discovery
279
+ * would refuse itself into permanent pending.
280
+ */
281
+ async function discoverCodexResume(sessionName, opts = {}) {
282
+ const root = codexRoot(opts);
283
+ const files = listCodexRollouts(root, opts);
284
+ if (files.length === 0) {
285
+ return { ok: false, reason: `no Codex rollouts under ${short(root)} to search` };
286
+ }
287
+ const byConversation = new Map();
288
+ for (const file of files) {
289
+ const meta = await readCodexMeta(file);
290
+ if (!meta || !meta.resumeId)
291
+ continue;
292
+ if (meta.isSubagent)
293
+ continue; // a helper thread, not the session that joined the room
294
+ const scan = await scanTranscript(file, sessionName, opts, (line) => codexEvidence(line, sessionName));
295
+ if (!scan.evidence)
296
+ continue;
297
+ if (!byConversation.has(meta.resumeId)) {
298
+ byConversation.set(meta.resumeId, { resumeId: meta.resumeId, file, cwd: meta.cwd });
299
+ }
300
+ }
301
+ return oneOrRefuse([...byConversation.values()], sessionName, "Codex", root);
302
+ }
303
+ /** Route to the runtime's discovery, or say the runtime has none. */
304
+ async function discoverResume(target, opts = {}) {
305
+ if (target.runtime === "claude")
306
+ return discoverClaudeResume(target.name, opts);
307
+ if (target.runtime === "codex")
308
+ return discoverCodexResume(target.name, opts);
309
+ return { ok: false, reason: `${target.runtime} keeps no local session state to discover a resume id from` };
310
+ }
311
+ /**
312
+ * Exactly one candidate, or nothing.
313
+ *
314
+ * Two transcripts that both attached under one name means two terminals once
315
+ * claimed it and we cannot see which is live. Picking the newer one would be
316
+ * the recency guess this module exists to avoid, so it refuses and names both
317
+ * ids — a human reading `relay status` can then attach the right one.
318
+ */
319
+ function oneOrRefuse(hits, sessionName, label, root) {
320
+ if (hits.length === 0) {
321
+ return {
322
+ ok: false,
323
+ reason: `no ${label} session under ${short(root)} records \`relay attach --session ${sessionName}\` — nothing on disk identifies which session this is`,
324
+ };
325
+ }
326
+ if (hits.length > 1) {
327
+ return {
328
+ ok: false,
329
+ reason: `${hits.length} ${label} sessions record \`relay attach --session ${sessionName}\` (${hits
330
+ .map((h) => h.resumeId)
331
+ .join(", ")}) — refusing to guess which one is live`,
332
+ };
333
+ }
334
+ const hit = hits[0];
335
+ return {
336
+ ok: true,
337
+ resumeId: hit.resumeId,
338
+ source: "transcript",
339
+ evidence: `discovered from ${short(hit.file)} (it ran \`relay attach --session ${sessionName}\`)`,
340
+ cwd: hit.cwd,
341
+ };
342
+ }
343
+ // ---------------------------------------------------------------------------
344
+ // evidence extractors
345
+ // ---------------------------------------------------------------------------
346
+ /**
347
+ * A Claude transcript line is evidence only when it is a recorded **tool call**
348
+ * whose command attached this session. Matching raw text would fire on the
349
+ * usage string, on this file's own source, and on any conversation that merely
350
+ * mentions the session name.
351
+ */
352
+ function claudeEvidence(line, sessionName) {
353
+ const parsed = safeParse(line);
354
+ const content = parsed?.message?.content;
355
+ if (!Array.isArray(content))
356
+ return undefined;
357
+ for (const item of content) {
358
+ if (item?.type !== "tool_use")
359
+ continue;
360
+ const cmd = item?.input?.command;
361
+ if (typeof cmd === "string" && commandAttaches(cmd, sessionName))
362
+ return cmd;
363
+ }
364
+ return undefined;
365
+ }
366
+ /**
367
+ * Codex records shell work as `custom_tool_call` (newer, `input` is a JS
368
+ * snippet calling `tools.exec_command({cmd:"…"})`) or `function_call` (older,
369
+ * `arguments` is JSON). Either way the *shell command* has to be dug out before
370
+ * the predicate can run: applied to the raw JS snippet, every command would
371
+ * look quoted and nothing would ever match.
372
+ */
373
+ function codexEvidence(line, sessionName) {
374
+ const parsed = safeParse(line);
375
+ const payload = parsed?.payload;
376
+ if (!payload)
377
+ return undefined;
378
+ if (payload.type !== "custom_tool_call" && payload.type !== "function_call")
379
+ return undefined;
380
+ for (const raw of [payload.input, payload.arguments]) {
381
+ if (typeof raw !== "string")
382
+ continue;
383
+ for (const cmd of codexCommands(raw)) {
384
+ if (commandAttaches(cmd, sessionName))
385
+ return cmd;
386
+ }
387
+ }
388
+ return undefined;
389
+ }
390
+ /** Every shell command embedded in a Codex tool call payload. */
391
+ function codexCommands(raw) {
392
+ const out = [];
393
+ // Newer: `tools.exec_command({cmd:"…", workdir:"…"})`.
394
+ for (const m of raw.matchAll(/\bcmd\s*:\s*("(?:[^"\\]|\\.)*")/g)) {
395
+ try {
396
+ const cmd = JSON.parse(m[1]);
397
+ if (typeof cmd === "string")
398
+ out.push(cmd);
399
+ }
400
+ catch {
401
+ // Not a JSON string literal after all — not a command we can read.
402
+ }
403
+ }
404
+ // Older: `{"command":["bash","-lc","…"]}`.
405
+ const args = safeParse(raw);
406
+ if (typeof args?.command === "string")
407
+ out.push(args.command);
408
+ if (Array.isArray(args?.command))
409
+ out.push(args.command.filter((p) => typeof p === "string").join(" "));
410
+ return out;
411
+ }
412
+ // ---------------------------------------------------------------------------
413
+ // file plumbing
414
+ // ---------------------------------------------------------------------------
415
+ function claudeRoot(opts) {
416
+ return opts.claudeProjectsDir ?? path.join(os.homedir(), ".claude", "projects");
417
+ }
418
+ function codexRoot(opts) {
419
+ return opts.codexSessionsDir ?? path.join(os.homedir(), ".codex", "sessions");
420
+ }
421
+ /** `~/.claude/projects/<slug>/<uuid>.jsonl`, newest first, bounded. */
422
+ function listClaudeTranscripts(root, opts) {
423
+ const cutoff = Date.now() - (opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS);
424
+ const out = [];
425
+ for (const project of readDirSafe(root)) {
426
+ if (!project.isDirectory())
427
+ continue;
428
+ const dir = path.join(root, project.name);
429
+ for (const entry of readDirSafe(dir)) {
430
+ // Only top-level transcripts: `<uuid>/subagents/agent-*.jsonl` are helper
431
+ // threads that mirror the parent's tool calls and cannot be resumed.
432
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl"))
433
+ continue;
434
+ const id = entry.name.slice(0, -".jsonl".length);
435
+ if (!isUuid(id))
436
+ continue;
437
+ const file = path.join(dir, entry.name);
438
+ const mtimeMs = mtimeOf(file);
439
+ if (mtimeMs < cutoff)
440
+ continue;
441
+ out.push({ file, id, mtimeMs });
442
+ }
443
+ }
444
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs).slice(0, opts.maxFiles ?? DEFAULT_MAX_FILES);
445
+ }
446
+ /** `~/.codex/sessions/**\/rollout-*.jsonl`, newest first, bounded. */
447
+ function listCodexRollouts(root, opts) {
448
+ const cutoff = Date.now() - (opts.maxAgeMs ?? DEFAULT_MAX_AGE_MS);
449
+ const found = [];
450
+ const walk = (dir, depth) => {
451
+ if (depth > 6)
452
+ return;
453
+ for (const entry of readDirSafe(dir)) {
454
+ const p = path.join(dir, entry.name);
455
+ if (entry.isDirectory()) {
456
+ walk(p, depth + 1);
457
+ continue;
458
+ }
459
+ if (!entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl"))
460
+ continue;
461
+ const mtimeMs = mtimeOf(p);
462
+ if (mtimeMs < cutoff)
463
+ continue;
464
+ found.push({ file: p, mtimeMs });
465
+ }
466
+ };
467
+ walk(root, 0);
468
+ return found
469
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
470
+ .slice(0, opts.maxFiles ?? DEFAULT_MAX_FILES)
471
+ .map((f) => f.file);
472
+ }
473
+ /** Parse the `session_meta` first line of a rollout. */
474
+ async function readCodexMeta(file) {
475
+ const head = await readHead(file, 256 * 1024);
476
+ const firstLine = head.split("\n", 1)[0];
477
+ const parsed = safeParse(firstLine);
478
+ if (!parsed || parsed.type !== "session_meta" || !parsed.payload)
479
+ return undefined;
480
+ const p = parsed.payload;
481
+ // `session_id` is the conversation; `id` is this rollout. They are equal for a
482
+ // first run and diverge once the conversation is resumed or forked, so the
483
+ // conversation id is the stable handle to hand `codex exec resume`.
484
+ const resumeId = str(p.session_id) ?? str(p.id);
485
+ const source = p.source;
486
+ const isSubagent = (typeof source === "object" && source !== null && "subagent" in source) || p.parent_thread_id != null;
487
+ return { resumeId, cwd: str(p.cwd), isSubagent };
488
+ }
489
+ /** Locate a Codex rollout by conversation id, ignoring subagent threads. */
490
+ async function codexRolloutById(id, opts) {
491
+ for (const file of listCodexRollouts(codexRoot(opts), opts)) {
492
+ const meta = await readCodexMeta(file);
493
+ if (!meta || meta.isSubagent)
494
+ continue;
495
+ if (meta.resumeId === id)
496
+ return { resumeId: meta.resumeId, file, cwd: meta.cwd };
497
+ }
498
+ return undefined;
499
+ }
500
+ /** Locate a Claude transcript by session id and read the cwd it started in. */
501
+ async function claudeTranscriptById(id, opts) {
502
+ for (const { file, id: candidate } of listClaudeTranscripts(claudeRoot(opts), opts)) {
503
+ if (candidate !== id)
504
+ continue;
505
+ const head = await readHead(file, 512 * 1024);
506
+ for (const line of head.split("\n")) {
507
+ const cwd = str(safeParse(line)?.cwd);
508
+ if (cwd)
509
+ return { file, cwd };
510
+ }
511
+ return { file };
512
+ }
513
+ return undefined;
514
+ }
515
+ /**
516
+ * Stream one transcript looking for evidence, and pick up the session's own cwd
517
+ * on the way past.
518
+ *
519
+ * The cheap raw-substring pre-filter matters: these files run to several
520
+ * megabytes and `JSON.parse` on every line of every transcript would make a
521
+ * detached wake noticeably slow. A line that does not contain the session name
522
+ * at all cannot be evidence.
523
+ */
524
+ async function scanTranscript(file, sessionName, opts, extract) {
525
+ let evidence;
526
+ let cwd;
527
+ const limit = opts.maxBytesPerFile ?? DEFAULT_MAX_BYTES;
528
+ let handle;
529
+ try {
530
+ handle = await fs.promises.open(file, "r");
531
+ const buf = Buffer.alloc(1 << 20);
532
+ let rest = "";
533
+ let read = 0;
534
+ for (;;) {
535
+ const { bytesRead } = await handle.read(buf, 0, buf.length, null);
536
+ if (bytesRead <= 0)
537
+ break;
538
+ read += bytesRead;
539
+ const parts = (rest + buf.toString("utf8", 0, bytesRead)).split("\n");
540
+ rest = parts.pop() ?? "";
541
+ for (const line of parts) {
542
+ if (!cwd && line.includes('"cwd"'))
543
+ cwd = str(safeParse(line)?.cwd);
544
+ if (!evidence && line.includes("relay") && line.includes(sessionName))
545
+ evidence = extract(line);
546
+ }
547
+ if (evidence && cwd)
548
+ break;
549
+ if (read >= limit)
550
+ break;
551
+ }
552
+ if (!evidence && rest.includes("relay") && rest.includes(sessionName))
553
+ evidence = extract(rest);
554
+ }
555
+ catch {
556
+ // An unreadable transcript is not evidence. Refusing the whole search
557
+ // because one file is locked would turn a readable answer into pending.
558
+ }
559
+ finally {
560
+ await handle?.close().catch(() => undefined);
561
+ }
562
+ return { evidence, cwd };
563
+ }
564
+ async function readHead(file, bytes) {
565
+ let handle;
566
+ try {
567
+ handle = await fs.promises.open(file, "r");
568
+ const buf = Buffer.alloc(bytes);
569
+ const { bytesRead } = await handle.read(buf, 0, bytes, 0);
570
+ return buf.toString("utf8", 0, bytesRead);
571
+ }
572
+ catch {
573
+ return "";
574
+ }
575
+ finally {
576
+ await handle?.close().catch(() => undefined);
577
+ }
578
+ }
579
+ function readDirSafe(dir) {
580
+ try {
581
+ return fs.readdirSync(dir, { withFileTypes: true });
582
+ }
583
+ catch {
584
+ return [];
585
+ }
586
+ }
587
+ function mtimeOf(file) {
588
+ try {
589
+ return fs.statSync(file).mtimeMs;
590
+ }
591
+ catch {
592
+ return 0;
593
+ }
594
+ }
595
+ function safeParse(line) {
596
+ if (!line || line[0] !== "{")
597
+ return undefined;
598
+ try {
599
+ return JSON.parse(line);
600
+ }
601
+ catch {
602
+ return undefined;
603
+ }
604
+ }
605
+ function str(value) {
606
+ return typeof value === "string" && value.length > 0 ? value : undefined;
607
+ }
608
+ function truncate(value, max) {
609
+ return value.length > max ? `${value.slice(0, max)}…` : value;
610
+ }
611
+ /** Shorten a path for display: `$HOME` back to `~`. */
612
+ function short(file) {
613
+ const home = os.homedir();
614
+ return file.startsWith(home) ? `~${file.slice(home.length)}` : file;
615
+ }
@@ -97,22 +97,36 @@ async function runUpdatesLoop(opts) {
97
97
  }
98
98
  }
99
99
  /**
100
- * Re-read each watched conversation from its watermark. Used on 409 recovery
101
- * and on the WebSocket's `{"t":"reset"}`, which is the same condition in frame
102
- * form — one recovery, so the two transports cannot drift apart. Messages we
103
- * already delivered come back here; the caller de-dupes them by id, so a replay
104
- * is cheap rather than duplicated into the room.
100
+ * Re-read what an expired cursor swallowed, for EVERY live session on this
101
+ * device. Used by 409 recovery and by the WebSocket's `{"t":"reset"}`, which is
102
+ * the same condition in frame form — one recovery, so the two transports cannot
103
+ * drift apart.
105
104
  *
106
- * A 404 for one conversation is skipped rather than fatal: a session may have
107
- * ended or left the room between the poll and the recovery, and one dead room
108
- * must not strand the catch-up for every other.
105
+ * **One read per (session, conversation), naming the session.** The endpoint
106
+ * answers as *a* session that participates, so a read with no `session=` is
107
+ * answered by whichever of this device's sessions happens to be tried first.
108
+ * That is correct for that session and silently nothing for its siblings: two
109
+ * sessions in one shared room, one recovered, one re-baselined at the moment of
110
+ * the 409 with the missed window unreachable. Naming the session is what makes
111
+ * recovery cover the device rather than one arbitrary member of it.
112
+ *
113
+ * Messages already delivered come back here; the caller de-dupes on (session,
114
+ * message id), so a replay is free and a sibling's copy of one message is never
115
+ * mistaken for a duplicate.
116
+ *
117
+ * A 404 for one pair is skipped rather than fatal — that session is simply not
118
+ * in that room, which is the expected answer for most pairs the plan contains,
119
+ * and a room that has gone away must not strand the catch-up for every other.
120
+ * Anything else throws: a partial recovery reported as a complete one is the
121
+ * silent gap this endpoint exists to prevent.
109
122
  */
110
123
  async function catchUpFromWatermarks(auth, watermarks, request) {
111
124
  const events = [];
112
- for (const [conversationId, since] of watermarks) {
125
+ for (const { sessionName, conversationId, since } of watermarks.catchUpPlan()) {
126
+ const query = new URLSearchParams({ since, session: sessionName });
113
127
  let res;
114
128
  try {
115
- res = await request(auth, "GET", `/api/device-api/conversations/${conversationId}/messages?since=${encodeURIComponent(since)}`);
129
+ res = await request(auth, "GET", `/api/device-api/conversations/${encodeURIComponent(conversationId)}/messages?${query.toString()}`);
116
130
  }
117
131
  catch (err) {
118
132
  if (err instanceof api_1.ApiError && err.status === 404)
@@ -123,8 +137,11 @@ async function catchUpFromWatermarks(auth, watermarks, request) {
123
137
  if (m.deletedAt)
124
138
  continue;
125
139
  events.push({
140
+ // The server's answer, not the name we asked with: a server too old to
141
+ // honour `session=` answers as someone else, and tagging its messages
142
+ // with the session we wanted would wake that session with another's view.
126
143
  conversationId,
127
- sessionName: res.sessionName,
144
+ sessionName: res.sessionName ?? sessionName,
128
145
  message: { ...m, conversationId },
129
146
  });
130
147
  }