relay-companion 0.1.80 → 0.1.82

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.
@@ -1702,7 +1702,7 @@
1702
1702
  ${previewHtml}
1703
1703
  ${actions}
1704
1704
  ${openCard}
1705
- <div class="row-err" data-err="${esc(r.id)}"></div>
1705
+ <div class="row-err${rowNotes.get(r.id)?.cls === "ok" ? " ok" : ""}" data-err="${esc(r.id)}">${esc(rowNotes.get(r.id)?.text || "")}</div>
1706
1706
  </div>`;
1707
1707
  }
1708
1708
 
@@ -1756,9 +1756,23 @@
1756
1756
  if (el) el.textContent = message || "";
1757
1757
  }
1758
1758
  function cssId(id) { return String(id).replace(/"/g, '\\"'); }
1759
+ // Injection hand-off notes are STATE, not one-shot DOM writes: the relay list is
1760
+ // rebuilt via innerHTML on every payload push, which used to wipe the note within
1761
+ // seconds — precisely while the user was staring at a click that looked dead.
1762
+ // relayRowHtml renders the current note into every rebuild; timers only ever
1763
+ // remove entries from this map. id -> { text, cls }
1764
+ const rowNotes = new Map();
1765
+ function setRowNote(id, text, cls) {
1766
+ rowNotes.set(id, { text, cls });
1767
+ for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
1768
+ el.textContent = text;
1769
+ el.classList.toggle("ok", cls === "ok");
1770
+ }
1771
+ }
1759
1772
  // The .row-err slot carries both inline errors and the green injection note, and one
1760
1773
  // relay can be rendered into more than one list, so wipe every copy of the row's slot.
1761
1774
  function clearRowNote(id) {
1775
+ rowNotes.delete(id);
1762
1776
  for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
1763
1777
  el.textContent = "";
1764
1778
  el.classList.remove("ok");
@@ -3067,31 +3081,48 @@
3067
3081
  // local flag so the next mousemove re-runs the interactivity handshake cleanly.
3068
3082
  if (window.relay.onShown) window.relay.onShown(() => { interactive = false; });
3069
3083
  if (window.relay.onOpenDone) window.relay.onOpenDone((id) => stopOpening(id));
3070
- // Visible confirmation for "Open in current chat": the injection is staged,
3071
- // but the agent only ingests it at its next checkpoint (tool boundary / turn
3072
- // end — or your next message if it's idle). Say so, briefly, in green.
3084
+ // Visible confirmation for "Open in current chat". Two honest states:
3085
+ // waiting — staged for an idle chat; ONLY that chat's next turn can ingest
3086
+ // it, so the note stays up (surviving re-renders) until delivery
3087
+ // and tells the user how to pull it in themselves.
3088
+ // delivered — main watched the consume-once injection file disappear; the
3089
+ // instruction is IN the chat. Green check, then fade.
3090
+ // A Codex bridge/automation hand-off keeps the classic brief confirmation.
3073
3091
  const injectedNoteTimers = new Map();
3092
+ function fadeRowNoteLater(id, ms) {
3093
+ if (injectedNoteTimers.has(id)) clearTimeout(injectedNoteTimers.get(id));
3094
+ injectedNoteTimers.set(id, setTimeout(() => {
3095
+ injectedNoteTimers.delete(id);
3096
+ clearRowNote(id);
3097
+ }, ms));
3098
+ }
3074
3099
  if (window.relay.onInjected) window.relay.onInjected((id, info) => {
3075
- const hostName = info.host === "codex" ? "Codex" : "Claude";
3076
- for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
3077
- el.textContent = `✓ Sent to your current ${hostName} chat — it will pick this up at its next checkpoint (or your next message if it's idle).`;
3078
- el.classList.add("ok");
3079
- if (injectedNoteTimers.has(id)) clearTimeout(injectedNoteTimers.get(id));
3080
- injectedNoteTimers.set(id, setTimeout(() => {
3081
- injectedNoteTimers.delete(id);
3082
- if (el.isConnected) { el.textContent = ""; el.classList.remove("ok"); }
3083
- }, 12000));
3100
+ if (info.awaitingTurn) {
3101
+ // Persistent until delivery / the auto-fresh hop — never a silent limbo.
3102
+ if (injectedNoteTimers.has(id)) { clearTimeout(injectedNoteTimers.get(id)); injectedNoteTimers.delete(id); }
3103
+ setRowNote(id, "Handed to your current Claude chat…", "ok");
3104
+ return;
3084
3105
  }
3106
+ const hostName = info.host === "codex" ? "Codex" : "Claude";
3107
+ setRowNote(id, `✓ Sent to your current ${hostName} chat — it will pick this up at its next checkpoint.`, "ok");
3108
+ fadeRowNoteLater(id, 12000);
3109
+ });
3110
+ if (window.relay.onInjectionDelivered) window.relay.onInjectionDelivered((id) => {
3111
+ setRowNote(id, "✓ Delivered — your current Claude chat is acting on it now.", "ok");
3112
+ fadeRowNoteLater(id, 8000);
3113
+ });
3114
+ if (window.relay.onInjectionAutoFresh) window.relay.onInjectionAutoFresh((id) => {
3115
+ setRowNote(id, "Your current chat was idle, so this is opening in a new chat instead.", "ok");
3116
+ fadeRowNoteLater(id, 10000);
3085
3117
  });
3086
3118
  // An open that failed leaves the relay UNREAD on purpose (it never reached the user),
3087
3119
  // so the row has to say what happened — otherwise the spinner just stops and the click
3088
- // looks like it worked. Cleared by the next open of the same row.
3120
+ // looks like it worked. Persisted via rowNotes so a payload-push rebuild can't wipe
3121
+ // the explanation while the user is reading it. Cleared by the next open of the row.
3089
3122
  if (window.relay.onOpenError) window.relay.onOpenError((id, message) => {
3090
3123
  if (!message) return;
3091
- for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
3092
- el.classList.remove("ok");
3093
- el.innerHTML = esc(message);
3094
- }
3124
+ if (injectedNoteTimers.has(id)) { clearTimeout(injectedNoteTimers.get(id)); injectedNoteTimers.delete(id); }
3125
+ setRowNote(id, message, "err");
3095
3126
  });
3096
3127
  window.relay.refresh().then((p) => {
3097
3128
  onPayload(p);
package/overlay/main.cjs CHANGED
@@ -1890,6 +1890,81 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1890
1890
  // How many messages (inbound rows + sent copies, deduped) share a thread.
1891
1891
  // Gives the injected instruction its "N messages so far" and tells the model
1892
1892
  // when fetching the whole thread is worth it.
1893
+ // The staged injection file is consume-once: the claude-hook renames it away at
1894
+ // the moment of delivery. Watching for its disappearance is therefore a REAL
1895
+ // delivery signal, and the row note can tell the truth in realtime — "waiting
1896
+ // for that chat's turn" versus "delivered". Field lesson (2026-08-05): an
1897
+ // injection for an idle session sat invisible for minutes and the click read as
1898
+ // completely dead; a waiting state that never lies beats a success toast.
1899
+ //
1900
+ // The GRACE WINDOW closes the remaining gap. Nothing outside a session can make
1901
+ // it take a turn (verified against the platform docs: deep links only pre-fill
1902
+ // NEW sessions; no external submit API exists), so an idle chat cannot be woken
1903
+ // — but the user must still get the relay QUICKLY. A busy chat ingests at a
1904
+ // tool boundary within seconds; if the grace window passes unconsumed, the
1905
+ // target is idle: reclaim the injection (same rename-first consume-once
1906
+ // discipline as the hook, so a racing delivery always wins) and open the relay
1907
+ // in a fresh chat instead. Every click produces a visible result in seconds.
1908
+ const injectionWatchers = new Map(); // packetId -> interval
1909
+ const AUTO_FRESH_GRACE_MS = (() => {
1910
+ const raw = Number(process.env.RELAY_CURRENT_CHAT_GRACE_MS);
1911
+ return Number.isFinite(raw) && raw >= 0 ? raw : 6000; // 0 disables auto-fresh
1912
+ })();
1913
+ function reclaimInjection(stagedPath) {
1914
+ const claimed = `${stagedPath}.${process.pid}.${Date.now()}.autofresh`;
1915
+ try {
1916
+ fs.renameSync(stagedPath, claimed); // loses the race iff the hook consumed it
1917
+ } catch {
1918
+ return false;
1919
+ }
1920
+ try {
1921
+ fs.rmSync(claimed, { force: true });
1922
+ } catch {}
1923
+ return true;
1924
+ }
1925
+ function watchInjectionDelivery(packetId, stagedPath, { timeoutMs = 10 * 60 * 1000, intervalMs = 500, autoFreshMs = AUTO_FRESH_GRACE_MS, onAutoFresh = null } = {}) {
1926
+ if (!stagedPath) return;
1927
+ const existing = injectionWatchers.get(packetId);
1928
+ if (existing) clearInterval(existing); // newest click owns the watch
1929
+ const startedAt = Date.now();
1930
+ const timer = setInterval(() => {
1931
+ let gone = false;
1932
+ try {
1933
+ gone = !fs.existsSync(stagedPath);
1934
+ } catch {
1935
+ gone = false;
1936
+ }
1937
+ if (gone) {
1938
+ clearInterval(timer);
1939
+ injectionWatchers.delete(packetId);
1940
+ console.error(`[overlay] openInCurrent ${packetId}: delivered (injection consumed after ${Math.round((Date.now() - startedAt) / 1000)}s)`);
1941
+ if (win && !win.isDestroyed()) win.webContents.send("injectionDelivered", packetId);
1942
+ return;
1943
+ }
1944
+ if (onAutoFresh && autoFreshMs > 0 && Date.now() - startedAt >= autoFreshMs) {
1945
+ clearInterval(timer);
1946
+ injectionWatchers.delete(packetId);
1947
+ if (!reclaimInjection(stagedPath)) {
1948
+ // The hook won the rename race: this IS a delivery.
1949
+ console.error(`[overlay] openInCurrent ${packetId}: delivered (won by the hook at the grace boundary)`);
1950
+ if (win && !win.isDestroyed()) win.webContents.send("injectionDelivered", packetId);
1951
+ return;
1952
+ }
1953
+ console.error(`[overlay] openInCurrent ${packetId}: current chat idle for ${autoFreshMs}ms; reclaimed the injection and opening fresh`);
1954
+ onAutoFresh();
1955
+ return;
1956
+ }
1957
+ // A restaged/overwritten click or a very long idle: stop polling quietly.
1958
+ // The waiting note remains accurate — the injection is still pending.
1959
+ if (Date.now() - startedAt > timeoutMs) {
1960
+ clearInterval(timer);
1961
+ injectionWatchers.delete(packetId);
1962
+ }
1963
+ }, intervalMs);
1964
+ if (typeof timer.unref === "function") timer.unref();
1965
+ injectionWatchers.set(packetId, timer);
1966
+ }
1967
+
1893
1968
  function threadInfoFor(row) {
1894
1969
  const threadId = (row && row.threadId) || null;
1895
1970
  if (!threadId) return { threadId: null, threadCount: 0 };
@@ -1913,8 +1988,10 @@ async function openPacketInCurrent(packetId) {
1913
1988
  };
1914
1989
  // Confirm the staging in the UI: without this, a successful injection is
1915
1990
  // indistinguishable from a dead click until the agent's next checkpoint.
1916
- const confirmInjected = (host) => {
1917
- if (win && !win.isDestroyed()) win.webContents.send("injected", packetId, { host });
1991
+ // extra.awaitingTurn tells the renderer this is a WAITING state (idle target
1992
+ // session), not a completed hand-off.
1993
+ const confirmInjected = (host, extra = {}) => {
1994
+ if (win && !win.isDestroyed()) win.webContents.send("injected", packetId, { host, ...extra });
1918
1995
  };
1919
1996
  const fallbackFresh = (note) => {
1920
1997
  // The click said "current chat" but we're opening a NEW one — never do that
@@ -1983,8 +2060,9 @@ async function openPacketInCurrent(packetId) {
1983
2060
  `(${target.source}, active ${Math.round((Date.now() - target.lastActiveAt) / 1000)}s ago)`,
1984
2061
  );
1985
2062
  const stageNow = () => {
2063
+ let staged = null;
1986
2064
  try {
1987
- claudeInject.stageInjection(RELAY_HOME, target.sessionId, {
2065
+ staged = claudeInject.stageInjection(RELAY_HOME, target.sessionId, {
1988
2066
  relayId: packetId,
1989
2067
  senderName: row.senderName || "",
1990
2068
  title: row.title || row.displayTitle || "",
@@ -1997,8 +2075,20 @@ async function openPacketInCurrent(packetId) {
1997
2075
  // Raise Claude Desktop so the user watches the instruction land; a
1998
2076
  // terminal-only session stays where it is (its window is not ours to raise).
1999
2077
  if (target.source === "desktop") activateHost("claude", bundle);
2000
- confirmInjected("claude");
2078
+ // awaitingTurn: an idle session consumes only on its NEXT hook event, so the
2079
+ // renderer must show an honest "waiting for that chat's turn" state, not a
2080
+ // success toast. The watcher below flips it to Delivered in realtime — or,
2081
+ // past the grace window, hops to a fresh chat so the click is never slow.
2082
+ confirmInjected("claude", { awaitingTurn: true });
2001
2083
  finish();
2084
+ watchInjectionDelivery(packetId, staged.path, {
2085
+ onAutoFresh: () => {
2086
+ if (win && !win.isDestroyed()) win.webContents.send("injectionAutoFresh", packetId);
2087
+ openPacket(packetId, { fresh: true }).catch((error) =>
2088
+ console.error("[overlay] auto-fresh after idle grace failed:", error && error.message),
2089
+ );
2090
+ },
2091
+ });
2002
2092
  };
2003
2093
  // NOTHING but the `relay claude-hook` runtime registered in ~/.claude/settings.json
2004
2094
  // ever reads the staged file. Where setup never wrote those hooks (Windows installs
@@ -11,6 +11,12 @@ contextBridge.exposeInMainWorld("relay", {
11
11
  // "Open in current chat" staged an injection: the UI confirms it so the
12
12
  // click has visible feedback (delivery is checkpoint-based, not instant).
13
13
  onInjected: (cb) => ipcRenderer.on("injected", (_e, id, info) => cb(id, info || {})),
14
+ // The staged injection was actually CONSUMED by the target session — flip the
15
+ // waiting note to a delivered one (main watches the consume-once file).
16
+ onInjectionDelivered: (cb) => ipcRenderer.on("injectionDelivered", (_e, id) => cb(id)),
17
+ // The current chat stayed idle through the grace window; the injection was
18
+ // reclaimed and the relay is opening in a fresh chat instead.
19
+ onInjectionAutoFresh: (cb) => ipcRenderer.on("injectionAutoFresh", (_e, id) => cb(id)),
14
20
  // The open failed (CLI error / helper wouldn't spawn). The row stays unread, so
15
21
  // say why inline instead of letting the spinner just stop.
16
22
  onOpenError: (cb) => ipcRenderer.on("openError", (_e, id, message) => cb(id, message || "")),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
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": {