relay-companion 0.1.98 → 0.1.99

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/overlay/main.cjs CHANGED
@@ -2180,10 +2180,14 @@ async function openPacketInCurrent(packetId, { sent = false } = {}) {
2180
2180
  console.error("[overlay] claude session resolution failed:", error && error.message);
2181
2181
  }
2182
2182
  }
2183
- if (!target) return fallbackFresh("No live Claude chat found");
2183
+ // Tier 0 (focus log) returns null when Claude Desktop reports NO chat on
2184
+ // screen — the Home screen. That is a real answer, not a failure: there is
2185
+ // no "current chat" to hand this to, so say so instead of guessing at the
2186
+ // last chat the user happened to visit.
2187
+ if (!target) return fallbackFresh("No chat is open in Claude");
2184
2188
  console.error(
2185
2189
  `[overlay] openInCurrent ${packetId}: staging for claude session ${String(target.sessionId).slice(0, 8)}… ` +
2186
- `(${target.source}${target.label ? `, "${target.label}"` : ""}, active ${Math.round((Date.now() - target.lastActiveAt) / 1000)}s ago` +
2190
+ `(${target.source} via ${target.via || "ranking"}${target.label ? `, "${target.label}"` : ""}, active ${Math.round((Date.now() - target.lastActiveAt) / 1000)}s ago` +
2187
2191
  `${target.skippedStubs ? `, skipped ${target.skippedStubs} never-used chat(s)` : ""})`,
2188
2192
  );
2189
2193
  const stageNow = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.98",
3
+ "version": "0.1.99",
4
4
  "description": "Relay companion for ordinary messages, with dormant coordination features available only by explicit opt-in.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,21 @@ const SESSION_MAX_AGE_MS = 6 * 60 * 60 * 1000; // a "current" session was active
37
37
  // user is actually looking at (field-observed: an Open-in-current-chat landed
38
38
  // inside another session's workflow subagent, invisibly).
39
39
  const DESKTOP_FOCUS_PRIORITY_MS = 15 * 60 * 1000;
40
+ // Claude Desktop's main process logs every focus change as
41
+ // ... [CCD] LocalSessions.setFocusedSession: sessionId=local_<uuid>|null
42
+ // That line is the ONLY signal that states what is on screen NOW rather than
43
+ // when something was last touched, and it is a plain file in the user's own
44
+ // Logs directory (no permission of any kind). Two measured failures it fixes,
45
+ // both field-reported as relays landing in the wrong chat:
46
+ // 1. lastFocusedAt is written to disk 2-4s AFTER a switch, so clicking right
47
+ // after switching chats targeted the chat the user had just left.
48
+ // 2. sessionId=null means "no chat is open" (the Home screen) — a state the
49
+ // timestamp ranking cannot represent at all, so it always named some chat.
50
+ // Treated as a strong hint, never a contract: it is an internal log, so a
51
+ // missing/stale/renamed format falls back to the timestamp ranking below.
52
+ const FOCUS_LOG_MAX_AGE_MS = 12 * 60 * 60 * 1000;
53
+ const FOCUS_LOG_TAIL_BYTES = 256 * 1024;
54
+ const FOCUS_LINE_RE = /^(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})[^\n]*setFocusedSession:\s*sessionId=(null|local_[0-9A-Za-z._-]+)/;
40
55
  const RENDEZVOUS_PRUNE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
41
56
  const INJECT_DIR = "claude-inject";
42
57
  const RENDEZVOUS_DIR = "claude-sessions";
@@ -255,6 +270,107 @@ function hookResponseFor(eventName, instruction) {
255
270
 
256
271
  // ---- current-session resolution (pill side) --------------------------------
257
272
 
273
+ // Default location of Claude Desktop's main-process logs (override for tests).
274
+ function defaultClaudeLogDir() {
275
+ return process.env.CLAUDE_LOG_DIR || path.join(os.homedir(), "Library", "Logs", "Claude");
276
+ }
277
+
278
+ // The rotated main logs, freshest first (main.log, main1.log, ... main4.log).
279
+ function claudeLogFiles(logDir) {
280
+ let names = [];
281
+ try {
282
+ names = fs.readdirSync(logDir).filter((n) => /^main\d*\.log$/.test(n));
283
+ } catch {
284
+ return [];
285
+ }
286
+ return names
287
+ .map((n) => {
288
+ const file = path.join(logDir, n);
289
+ let mtimeMs = 0;
290
+ try {
291
+ mtimeMs = fs.statSync(file).mtimeMs;
292
+ } catch {}
293
+ return { file, mtimeMs };
294
+ })
295
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
296
+ .map((x) => x.file);
297
+ }
298
+
299
+ // The LAST focus transition Claude Desktop recorded. Reads only the tail of each
300
+ // log (these grow to megabytes; the newest line is what matters). Returns:
301
+ // { localId: "local_…", at } a chat is on screen
302
+ // { localId: null, at } no chat is open (Home screen)
303
+ // null no usable signal — caller must fall back
304
+ function readFocusedSessionFromLog({ logDir = defaultClaudeLogDir(), nowMs = Date.now(), maxAgeMs = FOCUS_LOG_MAX_AGE_MS } = {}) {
305
+ for (const file of claudeLogFiles(logDir)) {
306
+ let text = "";
307
+ try {
308
+ const fd = fs.openSync(file, "r");
309
+ try {
310
+ const size = fs.fstatSync(fd).size;
311
+ const length = Math.min(size, FOCUS_LOG_TAIL_BYTES);
312
+ const buf = Buffer.alloc(length);
313
+ fs.readSync(fd, buf, 0, length, size - length);
314
+ text = buf.toString("utf8");
315
+ } finally {
316
+ fs.closeSync(fd);
317
+ }
318
+ } catch {
319
+ continue;
320
+ }
321
+ const lines = text.split("\n");
322
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
323
+ const m = FOCUS_LINE_RE.exec(lines[i]);
324
+ if (!m) continue;
325
+ const at = Date.parse(m[1].replace(" ", "T"));
326
+ if (!Number.isFinite(at)) continue;
327
+ // A log left behind by a long-dead Claude says nothing about now.
328
+ if (nowMs - at > maxAgeMs) return null;
329
+ return { localId: m[2] === "null" ? null : m[2], at };
330
+ }
331
+ }
332
+ return null;
333
+ }
334
+
335
+ // Resolve Claude Desktop's local_<uuid> to the CLI session id the hook runtime
336
+ // uses (plus the chat's title for the confirmation).
337
+ function desktopSessionByLocalId(desktopSessionsDir, localId) {
338
+ if (!desktopSessionsDir || !localId) return null;
339
+ const wanted = `${localId}.json`;
340
+ const stack = [desktopSessionsDir];
341
+ while (stack.length) {
342
+ const dir = stack.pop();
343
+ let entries = [];
344
+ try {
345
+ entries = fs.readdirSync(dir, { withFileTypes: true });
346
+ } catch {
347
+ continue;
348
+ }
349
+ for (const entry of entries) {
350
+ const full = path.join(dir, entry.name);
351
+ if (entry.isDirectory()) {
352
+ stack.push(full);
353
+ continue;
354
+ }
355
+ if (entry.name !== wanted) continue;
356
+ try {
357
+ const meta = JSON.parse(fs.readFileSync(full, "utf8"));
358
+ const sessionId = String(meta.cliSessionId || "").trim();
359
+ if (!sessionId) return null;
360
+ return {
361
+ sessionId,
362
+ label: String(meta.title || "").trim() || cwdLabel(meta.cwd || meta.originCwd),
363
+ cwd: String(meta.cwd || meta.originCwd || ""),
364
+ archived: meta.isArchived === true,
365
+ };
366
+ } catch {
367
+ return null;
368
+ }
369
+ }
370
+ }
371
+ return null;
372
+ }
373
+
258
374
  // A short, human-recognizable name for a chat with no title: its working
259
375
  // directory's last segment ("granular-relay"), which is how people refer to
260
376
  // their chats anyway.
@@ -410,7 +526,37 @@ function findCurrentClaudeSession({
410
526
  nowMs = Date.now(),
411
527
  maxAgeMs = SESSION_MAX_AGE_MS,
412
528
  focusPriorityMs = DESKTOP_FOCUS_PRIORITY_MS,
529
+ logDir = defaultClaudeLogDir(),
530
+ useFocusLog = true,
413
531
  } = {}) {
532
+ // TIER 0 — what is on screen RIGHT NOW, straight from Claude Desktop.
533
+ // Everything below this point reasons about timestamps of past events, which
534
+ // is why it used to answer with the chat the user had just left (the write of
535
+ // lastFocusedAt trails a switch by 2-4s) and could never say "no chat open".
536
+ if (useFocusLog) {
537
+ const focused = readFocusedSessionFromLog({ logDir, nowMs });
538
+ if (focused && focused.localId === null) {
539
+ // The user is on the Home screen: there IS no current chat. Refusing here
540
+ // is the honest answer — the caller opens a new chat and says why.
541
+ return null;
542
+ }
543
+ if (focused && focused.localId) {
544
+ const resolved = desktopSessionByLocalId(desktopSessionsDir, focused.localId);
545
+ if (resolved && !resolved.archived) {
546
+ return {
547
+ sessionId: resolved.sessionId,
548
+ lastActiveAt: focused.at,
549
+ source: "desktop",
550
+ label: resolved.label,
551
+ cwd: resolved.cwd,
552
+ skippedStubs: 0,
553
+ via: "focus-log",
554
+ };
555
+ }
556
+ }
557
+ // No usable line, an unknown local id, or an unreadable session file: fall
558
+ // through to the ranking rather than failing the click.
559
+ }
414
560
  const merged = new Map(); // sessionId -> { sessionId, lastActiveAt, desktop, focusedAt, ... }
415
561
  const fold = (candidate) => {
416
562
  const existing = merged.get(candidate.sessionId);
@@ -471,12 +617,16 @@ function findCurrentClaudeSession({
471
617
  label: chosen.label || "",
472
618
  cwd: chosen.cwd || "",
473
619
  skippedStubs: all.length - pool.length,
620
+ via: "ranking",
474
621
  }
475
622
  : null;
476
623
  }
477
624
 
478
625
  module.exports = {
479
626
  SESSION_MAX_AGE_MS,
627
+ FOCUS_LOG_MAX_AGE_MS,
628
+ readFocusedSessionFromLog,
629
+ desktopSessionByLocalId,
480
630
  DESKTOP_FOCUS_PRIORITY_MS,
481
631
  BROADCAST_KEY,
482
632
  buildInjectionInstruction,