relay-companion 0.1.93 → 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.
@@ -679,14 +679,32 @@
679
679
  font-family:var(--serif); font-size:14.5px; font-weight:500; color:var(--ink);
680
680
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; flex:1 1 auto;
681
681
  }
682
+ /* Conversation messages read like a group chat: each person's own messages
683
+ sit on one side, and every party's name keeps a stable color so a busy
684
+ thread is scannable by who-said-what (the WhatsApp convention). */
685
+ .th-detail-title { padding:2px 16px 10px; font-family:var(--serif); font-size:15px; line-height:1.3; color:var(--ink); font-weight:600; overflow-wrap:anywhere; }
686
+ .th-detail-title:empty { display:none; }
682
687
  .th-msg { position:relative; display:block; width:100%; text-align:left; appearance:none; border:0; cursor:pointer; background:transparent; padding:11px 16px; border-bottom:1px solid var(--hair); font-family:var(--sans); }
683
688
  .th-msg:hover { background:rgba(31,26,23,.035); }
689
+ .th-msg.expanded { background:rgba(31,26,23,.045); }
690
+ .th-msg .open-actions { margin-top:9px; text-align:left; }
684
691
  .th-msg-top { display:flex; align-items:baseline; gap:7px; min-width:0; }
685
692
  .th-dir { flex:0 0 auto; font-family:var(--mono); font-size:11px; line-height:1; align-self:center; color:var(--muted-2); }
686
693
  .th-dir.out { color:var(--accent); }
687
694
  .th-msg-title { font-family:var(--serif); font-size:14px; line-height:1.3; color:var(--muted); margin-top:4px;
688
695
  overflow:hidden; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow-wrap:anywhere; }
689
696
  .th-msg.unread .th-msg-title { color:var(--ink); font-weight:500; }
697
+ /* Your own messages hang to the right, like your side of a group chat. */
698
+ .th-msg.mine { text-align:right; background:rgba(31,26,23,.022); }
699
+ .th-msg.mine .th-msg-top { flex-direction:row-reverse; }
700
+ .th-msg.mine .th-party { text-align:right; }
701
+ .th-msg.mine .th-msg-title { text-align:right; }
702
+ /* Per-party name colors. Assigned by a stable hash of the party name, so a
703
+ person keeps the same color across every conversation and every restart. */
704
+ .th-party.p0 { color:#1f6f5c; } .th-party.p1 { color:#8a4b2a; } .th-party.p2 { color:#4a4aa8; }
705
+ .th-party.p3 { color:#8a2f5e; } .th-party.p4 { color:#2f6f8a; } .th-party.p5 { color:#6b5a1f; }
706
+ .th-party.p6 { color:#7a3030; } .th-party.p7 { color:#3f6b2a; }
707
+ .th-party.me { color:var(--accent); }
690
708
 
691
709
  /* Sent-row read receipt chip: "read" sits in a light-green enclosure, "unread" stays quiet. */
692
710
  .sent-chip {
@@ -828,6 +846,7 @@
828
846
  </button>
829
847
  <span class="th-detail-name" id="thDetailName"></span>
830
848
  </div>
849
+ <div class="th-detail-title" id="thDetailTitle"></div>
831
850
  <div id="thHistory"></div>
832
851
  </div>
833
852
  </section>
@@ -944,6 +963,7 @@
944
963
  const thDetailEl = document.getElementById("thDetail");
945
964
  const thBackEl = document.getElementById("thBack");
946
965
  const thDetailNameEl = document.getElementById("thDetailName");
966
+ const thDetailTitleEl = document.getElementById("thDetailTitle");
947
967
  const thHistoryEl = document.getElementById("thHistory");
948
968
  const settingsViewEl = document.getElementById("settingsView");
949
969
  const signInBannerEl = document.getElementById("signInBanner");
@@ -1702,7 +1722,7 @@
1702
1722
  ${t.msgs.length > 1 ? `<span class="th-count">${t.msgs.length}</span>` : ""}
1703
1723
  <span class="th-time">${esc(timeAgo(t.latest.at))}</span>
1704
1724
  </span>
1705
- <span class="th-title">${esc(t.latest.title)}</span>
1725
+ <span class="th-title">${esc(t.title || t.latest.title)}</span>
1706
1726
  ${tMenu}
1707
1727
  ${newestInbound ? `<div class="row-err${rowNotes.get(newestInbound.id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(newestInbound.id)}" data-stop="1">${esc(rowNotes.get(newestInbound.id)?.text || "")}</div>` : ""}
1708
1728
  </div>`;
@@ -2984,6 +3004,7 @@
2984
3004
  // relays plus the live Sent list. Local data only — the pill shows the
2985
3005
  // user's own exchanges; the API's /v1/threads serves web and agents.
2986
3006
  let threadDetailId = null; // threadId while a thread's history is open
3007
+ let expandedThreadMsgId = null; // message inside the thread whose actions are open
2987
3008
  let threadDetailScrolledFor = null; // thread already auto-scrolled to newest (reset on leave)
2988
3009
  let threadDetailPartyHint = ""; // who it's with, for the header while messages load
2989
3010
  function threadMessages() {
@@ -2993,7 +3014,7 @@
2993
3014
  msgs.push({
2994
3015
  id: r.id, threadId: r.threadId || r.id, direction: "in",
2995
3016
  title: relaySubject(r), party: relaySender(r), at: r.createdAt || r.updatedAt,
2996
- unread: Boolean(r.unread),
3017
+ unread: Boolean(r.unread), threadTitle: r.threadTitle || "",
2997
3018
  });
2998
3019
  }
2999
3020
  // A self-send exists on BOTH sides with the same relay id; keep only the
@@ -3005,7 +3026,7 @@
3005
3026
  msgs.push({
3006
3027
  id, threadId: s.threadId || id, direction: "out",
3007
3028
  title: sentSubject(s), party: sentRecipient(s), at: s.createdAt,
3008
- unread: false,
3029
+ unread: false, threadTitle: s.threadTitle || "",
3009
3030
  });
3010
3031
  }
3011
3032
  return msgs;
@@ -3027,6 +3048,10 @@
3027
3048
  // inbound sender, else the latest outbound recipient.
3028
3049
  const lastIn = [...t.msgs].reverse().find((m) => m.direction === "in");
3029
3050
  t.party = (lastIn && lastIn.party) || latest.party || "Conversation";
3051
+ // The conversation's NAME: an explicit thread title (set by whoever named
3052
+ // the exchange, carried on every item) wins; otherwise the oldest
3053
+ // message's subject, which is the relay that started it.
3054
+ t.title = (t.msgs.find((m) => m.threadTitle) || {}).threadTitle || t.msgs[0].title || "";
3030
3055
  }
3031
3056
  threads.sort((a, b) => new Date(b.latest.at || 0) - new Date(a.latest.at || 0));
3032
3057
  return threads;
@@ -3046,7 +3071,7 @@
3046
3071
  <span class="th-count">${t.msgs.length}</span>
3047
3072
  <span class="th-time">${esc(timeAgo(t.latest.at))}</span>
3048
3073
  </span>
3049
- <span class="th-title">${esc(t.latest.title)}</span>
3074
+ <span class="th-title">${esc(t.title || t.latest.title)}</span>
3050
3075
  </button>`).join("");
3051
3076
  for (const el of threadsListEl.querySelectorAll(".th-item")) {
3052
3077
  el.addEventListener("click", () => {
@@ -3055,6 +3080,22 @@
3055
3080
  });
3056
3081
  }
3057
3082
  }
3083
+ // Stable per-party color: same person, same color, every conversation and
3084
+ // every restart (a hash, never an index into render order).
3085
+ function partyColorClass(name, mine) {
3086
+ if (mine) return "me";
3087
+ const key = String(name || "").trim().toLowerCase();
3088
+ let h = 0;
3089
+ for (let i = 0; i < key.length; i += 1) h = (h * 31 + key.charCodeAt(i)) >>> 0;
3090
+ return `p${h % 8}`;
3091
+ }
3092
+ // What the user's own messages are labelled with in a conversation. Read from
3093
+ // the inbox payload's account (always present, unlike the Settings view's
3094
+ // lazily-loaded info), falling back to the chat convention "You".
3095
+ function ownDisplayName() {
3096
+ const acct = (payload && payload.account) || {};
3097
+ return String(acct.name || acct.email || "You").trim() || "You";
3098
+ }
3058
3099
  function renderThreadDetail() {
3059
3100
  const thread = buildThreads().find((t) => t.threadId === threadDetailId);
3060
3101
  if (!thread) {
@@ -3068,6 +3109,11 @@
3068
3109
  return;
3069
3110
  }
3070
3111
  thDetailNameEl.textContent = thread.party;
3112
+ // The conversation's name sits under the party, the way a chat header shows
3113
+ // the subject: an explicit thread title when one was set, else the relay
3114
+ // that started the exchange. Hidden when it would only repeat the party.
3115
+ const headerTitle = String(thread.title || "").trim();
3116
+ thDetailTitleEl.textContent = headerTitle && headerTitle !== thread.party ? headerTitle : "";
3071
3117
  // Thread-level opens act on the newest inbound message (falling back to the
3072
3118
  // newest message overall): "current" injects with the threadId so the agent
3073
3119
  // fetches the whole exchange; "new chat" seeds the full transcript.
@@ -3088,25 +3134,88 @@
3088
3134
  // note): messages read oldest -> newest downward, the open actions sit at
3089
3135
  // the BOTTOM next to the newest message, and entering the thread lands you
3090
3136
  // at the newest message, not the stalest.
3091
- thHistoryEl.innerHTML = thread.msgs.map((m) => `
3092
- <button class="th-msg${m.unread ? " unread" : ""}" type="button" data-msg="${esc(m.id)}" data-dir="${m.direction}">
3137
+ const me = ownDisplayName();
3138
+ const nextHistory = thread.msgs.map((m) => {
3139
+ // Outbound = this user's own message: labelled with their name (not the
3140
+ // recipient's) and hung on the right, like your side of a group chat.
3141
+ // Labelling it with the recipient read as "Shane sent this" on a message
3142
+ // David sent, contradicting the transcript's own "You" (field report).
3143
+ const mine = m.direction === "out";
3144
+ const who = mine ? me : m.party;
3145
+ // Clicking a message expands ITS own actions rather than firing an open:
3146
+ // a single click used to jump straight to a new chat with no choice.
3147
+ const expanded = m.id === expandedThreadMsgId;
3148
+ const menu = expanded
3149
+ ? openActionsHtml(m.id, { sent: mine, previewable: true })
3150
+ : "";
3151
+ return `
3152
+ <div class="th-msg${m.unread ? " unread" : ""}${mine ? " mine" : ""}${expanded ? " expanded" : ""}" role="button" tabindex="0" data-msg="${esc(m.id)}" data-dir="${m.direction}" data-opening-id="${esc(m.id)}">
3093
3153
  <span class="th-msg-top">
3094
- <span class="th-dir${m.direction === "out" ? " out" : ""}">${m.direction === "out" ? "←" : "→"}</span>
3095
- <span class="th-party">${esc(m.party)}</span>
3154
+ <span class="th-party ${partyColorClass(who, mine)}">${esc(who)}</span>
3096
3155
  <span class="th-time">${esc(timeAgo(m.at))}</span>
3097
3156
  </span>
3098
3157
  <span class="th-msg-title">${esc(m.title)}</span>
3099
- </button>`).join("") + threadActions + threadNote;
3100
- // Land at the newest message when ENTERING the thread; rebuilds of the same
3101
- // open thread (payload pushes) keep the user's scroll position untouched.
3102
- if (threadDetailScrolledFor !== thread.threadId) {
3158
+ ${menu}
3159
+ <div class="row-err${rowNotes.get(m.id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(m.id)}" data-stop="1">${esc(rowNotes.get(m.id)?.text || "")}</div>
3160
+ </div>`;
3161
+ }).join("") + threadActions + threadNote;
3162
+ // Repaint guard. This view is rebuilt on EVERY payload push (and entering it
3163
+ // triggers a sent refresh), which tore down and recreated the open-actions
3164
+ // card ~a second after opening — the flash David reported on "Read the
3165
+ // Conversation". Identical markup now means no DOM write and no rewiring.
3166
+ if (thHistoryEl.innerHTML !== nextHistory) {
3167
+ thHistoryEl.innerHTML = nextHistory;
3168
+ // Land at the newest message when ENTERING the thread; rebuilds of the same
3169
+ // open thread (payload pushes) keep the user's scroll position untouched.
3170
+ if (threadDetailScrolledFor !== thread.threadId) {
3171
+ threadDetailScrolledFor = thread.threadId;
3172
+ requestAnimationFrame(() => { scrollEl.scrollTop = scrollEl.scrollHeight; });
3173
+ }
3174
+ } else if (threadDetailScrolledFor !== thread.threadId) {
3103
3175
  threadDetailScrolledFor = thread.threadId;
3104
3176
  requestAnimationFrame(() => { scrollEl.scrollTop = scrollEl.scrollHeight; });
3177
+ return; // markup unchanged: existing handlers are still bound
3178
+ } else {
3179
+ return; // nothing changed at all
3180
+ }
3181
+ for (const z of thHistoryEl.querySelectorAll('[data-stop="1"]')) {
3182
+ z.addEventListener("click", (e) => e.stopPropagation());
3105
3183
  }
3106
3184
  for (const el of thHistoryEl.querySelectorAll(".th-msg")) {
3185
+ const id = el.getAttribute("data-msg");
3186
+ if (openingIds.has(id)) el.classList.add("opening");
3107
3187
  el.addEventListener("click", () => {
3108
- const id = el.getAttribute("data-msg");
3109
- openRelayFromUI(id, el.getAttribute("data-dir") === "out" ? "sent" : "relay");
3188
+ // Expand this message's own actions — never open straight into a new
3189
+ // chat. Same contract as a relay row in the Relays list.
3190
+ expandedThreadMsgId = expandedThreadMsgId === id ? null : id;
3191
+ renderThreadDetail();
3192
+ });
3193
+ }
3194
+ // Per-message actions. A message this user SENT routes through the sent
3195
+ // paths (its copy is forged before injection); a received one through the
3196
+ // relay paths. Both leave the menu closed after acting.
3197
+ for (const btn of thHistoryEl.querySelectorAll("[data-preview], [data-sent-preview]")) {
3198
+ const id = btn.getAttribute("data-preview") || btn.getAttribute("data-sent-preview");
3199
+ btn.addEventListener("click", () => {
3200
+ expandedThreadMsgId = null;
3201
+ window.relay.preview(id);
3202
+ renderThreadDetail();
3203
+ });
3204
+ }
3205
+ for (const btn of thHistoryEl.querySelectorAll("[data-open-current], [data-sent-open-current]")) {
3206
+ const sentSide = btn.hasAttribute("data-sent-open-current");
3207
+ const id = btn.getAttribute("data-open-current") || btn.getAttribute("data-sent-open-current");
3208
+ btn.addEventListener("click", () => {
3209
+ expandedThreadMsgId = null;
3210
+ openRelayFromUI(id, sentSide ? "sent" : "relay", "current");
3211
+ });
3212
+ }
3213
+ for (const btn of thHistoryEl.querySelectorAll("[data-open-fresh], [data-sent-open-fresh]")) {
3214
+ const sentSide = btn.hasAttribute("data-sent-open-fresh");
3215
+ const id = btn.getAttribute("data-open-fresh") || btn.getAttribute("data-sent-open-fresh");
3216
+ btn.addEventListener("click", () => {
3217
+ expandedThreadMsgId = null;
3218
+ openRelayFromUI(id, sentSide ? "sent" : "relay", "fresh");
3110
3219
  });
3111
3220
  }
3112
3221
  const curBtn = thHistoryEl.querySelector("[data-thread-open-current]");
@@ -3119,12 +3228,14 @@
3119
3228
  thBackEl.addEventListener("click", () => {
3120
3229
  threadDetailId = null;
3121
3230
  threadDetailScrolledFor = null;
3231
+ expandedThreadMsgId = null;
3122
3232
  activeView = "relays";
3123
3233
  syncTabs();
3124
3234
  applyView();
3125
3235
  renderAll();
3126
3236
  });
3127
3237
  function openThreadDetail(threadId, partyHint = "") {
3238
+ expandedThreadMsgId = null; // a fresh entry never opens with a menu up
3128
3239
  threadDetailId = threadId;
3129
3240
  threadDetailPartyHint = partyHint;
3130
3241
  activeView = "threads";
@@ -3486,6 +3597,7 @@
3486
3597
  // instruction is IN the chat. Green check, then fade.
3487
3598
  // A Codex bridge/automation hand-off keeps the classic brief confirmation.
3488
3599
  const injectedNoteTimers = new Map();
3600
+ const deliveredChatNames = new Map(); // packetId -> chat the injection was staged for
3489
3601
  function fadeRowNoteLater(id, ms) {
3490
3602
  if (injectedNoteTimers.has(id)) clearTimeout(injectedNoteTimers.get(id));
3491
3603
  injectedNoteTimers.set(id, setTimeout(() => {
@@ -3503,7 +3615,12 @@
3503
3615
  if (info.awaitingTurn) {
3504
3616
  // Persistent until delivery / the auto-fresh hop — never a silent limbo.
3505
3617
  if (injectedNoteTimers.has(id)) { clearTimeout(injectedNoteTimers.get(id)); injectedNoteTimers.delete(id); }
3506
- 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");
3507
3624
  return;
3508
3625
  }
3509
3626
  const hostName = info.host === "codex" ? "Codex" : "Claude";
@@ -3511,7 +3628,8 @@
3511
3628
  fadeRowNoteLater(id, 12000);
3512
3629
  });
3513
3630
  if (window.relay.onInjectionDelivered) window.relay.onInjectionDelivered((id) => {
3514
- 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");
3515
3633
  fadeRowNoteLater(id, 8000);
3516
3634
  });
3517
3635
  if (window.relay.onInjectionAutoFresh) window.relay.onInjectionAutoFresh((id) => {
package/overlay/main.cjs CHANGED
@@ -697,6 +697,7 @@ function readRelays() {
697
697
  updatedAt: p.updatedAt || "",
698
698
  inReplyToRelayId: p.inReplyToRelayId || null,
699
699
  threadId: p.threadId || null,
700
+ threadTitle: p.threadTitle || "",
700
701
  actionUrl: p.actionUrl || "",
701
702
  action: p.action || {},
702
703
  quickReply: Boolean(p.quickReply),
@@ -1231,6 +1232,7 @@ async function pushInboxNow(force) {
1231
1232
  // Thread identity: a re-parented or newly threaded relay must repaint so
1232
1233
  // per-thread grouping and unread counts stay correct.
1233
1234
  r.threadId,
1235
+ r.threadTitle,
1234
1236
  r.inReplyToRelayId,
1235
1237
  ]),
1236
1238
  sent: payload.sent.map((r) => [
@@ -2164,7 +2166,8 @@ async function openPacketInCurrent(packetId, { sent = false } = {}) {
2164
2166
  if (!target) return fallbackFresh("No live Claude chat found");
2165
2167
  console.error(
2166
2168
  `[overlay] openInCurrent ${packetId}: staging for claude session ${String(target.sessionId).slice(0, 8)}… ` +
2167
- `(${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)` : ""})`,
2168
2171
  );
2169
2172
  const stageNow = () => {
2170
2173
  let staged = null;
@@ -2186,7 +2189,10 @@ async function openPacketInCurrent(packetId, { sent = false } = {}) {
2186
2189
  // renderer must show an honest "waiting for that chat's turn" state, not a
2187
2190
  // success toast. The watcher below flips it to Delivered in realtime — or,
2188
2191
  // past the grace window, hops to a fresh chat so the click is never slow.
2189
- 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 || "" });
2190
2196
  finish();
2191
2197
  watchInjectionDelivery(packetId, staged.path, {
2192
2198
  onAutoFresh: () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.93",
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
 
package/src/mcp.js CHANGED
@@ -14,7 +14,7 @@ export const TOOLS = [
14
14
  {
15
15
  name: "relay_send",
16
16
  description:
17
- "Send an ordinary Relay message or handoff. Use this for the normal product flow when the human says things like 'send Sven a relay asking how he is doing' or 'relay this context to Jordan'. THREADING: Relay groups messages into conversation threads built from reply chains, so before sending, check the recent exchange with this recipient — call relay_inbox_list for what they sent this human (each item carries threadId and inReplyToRelayId), and use the sent context you already have from earlier sends. If the new message continues an ongoing exchange — topically, or within a recent back-and-forth window — pass inReplyToRelayId set to the LATEST relay id of that exchange (whichever side sent it) so the conversation stays one thread. If it starts a new topic, omit inReplyToRelayId and send it standalone. Granular digital employees appear as managed, workspace-labelled contacts; use relay_contacts_search and pass the exact matching contactId so a user with several workspaces reaches the intended employee. That message is authored by this human and wakes their employee; it is NOT a Relay task or deterministic workflow. Never use task-only tools to contact a Granular employee. For other recipients, an on-Relay message appears in their companion and Relays list; an off-Relay recipient receives an email open link.",
17
+ "Send an ordinary Relay message or handoff. Use this for the normal product flow when the human says things like 'send Sven a relay asking how he is doing' or 'relay this context to Jordan'. THREADING: Relay groups messages into conversation threads built from reply chains, so before sending, check the recent exchange with this recipient — call relay_inbox_list for what they sent this human (each item carries threadId and inReplyToRelayId), and use the sent context you already have from earlier sends. If the new message continues an ongoing exchange — topically, or within a recent back-and-forth window — pass inReplyToRelayId set to the LATEST relay id of that exchange (whichever side sent it) so the conversation stays one thread. If it starts a new topic, omit inReplyToRelayId and send it standalone. A thread has a NAME the human scans in their Relay list: whenever you reply into a thread that is not already well named, pass threadTitle to name the conversation (see that field). Granular digital employees appear as managed, workspace-labelled contacts; use relay_contacts_search and pass the exact matching contactId so a user with several workspaces reaches the intended employee. That message is authored by this human and wakes their employee; it is NOT a Relay task or deterministic workflow. Never use task-only tools to contact a Granular employee. For other recipients, an on-Relay message appears in their companion and Relays list; an off-Relay recipient receives an email open link.",
18
18
  inputSchema: {
19
19
  type: "object",
20
20
  properties: {
@@ -79,6 +79,11 @@ export const TOOLS = [
79
79
  description:
80
80
  "The id of the relay this message replies to. Threads are built from reply chains: when continuing an ongoing exchange, reply to the NEWEST relay in that exchange (from relay_inbox_list or an earlier send), never an older one, so the whole conversation shares one thread. Omit it to start a new standalone thread. When answering a question relay, pass that relay's id here — this is what resumes the asking agent's run.",
81
81
  },
82
+ threadTitle: {
83
+ type: "string",
84
+ description:
85
+ "Name the CONVERSATION (not this message). A thread comes into existence the moment a relay replies to another, and its name is what both people scan in their Relay list ever after — so whenever you send a reply (inReplyToRelayId set) into a thread that is not already well named, title it here. Same discipline as `title`, one level up: in as few words as it honestly takes (~5-12 words, hard stop 140 characters), say what this exchange IS and what it is for, so someone reading the list weeks later knows whether to open it. Name the ongoing subject, not the latest turn: 'Postmark migration for sendrelays.com — DNS + verification' beats 'Re: DNS records' or 'Postmark'. Never a bare noun ('Update', 'Question'), never a timestamp, never the other person's name alone. Setting it again renames the thread — use that when a conversation genuinely changes subject, and leave it alone when the existing name still fits.",
86
+ },
82
87
  type: {
83
88
  type: "string",
84
89
  enum: ["question", "completion"],
@@ -181,7 +186,7 @@ export const TOOLS = [
181
186
  {
182
187
  name: "relay_thread_fetch",
183
188
  description:
184
- "Fetch ONE full Relay conversation thread, oldest first: every relay in the exchange (both directions) with sender, recipients, timestamps, title, body, and read state. Use this whenever a relay you are opening or replying to is part of a thread (its threadId differs from its own id, or an Open-in-chat instruction names a threadId) so the WHOLE exchange is in context before you act — never reconstruct a conversation by fetching relays one at a time. Pass the threadId (the root relay's id, carried on every relay_inbox_list item).",
189
+ "Fetch ONE full Relay conversation thread, oldest first: its threadTitle (the conversation's name — check it before replying; if it is missing or no longer describes the exchange, pass a better one as relay_send's threadTitle) and every relay in the exchange (both directions) with sender, recipients, timestamps, title, body, and read state. Use this whenever a relay you are opening or replying to is part of a thread (its threadId differs from its own id, or an Open-in-chat instruction names a threadId) so the WHOLE exchange is in context before you act — never reconstruct a conversation by fetching relays one at a time. Pass the threadId (the root relay's id, carried on every relay_inbox_list item).",
185
190
  inputSchema: {
186
191
  type: "object",
187
192
  properties: {
@@ -602,6 +607,7 @@ export async function handleCall(client, name, args, { mode = DEFAULT_COMPANION_
602
607
  targetSurfaces: args.targetSurfaces || [],
603
608
  attachments: await prepareOrdinaryRelayAttachments(args),
604
609
  ...(args.inReplyToRelayId ? { inReplyToRelayId: args.inReplyToRelayId } : {}),
610
+ ...(args.threadTitle ? { threadTitle: args.threadTitle } : {}),
605
611
  ...(args.type ? { type: args.type } : {}),
606
612
  idempotencyKey: args.idempotencyKey,
607
613
  })),
@@ -650,6 +650,9 @@ export function stagePlainRelayItem(
650
650
  // exchange and keep its unread counting stable per thread.
651
651
  inReplyToRelayId: item.inReplyToRelayId || packet?.inReplyToRelayId || null,
652
652
  threadId: item.threadId || packet?.threadId || item.relayId,
653
+ // The conversation's name, carried on every item by the API so the pill can
654
+ // label a thread without fetching it.
655
+ threadTitle: item.threadTitle || packet?.threadTitle || null,
653
656
  // A restoration is a fresh inbox delivery even when the underlying relay was
654
657
  // read before deletion. Use that timestamp so an older account read-all
655
658
  // cutoff cannot immediately collapse the restored row back to read.
@@ -746,6 +749,7 @@ export function stageSentRelayItem(
746
749
  // inbound side. An item without one must not clobber a previously staged
747
750
  // id (this row is re-staged on every open).
748
751
  threadId: item.threadId || existing.threadId || null,
752
+ threadTitle: item.threadTitle || existing.threadTitle || null,
749
753
  inReplyToRelayId: item.inReplyToRelayId || existing.inReplyToRelayId || null,
750
754
  };
751
755
  const contentPath = writeNotificationPacketContent({ id: materializationId }, content, statePath);