relay-companion 0.1.7 → 0.1.9

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.
@@ -57,6 +57,18 @@
57
57
  }
58
58
  @keyframes appIn { from { opacity:0; transform:scale(.97); } to { opacity:1; transform:none; } }
59
59
 
60
+ /* Dismissal states. `offstage` parks the card invisibly while the OS window is hidden.
61
+ `bye` is the graceful exit (✕ click, or a ghost notification finishing). A `ghost`
62
+ card is the designed notification shown while the pill is dismissed: it emerges like
63
+ a banner (drift down + fade) and NEVER folds back into a pill — it just leaves. */
64
+ .card.offstage { visibility:hidden; animation:none; }
65
+ .card.bye { animation:byeOut .3s var(--settle) both; pointer-events:none; }
66
+ @keyframes byeOut { from { opacity:1; transform:none; } to { opacity:0; transform:scale(.96) translateY(-10px); } }
67
+ .card.ghost { animation:ghostIn .5s var(--soft) both; }
68
+ @keyframes ghostIn { from { opacity:0; transform:translateY(-14px) scale(.98); } to { opacity:1; transform:none; } }
69
+ .card.ghost.bye { animation:ghostOut .38s var(--settle) both; }
70
+ @keyframes ghostOut { from { opacity:1; transform:none; } to { opacity:0; transform:translateY(-12px) scale(.985); } }
71
+
60
72
  /* brand lockup — present in BOTH states; no surface, no rule */
61
73
  .lockup {
62
74
  flex:0 0 44px; display:flex; align-items:center; gap:9px;
@@ -78,7 +90,9 @@
78
90
  transition:color .18s var(--settle), opacity .18s var(--settle), width .18s var(--settle), margin .18s var(--settle);
79
91
  }
80
92
  .lockup:hover .minimize-hint { color:var(--muted-2); }
81
- .card.collapsed .minimize-hint {
93
+ .card.collapsed .minimize-hint,
94
+ .card.peek .minimize-hint {
95
+ /* folded pill and notification banner: tapping the lockup EXPANDS, so no "Minimize" */
82
96
  width:0;
83
97
  margin-right:0;
84
98
  overflow:hidden;
@@ -89,6 +103,17 @@
89
103
  color:var(--accent); line-height:1; transition:color .3s var(--settle);
90
104
  }
91
105
  .count.zero { color:var(--muted-3); }
106
+ /* ✕ — fully hide the overlay; the Relay mark in the menu bar brings it back. Present in
107
+ every card state (expanded, pill, notification) so the pill is always escapable. */
108
+ .close-x {
109
+ flex:0 0 auto; appearance:none; border:0; background:transparent; cursor:pointer;
110
+ width:20px; height:20px; border-radius:6px; padding:0; margin-left:6px; margin-right:-6px;
111
+ display:inline-flex; align-items:center; justify-content:center;
112
+ color:var(--muted-3);
113
+ transition:color .15s var(--settle), background-color .15s var(--settle);
114
+ }
115
+ .close-x:hover { color:var(--ink-2); background:rgba(31,26,23,.06); }
116
+ .close-x:active { color:var(--ink); }
92
117
  @keyframes countRoll { from { transform:translateY(-6px); opacity:.3; } to { transform:none; opacity:1; } }
93
118
 
94
119
  /* tab control — individual rounded-rect buttons (the loved control) */
@@ -412,6 +437,9 @@
412
437
  <span class="spacer"></span>
413
438
  <span class="minimize-hint">Minimize</span>
414
439
  <span class="count zero" id="count">0</span>
440
+ <button class="close-x" id="closeX" type="button" aria-label="Hide Relay" title="Hide Relay — reopen from the menu bar">
441
+ <svg width="9" height="9" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M1 1l8 8M9 1L1 9"/></svg>
442
+ </button>
415
443
  </div>
416
444
 
417
445
  <nav class="tabs" aria-label="Relay views">
@@ -484,6 +512,7 @@
484
512
  <script>
485
513
  const cardEl = document.getElementById("card");
486
514
  const lockupEl = document.getElementById("lockup");
515
+ const closeXEl = document.getElementById("closeX");
487
516
  const countEl = document.getElementById("count");
488
517
  const tabEls = [...document.querySelectorAll(".tab")];
489
518
  const scrollEl = document.getElementById("scroll");
@@ -512,7 +541,7 @@
512
541
 
513
542
  const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
514
543
  const EXPANDED = { w: 344, h: 524 };
515
- const PILL = { w: 168, h: 44 };
544
+ const PILL = { w: 178, h: 44 }; // wide enough for mark + word + count + ✕
516
545
  const PEEK = { w: 344, h: 118 };
517
546
 
518
547
  // ---------- zero-latency sound (Web Audio, pre-decoded) ----------
@@ -581,6 +610,7 @@
581
610
  // ---------- collapse / expand / peek ----------
582
611
  let collapsed = false;
583
612
  let peeking = false, peekTimer = null;
613
+ let ghost = false, ghostTimer = null; // notification-only mode while the pill is dismissed
584
614
  function clearPeek() {
585
615
  if (peekTimer) { clearTimeout(peekTimer); peekTimer = null; }
586
616
  peeking = false;
@@ -594,6 +624,87 @@
594
624
  springTo(collapsed ? PILL.w : EXPANDED.w, collapsed ? PILL.h : EXPANDED.h);
595
625
  playTink();
596
626
  }
627
+ // Park the card invisibly at expanded size while the OS window is hidden, so the
628
+ // next appearance (tray reopen / ghost banner) starts from a clean slate.
629
+ function snapSize(w, h) {
630
+ W.v = W.t = w; H.v = H.t = h;
631
+ if (raf) { cancelAnimationFrame(raf); raf = null; cardEl.style.willChange = ""; }
632
+ cardEl.style.width = w + "px";
633
+ cardEl.style.height = h + "px";
634
+ }
635
+ function resetOffstage() {
636
+ ghost = false;
637
+ if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
638
+ clearPeek();
639
+ collapsed = false;
640
+ cardEl.classList.add("offstage");
641
+ cardEl.classList.remove("collapsed", "ghost", "bye");
642
+ snapSize(EXPANDED.w, EXPANDED.h);
643
+ }
644
+ // ✕ — the card leaves gracefully, then the main process hides the window and keeps
645
+ // it hidden (persisted) until the menu-bar Relay mark is clicked.
646
+ function dismissOverlay() {
647
+ if (cardEl.classList.contains("bye")) return;
648
+ if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
649
+ if (peekTimer) { clearTimeout(peekTimer); peekTimer = null; }
650
+ const wasGhost = ghost;
651
+ cardEl.classList.add("bye");
652
+ setInteractive(false);
653
+ setTimeout(() => {
654
+ if (wasGhost) window.relay.notifDone(); // already dismissed; just re-hide the window
655
+ else window.relay.dismiss();
656
+ resetOffstage();
657
+ }, wasGhost ? 380 : 300);
658
+ }
659
+ // A relay arrived while dismissed: the same designed banner, but as a free-standing
660
+ // notification. It emerges at banner size (no pill morph — there is no pill), and
661
+ // when done it fades away instead of folding back into anything.
662
+ function ghostArrival(row) {
663
+ if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
664
+ clearPeek();
665
+ collapsed = false;
666
+ cardEl.classList.remove("collapsed", "offstage", "bye");
667
+ snapSize(PEEK.w, PEEK.h);
668
+ ghost = true; peeking = true;
669
+ cardEl.classList.add("peek", "ghost");
670
+ const kind = row && row.relayNotificationKind;
671
+ activeView = (kind === "task_request" || kind === "share_approval") ? "tasks" : "relays";
672
+ syncTabs();
673
+ applyView();
674
+ renderAll();
675
+ scrollEl.scrollTop = 0;
676
+ playTink();
677
+ // Leave after ~7s — but never rug-pull someone mid-reply or with the pointer on
678
+ // the banner; check again shortly instead (macOS banners hold while hovered too).
679
+ ghostTimer = setTimeout(function tick() {
680
+ ghostTimer = null;
681
+ if (interactive || quickReplyIsActive()) { ghostTimer = setTimeout(tick, 4000); return; }
682
+ dismissOverlay(); // rides the ghostOut exit, then main re-hides the window
683
+ }, 7000);
684
+ }
685
+ // Menu-bar Relay mark clicked: the card comes back FULLY OPEN (never the pill).
686
+ function trayOpen() {
687
+ const wasOffstage = cardEl.classList.contains("offstage") || cardEl.classList.contains("bye");
688
+ if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
689
+ ghost = false;
690
+ clearPeek();
691
+ collapsed = false;
692
+ cardEl.classList.remove("collapsed");
693
+ if (wasOffstage) {
694
+ // fresh entrance: snap to expanded and replay the appear animation
695
+ cardEl.classList.remove("ghost", "bye", "offstage");
696
+ snapSize(EXPANDED.w, EXPANDED.h);
697
+ cardEl.style.animation = "none";
698
+ void cardEl.offsetWidth;
699
+ cardEl.style.animation = "";
700
+ } else {
701
+ // visible (maybe mid-banner): keep animation-bearing classes untouched so nothing
702
+ // restarts — just morph to full size on the spring.
703
+ springTo(EXPANDED.w, EXPANDED.h);
704
+ }
705
+ renderAll();
706
+ playTink();
707
+ }
597
708
  // A new relay arrived. If folded, peek open like an iOS banner (~7s) then fold back.
598
709
  // The peek routes to the tab the item lives in: task_request / share_approval -> Tasks
599
710
  // (the clean split), everything else -> Relays.
@@ -620,6 +731,14 @@
620
731
  }, 7000);
621
732
  }
622
733
  function openFull() {
734
+ if (ghost) {
735
+ // Engaging with a ghost banner brings the pill back for real. Keep the .ghost
736
+ // class (removing it while visible would restart the base entrance animation);
737
+ // it only influences which exit animation would play later.
738
+ ghost = false;
739
+ if (ghostTimer) { clearTimeout(ghostTimer); ghostTimer = null; }
740
+ window.relay.undismiss();
741
+ }
623
742
  clearPeek();
624
743
  collapsed = false;
625
744
  cardEl.classList.remove("collapsed");
@@ -627,6 +746,14 @@
627
746
  playTink();
628
747
  }
629
748
 
749
+ // ---------- the ✕ ----------
750
+ // Swallow the mousedown so the lockup's drag/tap-fold never engages.
751
+ closeXEl.addEventListener("mousedown", (e) => { e.stopPropagation(); });
752
+ closeXEl.addEventListener("click", (e) => {
753
+ e.stopPropagation();
754
+ dismissOverlay();
755
+ });
756
+
630
757
  // ---------- drag the lockup to move, tap it to fold ----------
631
758
  let dragging = false, offX = 0, offY = 0, downSX = 0, downSY = 0, moved = 0;
632
759
  lockupEl.addEventListener("mousedown", (e) => {
@@ -1418,7 +1545,14 @@
1418
1545
  }
1419
1546
 
1420
1547
  window.relay.onInbox(onPayload);
1421
- if (window.relay.onNewRelay) window.relay.onNewRelay((row) => notifyArrival(row));
1548
+ if (window.relay.onNewRelay) window.relay.onNewRelay((row, opts) => {
1549
+ if (opts && opts.ghost) ghostArrival(row); // pill dismissed -> free-standing banner
1550
+ else notifyArrival(row);
1551
+ });
1552
+ if (window.relay.onOpenFull) window.relay.onOpenFull(() => trayOpen());
1553
+ // The OS window was just re-shown and main re-asserted click-through; reset the
1554
+ // local flag so the next mousemove re-runs the interactivity handshake cleanly.
1555
+ if (window.relay.onShown) window.relay.onShown(() => { interactive = false; });
1422
1556
  if (window.relay.onOpenDone) window.relay.onOpenDone((id) => stopOpening(id));
1423
1557
  window.relay.refresh().then((p) => {
1424
1558
  onPayload(p);
package/overlay/main.cjs CHANGED
@@ -6,6 +6,11 @@
6
6
  // on the card inside it, so it stays perfectly smooth. The empty area around the card is
7
7
  // click-through, so the overlay never blocks the app behind it.
8
8
  //
9
+ // Visibility: the card carries a ✕ that hides the overlay entirely (persisted). The Relay
10
+ // mark in the macOS menu bar (Tray) brings it back fully open. While dismissed, fresh
11
+ // relays still show the designed banner as a "ghost" notification — the window appears
12
+ // just for the banner, which fades away instead of folding back into a pill.
13
+ //
9
14
  // Data:
10
15
  // - Relay attention rows come from ${RELAY_HOME:-~/.relay-companion}/state.json under
11
16
  // `state.packets` (staged by the companion daemon). Read it directly + fs.watch it.
@@ -14,7 +19,7 @@
14
19
  // - Mutations (accept/reject/approve/decline/answer) call RelayClient with device-token
15
20
  // auth. ack/mark-read writes state.json directly (atomic temp+rename).
16
21
 
17
- const { app, BrowserWindow, ipcMain, shell, screen } = require("electron");
22
+ const { app, BrowserWindow, Tray, ipcMain, nativeImage, shell, screen } = require("electron");
18
23
  const fs = require("node:fs");
19
24
  const os = require("node:os");
20
25
  const path = require("node:path");
@@ -36,11 +41,33 @@ const MARGIN = 8;
36
41
  const DEFAULT_WEB_BASE = "https://sendrelays.com";
37
42
 
38
43
  let win = null;
44
+ let tray = null;
39
45
  let hostRunning = false; // Claude or Codex desktop app is running
40
46
  let lastHost = null; // most-recently foregrounded host: "claude" | "codex"
41
47
  let lastHostSeenAt = 0;
42
48
  let lastSig = "";
43
49
 
50
+ // Dismissed mode: the user clicked the ✕ on the card, so the overlay stays hidden
51
+ // until they click the Relay menu-bar icon. Persisted across relaunches. While
52
+ // dismissed, a fresh relay still shows the designed notification (a "ghost" peek):
53
+ // the window appears just for the banner, then hides again when it finishes.
54
+ const OVERLAY_PREFS_PATH = path.join(RELAY_HOME, "overlay-prefs.json");
55
+ let dismissed = false;
56
+ let ghostActive = false; // a ghost notification is on screen while dismissed
57
+ let trayForcedVisible = false; // tray click while no host is running still shows the card
58
+ try {
59
+ dismissed = JSON.parse(fs.readFileSync(OVERLAY_PREFS_PATH, "utf8")).dismissed === true;
60
+ } catch {}
61
+ function setDismissed(value) {
62
+ dismissed = Boolean(value);
63
+ try {
64
+ fs.mkdirSync(RELAY_HOME, { recursive: true });
65
+ fs.writeFileSync(OVERLAY_PREFS_PATH, `${JSON.stringify({ dismissed }, null, 2)}\n`);
66
+ } catch (error) {
67
+ console.error("[overlay] prefs write failed:", error && error.message);
68
+ }
69
+ }
70
+
44
71
  // The ESM companion modules (config/client) are loaded lazily via dynamic import,
45
72
  // because this overlay runs as CommonJS under Electron while the package is ESM.
46
73
  let relayModules = null;
@@ -290,7 +317,20 @@ async function pushInbox(force) {
290
317
  } else {
291
318
  const fresh = rows.filter((r) => r.unread && !knownIds.has(r.id));
292
319
  knownIds = new Set(rows.map((r) => r.id));
293
- if (fresh.length && win && !win.isDestroyed()) win.webContents.send("newRelay", fresh[0]); // newest arrival drives the peek
320
+ if (fresh.length && win && !win.isDestroyed()) {
321
+ // newest arrival drives the peek. While dismissed, the same designed banner still
322
+ // shows — as a "ghost": the window appears for the notification only, and the
323
+ // renderer fades it out (never folding back into a pill) when it's done.
324
+ if (dismissed) {
325
+ if (hostRunning) {
326
+ ghostActive = true;
327
+ showOverlayWindow();
328
+ win.webContents.send("newRelay", fresh[0], { ghost: true });
329
+ }
330
+ } else {
331
+ win.webContents.send("newRelay", fresh[0], { ghost: false });
332
+ }
333
+ }
294
334
  }
295
335
  const sig = JSON.stringify({
296
336
  relays: rows.map((r) => [r.id, r.relayNotificationKind, r.unread, r.title, r.senderName, r.urgency]),
@@ -646,10 +686,25 @@ function anchorTopRight() {
646
686
  return { x: wa.x + wa.width - WIN.width - MARGIN, y: wa.y + MARGIN, width: WIN.width, height: WIN.height };
647
687
  }
648
688
 
689
+ // Show the overlay window. Electron's forwarded-mousemove stream (the thing that
690
+ // lets the renderer detect hover on a click-through window) can stall across a
691
+ // hide()/show() cycle, leaving the window permanently click-through. Re-assert
692
+ // setIgnoreMouseEvents on every hidden->shown transition and tell the renderer to
693
+ // reset its interactivity handshake.
694
+ function showOverlayWindow() {
695
+ if (!win || win.isDestroyed() || win.isVisible()) return;
696
+ win.showInactive();
697
+ win.setIgnoreMouseEvents(true, { forward: true });
698
+ win.webContents.send("shown");
699
+ }
700
+
649
701
  function maybeShow() {
650
702
  if (!win || win.isDestroyed()) return;
651
- if (hostRunning) {
652
- if (!win.isVisible()) win.showInactive();
703
+ // Dismissed keeps the window hidden except while a ghost notification is up.
704
+ // trayForcedVisible honors an explicit menu-bar click even when no host runs.
705
+ const wanted = (hostRunning || trayForcedVisible) && (!dismissed || ghostActive);
706
+ if (wanted) {
707
+ showOverlayWindow();
653
708
  } else if (win.isVisible()) {
654
709
  win.hide();
655
710
  }
@@ -662,6 +717,7 @@ function pollHosts() {
662
717
  const claude = !err && /\/Claude\.app\/Contents\/MacOS\/Claude(\n|$)/.test(s);
663
718
  const codex = !err && /\/Codex\.app\/Contents\/MacOS\/Codex(\n|$)/.test(s);
664
719
  hostRunning = claude || codex;
720
+ if (hostRunning) trayForcedVisible = false; // normal host-based visibility takes back over
665
721
  maybeShow();
666
722
  });
667
723
  frontmostBundleId((bundle) => {
@@ -730,6 +786,29 @@ function createWindow() {
730
786
  setInterval(pollHosts, 1500);
731
787
  }
732
788
 
789
+ // ---- menu bar (tray) -------------------------------------------------------
790
+
791
+ // The Relay mark lives in the macOS menu bar. Clicking it brings the card back
792
+ // FULLY OPEN (never the folded pill) — the counterpart to the card's ✕, which
793
+ // hides the overlay entirely. The *Template naming makes macOS tint the mark
794
+ // for light/dark menu bars automatically.
795
+ function createTray() {
796
+ const icon = nativeImage.createFromPath(path.join(__dirname, "relayTrayTemplate.png"));
797
+ if (icon.isEmpty()) {
798
+ console.error("[overlay] tray icon missing; menu bar reopen unavailable");
799
+ return;
800
+ }
801
+ tray = new Tray(icon);
802
+ tray.setToolTip("Relay");
803
+ tray.on("click", () => {
804
+ setDismissed(false);
805
+ ghostActive = false;
806
+ if (!hostRunning) trayForcedVisible = true; // an explicit click always gets the card
807
+ maybeShow();
808
+ if (win && !win.isDestroyed()) win.webContents.send("openFull");
809
+ });
810
+ }
811
+
733
812
  // ---- ipc -----------------------------------------------------------------
734
813
 
735
814
  ipcMain.handle("relay:get", () => buildPayload());
@@ -772,6 +851,27 @@ ipcMain.on("relay:interactive", (_e, interactive) => {
772
851
  ipcMain.on("relay:setPos", (_e, x, y) => {
773
852
  if (win && !win.isDestroyed() && Number.isFinite(x) && Number.isFinite(y)) win.setPosition(Math.round(x), Math.round(y));
774
853
  });
854
+ // ✕ on the card: hide the overlay entirely until the menu-bar Relay mark is clicked.
855
+ // The renderer plays its exit animation first, then sends this.
856
+ ipcMain.on("relay:dismiss", () => {
857
+ setDismissed(true);
858
+ ghostActive = false;
859
+ trayForcedVisible = false;
860
+ if (win && !win.isDestroyed() && win.isVisible()) win.hide();
861
+ });
862
+ // The user engaged with a ghost notification (tapped the lockup to expand it):
863
+ // the card is fully open again, so leave dismissed mode.
864
+ ipcMain.on("relay:undismiss", () => {
865
+ setDismissed(false);
866
+ ghostActive = false;
867
+ maybeShow();
868
+ });
869
+ // A ghost notification finished its exit animation; re-hide the window if the
870
+ // overlay is still dismissed.
871
+ ipcMain.on("relay:notifDone", () => {
872
+ ghostActive = false;
873
+ if (dismissed && win && !win.isDestroyed() && win.isVisible()) win.hide();
874
+ });
775
875
  ipcMain.handle("relay:soundBytes", (_e, name) => {
776
876
  const clean = String(name).replace(/[^A-Za-z]/g, "");
777
877
  const candidates = [
@@ -799,6 +899,7 @@ if (!gotSingleInstanceLock) {
799
899
  app.whenReady().then(() => {
800
900
  if (process.platform === "darwin" && app.dock) app.dock.hide(); // accessory: no dock icon, no NC banners
801
901
  createWindow();
902
+ createTray();
802
903
  });
803
904
 
804
905
  app.on("window-all-closed", () => {
@@ -3,8 +3,10 @@ const { contextBridge, ipcRenderer } = require("electron");
3
3
  contextBridge.exposeInMainWorld("relay", {
4
4
  // streams from main -> renderer
5
5
  onInbox: (cb) => ipcRenderer.on("inbox", (_e, payload) => cb(payload)),
6
- onNewRelay: (cb) => ipcRenderer.on("newRelay", (_e, relay) => cb(relay)),
6
+ onNewRelay: (cb) => ipcRenderer.on("newRelay", (_e, relay, opts) => cb(relay, opts || {})),
7
7
  onOpenDone: (cb) => ipcRenderer.on("openDone", (_e, id) => cb(id)),
8
+ onOpenFull: (cb) => ipcRenderer.on("openFull", () => cb()), // menu-bar icon clicked
9
+ onShown: (cb) => ipcRenderer.on("shown", () => cb()), // window re-shown; reset hover handshake
8
10
 
9
11
  // pull the full payload on demand
10
12
  refresh: () => ipcRenderer.invoke("relay:get"),
@@ -34,4 +36,9 @@ contextBridge.exposeInMainWorld("relay", {
34
36
  interactive: (v) => ipcRenderer.send("relay:interactive", v),
35
37
  setPos: (x, y) => ipcRenderer.send("relay:setPos", x, y),
36
38
  soundBytes: (name) => ipcRenderer.invoke("relay:soundBytes", name),
39
+
40
+ // dismissal (the card's ✕ / the menu-bar mark / ghost notifications)
41
+ dismiss: () => ipcRenderer.send("relay:dismiss"),
42
+ undismiss: () => ipcRenderer.send("relay:undismiss"),
43
+ notifDone: () => ipcRenderer.send("relay:notifDone"),
37
44
  });
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Relay companion: connects local coding agents to Relay tasks, approvals, and connector tools.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/mcp.js CHANGED
@@ -543,7 +543,21 @@ export async function runMcpServer() {
543
543
  try {
544
544
  return await handleCall(client, req.params.name, req.params.arguments || {});
545
545
  } catch (err) {
546
- return { content: [{ type: "text", text: `Relay error: ${err.message}` }], isError: true };
546
+ // Surface the API's validation detail so the calling agent can self-correct
547
+ // instead of guessing what "invalid_request" meant.
548
+ let detail = "";
549
+ const issues = err && err.body && Array.isArray(err.body.issues) ? err.body.issues : null;
550
+ if (issues && issues.length) {
551
+ detail =
552
+ "\n" +
553
+ issues
554
+ .slice(0, 8)
555
+ .map((i) => `- ${Array.isArray(i.path) && i.path.length ? i.path.join(".") : "(request)"}: ${i.message}`)
556
+ .join("\n");
557
+ } else if (err && err.body && typeof err.body.instruction === "string") {
558
+ detail = `\n${err.body.instruction}`;
559
+ }
560
+ return { content: [{ type: "text", text: `Relay error: ${err.message}${detail}` }], isError: true };
547
561
  }
548
562
  });
549
563
 
@@ -287,6 +287,11 @@ function senderForNotification(notification) {
287
287
  // anyway via RELAY_HIDDEN_KINDS, but the staged senderName must still be coherent.)
288
288
  if (notification.kind === "task_request") return "Your agent";
289
289
  if (notification.kind === "share_approval") return "Your agent";
290
+ // Task lifecycle notices (completed/cancelled/failed) are the agent run reporting
291
+ // back, not a person — label them as the automation, never "Someone".
292
+ if (notification.kind === "task_completed") return "Your agent";
293
+ if (notification.kind === "task_cancelled") return "Your agent";
294
+ if (notification.kind === "task_failed") return "Your agent";
290
295
  if (notification.kind === "connector_reauth") return "Relay connectors";
291
296
  return "Someone";
292
297
  }