relay-companion 0.1.113 → 0.1.115

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.
package/bin/relay.js CHANGED
@@ -178,14 +178,37 @@ async function applyInstall({
178
178
  const detail = pill.reason ? ` (${pill.reason})` : "";
179
179
  console.log(`Could not install Relay pill autostart${detail}. Run \`relay pill\` to open it.`);
180
180
  }
181
+ // Nothing was installed because no agent lives on this machine. This is the
182
+ // single most likely outcome for someone who was sent a relay and followed the
183
+ // instructions, and it used to print the skip lines above and exit 0 -- with
184
+ // the downloadable .command wrapper then adding "Relay is set up." The person
185
+ // ends up with a paired device, a daemon and a pill, and no way to receive a
186
+ // relay in an agent, having been told it worked.
187
+ if (!installed.length) {
188
+ console.log("");
189
+ console.log("Relay could not finish: no agent was found on this machine.");
190
+ console.log("Relay delivers into a coding agent, so it needs one of these installed first:");
191
+ console.log(" Claude Code https://claude.com/claude-code");
192
+ console.log(" Codex https://developers.openai.com/codex");
193
+ console.log("Install one, then run this command again.");
194
+ console.log("");
195
+ console.log(`In the meantime you can read and reply to your relays on the web: ${absoluteWebTarget("/app/relays")}`);
196
+ return { installed, missing, daemon, pill, activations, agentMissing: true };
197
+ }
198
+
181
199
  const liveRequirement = liveToolRequirement({ activations, requiredHosts });
182
- if (requireLiveTools && installed.length && !liveRequirement.ok) {
200
+ if (requireLiveTools && !liveRequirement.ok) {
201
+ // Registration only takes effect in a NEW agent session, so the session that
202
+ // ran this command can never report itself ready -- verifyClaudeMcpRegistration
203
+ // returns currentSessionReady:false on every path by construction. Treating
204
+ // that as a failure turned a successful install into `exit 1`, and skipped the
205
+ // open-relay step that is the entire reason the recipient ran this. Say what
206
+ // to do instead of failing.
183
207
  const hostLabel = liveRequirement.missingHosts.length
184
208
  ? liveRequirement.missingHosts.join(" and ")
185
- : "this running agent session";
186
- throw new Error(
187
- `Relay paired and installed, but ${hostLabel} cannot call relay_send yet. Setup is not ready in the current agent.`,
188
- );
209
+ : "this agent session";
210
+ console.log("");
211
+ console.log(`Relay is installed. Restart ${hostLabel} (or open a new session) to use it.`);
189
212
  }
190
213
  return { installed, missing, daemon, pill, activations };
191
214
  }
@@ -196,7 +219,7 @@ async function cmdSetup(flags) {
196
219
  writeConfig({ companionMode: mode });
197
220
  await cmdPair(flags, { promptForDefaults: Boolean(flags.interactive) });
198
221
  console.log("");
199
- await applyInstall({
222
+ const install = await applyInstall({
200
223
  requireLiveTools: Boolean(flags["require-live-tools"]) || shouldRequireLiveTools(),
201
224
  requiredHosts: requiredLiveHosts(),
202
225
  mode,
@@ -222,6 +245,10 @@ async function cmdSetup(flags) {
222
245
  console.log(`Relay ${result.relayId} is staged in the Relay pill.`);
223
246
  }
224
247
  }
248
+ // The relay is opened/staged first -- the recipient should still get the thing
249
+ // they came for -- but setup did not achieve what it claims, so it must not
250
+ // report success. `exitCode` rather than `exit()` so nothing above is truncated.
251
+ if (install?.agentMissing) process.exitCode = 1;
225
252
  }
226
253
 
227
254
  /** Install the tools + daemon on a device that is already paired. */
@@ -3557,6 +3557,7 @@
3557
3557
  </span>
3558
3558
  </div>
3559
3559
  <div class="open-actions sv-actions" data-stop="1" role="menu">
3560
+ <button class="oa-item" type="button" role="menuitem" id="svAccount" ${svBusy ? "disabled" : ""}>Account Settings…</button>
3560
3561
  <button class="oa-item oa-primary" type="button" role="menuitem" id="svSwitch" ${svBusy ? "disabled" : ""}>Switch Account…</button>
3561
3562
  <div class="oa-sep" role="separator"></div>
3562
3563
  <button class="oa-item${svSignOutArmed ? " sv-signout-armed" : ""}" type="button" role="menuitem" id="svSignOut" ${svBusy ? "disabled" : ""}>${svSignOutArmed ? "Sign Out — click again to confirm" : "Sign Out"}</button>
@@ -3597,6 +3598,16 @@
3597
3598
  for (const z of settingsViewEl.querySelectorAll('[data-stop="1"]')) {
3598
3599
  z.addEventListener("click", (e) => e.stopPropagation());
3599
3600
  }
3601
+ // Managing addresses lives on the web (Clerk owns verification and the
3602
+ // reverification challenge), so the pill's job is to get you there rather
3603
+ // than to reimplement it. Without this the account panel showed an email the
3604
+ // user had no way to change from the surface they actually live in.
3605
+ const accountEl = document.getElementById("svAccount");
3606
+ if (accountEl) {
3607
+ accountEl.addEventListener("click", () => {
3608
+ if (window.relay.openUrl) window.relay.openUrl("/app/settings");
3609
+ });
3610
+ }
3600
3611
  const switchEl = document.getElementById("svSwitch");
3601
3612
  if (switchEl) switchEl.addEventListener("click", beginPairFlow);
3602
3613
  const signOutEl = document.getElementById("svSignOut");
package/overlay/main.cjs CHANGED
@@ -96,6 +96,7 @@ const {
96
96
  companionModeFromRuntime,
97
97
  taskFeaturesAllowed,
98
98
  } = require("./mode-policy.cjs");
99
+ const { openingFaceFor, titleFromBody } = require("./message-face.cjs");
99
100
  const perf = require("./perf-counters.cjs");
100
101
 
101
102
  const RELAY_HOME = process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion");
@@ -1392,6 +1393,17 @@ function previewPayloadForPacket(packetId) {
1392
1393
  // The thread this message belongs to, so the preview can ask for the
1393
1394
  // conversation around it. A relay that predates threads is its own root.
1394
1395
  threadId: String(row.threadId || id),
1396
+ // A chat message opens IN its conversation; a relay carrying a subject of
1397
+ // its own opens on the reading face. Named fields only — the projector
1398
+ // never spreads a staged row, here or anywhere. See message-face.cjs.
1399
+ openFace: openingFaceFor({
1400
+ threadId: row.threadId || id,
1401
+ title: row.title || row.displayTitle,
1402
+ bodyMarkdown: row.bodyMarkdown,
1403
+ relayNotificationKind: row.relayNotificationKind,
1404
+ taskId: row.taskId,
1405
+ type: row.type,
1406
+ }),
1395
1407
  };
1396
1408
  }
1397
1409
 
@@ -1414,6 +1426,12 @@ function previewPayloadForSent(relayId) {
1414
1426
  unread: false,
1415
1427
  outbound: true,
1416
1428
  threadId: String(item.threadId || id),
1429
+ openFace: openingFaceFor({
1430
+ threadId: item.threadId || id,
1431
+ title: item.title || item.displayTitle,
1432
+ bodyMarkdown: item.bodyMarkdown || item.preview,
1433
+ type: item.type,
1434
+ }),
1417
1435
  };
1418
1436
  }
1419
1437
 
@@ -3071,20 +3089,6 @@ async function previewChat(threadId) {
3071
3089
  }
3072
3090
  }
3073
3091
 
3074
- /**
3075
- * A chat message has a body, not a subject — but a relay carries a title, and
3076
- * the Relays list, notifications and email subjects all read it. Take the first
3077
- * line so a quick reply still looks like something in every one of those.
3078
- */
3079
- function replyTitleFromBody(body) {
3080
- const first = String(body || "")
3081
- .split("\n")
3082
- .map((line) => line.replace(/^\s*[>#*\-\d.]+\s*/, "").trim())
3083
- .find(Boolean);
3084
- const title = (first || "Reply").replace(/\s+/g, " ").trim();
3085
- return title.length > 120 ? `${title.slice(0, 119).trimEnd()}…` : title;
3086
- }
3087
-
3088
3092
  /**
3089
3093
  * Send a reply. The recipient is deliberately left unset: the API addresses a
3090
3094
  * reply to the other party of the message it answers, and fans it out to the
@@ -3106,7 +3110,10 @@ async function sendPreviewReply(input) {
3106
3110
  const client = await relayClient();
3107
3111
  const sent = await client.sendRelay({
3108
3112
  recipient: {},
3109
- title: replyTitleFromBody(body),
3113
+ // A chat message has a body, not a subject: the first line becomes the
3114
+ // title every Relay surface reads — and is what marks this, when it comes
3115
+ // back, as a line of a conversation rather than a relay to read.
3116
+ title: titleFromBody(body),
3110
3117
  bodyMarkdown: body,
3111
3118
  inReplyToRelayId,
3112
3119
  idempotencyKey,
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Which face the preview window opens on: the single message it was opened
5
+ * from, or the conversation that message belongs to.
6
+ *
7
+ * A relay carries a title. A CHAT MESSAGE does not — every surface that needs a
8
+ * subject for one (the Relays and Sent lists, notifications, the email subject)
9
+ * derives it from the first line of what the person typed, in `titleFromBody`
10
+ * below. So a message whose title says nothing its own body has not already
11
+ * said has nothing to READ on the message face: it is one line of a
12
+ * conversation, and it opens IN that conversation. Anything carrying a subject
13
+ * of its own — an agent's relay, a question, a task — still opens on its own
14
+ * reading face, with the conversation one click away on the chip.
15
+ */
16
+
17
+ /** Staged kinds that are agent or system output, never someone talking. */
18
+ const NON_CHAT_KINDS = new Set([
19
+ "human_question",
20
+ "task_request",
21
+ "task_open",
22
+ "task_completed",
23
+ "result",
24
+ "share_approval",
25
+ "connector_reauth",
26
+ ]);
27
+
28
+ /** The length a derived title is cut to before it is ellipsised. */
29
+ const TITLE_MAX = 120;
30
+
31
+ /** The first line that says something, with its Markdown marker stripped. */
32
+ function firstLineOf(body) {
33
+ return (
34
+ String(body || "")
35
+ .split("\n")
36
+ .map((line) => line.replace(/^\s*[>#*\-\d.]+\s*/, "").trim())
37
+ .find(Boolean) || ""
38
+ );
39
+ }
40
+
41
+ /**
42
+ * A chat message has a body, not a subject — but a relay carries a title that
43
+ * Relay's lists, notifications and email subjects all read. Use the first line
44
+ * so a conversational message still reads as something in every one of those.
45
+ * This is the derivation that WRITES those titles; the recogniser below reads
46
+ * them back, and they live together so they cannot drift apart.
47
+ */
48
+ function titleFromBody(body) {
49
+ const title = (firstLineOf(body) || "Reply").replace(/\s+/g, " ").trim();
50
+ return title.length > TITLE_MAX ? `${title.slice(0, TITLE_MAX - 1).trimEnd()}…` : title;
51
+ }
52
+
53
+ function normalize(value) {
54
+ return String(value == null ? "" : value)
55
+ .replace(/\s+/g, " ")
56
+ .trim()
57
+ .toLowerCase();
58
+ }
59
+
60
+ /**
61
+ * Does this title say anything the body's first line does not? Compared
62
+ * loosely on purpose: a derived title is cut at TITLE_MAX while the line it
63
+ * came from keeps going, so a long single-line message is recognised by its
64
+ * stem. The stem has to be substantial before a prefix counts — a two-word
65
+ * subject that happens to start a longer sentence is a real subject.
66
+ */
67
+ function titleIsJustTheFirstLine(title, body) {
68
+ const lead = normalize(firstLineOf(body));
69
+ if (!lead) return false; // no body to speak for itself
70
+ const subject = normalize(title);
71
+ if (!subject) return true; // nothing but a body
72
+ if (subject === lead) return true;
73
+ const stem = subject.replace(/…+$/, "").trim();
74
+ return stem.length >= 24 && lead.startsWith(stem);
75
+ }
76
+
77
+ /** Is this relay one line of a conversation rather than something to read? */
78
+ function isChatMessage(row = {}) {
79
+ const kind = String((row && row.relayNotificationKind) || "")
80
+ .trim()
81
+ .toLowerCase();
82
+ if (NON_CHAT_KINDS.has(kind)) return false;
83
+ // A relay attached to a task, or one an agent marked as a question or a
84
+ // completion, is that agent's output — it belongs on the reading face.
85
+ if (row && row.taskId) return false;
86
+ const type = String((row && row.type) || "")
87
+ .trim()
88
+ .toLowerCase();
89
+ if (type === "question" || type === "completion") return false;
90
+ return titleIsJustTheFirstLine((row && (row.title || row.displayTitle)) || "", row && row.bodyMarkdown);
91
+ }
92
+
93
+ /**
94
+ * "chat" opens in the conversation; "message" opens on the reading face. A
95
+ * message with no conversation to open into always gets the reading face.
96
+ */
97
+ function openingFaceFor(row = {}) {
98
+ if (!String((row && row.threadId) || "").trim()) return "message";
99
+ return isChatMessage(row) ? "chat" : "message";
100
+ }
101
+
102
+ module.exports = {
103
+ NON_CHAT_KINDS,
104
+ TITLE_MAX,
105
+ firstLineOf,
106
+ isChatMessage,
107
+ openingFaceFor,
108
+ titleFromBody,
109
+ titleIsJustTheFirstLine,
110
+ };
@@ -45,6 +45,8 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
45
45
  let content = {};
46
46
  /** The loaded conversation this message belongs to, or null. */
47
47
  let chat = null;
48
+ /** Has the reader already been placed in this conversation once? */
49
+ let landed = false;
48
50
  let face = "message";
49
51
  let sending = false;
50
52
  /** Replies shown before the server has confirmed them. */
@@ -155,7 +157,9 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
155
157
  }
156
158
 
157
159
  function composerPlaceholder() {
158
- if (face === "chat" && chat) return `Message ${text(chat.title) || "this conversation"}…`;
160
+ if (face === "chat") {
161
+ return `Message ${text(chat && chat.title) || chatTitleHint(content) || "this conversation"}…`;
162
+ }
159
163
  const who = text(content.senderName).replace(/^You → /, "");
160
164
  return who && !content.outbound ? `Reply to ${who.split(" ")[0]}…` : "Write a reply…";
161
165
  }
@@ -199,19 +203,60 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
199
203
  headChatEl.classList.toggle("gone", !onChat);
200
204
  faceMessageEl.setAttribute("aria-hidden", onChat ? "true" : "false");
201
205
  faceChatEl.setAttribute("aria-hidden", onChat ? "false" : "true");
202
- if (onChat) {
203
- renderChat();
204
- scrollChatToLatest();
205
- }
206
+ if (onChat) paintChat();
206
207
  refreshComposer();
207
208
  }
208
209
 
210
+ /**
211
+ * Show the conversation, and put the reader where they belong in it: on the
212
+ * message they came in on the first time, at the live end after that.
213
+ */
214
+ function paintChat({ stick = true } = {}) {
215
+ renderChat();
216
+ if (!chat) return; // still loading — the load will place them
217
+ if (landed) {
218
+ if (stick) scrollChatToLatest();
219
+ return;
220
+ }
221
+ landed = true;
222
+ scrollChatToOpened();
223
+ }
224
+
209
225
  function scrollChatToLatest() {
210
226
  requestAnimationFrame(() => {
211
227
  chatScrollEl.scrollTop = chatScrollEl.scrollHeight;
212
228
  });
213
229
  }
214
230
 
231
+ const cssEscape = (value) =>
232
+ typeof CSS !== "undefined" && CSS.escape ? CSS.escape(value) : String(value).replace(/["\\]/g, "\\$&");
233
+
234
+ /**
235
+ * Land on the message the window was opened from. Opening a text message
236
+ * three days old should not drop the reader at the bottom of a conversation
237
+ * that has moved on since — but the newest message belongs where a
238
+ * conversation always sits, at the end.
239
+ *
240
+ * Centred by moving THIS scroller and nothing else. scrollIntoView is wrong
241
+ * here: it scrolls every scrollable ancestor, and the faces track is one of
242
+ * them (overflow:hidden still scrolls programmatically), so it slid the whole
243
+ * conversation sideways out of the window while every DOM check still passed.
244
+ */
245
+ function scrollChatToOpened() {
246
+ const id = text(content.relayId);
247
+ const target = id ? chatListEl.querySelector(`.bubble-row[data-relay-id="${cssEscape(id)}"]`) : null;
248
+ if (!target || !target.nextElementSibling) {
249
+ scrollChatToLatest();
250
+ return;
251
+ }
252
+ requestAnimationFrame(() => {
253
+ if (!target.isConnected) return;
254
+ const view = chatScrollEl.getBoundingClientRect();
255
+ const row = target.getBoundingClientRect();
256
+ chatScrollEl.scrollTop += row.top + row.height / 2 - (view.top + view.height / 2);
257
+ });
258
+ }
259
+
215
260
  function nearChatBottom() {
216
261
  return chatScrollEl.scrollHeight - chatScrollEl.scrollTop - chatScrollEl.clientHeight < 90;
217
262
  }
@@ -228,6 +273,8 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
228
273
  if (!threadId) {
229
274
  chat = null;
230
275
  updateChip();
276
+ // Nothing to open into: never strand the reader on an empty conversation.
277
+ if (face === "chat") showFace("message");
231
278
  return;
232
279
  }
233
280
  let result;
@@ -247,7 +294,13 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
247
294
  }
248
295
  chat = null;
249
296
  updateChip();
250
- if (face === "chat") renderChatState(result && result.error, true);
297
+ // A message that opened straight into its conversation has nowhere to sit
298
+ // if that conversation will not load. Give the reader back the message
299
+ // they clicked, with the reason on the composer's note line.
300
+ if (face === "chat") {
301
+ showFace("message");
302
+ setNote(text(result && result.error) || "Could not open this conversation.", true);
303
+ }
251
304
  return;
252
305
  }
253
306
  chat = result.chat || null;
@@ -258,11 +311,7 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
258
311
  // the server accepted is retired here by its own local id instead.
259
312
  outgoing = outgoing.filter((o) => o.state !== "sent");
260
313
  updateChip();
261
- if (face === "chat") {
262
- const stick = keepScroll ? nearChatBottom() : true;
263
- renderChat();
264
- if (stick) scrollChatToLatest();
265
- }
314
+ if (face === "chat") paintChat({ stick: keepScroll ? nearChatBottom() : true });
266
315
  refreshComposer();
267
316
  }
268
317
 
@@ -276,6 +325,17 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
276
325
  return `p${hash % 8}`;
277
326
  }
278
327
 
328
+ /**
329
+ * Who the conversation is with, read off the message alone. Shown while the
330
+ * chat loads so a window that opens straight into a conversation names it
331
+ * from the first frame instead of saying "Conversation" and then changing.
332
+ */
333
+ function chatTitleHint(row) {
334
+ return text(row && row.senderName)
335
+ .replace(/^From\s+/i, "")
336
+ .replace(/^You\s*→\s*/, "");
337
+ }
338
+
279
339
  function renderChatState(message, isError) {
280
340
  chatListEl.replaceChildren();
281
341
  chatStateEl.textContent = text(message) || "No messages here yet.";
@@ -304,6 +364,8 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
304
364
  function buildBubble(entry, { showAuthor, lastOfRun, firstOfRun }) {
305
365
  const row = document.createElement("div");
306
366
  row.className = `bubble-row ${entry.mine ? "mine" : "theirs"}`;
367
+ // Named so the conversation can be scrolled to one particular message.
368
+ if (entry.relayId) row.dataset.relayId = entry.relayId;
307
369
  if (firstOfRun) row.classList.add("first-of-run");
308
370
  if (lastOfRun) row.classList.add("last-of-run");
309
371
  if (entry.unread) row.classList.add("unread");
@@ -638,11 +700,17 @@ try { if (localStorage.getItem("relayTheme") === "dark") document.documentElemen
638
700
  chat = null;
639
701
  outgoing = [];
640
702
  expanded.clear();
703
+ landed = false;
641
704
  chatRevision += 1;
642
705
  updateChip();
643
- // A new message always opens on the message face, whichever face the reader
644
- // left the last one on.
645
- showFace("message", { force: true });
706
+ // A new message opens on the face its own content earns, whichever face the
707
+ // reader left the last one on: a chat message opens IN its conversation —
708
+ // there is nothing on the reading face the conversation does not already
709
+ // show — and everything else opens on the message itself. main decides;
710
+ // see message-face.cjs.
711
+ const opensInChat = text(row.openFace) === "chat" && Boolean(text(row.threadId));
712
+ if (opensInChat) chatNameEl.textContent = chatTitleHint(row) || "Conversation";
713
+ showFace(opensInChat ? "chat" : "message", { force: true });
646
714
  setNote("");
647
715
  refreshComposer();
648
716
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.113",
3
+ "version": "0.1.115",
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": {