relay-companion 0.1.63 → 0.1.65

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.
@@ -50,7 +50,7 @@ function defaultLog(...args) {
50
50
  // silent dead end. `finish` (ack + spinner stop) fires exactly once for the
51
51
  // bridge/automation tiers; the fresh tier's openPacket owns its own lifecycle.
52
52
  async function openCodexInCurrent(
53
- { packetId, senderName = "", title = "" } = {},
53
+ { packetId, senderName = "", title = "", threadId = null, threadCount = 0 } = {},
54
54
  {
55
55
  inject, // src/codex-inject.js
56
56
  desktop, // src/codex-desktop.js
@@ -90,7 +90,7 @@ async function openCodexInCurrent(
90
90
  `last active ${new Date(target.lastActiveAt).toISOString()})`,
91
91
  );
92
92
 
93
- const instruction = inject.buildInjectionInstruction({ relayId: packetId, senderName, title });
93
+ const instruction = inject.buildInjectionInstruction({ relayId: packetId, senderName, title, threadId, threadCount });
94
94
 
95
95
  // ---- (a) bridge tier ----------------------------------------------------
96
96
  if (!bridgeSubmitEnabled(env)) {
@@ -2462,7 +2462,18 @@
2462
2462
  return renderThreads();
2463
2463
  }
2464
2464
  thDetailNameEl.textContent = thread.party;
2465
- thHistoryEl.innerHTML = thread.msgs.map((m) => `
2465
+ // Thread-level opens act on the newest inbound message (falling back to the
2466
+ // newest message overall): "current" injects with the threadId so the agent
2467
+ // fetches the whole exchange; "new chat" seeds the full transcript.
2468
+ const newestInbound = [...thread.msgs].reverse().find((m) => m.direction === "in") || thread.msgs[thread.msgs.length - 1];
2469
+ const threadActions = newestInbound ? `
2470
+ <div class="td-card row-open-card" data-stop="1">
2471
+ <div class="td-card-actions">
2472
+ <button class="td-btn primary" type="button" data-thread-open-current="${esc(newestInbound.id)}">Open thread in current chat</button>
2473
+ <button class="td-btn ghost" type="button" data-thread-open-fresh="${esc(newestInbound.id)}">Open thread in new chat</button>
2474
+ </div>
2475
+ </div>` : "";
2476
+ thHistoryEl.innerHTML = threadActions + thread.msgs.map((m) => `
2466
2477
  <button class="th-msg${m.unread ? " unread" : ""}" type="button" data-msg="${esc(m.id)}" data-dir="${m.direction}">
2467
2478
  <span class="th-msg-top">
2468
2479
  <span class="th-dir${m.direction === "out" ? " out" : ""}">${m.direction === "out" ? "←" : "→"}</span>
@@ -2477,6 +2488,10 @@
2477
2488
  openRelayFromUI(id, el.getAttribute("data-dir") === "out" ? "sent" : "relay");
2478
2489
  });
2479
2490
  }
2491
+ const curBtn = thHistoryEl.querySelector("[data-thread-open-current]");
2492
+ if (curBtn) curBtn.addEventListener("click", () => openRelayFromUI(curBtn.getAttribute("data-thread-open-current"), "relay", "current"));
2493
+ const freshBtn = thHistoryEl.querySelector("[data-thread-open-fresh]");
2494
+ if (freshBtn) freshBtn.addEventListener("click", () => openRelayFromUI(freshBtn.getAttribute("data-thread-open-fresh"), "relay", "fresh"));
2480
2495
  }
2481
2496
  thBackEl.addEventListener("click", () => {
2482
2497
  threadDetailId = null;
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");
@@ -1508,6 +1509,21 @@ async function openPacket(packetId, { sent = false, fresh = false } = {}) {
1508
1509
  // tools; the sender's content is never inlined (relay-briefing's
1509
1510
  // untrusted-content discipline). A missing row or no live session/thread
1510
1511
  // within 6h falls back to the fresh-open flow.
1512
+ // How many messages (inbound rows + sent copies, deduped) share a thread.
1513
+ // Gives the injected instruction its "N messages so far" and tells the model
1514
+ // when fetching the whole thread is worth it.
1515
+ function threadInfoFor(row) {
1516
+ const threadId = (row && row.threadId) || null;
1517
+ if (!threadId) return { threadId: null, threadCount: 0 };
1518
+ const ids = new Set();
1519
+ for (const r of readRelays()) if (r.threadId === threadId) ids.add(r.id);
1520
+ for (const s of sentCache || []) {
1521
+ const sid = s.relayId || s.id;
1522
+ if (sid && (s.threadId || sid) === threadId) ids.add(sid);
1523
+ }
1524
+ return { threadId, threadCount: ids.size };
1525
+ }
1526
+
1511
1527
  async function openPacketInCurrent(packetId) {
1512
1528
  if (!packetId) return;
1513
1529
  const row = rowById(packetId);
@@ -1539,6 +1555,7 @@ async function openPacketInCurrent(packetId) {
1539
1555
  packetId,
1540
1556
  senderName: row.senderName || "",
1541
1557
  title: row.title || row.displayTitle || "",
1558
+ ...threadInfoFor(row),
1542
1559
  },
1543
1560
  {
1544
1561
  inject,
@@ -1574,6 +1591,7 @@ async function openPacketInCurrent(packetId) {
1574
1591
  relayId: packetId,
1575
1592
  senderName: row.senderName || "",
1576
1593
  title: row.title || row.displayTitle || "",
1594
+ ...threadInfoFor(row),
1577
1595
  });
1578
1596
  } catch (error) {
1579
1597
  console.error("[overlay] claude injection staging failed:", error && error.message);
@@ -2158,6 +2176,24 @@ function syncTray() {
2158
2176
  } catch {}
2159
2177
  }
2160
2178
 
2179
+ // "Quit Relay" from the tray: stop the daemon and the pill's own service via a
2180
+ // DETACHED shell (the second bootout kills this process, so the shell must
2181
+ // outlive us), then quit the app immediately for instant visual feedback.
2182
+ function quitRelayCompletely() {
2183
+ try {
2184
+ const uid = typeof process.getuid === "function" ? process.getuid() : 501;
2185
+ const [cmd, args] = quitRelayCommand({ platform: process.platform, uid });
2186
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
2187
+ child.on("error", (error) => console.error(`[overlay] ${new Date().toISOString()} quit spawn failed:`, error && error.message));
2188
+ child.unref();
2189
+ } catch (error) {
2190
+ console.error(`[overlay] ${new Date().toISOString()} quit failed:`, error && error.message);
2191
+ }
2192
+ try {
2193
+ app.quit();
2194
+ } catch {}
2195
+ }
2196
+
2161
2197
  function createTray() {
2162
2198
  const icon = createTrayIcon();
2163
2199
  if (!icon || icon.isEmpty()) {
@@ -2178,7 +2214,11 @@ function createTray() {
2178
2214
  tray.on("right-click", () => {
2179
2215
  try {
2180
2216
  tray.popUpContextMenu(
2181
- Menu.buildFromTemplate([{ label: pillIsOnScreen() ? "Hide Relay" : "Show Relay", click: toggleFromTray }]),
2217
+ Menu.buildFromTemplate([
2218
+ { label: pillIsOnScreen() ? "Hide Relay" : "Show Relay", click: toggleFromTray },
2219
+ { type: "separator" },
2220
+ { label: "Quit Relay", click: quitRelayCompletely },
2221
+ ]),
2182
2222
  );
2183
2223
  } catch (error) {
2184
2224
  console.error("[overlay] tray menu failed:", error && error.message);
@@ -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.63",
3
+ "version": "0.1.65",
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": {
@@ -81,15 +81,28 @@ function sanitizeInline(value, cap = 140) {
81
81
  return flat.length > cap ? `${flat.slice(0, cap - 1).trimEnd()}\u2026` : flat;
82
82
  }
83
83
 
84
- function buildInjectionInstruction({ relayId, senderName, title } = {}) {
84
+ function buildInjectionInstruction({ relayId, senderName, title, threadId, threadCount } = {}) {
85
85
  const id = sanitizeInline(relayId, 80) || "unknown";
86
86
  const sender = sanitizeInline(senderName) || "someone";
87
87
  const subject = sanitizeInline(title) || "a relay";
88
- return (
88
+ const base =
89
89
  `The user received relay ${id} from ${sender}: '${subject}'. ` +
90
90
  "They clicked Open-in-current-chat. Fetch and open it now with the relay MCP tools " +
91
- "(relay_inbox_list / GET the relay), then act on it."
92
- );
91
+ "(relay_inbox_list / GET the relay), then act on it.";
92
+ const tid = sanitizeInline(threadId, 80);
93
+ const count = Number(threadCount) || 0;
94
+ if (tid && count > 1) {
95
+ return (
96
+ base +
97
+ ` This relay is part of a conversation thread (threadId ${tid}, ${count} messages so far). ` +
98
+ "Call relay_thread_fetch with that threadId FIRST so the whole exchange is in your context, " +
99
+ "then act on the latest message in the thread."
100
+ );
101
+ }
102
+ if (tid) {
103
+ return base + ` (Its threadId is ${tid}; if replies exist later, relay_thread_fetch retrieves the full conversation.)`;
104
+ }
105
+ return base;
93
106
  }
94
107
 
95
108
  // Stage one pending injection for a live session (or `sessionId: "any"` to
package/src/client.js CHANGED
@@ -201,6 +201,10 @@ export class RelayClient {
201
201
  return this.#req("POST", "/v1/tools/call", payload);
202
202
  }
203
203
 
204
+ thread(threadId) {
205
+ return this.#req("GET", `/v1/threads/${encodeURIComponent(threadId)}`);
206
+ }
207
+
204
208
  searchContacts(q) {
205
209
  return this.#req("GET", `/v1/contacts/search?q=${encodeURIComponent(q)}`);
206
210
  }
@@ -149,9 +149,43 @@ async function resolveRow(id, { log = () => {}, allowTaskRows = false } = {}) {
149
149
  // the next render echo the prior seed into itself (a doubled body). briefingMarkdown
150
150
  // is preview-only and is not read back by the seed builders, so it is safe to set.
151
151
  row.briefingMarkdown = renderRelayRowBriefing(row);
152
+ await prependThreadTranscript(row, { log });
152
153
  return { row, rowState };
153
154
  }
154
155
 
156
+ // "Open in new chat" on a relay that belongs to a multi-message thread must
157
+ // seed the WHOLE conversation — like opening a group chat — not just the one
158
+ // message. Fetch the thread from the API and prepend a chronological
159
+ // transcript to the briefing; on any failure the single-relay seed stands.
160
+ async function prependThreadTranscript(row, { log = () => {} } = {}) {
161
+ const threadId = row?.threadId || null;
162
+ if (!threadId) return;
163
+ try {
164
+ const { RelayClient } = await import("./client.js");
165
+ const client = new RelayClient();
166
+ const thread = await client.thread(threadId);
167
+ const msgs = Array.isArray(thread?.messages) ? thread.messages : Array.isArray(thread?.items) ? thread.items : [];
168
+ if (msgs.length < 2) return;
169
+ const lines = msgs.map((m) => {
170
+ const id = m.relayId || m.id || "";
171
+ const from = m.fromDisplayName || m.senderName || (m.direction === "outbound" ? "You" : "Them");
172
+ const when = m.createdAt || "";
173
+ const title = m.title || "";
174
+ const body = String(m.bodyMarkdown || m.preview || "").trim();
175
+ return `**${from}** — ${when} (relay ${id})\n${title ? `*${title}*\n` : ""}${body}`;
176
+ });
177
+ row.briefingMarkdown =
178
+ `## Conversation thread (${msgs.length} messages, oldest first — threadId ${threadId})\n\n` +
179
+ `${lines.join("\n\n---\n\n")}\n\n` +
180
+ `> The relay the user opened is the one addressed below. The full exchange above is context; ` +
181
+ `relay_thread_fetch with threadId ${threadId} re-fetches it at any time.\n\n---\n\n` +
182
+ row.briefingMarkdown;
183
+ log(`thread transcript seeded: ${msgs.length} messages from ${threadId}`);
184
+ } catch (error) {
185
+ log(`thread transcript skipped: ${error?.message || error}`);
186
+ }
187
+ }
188
+
155
189
  async function resolvePublicOpenToken(token, { log = () => {} } = {}) {
156
190
  try {
157
191
  const { RelayClient } = await import("./client.js");
package/src/mcp.js CHANGED
@@ -123,6 +123,21 @@ export const TOOLS = [
123
123
  "List ordinary Relay messages addressed to this human. Each item carries threadId (the conversation thread it belongs to — the root relay's id) and, on replies, inReplyToRelayId; use them to reconstruct the recent exchange with a sender before continuing it via relay_send's inReplyToRelayId. This does not list task coordination relays; use relay_task_status for task state.",
124
124
  inputSchema: { type: "object", properties: {} },
125
125
  },
126
+ {
127
+ name: "relay_thread_fetch",
128
+ description:
129
+ "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).",
130
+ inputSchema: {
131
+ type: "object",
132
+ properties: {
133
+ threadId: {
134
+ type: "string",
135
+ description: "The conversation thread id — the root relay's id, as carried on inbox items and sent responses.",
136
+ },
137
+ },
138
+ required: ["threadId"],
139
+ },
140
+ },
126
141
  {
127
142
  name: "relay_inbox_delete",
128
143
  description:
@@ -479,6 +494,7 @@ export const MESSAGE_ONLY_TOOL_NAMES = new Set([
479
494
  "relay_contact_update",
480
495
  "relay_groups_list",
481
496
  "relay_inbox_list",
497
+ "relay_thread_fetch",
482
498
  "relay_acknowledge",
483
499
  ]);
484
500
 
@@ -548,6 +564,8 @@ export async function handleCall(client, name, args, { mode = DEFAULT_COMPANION_
548
564
  );
549
565
  case "relay_inbox_list":
550
566
  return text(await client.inbox());
567
+ case "relay_thread_fetch":
568
+ return text(await client.thread(args.threadId));
551
569
  case "relay_inbox_delete":
552
570
  return text(await client.deleteInboxItem(args.itemId, { idempotencyKey: args.idempotencyKey }));
553
571
  case "relay_recently_deleted_list":