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.
@@ -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
- setRowNote(id, "Handed to your current Claude chat — lands when it next takes a turn. If it\u2019s idle: type anything there, or use Open in New Chat for instant.", "ok");
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
- setRowNote(id, "✓ Delivered — your current Claude chat is acting on it now.", "ok");
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
- confirmInjected("claude", { awaitingTurn: true });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.94",
3
+ "version": "0.1.95",
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": {
@@ -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`,
@@ -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
- candidates.push({ sessionId, lastActiveAt, source: "desktop" });
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({ sessionId, lastActiveAt, source: "terminal" });
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 merged.values()) {
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
- ? { sessionId: chosen.sessionId, lastActiveAt: chosen.lastActiveAt, source: chosen.desktop ? "desktop" : "terminal" }
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