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 +6 -2
- package/package.json +1 -1
- package/src/claude-inject.cjs +150 -0
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
|
-
|
|
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
package/src/claude-inject.cjs
CHANGED
|
@@ -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,
|