relay-companion 0.1.49 → 0.1.50

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.
@@ -122,7 +122,7 @@
122
122
  .tabs {
123
123
  flex:0 0 auto;
124
124
  display:flex; gap:4px;
125
- padding:0 14px 6px;
125
+ padding:0 12px 6px;
126
126
  opacity:1;
127
127
  transition:opacity .24s var(--settle);
128
128
  }
@@ -533,6 +533,72 @@
533
533
  .cv-sugg:hover, .cv-sugg.active { background:rgba(31,26,23,.04); color:var(--ink); }
534
534
  .cv-hint { font-size:11px; line-height:1.45; color:var(--muted-2); margin-top:2px; }
535
535
  .cv-hint:empty { display:none; }
536
+
537
+ /* ---- My agents tab ---- */
538
+ /* Agent roster rows mirror the Contacts list; the history list mirrors relay rows. */
539
+ .ag-item {
540
+ display:flex; align-items:center; gap:11px; padding:10px 16px; cursor:pointer;
541
+ text-align:left; appearance:none; border:0; border-bottom:1px solid var(--hair-2);
542
+ background:transparent; width:100%; font-family:var(--sans);
543
+ transition:background-color .14s var(--settle);
544
+ }
545
+ .ag-item:hover { background:rgba(31,26,23,.035); }
546
+ .ag-avatar {
547
+ flex:0 0 auto; width:32px; height:32px; border-radius:9px; background:var(--accent-soft); color:var(--accent);
548
+ font-size:11px; font-weight:600; display:flex; align-items:center; justify-content:center; letter-spacing:.02em;
549
+ }
550
+ .ag-body { display:flex; flex-direction:column; min-width:0; flex:1 1 auto; }
551
+ .ag-name-line { display:flex; align-items:center; gap:7px; min-width:0; }
552
+ .ag-name { font-size:13px; font-weight:600; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
553
+ .ag-sub { font-size:11.5px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:1px; }
554
+ .ag-time { flex:0 0 auto; margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--muted-2); font-variant-numeric:tabular-nums; padding-left:6px; }
555
+ /* Role badges: Owner = filled prussian pill, Viewer = quiet outline pill. */
556
+ .ag-badge {
557
+ flex:0 0 auto; display:inline-flex; align-items:center;
558
+ font-size:10px; font-weight:600; letter-spacing:.03em; line-height:1;
559
+ padding:3px 7px; border-radius:999px;
560
+ background:var(--accent); color:#fff; border:1px solid var(--accent);
561
+ }
562
+ .ag-badge.viewer { background:transparent; color:var(--muted-2); border-color:rgba(31,26,23,.22); }
563
+ /* history header: back chevron, agent name, role badge */
564
+ .ag-detail-head { display:flex; align-items:center; gap:9px; padding:10px 16px 8px; border-bottom:1px solid var(--hair-2); }
565
+ .ag-back {
566
+ appearance:none; border:0; background:transparent; cursor:pointer; padding:0;
567
+ display:inline-flex; align-items:center; gap:5px;
568
+ font-family:var(--sans); font-size:12px; font-weight:600; color:var(--accent);
569
+ transition:opacity .13s var(--settle);
570
+ }
571
+ .ag-back:hover { opacity:.72; }
572
+ .ag-detail-name {
573
+ font-family:var(--serif); font-size:14.5px; font-weight:500; color:var(--ink);
574
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; flex:1 1 auto;
575
+ }
576
+ /* history rows: flat, hairline-divided like relay rows */
577
+ .ag-relay { position:relative; display:block; padding:11px 16px; border-bottom:1px solid var(--hair); }
578
+ .ag-relay-top { display:flex; align-items:baseline; gap:7px; min-width:0; }
579
+ .ag-dir { flex:0 0 auto; font-family:var(--mono); font-size:11px; line-height:1; align-self:center; color:var(--muted-2); }
580
+ .ag-dir.out { color:var(--accent); }
581
+ .ag-parties { font-size:11px; color:var(--muted-2); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0; }
582
+ .ag-parties .p { color:var(--ink-2); font-weight:500; }
583
+ /* type chips: question = filled accent-soft, completion = quiet outline */
584
+ .ag-chip {
585
+ flex:0 0 auto; display:inline-flex; align-items:center; align-self:center;
586
+ font-size:10px; font-weight:600; letter-spacing:.03em; line-height:1;
587
+ padding:3px 7px; border-radius:999px;
588
+ }
589
+ .ag-chip.question { background:var(--accent-soft); color:var(--accent); }
590
+ .ag-chip.completion { background:transparent; color:var(--muted-2); border:1px solid rgba(31,26,23,.18); }
591
+ .ag-relay-title {
592
+ font-family:var(--serif); font-size:14px; line-height:1.3; color:var(--muted); font-weight:400; margin-top:4px;
593
+ white-space:nowrap; overflow:hidden;
594
+ -webkit-mask-image:linear-gradient(to right, #000 calc(100% - 26px), transparent);
595
+ mask-image:linear-gradient(to right, #000 calc(100% - 26px), transparent);
596
+ }
597
+ .ag-relay.unread .ag-relay-title { color:var(--ink); font-weight:500; }
598
+ .ag-unread-dot {
599
+ flex:0 0 auto; width:7px; height:7px; border-radius:50%; align-self:center;
600
+ background:var(--accent); border:1.5px solid var(--accent); box-sizing:border-box;
601
+ }
536
602
  </style>
537
603
  </head>
538
604
  <body>
@@ -570,6 +636,7 @@
570
636
  </button>
571
637
  <button class="tab" type="button" data-view="sent">Sent</button>
572
638
  <button class="tab" type="button" data-view="contacts">Contacts</button>
639
+ <button class="tab" type="button" data-view="agents">My agents</button>
573
640
  <button class="mark-all-read gone" id="markAllRead" type="button">Mark all as read</button>
574
641
  </nav>
575
642
 
@@ -631,6 +698,24 @@
631
698
  <div class="cv-list" id="cvList"></div>
632
699
  <div id="contactsEmpty" class="cv-empty gone">No contacts yet.<br>Add someone, or your agent saves them as you relay.</div>
633
700
  </section>
701
+
702
+ <!-- My agents view: agent roster, swapping to one agent's relay history -->
703
+ <section class="view hidden" id="agentsView">
704
+ <div id="agList"></div>
705
+ <div id="agentsEmpty" class="cv-empty gone">No agents yet.<br>Agents your firm deploys with Granular will appear here.</div>
706
+ <div id="agDetail" class="gone">
707
+ <div class="ag-detail-head">
708
+ <button class="ag-back" type="button" id="agBack" aria-label="Back to agents">
709
+ <svg width="7" height="12" viewBox="0 0 8 14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M7 1L1 7l6 6"/></svg>
710
+ Agents
711
+ </button>
712
+ <span class="ag-detail-name" id="agDetailName"></span>
713
+ <span class="ag-badge" id="agDetailRole"></span>
714
+ </div>
715
+ <div id="agHistory"></div>
716
+ <div id="agHistoryEmpty" class="cv-empty gone">No relays with this agent yet.</div>
717
+ </div>
718
+ </section>
634
719
  </div>
635
720
  </div>
636
721
 
@@ -668,6 +753,15 @@
668
753
  const cvSaveEl = document.getElementById("cvSave");
669
754
  const cvCancelEl = document.getElementById("cvCancel");
670
755
  const cvDeleteEl = document.getElementById("cvDelete");
756
+ const agentsViewEl = document.getElementById("agentsView");
757
+ const agListEl = document.getElementById("agList");
758
+ const agentsEmptyEl = document.getElementById("agentsEmpty");
759
+ const agDetailEl = document.getElementById("agDetail");
760
+ const agBackEl = document.getElementById("agBack");
761
+ const agDetailNameEl = document.getElementById("agDetailName");
762
+ const agDetailRoleEl = document.getElementById("agDetailRole");
763
+ const agHistoryEl = document.getElementById("agHistory");
764
+ const agHistoryEmptyEl = document.getElementById("agHistoryEmpty");
671
765
 
672
766
  const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
673
767
  const EXPANDED = { w: 344, h: 524 };
@@ -2123,6 +2217,95 @@
2123
2217
  }
2124
2218
  });
2125
2219
 
2220
+ // ---------- My agents view ----------
2221
+ // Roster of Granular foundry agents this human is linked to. Clicking one
2222
+ // swaps the section to its relay history; Back returns to the roster.
2223
+ let agentsList = [];
2224
+ let agentDetail = null; // { agentUserId, name, role } while the history is open
2225
+ function agInitials(name) {
2226
+ const base = String(name || "?").trim();
2227
+ const parts = base.split(/\s+/).filter(Boolean);
2228
+ if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
2229
+ return base.slice(0, 2).toUpperCase();
2230
+ }
2231
+ function agRoleBadge(role) {
2232
+ const viewer = role === "viewer";
2233
+ return `<span class="ag-badge${viewer ? " viewer" : ""}">${viewer ? "Viewer" : "Owner"}</span>`;
2234
+ }
2235
+ function renderAgents() {
2236
+ const showingDetail = Boolean(agentDetail);
2237
+ agDetailEl.classList.toggle("gone", !showingDetail);
2238
+ agListEl.classList.toggle("gone", showingDetail);
2239
+ agentsEmptyEl.classList.toggle("gone", showingDetail || agentsList.length > 0);
2240
+ if (showingDetail) return;
2241
+ agListEl.innerHTML = agentsList.map((a) => `
2242
+ <button class="ag-item" type="button" data-agent="${esc(a.agentUserId)}">
2243
+ <span class="ag-avatar">${esc(agInitials(a.name))}</span>
2244
+ <span class="ag-body">
2245
+ <span class="ag-name-line">
2246
+ <span class="ag-name">${esc(a.name)}</span>
2247
+ ${agRoleBadge(a.role)}
2248
+ </span>
2249
+ <span class="ag-sub">${a.role === "viewer" ? "Follows this agent's relays" : "Exchanges relays with you"}</span>
2250
+ </span>
2251
+ <span class="ag-time">${a.lastRelayAt ? esc(timeAgo(a.lastRelayAt)) : ""}</span>
2252
+ </button>`).join("");
2253
+ for (const el of agListEl.querySelectorAll(".ag-item")) {
2254
+ el.addEventListener("click", () => openAgentHistory(el.getAttribute("data-agent")));
2255
+ }
2256
+ }
2257
+ async function loadAgents() {
2258
+ try { agentsList = (await window.relay.agents()) || []; }
2259
+ catch { agentsList = []; }
2260
+ renderAgents();
2261
+ }
2262
+ function agHistoryRow(item) {
2263
+ const outbound = item.direction === "outbound";
2264
+ const parties = `<span class="p">${esc(item.fromDisplayName)}</span> → <span class="p">${esc((item.toDisplayNames || []).join(", "))}</span>`;
2265
+ const chip = item.type === "question" || item.type === "completion"
2266
+ ? `<span class="ag-chip ${item.type}">${item.type === "question" ? "Question" : "Completion"}</span>`
2267
+ : "";
2268
+ return `
2269
+ <div class="ag-relay${item.unread ? " unread" : ""}">
2270
+ <div class="ag-relay-top">
2271
+ <span class="ag-dir${outbound ? " out" : ""}">${outbound ? "←" : "→"}</span>
2272
+ ${item.unread ? '<span class="ag-unread-dot"></span>' : ""}
2273
+ <span class="ag-parties">${parties}</span>
2274
+ ${chip}
2275
+ <span class="ag-time">${esc(timeAgo(item.createdAt))}</span>
2276
+ </div>
2277
+ <div class="ag-relay-title">${esc(item.title)}</div>
2278
+ </div>`;
2279
+ }
2280
+ async function openAgentHistory(agentUserId) {
2281
+ const agent = agentsList.find((a) => a.agentUserId === agentUserId);
2282
+ if (!agent) return;
2283
+ agentDetail = agent;
2284
+ agDetailNameEl.textContent = agent.name;
2285
+ agDetailRoleEl.textContent = agent.role === "viewer" ? "Viewer" : "Owner";
2286
+ agDetailRoleEl.classList.toggle("viewer", agent.role === "viewer");
2287
+ agHistoryEl.innerHTML = "";
2288
+ agHistoryEmptyEl.classList.add("gone");
2289
+ renderAgents();
2290
+ try {
2291
+ const res = await window.relay.agentRelays(agentUserId);
2292
+ if (!agentDetail || agentDetail.agentUserId !== agentUserId) return; // navigated away
2293
+ const items = (res && Array.isArray(res.items)) ? res.items : [];
2294
+ // Newest first for the pill (the API returns oldest-first chronology).
2295
+ agHistoryEl.innerHTML = items.slice().reverse().map(agHistoryRow).join("");
2296
+ agHistoryEmptyEl.classList.toggle("gone", items.length > 0);
2297
+ } catch {
2298
+ if (!agentDetail || agentDetail.agentUserId !== agentUserId) return;
2299
+ agHistoryEmptyEl.classList.remove("gone");
2300
+ }
2301
+ }
2302
+ function closeAgentHistory() {
2303
+ agentDetail = null;
2304
+ agHistoryEl.innerHTML = "";
2305
+ renderAgents();
2306
+ }
2307
+ agBackEl.addEventListener("click", closeAgentHistory);
2308
+
2126
2309
  // ---------- views / tabs ----------
2127
2310
  function syncTabs() {
2128
2311
  const tabView = activeView === "taskDetail" ? "sent" : activeView;
@@ -2133,6 +2316,7 @@
2133
2316
  sentViewEl.classList.toggle("hidden", activeView !== "sent");
2134
2317
  taskDetailViewEl.classList.toggle("hidden", activeView !== "taskDetail");
2135
2318
  contactsViewEl.classList.toggle("hidden", activeView !== "contacts");
2319
+ agentsViewEl.classList.toggle("hidden", activeView !== "agents");
2136
2320
  if (activeView !== "taskDetail" && taskDetailTimer) {
2137
2321
  clearInterval(taskDetailTimer);
2138
2322
  taskDetailTimer = null;
@@ -2143,6 +2327,11 @@
2143
2327
  cvFormEl.classList.add("hidden");
2144
2328
  if (window.relay.setFocusable) window.relay.setFocusable(false);
2145
2329
  }
2330
+ if (activeView === "agents") {
2331
+ agentDetail = null; // always land on the roster
2332
+ renderAgents();
2333
+ loadAgents();
2334
+ }
2146
2335
  if (activeView === "sent" && window.relay.refreshSent) {
2147
2336
  window.relay.refreshSent().catch(() => {});
2148
2337
  }
package/overlay/main.cjs CHANGED
@@ -544,6 +544,41 @@ async function deleteContactFromBook(input) {
544
544
  return readContacts();
545
545
  }
546
546
 
547
+ // ---- foundry agents (cached like contacts; refreshed on demand) ------------
548
+ // Same rule as contacts: a slow or failing network must never blank the list.
549
+ // Keep the last good roster/history; refresh lazily when the tab is opened.
550
+
551
+ let agentsCache = [];
552
+ const agentRelaysCache = new Map(); // agentUserId -> last good history payload
553
+ async function refreshAgents() {
554
+ try {
555
+ const client = await relayClient();
556
+ const res = await client.agents();
557
+ agentsCache = Array.isArray(res && res.agents) ? res.agents : [];
558
+ } catch (error) {
559
+ // Keep the last good cache; a transient network failure must not blank the UI.
560
+ console.error("[overlay] listAgents failed:", error && error.message);
561
+ }
562
+ return agentsCache;
563
+ }
564
+ async function readAgents() {
565
+ await refreshAgents();
566
+ return agentsCache;
567
+ }
568
+ async function readAgentRelays(agentUserId) {
569
+ const id = String(agentUserId || "").trim();
570
+ if (!id) throw new Error("Missing agent id.");
571
+ try {
572
+ const client = await relayClient();
573
+ const res = await client.agentRelays(id);
574
+ agentRelaysCache.set(id, res);
575
+ } catch (error) {
576
+ console.error("[overlay] agentRelays failed:", error && error.message);
577
+ if (!agentRelaysCache.has(id)) throw error;
578
+ }
579
+ return agentRelaysCache.get(id);
580
+ }
581
+
547
582
  // ---- payload assembly + push to renderer ---------------------------------
548
583
 
549
584
  // buildPayload is synchronous over CACHES so the first paint never waits on the
@@ -1750,6 +1785,8 @@ ipcMain.handle("relay:answer", (_e, taskId, messageId, text, packetId) => answer
1750
1785
  ipcMain.handle("relay:contacts", () => readContacts());
1751
1786
  ipcMain.handle("relay:contactSave", (_e, input) => saveContact(input));
1752
1787
  ipcMain.handle("relay:contactDelete", (_e, input) => deleteContactFromBook(input));
1788
+ ipcMain.handle("relay:agents", () => readAgents());
1789
+ ipcMain.handle("relay:agentRelays", (_e, agentUserId) => readAgentRelays(agentUserId));
1753
1790
 
1754
1791
  // The overlay is normally focusable:false so it never steals keyboard focus from
1755
1792
  // Claude/Codex. Text fields (the contact form, quick reply) need focus, so the renderer
@@ -37,6 +37,10 @@ contextBridge.exposeInMainWorld("relay", {
37
37
  contactSave: (input) => ipcRenderer.invoke("relay:contactSave", input),
38
38
  contactDelete: (input) => ipcRenderer.invoke("relay:contactDelete", input),
39
39
 
40
+ // foundry agents ("My agents" tab)
41
+ agents: () => ipcRenderer.invoke("relay:agents"),
42
+ agentRelays: (agentUserId) => ipcRenderer.invoke("relay:agentRelays", agentUserId),
43
+
40
44
  // window plumbing
41
45
  setFocusable: (v) => ipcRenderer.send("relay:setFocusable", v),
42
46
  interactive: (v) => ipcRenderer.send("relay:interactive", v),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.49",
3
+ "version": "0.1.50",
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": {
package/src/client.js CHANGED
@@ -201,6 +201,14 @@ export class RelayClient {
201
201
  return this.#req("POST", "/v1/tools/call", payload);
202
202
  }
203
203
 
204
+ agents() {
205
+ return this.#req("GET", "/v1/agents");
206
+ }
207
+
208
+ agentRelays(agentUserId) {
209
+ return this.#req("GET", `/v1/agents/${encodeURIComponent(agentUserId)}/relays`);
210
+ }
211
+
204
212
  searchContacts(q) {
205
213
  return this.#req("GET", `/v1/contacts/search?q=${encodeURIComponent(q)}`);
206
214
  }
package/src/mcp.js CHANGED
@@ -60,6 +60,17 @@ export const TOOLS = [
60
60
  description: "Absolute local file paths to attach. Relay reads, hashes, and includes the bytes safely.",
61
61
  items: { type: "string" },
62
62
  },
63
+ inReplyToRelayId: {
64
+ type: "string",
65
+ description:
66
+ "When answering a question relay, pass that relay's id here — this is what resumes the asking agent's run.",
67
+ },
68
+ type: {
69
+ type: "string",
70
+ enum: ["question", "completion"],
71
+ description:
72
+ "Agent-only semantics for agent-outbound relays: 'question' pauses the sending agent's run until an owner answers; 'completion' reports a finished run. Ordinary humans omit this field.",
73
+ },
63
74
  idempotencyKey: { type: "string" },
64
75
  },
65
76
  required: ["recipient", "title", "bodyMarkdown", "idempotencyKey"],
@@ -501,6 +512,8 @@ export async function handleCall(client, name, args, { mode = DEFAULT_COMPANION_
501
512
  source: { host: "relay-mcp" },
502
513
  targetSurfaces: args.targetSurfaces || [],
503
514
  attachments: await prepareOrdinaryRelayAttachments(args),
515
+ ...(args.inReplyToRelayId ? { inReplyToRelayId: args.inReplyToRelayId } : {}),
516
+ ...(args.type ? { type: args.type } : {}),
504
517
  idempotencyKey: args.idempotencyKey,
505
518
  })),
506
519
  );