relay-companion 0.1.64 → 0.1.66

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.
@@ -301,6 +301,8 @@
301
301
  .row-note { font-size:11.5px; color:var(--muted-2); margin-top:9px; line-height:1.45; }
302
302
  .row-err { font-size:11.5px; color:#b4332a; margin-top:8px; line-height:1.4; }
303
303
  .row-err:empty { display:none; }
304
+ /* Positive variant: injection confirmations reuse the same slot in green. */
305
+ .row-err.ok { color:#1a7f37; }
304
306
 
305
307
  /* ---- task items — also flat, divided by one hairline ---- */
306
308
  .task {
@@ -2629,6 +2631,22 @@
2629
2631
  // local flag so the next mousemove re-runs the interactivity handshake cleanly.
2630
2632
  if (window.relay.onShown) window.relay.onShown(() => { interactive = false; });
2631
2633
  if (window.relay.onOpenDone) window.relay.onOpenDone((id) => stopOpening(id));
2634
+ // Visible confirmation for "Open in current chat": the injection is staged,
2635
+ // but the agent only ingests it at its next checkpoint (tool boundary / turn
2636
+ // end — or your next message if it's idle). Say so, briefly, in green.
2637
+ const injectedNoteTimers = new Map();
2638
+ if (window.relay.onInjected) window.relay.onInjected((id, info) => {
2639
+ const hostName = info.host === "codex" ? "Codex" : "Claude";
2640
+ for (const el of document.querySelectorAll(`[data-err="${CSS.escape(id)}"]`)) {
2641
+ 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).`;
2642
+ el.classList.add("ok");
2643
+ if (injectedNoteTimers.has(id)) clearTimeout(injectedNoteTimers.get(id));
2644
+ injectedNoteTimers.set(id, setTimeout(() => {
2645
+ injectedNoteTimers.delete(id);
2646
+ if (el.isConnected) { el.textContent = ""; el.classList.remove("ok"); }
2647
+ }, 12000));
2648
+ }
2649
+ });
2632
2650
  window.relay.refresh().then((p) => {
2633
2651
  onPayload(p);
2634
2652
  console.log("[overlay] booted ok, relays=" + (p && p.relays ? p.relays.length : 0) + " sent=" + (p && p.sent ? p.sent.length : 0));
package/overlay/main.cjs CHANGED
@@ -42,6 +42,7 @@ const {
42
42
  shouldIgnoreDismiss,
43
43
  boundedPresentedRelayIds,
44
44
  recoverInterruptedAttentionPrefs,
45
+ quitRelayCommand,
45
46
  hostPollDelayMs,
46
47
  sentRefreshDelayMs,
47
48
  } = require("./visibility.cjs");
@@ -1532,6 +1533,11 @@ async function openPacketInCurrent(packetId) {
1532
1533
  ackPacket(packetId);
1533
1534
  if (win && !win.isDestroyed()) win.webContents.send("openDone", packetId); // stop the row spinner
1534
1535
  };
1536
+ // Confirm the staging in the UI: without this, a successful injection is
1537
+ // indistinguishable from a dead click until the agent's next checkpoint.
1538
+ const confirmInjected = (host) => {
1539
+ if (win && !win.isDestroyed()) win.webContents.send("injected", packetId, { host });
1540
+ };
1535
1541
  const fallbackFresh = () => {
1536
1542
  openPacket(packetId, { fresh: true }).catch((error) =>
1537
1543
  console.error("[overlay] openInCurrent fresh fallback failed:", error && error.message),
@@ -1561,7 +1567,10 @@ async function openPacketInCurrent(packetId) {
1561
1567
  desktop,
1562
1568
  codexRunning,
1563
1569
  activateHost: () => activateHost("codex", bundle),
1564
- finish,
1570
+ finish: () => {
1571
+ confirmInjected("codex");
1572
+ finish();
1573
+ },
1565
1574
  fallbackFresh,
1566
1575
  },
1567
1576
  ),
@@ -1599,6 +1608,7 @@ async function openPacketInCurrent(packetId) {
1599
1608
  // Raise Claude Desktop so the user watches the instruction land; a
1600
1609
  // terminal-only session stays where it is (its window is not ours to raise).
1601
1610
  if (target.source === "desktop") activateHost("claude", bundle);
1611
+ confirmInjected("claude");
1602
1612
  finish();
1603
1613
  });
1604
1614
  }
@@ -2175,6 +2185,24 @@ function syncTray() {
2175
2185
  } catch {}
2176
2186
  }
2177
2187
 
2188
+ // "Quit Relay" from the tray: stop the daemon and the pill's own service via a
2189
+ // DETACHED shell (the second bootout kills this process, so the shell must
2190
+ // outlive us), then quit the app immediately for instant visual feedback.
2191
+ function quitRelayCompletely() {
2192
+ try {
2193
+ const uid = typeof process.getuid === "function" ? process.getuid() : 501;
2194
+ const [cmd, args] = quitRelayCommand({ platform: process.platform, uid });
2195
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
2196
+ child.on("error", (error) => console.error(`[overlay] ${new Date().toISOString()} quit spawn failed:`, error && error.message));
2197
+ child.unref();
2198
+ } catch (error) {
2199
+ console.error(`[overlay] ${new Date().toISOString()} quit failed:`, error && error.message);
2200
+ }
2201
+ try {
2202
+ app.quit();
2203
+ } catch {}
2204
+ }
2205
+
2178
2206
  function createTray() {
2179
2207
  const icon = createTrayIcon();
2180
2208
  if (!icon || icon.isEmpty()) {
@@ -2195,7 +2223,11 @@ function createTray() {
2195
2223
  tray.on("right-click", () => {
2196
2224
  try {
2197
2225
  tray.popUpContextMenu(
2198
- Menu.buildFromTemplate([{ label: pillIsOnScreen() ? "Hide Relay" : "Show Relay", click: toggleFromTray }]),
2226
+ Menu.buildFromTemplate([
2227
+ { label: pillIsOnScreen() ? "Hide Relay" : "Show Relay", click: toggleFromTray },
2228
+ { type: "separator" },
2229
+ { label: "Quit Relay", click: quitRelayCompletely },
2230
+ ]),
2199
2231
  );
2200
2232
  } catch (error) {
2201
2233
  console.error("[overlay] tray menu failed:", error && error.message);
@@ -5,6 +5,9 @@ contextBridge.exposeInMainWorld("relay", {
5
5
  onInbox: (cb) => ipcRenderer.on("inbox", (_e, payload) => cb(payload)),
6
6
  onNewRelay: (cb) => ipcRenderer.on("newRelay", (_e, relay, opts) => cb(relay, opts || {})),
7
7
  onOpenDone: (cb) => ipcRenderer.on("openDone", (_e, id) => cb(id)),
8
+ // "Open in current chat" staged an injection: the UI confirms it so the
9
+ // click has visible feedback (delivery is checkpoint-based, not instant).
10
+ onInjected: (cb) => ipcRenderer.on("injected", (_e, id, info) => cb(id, info || {})),
8
11
  onOpenFull: (cb) => ipcRenderer.on("openFull", () => cb()), // status-area icon clicked
9
12
  onShown: (cb) => ipcRenderer.on("shown", () => cb()), // window re-shown; reset hover handshake
10
13
 
@@ -119,7 +119,30 @@ function sentRefreshDelayMs({ testMode = false, engaged = false, showActive = fa
119
119
  return engaged || showActive ? 5000 : 30000;
120
120
  }
121
121
 
122
+ // The tray's "Quit Relay": one detached shell that stops BOTH background
123
+ // services. Order matters — the daemon first, the pill's own service LAST,
124
+ // because the second bootout kills the process that spawned the shell.
125
+ // launchctl bootout (not kickstart/stop) is the verb that beats KeepAlive;
126
+ // the jobs return at next login (RunAtLoad) or via `relay pill`.
127
+ function quitRelayCommand({ platform = process.platform, uid = 501 } = {}) {
128
+ if (platform === "win32") {
129
+ return [
130
+ "cmd",
131
+ ["/c", 'schtasks /End /TN "Relay Companion Daemon" & schtasks /End /TN "Relay Companion Pill"'],
132
+ ];
133
+ }
134
+ return [
135
+ "/bin/sh",
136
+ [
137
+ "-c",
138
+ `/bin/launchctl bootout gui/${uid}/work.relay.companion 2>/dev/null; ` +
139
+ `/bin/launchctl bootout gui/${uid}/work.relay.companion.pill 2>/dev/null`,
140
+ ],
141
+ ];
142
+ }
143
+
122
144
  module.exports = {
145
+ quitRelayCommand,
123
146
  hostPollDelayMs,
124
147
  sentRefreshDelayMs,
125
148
  overlayWanted,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.64",
3
+ "version": "0.1.66",
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": {