relay-companion 0.1.97 → 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 +17 -4
- package/package.json +1 -1
- package/src/claude-inject.cjs +150 -0
package/overlay/main.cjs
CHANGED
|
@@ -460,10 +460,14 @@ function accountInfo() {
|
|
|
460
460
|
// hosts present NO certificate — the handshake fails outright, so a signed-out
|
|
461
461
|
// user hitting /app/setup was bounced to a page no browser can load. /sign-in
|
|
462
462
|
// renders through the proxy and carries the user on to setup afterwards.
|
|
463
|
-
|
|
463
|
+
// switch=1 is what makes this a SWITCH rather than a sign-in: the browser
|
|
464
|
+
// that runs this almost always still holds the old session, and without the
|
|
465
|
+
// flag the page treats that session as success and returns the pairing code
|
|
466
|
+
// for the very account the user was trying to leave.
|
|
467
|
+
setupPath: SETUP_PATH,
|
|
464
468
|
// Shown in the pill so the flow is not trapped in whichever browser the OS
|
|
465
469
|
// considers default (the session usually lives in a different one).
|
|
466
|
-
setupUrl: absoluteUrl(
|
|
470
|
+
setupUrl: absoluteUrl(SETUP_PATH),
|
|
467
471
|
};
|
|
468
472
|
}
|
|
469
473
|
|
|
@@ -556,6 +560,11 @@ async function signOutAccount() {
|
|
|
556
560
|
}
|
|
557
561
|
|
|
558
562
|
// Turn an actionUrl (absolute https, or a relative path) into an absolute URL.
|
|
563
|
+
// The one destination "Switch account" may use. Kept as a constant so the path
|
|
564
|
+
// and the copyable URL can never drift apart, and so the regression test has a
|
|
565
|
+
// single thing to assert against.
|
|
566
|
+
const SETUP_PATH = "/sign-in?switch=1&redirect_url=%2Fapp%2Fsetup";
|
|
567
|
+
|
|
559
568
|
function absoluteUrl(pathOrUrl) {
|
|
560
569
|
const value = String(pathOrUrl || "").trim();
|
|
561
570
|
if (!value) return webBase();
|
|
@@ -2171,10 +2180,14 @@ async function openPacketInCurrent(packetId, { sent = false } = {}) {
|
|
|
2171
2180
|
console.error("[overlay] claude session resolution failed:", error && error.message);
|
|
2172
2181
|
}
|
|
2173
2182
|
}
|
|
2174
|
-
|
|
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");
|
|
2175
2188
|
console.error(
|
|
2176
2189
|
`[overlay] openInCurrent ${packetId}: staging for claude session ${String(target.sessionId).slice(0, 8)}… ` +
|
|
2177
|
-
`(${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` +
|
|
2178
2191
|
`${target.skippedStubs ? `, skipped ${target.skippedStubs} never-used chat(s)` : ""})`,
|
|
2179
2192
|
);
|
|
2180
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,
|