relay-companion 0.1.94 → 0.1.95
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/inbox.html +9 -2
- package/overlay/main.cjs +6 -2
- package/package.json +1 -1
- package/src/auto-update.js +8 -0
- package/src/claude-inject.cjs +100 -5
package/overlay/inbox.html
CHANGED
|
@@ -3597,6 +3597,7 @@
|
|
|
3597
3597
|
// instruction is IN the chat. Green check, then fade.
|
|
3598
3598
|
// A Codex bridge/automation hand-off keeps the classic brief confirmation.
|
|
3599
3599
|
const injectedNoteTimers = new Map();
|
|
3600
|
+
const deliveredChatNames = new Map(); // packetId -> chat the injection was staged for
|
|
3600
3601
|
function fadeRowNoteLater(id, ms) {
|
|
3601
3602
|
if (injectedNoteTimers.has(id)) clearTimeout(injectedNoteTimers.get(id));
|
|
3602
3603
|
injectedNoteTimers.set(id, setTimeout(() => {
|
|
@@ -3614,7 +3615,12 @@
|
|
|
3614
3615
|
if (info.awaitingTurn) {
|
|
3615
3616
|
// Persistent until delivery / the auto-fresh hop — never a silent limbo.
|
|
3616
3617
|
if (injectedNoteTimers.has(id)) { clearTimeout(injectedNoteTimers.get(id)); injectedNoteTimers.delete(id); }
|
|
3617
|
-
|
|
3618
|
+
// Name the destination chat when we know it: "your \u201cgranular-relay\u201d chat"
|
|
3619
|
+
// tells the user WHERE to look; "your current chat" leaves them hunting
|
|
3620
|
+
// (field report: the injection landed in a chat they never found).
|
|
3621
|
+
if (info.chat) deliveredChatNames.set(id, info.chat);
|
|
3622
|
+
const where = info.chat ? `your \u201c${info.chat}\u201d chat` : "your current Claude chat";
|
|
3623
|
+
setRowNote(id, `Handed to ${where} — lands when it next takes a turn. If it\u2019s idle: type anything there, or use Open in New Chat for instant.`, "ok");
|
|
3618
3624
|
return;
|
|
3619
3625
|
}
|
|
3620
3626
|
const hostName = info.host === "codex" ? "Codex" : "Claude";
|
|
@@ -3622,7 +3628,8 @@
|
|
|
3622
3628
|
fadeRowNoteLater(id, 12000);
|
|
3623
3629
|
});
|
|
3624
3630
|
if (window.relay.onInjectionDelivered) window.relay.onInjectionDelivered((id) => {
|
|
3625
|
-
|
|
3631
|
+
const chat = deliveredChatNames.get(id);
|
|
3632
|
+
setRowNote(id, `✓ Delivered — ${chat ? `your \u201c${chat}\u201d chat` : "your current Claude chat"} is acting on it now.`, "ok");
|
|
3626
3633
|
fadeRowNoteLater(id, 8000);
|
|
3627
3634
|
});
|
|
3628
3635
|
if (window.relay.onInjectionAutoFresh) window.relay.onInjectionAutoFresh((id) => {
|
package/overlay/main.cjs
CHANGED
|
@@ -2166,7 +2166,8 @@ async function openPacketInCurrent(packetId, { sent = false } = {}) {
|
|
|
2166
2166
|
if (!target) return fallbackFresh("No live Claude chat found");
|
|
2167
2167
|
console.error(
|
|
2168
2168
|
`[overlay] openInCurrent ${packetId}: staging for claude session ${String(target.sessionId).slice(0, 8)}… ` +
|
|
2169
|
-
`(${target.source}, active ${Math.round((Date.now() - target.lastActiveAt) / 1000)}s ago
|
|
2169
|
+
`(${target.source}${target.label ? `, "${target.label}"` : ""}, active ${Math.round((Date.now() - target.lastActiveAt) / 1000)}s ago` +
|
|
2170
|
+
`${target.skippedStubs ? `, skipped ${target.skippedStubs} never-used chat(s)` : ""})`,
|
|
2170
2171
|
);
|
|
2171
2172
|
const stageNow = () => {
|
|
2172
2173
|
let staged = null;
|
|
@@ -2188,7 +2189,10 @@ async function openPacketInCurrent(packetId, { sent = false } = {}) {
|
|
|
2188
2189
|
// renderer must show an honest "waiting for that chat's turn" state, not a
|
|
2189
2190
|
// success toast. The watcher below flips it to Delivered in realtime — or,
|
|
2190
2191
|
// past the grace window, hops to a fresh chat so the click is never slow.
|
|
2191
|
-
|
|
2192
|
+
// Name the chat the relay actually went to: "your granular-relay chat"
|
|
2193
|
+
// beats "your current chat" when several are open (field report — the
|
|
2194
|
+
// injection landed somewhere invisible and nothing said where).
|
|
2195
|
+
confirmInjected("claude", { awaitingTurn: true, chat: target.label || "" });
|
|
2192
2196
|
finish();
|
|
2193
2197
|
watchInjectionDelivery(packetId, staged.path, {
|
|
2194
2198
|
onAutoFresh: () => {
|
package/package.json
CHANGED
package/src/auto-update.js
CHANGED
|
@@ -560,6 +560,14 @@ export function spawnDetachedUpdate({
|
|
|
560
560
|
`start_job "$DAEMON_PLIST" ${DAEMON_LABEL} || true`,
|
|
561
561
|
`ensure_job "$PILL_PLIST" ${PILL_LABEL} || { log_line "pill restart verification FAILED"; RESTART_FAILED=1; }`,
|
|
562
562
|
`ensure_job "$DAEMON_PLIST" ${DAEMON_LABEL} || { log_line "daemon restart verification FAILED"; RESTART_FAILED=1; }`,
|
|
563
|
+
// A "Boot-out failed: 3: No such process" means launchd had already lost the
|
|
564
|
+
// job while its PROCESS kept running — bootstrap then registers the job
|
|
565
|
+
// without replacing that stale pill, which keeps drawing with the old tree's
|
|
566
|
+
// window state (field report: wedged Space attachment after the 01:58
|
|
567
|
+
// restart, cured by hand with exactly this kickstart). Forcing the pill once
|
|
568
|
+
// more guarantees the process on screen came from the new tree. Pill only:
|
|
569
|
+
// kickstarting the daemon here would kill the job that owns this script.
|
|
570
|
+
`if [ "$RESTART_FAILED" != 1 ]; then "$LAUNCHCTL" kickstart -k "gui/${uid}/${PILL_LABEL}" >> ${LOG} 2>&1 || log_line "pill kickstart after bootstrap failed (continuing)"; fi`,
|
|
563
571
|
`if [ "$RESTART_FAILED" = 1 ]; then`,
|
|
564
572
|
` log_line "restart verification FAILED; restoring previous tree"`,
|
|
565
573
|
` restore_tree`,
|
package/src/claude-inject.cjs
CHANGED
|
@@ -255,6 +255,53 @@ function hookResponseFor(eventName, instruction) {
|
|
|
255
255
|
|
|
256
256
|
// ---- current-session resolution (pill side) --------------------------------
|
|
257
257
|
|
|
258
|
+
// A short, human-recognizable name for a chat with no title: its working
|
|
259
|
+
// directory's last segment ("granular-relay"), which is how people refer to
|
|
260
|
+
// their chats anyway.
|
|
261
|
+
function cwdLabel(cwd) {
|
|
262
|
+
const clean = String(cwd || "").replace(/[/\\]+$/, "");
|
|
263
|
+
if (!clean) return "";
|
|
264
|
+
const seg = clean.split(/[/\\]/).filter(Boolean).pop() || "";
|
|
265
|
+
return seg === "." || seg === ".." ? "" : seg;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Did a HUMAN ever speak in this session? Reads only the head of the transcript
|
|
269
|
+
// (the first real user message is always near the top), so this stays cheap on
|
|
270
|
+
// multi-megabyte logs. A user line that carries toolUseResult is a tool result
|
|
271
|
+
// being fed back, and isMeta lines are system-injected — neither is a person
|
|
272
|
+
// typing. Returns null when the transcript can't be read: unknown must never
|
|
273
|
+
// masquerade as "nobody spoke here" and disqualify a real chat.
|
|
274
|
+
const TRANSCRIPT_HEAD_BYTES = 512 * 1024;
|
|
275
|
+
function transcriptHasHumanTurn(transcriptPath) {
|
|
276
|
+
const file = String(transcriptPath || "").trim();
|
|
277
|
+
if (!file) return null;
|
|
278
|
+
let fd = null;
|
|
279
|
+
try {
|
|
280
|
+
fd = fs.openSync(file, "r");
|
|
281
|
+
const size = fs.fstatSync(fd).size;
|
|
282
|
+
const length = Math.min(size, TRANSCRIPT_HEAD_BYTES);
|
|
283
|
+
const buf = Buffer.alloc(length);
|
|
284
|
+
fs.readSync(fd, buf, 0, length, 0);
|
|
285
|
+
const text = buf.toString("utf8");
|
|
286
|
+
for (const line of text.split("\n")) {
|
|
287
|
+
if (!line.includes('"type":"user"') && !line.includes('"type": "user"')) continue;
|
|
288
|
+
if (line.includes('"toolUseResult"') || line.includes('"isMeta":true') || line.includes('"isMeta": true')) continue;
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
// Only trust a "no" when the whole file was inspected; a longer transcript
|
|
292
|
+
// whose head is all tool traffic stays unknown.
|
|
293
|
+
return size <= TRANSCRIPT_HEAD_BYTES ? false : null;
|
|
294
|
+
} catch {
|
|
295
|
+
return null;
|
|
296
|
+
} finally {
|
|
297
|
+
if (fd !== null) {
|
|
298
|
+
try {
|
|
299
|
+
fs.closeSync(fd);
|
|
300
|
+
} catch {}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
258
305
|
function listDesktopSessionCandidates(desktopSessionsDir, { nowMs, maxAgeMs }) {
|
|
259
306
|
const candidates = [];
|
|
260
307
|
let orgs = [];
|
|
@@ -292,7 +339,21 @@ function listDesktopSessionCandidates(desktopSessionsDir, { nowMs, maxAgeMs }) {
|
|
|
292
339
|
if (!(lastActiveAt > 0) || nowMs - lastActiveAt > maxAgeMs) continue;
|
|
293
340
|
const sessionId = String(meta.cliSessionId || name.replace(/^local_/, "").replace(/\.json$/, "")).trim();
|
|
294
341
|
if (!sessionId) continue;
|
|
295
|
-
|
|
342
|
+
// completedTurns is Claude Desktop's own count of finished turns: 0 means
|
|
343
|
+
// nobody has ever spoken in this chat. Field report (Sven, 2026-08-06): a
|
|
344
|
+
// never-used stub chat won targeting purely on file/hook activity and the
|
|
345
|
+
// injection sat there forever. `undefined` (older Desktop builds) stays
|
|
346
|
+
// unknown rather than false — never exclude on missing data.
|
|
347
|
+
const turns = Number(meta.completedTurns);
|
|
348
|
+
candidates.push({
|
|
349
|
+
sessionId,
|
|
350
|
+
lastActiveAt,
|
|
351
|
+
source: "desktop",
|
|
352
|
+
hasHumanTurn: Number.isFinite(turns) ? turns > 0 : null,
|
|
353
|
+
// What to call this chat when telling the user where the relay went.
|
|
354
|
+
label: String(meta.title || "").trim() || cwdLabel(meta.cwd || meta.originCwd),
|
|
355
|
+
cwd: String(meta.cwd || meta.originCwd || ""),
|
|
356
|
+
});
|
|
296
357
|
}
|
|
297
358
|
}
|
|
298
359
|
}
|
|
@@ -319,7 +380,16 @@ function listRendezvousCandidates(homeDir, { nowMs, maxAgeMs }) {
|
|
|
319
380
|
if (!(lastActiveAt > 0) || nowMs - lastActiveAt > maxAgeMs) continue;
|
|
320
381
|
const sessionId = String((row && row.sessionId) || name.replace(/\.json$/, "")).trim();
|
|
321
382
|
if (!sessionId) continue;
|
|
322
|
-
candidates.push({
|
|
383
|
+
candidates.push({
|
|
384
|
+
sessionId,
|
|
385
|
+
lastActiveAt,
|
|
386
|
+
source: "terminal",
|
|
387
|
+
// A terminal session has no turn counter, so ask its transcript whether a
|
|
388
|
+
// human ever spoke there (null when we cannot tell).
|
|
389
|
+
hasHumanTurn: transcriptHasHumanTurn(row && row.transcriptPath),
|
|
390
|
+
label: cwdLabel(row && row.cwd),
|
|
391
|
+
cwd: String((row && row.cwd) || ""),
|
|
392
|
+
});
|
|
323
393
|
}
|
|
324
394
|
return candidates;
|
|
325
395
|
}
|
|
@@ -341,7 +411,7 @@ function findCurrentClaudeSession({
|
|
|
341
411
|
maxAgeMs = SESSION_MAX_AGE_MS,
|
|
342
412
|
focusPriorityMs = DESKTOP_FOCUS_PRIORITY_MS,
|
|
343
413
|
} = {}) {
|
|
344
|
-
const merged = new Map(); // sessionId -> { sessionId, lastActiveAt, desktop, focusedAt }
|
|
414
|
+
const merged = new Map(); // sessionId -> { sessionId, lastActiveAt, desktop, focusedAt, ... }
|
|
345
415
|
const fold = (candidate) => {
|
|
346
416
|
const existing = merged.get(candidate.sessionId);
|
|
347
417
|
if (!existing) {
|
|
@@ -353,20 +423,37 @@ function findCurrentClaudeSession({
|
|
|
353
423
|
// activity. Track focus separately so priority never leaks to busy
|
|
354
424
|
// background sessions.
|
|
355
425
|
focusedAt: candidate.source === "desktop" ? candidate.lastActiveAt : 0,
|
|
426
|
+
hasHumanTurn: candidate.hasHumanTurn ?? null,
|
|
427
|
+
label: candidate.label || "",
|
|
428
|
+
cwd: candidate.cwd || "",
|
|
356
429
|
});
|
|
357
430
|
return;
|
|
358
431
|
}
|
|
359
432
|
existing.lastActiveAt = Math.max(existing.lastActiveAt, candidate.lastActiveAt);
|
|
360
433
|
existing.desktop = existing.desktop || candidate.source === "desktop";
|
|
361
434
|
if (candidate.source === "desktop") existing.focusedAt = Math.max(existing.focusedAt, candidate.lastActiveAt);
|
|
435
|
+
// Any surface that can prove a human spoke here settles it; otherwise a
|
|
436
|
+
// known false only survives while nothing else has evidence.
|
|
437
|
+
if (candidate.hasHumanTurn === true) existing.hasHumanTurn = true;
|
|
438
|
+
else if (candidate.hasHumanTurn === false && existing.hasHumanTurn === null) existing.hasHumanTurn = false;
|
|
439
|
+
// Desktop metadata carries the real chat title; prefer it over a cwd label.
|
|
440
|
+
if (candidate.label && (!existing.label || candidate.source === "desktop")) existing.label = candidate.label;
|
|
441
|
+
if (candidate.cwd && !existing.cwd) existing.cwd = candidate.cwd;
|
|
362
442
|
};
|
|
363
443
|
if (desktopSessionsDir) {
|
|
364
444
|
for (const candidate of listDesktopSessionCandidates(desktopSessionsDir, { nowMs, maxAgeMs })) fold(candidate);
|
|
365
445
|
}
|
|
366
446
|
for (const candidate of listRendezvousCandidates(homeDir, { nowMs, maxAgeMs })) fold(candidate);
|
|
447
|
+
// Chats nobody has ever spoken in are a LAST resort, not a rival: they win on
|
|
448
|
+
// raw activity (a stub can log file/hook events forever) while being exactly
|
|
449
|
+
// the place a relay must not be delivered. A never-used chat is still allowed
|
|
450
|
+
// when it is all that exists — the user may have just opened a fresh one.
|
|
451
|
+
const all = [...merged.values()];
|
|
452
|
+
const spoken = all.filter((e) => e.hasHumanTurn !== false);
|
|
453
|
+
const pool = spoken.length ? spoken : all;
|
|
367
454
|
let bestFocused = null;
|
|
368
455
|
let best = null;
|
|
369
|
-
for (const entry of
|
|
456
|
+
for (const entry of pool) {
|
|
370
457
|
if (entry.focusedAt > 0 && nowMs - entry.focusedAt <= focusPriorityMs) {
|
|
371
458
|
if (!bestFocused || entry.focusedAt > bestFocused.focusedAt) bestFocused = entry;
|
|
372
459
|
}
|
|
@@ -376,7 +463,15 @@ function findCurrentClaudeSession({
|
|
|
376
463
|
}
|
|
377
464
|
const chosen = bestFocused || best;
|
|
378
465
|
return chosen
|
|
379
|
-
? {
|
|
466
|
+
? {
|
|
467
|
+
sessionId: chosen.sessionId,
|
|
468
|
+
lastActiveAt: chosen.lastActiveAt,
|
|
469
|
+
source: chosen.desktop ? "desktop" : "terminal",
|
|
470
|
+
// What to call this chat when confirming where the relay went.
|
|
471
|
+
label: chosen.label || "",
|
|
472
|
+
cwd: chosen.cwd || "",
|
|
473
|
+
skippedStubs: all.length - pool.length,
|
|
474
|
+
}
|
|
380
475
|
: null;
|
|
381
476
|
}
|
|
382
477
|
|