relay-companion 0.1.2 → 0.1.4

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
@@ -13,6 +13,7 @@ import { runMcpServer } from "../src/mcp.js";
13
13
  import { runSetupInstall, runUninstall } from "../src/install.js";
14
14
  import { openRelay, openTask } from "../src/materializer.js";
15
15
  import { resetCompanionStateForAccount } from "../src/notifications.js";
16
+ import { finishSetupOpenRelay, normalizeSetupHost, setupOpenRelayToken, setupOpenStatus } from "../src/setup-open.js";
16
17
 
17
18
  function parseFlags(argv) {
18
19
  const flags = {};
@@ -83,6 +84,24 @@ async function cmdSetup(flags) {
83
84
  await cmdPair(flags, { promptForDefaults: Boolean(flags.interactive) });
84
85
  console.log("");
85
86
  applyInstall();
87
+ const token = setupOpenRelayToken(flags);
88
+ if (token) {
89
+ console.log("");
90
+ const host = normalizeSetupHost(flags.host || flags.in);
91
+ const result = await finishSetupOpenRelay({
92
+ token,
93
+ host,
94
+ log: (m) => process.stderr.write(`[relay] ${m}\n`),
95
+ });
96
+ const openStatus = setupOpenStatus(result.opened, openUrl);
97
+ if (openStatus.opened) {
98
+ console.log(`Opened relay ${result.relayId} in ${host === "codex" ? "Codex" : "Claude Code"}.`);
99
+ } else if (openStatus.url) {
100
+ console.log(`Relay ${result.relayId} is ready. Open this URL: ${openStatus.url}`);
101
+ } else {
102
+ console.log(`Relay ${result.relayId} is staged in the Relay pill.`);
103
+ }
104
+ }
86
105
  }
87
106
 
88
107
  /** Install the tools + daemon on a device that is already paired. */
@@ -251,6 +270,7 @@ async function main() {
251
270
  "",
252
271
  "Usage:",
253
272
  " relay setup [--code CODE] [--name NAME] Pair this machine and add Relay to Claude Code + Codex",
273
+ " relay setup --code CODE --open-relay TOKEN --host codex|claude",
254
274
  " relay setup --interactive Prompt for API URL and device name during setup",
255
275
  " relay install Add Relay to your agents (device already paired)",
256
276
  " relay uninstall Remove Relay from your agents and stop the daemon",
@@ -14,6 +14,7 @@
14
14
  --ink:#1f1a17; --ink-2:#2c2824;
15
15
  --muted:#7c736b; --muted-2:#9a928a; --muted-3:#b7b1a6;
16
16
  --accent:#305566; --accent-soft:rgba(48,85,102,.10);
17
+ --brand-anthropic:#D9704E;
17
18
  --hair:rgba(31,26,23,.07); /* between relay/task items */
18
19
  --hair-2:rgba(31,26,23,.06); /* between contact rows */
19
20
  --serif:'Newsreader',Georgia,serif;
@@ -62,8 +63,10 @@
62
63
  padding:0 18px; cursor:pointer;
63
64
  }
64
65
  .mark { display:block; }
65
- .mark rect { fill:var(--ink); transition:fill .25s var(--settle); }
66
- .card.has-unread .sq0 { fill:var(--accent); }
66
+ .mark rect { transition:fill .25s var(--settle); }
67
+ .mark rect:nth-child(1) { fill:var(--brand-anthropic); }
68
+ .mark rect:nth-child(2) { fill:var(--ink); }
69
+ .mark rect:nth-child(3) { fill:url(#relayCodexGradient); }
67
70
  .word { font-family:var(--serif); font-size:19px; font-weight:500; letter-spacing:-.01em; color:var(--ink); line-height:1; }
68
71
  .spacer { flex:1 1 auto; }
69
72
  .minimize-hint {
@@ -276,7 +279,6 @@
276
279
  height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center;
277
280
  gap:11px; padding:34px 28px; text-align:center;
278
281
  }
279
- .empty .mark rect { fill:var(--muted-3); }
280
282
  .empty .t1 { font-family:var(--serif); font-size:16px; color:#4f4740; }
281
283
  .empty .t2 { font-size:12.5px; color:var(--muted-2); max-width:210px; line-height:1.45; }
282
284
  .gone { display:none !important; }
@@ -387,10 +389,22 @@
387
389
  </style>
388
390
  </head>
389
391
  <body>
392
+ <svg width="0" height="0" viewBox="0 0 0 0" aria-hidden="true" focusable="false">
393
+ <defs>
394
+ <linearGradient id="relayCodexGradient" x1="0%" y1="0%" x2="100%" y2="100%">
395
+ <stop offset="0%" stop-color="#C1C1FA"/>
396
+ <stop offset="16%" stop-color="#C0B1F9"/>
397
+ <stop offset="38%" stop-color="#7790F7"/>
398
+ <stop offset="58%" stop-color="#5F74F6"/>
399
+ <stop offset="82%" stop-color="#342CF5"/>
400
+ <stop offset="100%" stop-color="#3229F5"/>
401
+ </linearGradient>
402
+ </defs>
403
+ </svg>
390
404
  <div class="card" id="card">
391
405
  <div class="lockup" id="lockup">
392
406
  <svg class="mark" width="17" height="17" viewBox="0 0 16 16" aria-hidden="true">
393
- <rect class="sq0" x="0" y="6" width="4" height="4"/>
407
+ <rect x="0" y="6" width="4" height="4"/>
394
408
  <rect x="6" y="6" width="4" height="4"/>
395
409
  <rect x="12" y="6" width="4" height="4"/>
396
410
  </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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/client.js CHANGED
@@ -74,6 +74,10 @@ export class RelayClient {
74
74
  return this.#req("GET", `/v1/open/${encodeURIComponent(token)}/packet`, undefined, { auth: false });
75
75
  }
76
76
 
77
+ bindOpenRelay(token) {
78
+ return this.#req("POST", `/v1/open/${encodeURIComponent(token)}/bind`, {});
79
+ }
80
+
77
81
  createFileUpload(payload) {
78
82
  return this.#req("POST", "/v1/files", payload);
79
83
  }
@@ -182,12 +186,21 @@ export class RelayClient {
182
186
  return this.#req("GET", "/v1/contacts");
183
187
  }
184
188
 
185
- upsertContact({ name, email, emails }) {
186
- return this.#req("POST", "/v1/contacts", { name, email, emails });
189
+ upsertContact({ name, firstName, surname, lastName, email, emails, notes, idempotencyKey }) {
190
+ return this.#req("POST", "/v1/contacts", { name, firstName, surname, lastName, email, emails, notes, idempotencyKey });
187
191
  }
188
192
 
189
- updateContact(contactId, { name, email, emails } = {}) {
190
- return this.#req("PATCH", `/v1/contacts/${encodeURIComponent(contactId)}`, { name, email, emails });
193
+ updateContact(contactId, { name, firstName, surname, lastName, email, emails, notes, idempotencyKey } = {}) {
194
+ return this.#req("PATCH", `/v1/contacts/${encodeURIComponent(contactId)}`, {
195
+ name,
196
+ firstName,
197
+ surname,
198
+ lastName,
199
+ email,
200
+ emails,
201
+ notes,
202
+ idempotencyKey,
203
+ });
191
204
  }
192
205
 
193
206
  importContacts(contacts, source = "imported") {
package/src/mcp.js CHANGED
@@ -29,7 +29,22 @@ export const TOOLS = [
29
29
  type: "array",
30
30
  items: { type: "string", enum: ["codex", "claude_code", "claude_desktop", "claude_cowork"] },
31
31
  },
32
- attachments: { type: "array", items: { type: "object" } },
32
+ attachments: {
33
+ type: "array",
34
+ description:
35
+ "Optional files to send with the relay. Each item must include id, name, contentType, bytes, and optionally sha256. If the recipient may not be on Relay and will receive email fallback, include contentBase64 with the exact file bytes so Relay can attach the files directly to the email; otherwise the API will reject the send instead of emailing a broken partial relay.",
36
+ items: {
37
+ type: "object",
38
+ properties: {
39
+ id: { type: "string" },
40
+ name: { type: "string" },
41
+ contentType: { type: "string" },
42
+ bytes: { type: "number" },
43
+ sha256: { type: "string" },
44
+ contentBase64: { type: "string" },
45
+ },
46
+ },
47
+ },
33
48
  idempotencyKey: { type: "string" },
34
49
  },
35
50
  required: ["recipient", "title", "bodyMarkdown", "idempotencyKey"],
@@ -45,6 +60,25 @@ export const TOOLS = [
45
60
  required: ["query"],
46
61
  },
47
62
  },
63
+ {
64
+ name: "relay_contact_update",
65
+ description:
66
+ "Correct a Relay contact in this human's contact book. Use this after relay_send auto-creates an email-only contact, or when the human asks you to fix a saved contact. Prefer firstName and surname over a single name string. If you are not sure of the person's first name and surname, ask the human for clarification before editing the contact.",
67
+ inputSchema: {
68
+ type: "object",
69
+ properties: {
70
+ contactId: { type: "string", description: "The contactId returned by relay_contacts_search or relay_send.contact.contactId." },
71
+ firstName: { type: "string" },
72
+ surname: { type: "string" },
73
+ name: { type: "string", description: "Fallback display name; prefer firstName + surname when possible." },
74
+ email: { type: "string" },
75
+ emails: { type: "array", items: { type: "string" } },
76
+ notes: { type: "string" },
77
+ idempotencyKey: { type: "string" },
78
+ },
79
+ required: ["contactId", "idempotencyKey"],
80
+ },
81
+ },
48
82
  {
49
83
  name: "relay_inbox_list",
50
84
  description:
@@ -318,11 +352,26 @@ function text(obj) {
318
352
  return { content: [{ type: "text", text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] };
319
353
  }
320
354
 
355
+ function relaySendResultForAgent(result) {
356
+ if (!result?.contact?.autoCreated) return result;
357
+ return {
358
+ ...result,
359
+ agentInstruction:
360
+ result.agentInstruction ||
361
+ [
362
+ `Relay auto-added ${result.contact.email} to this human's contact book because there was no saved contact for this recipient.`,
363
+ "You are responsible for correcting the contact's firstName and surname with relay_contact_update if you know them from reliable context.",
364
+ "If you are not sure of the person's first name and surname, ask the human for clarification before editing the contact.",
365
+ ].join(" "),
366
+ nextRecommendedTool: "relay_contact_update",
367
+ };
368
+ }
369
+
321
370
  export async function handleCall(client, name, args) {
322
371
  switch (name) {
323
372
  case "relay_send":
324
373
  return text(
325
- await client.sendRelay({
374
+ relaySendResultForAgent(await client.sendRelay({
326
375
  recipient: args.recipient,
327
376
  kind: args.kind || "message",
328
377
  title: args.title,
@@ -332,10 +381,22 @@ export async function handleCall(client, name, args) {
332
381
  targetSurfaces: args.targetSurfaces || [],
333
382
  attachments: args.attachments || [],
334
383
  idempotencyKey: args.idempotencyKey,
335
- }),
384
+ })),
336
385
  );
337
386
  case "relay_contacts_search":
338
387
  return text(await client.searchContacts(args.query));
388
+ case "relay_contact_update":
389
+ return text(
390
+ await client.updateContact(args.contactId, {
391
+ firstName: args.firstName,
392
+ surname: args.surname,
393
+ name: args.name,
394
+ email: args.email,
395
+ emails: args.emails,
396
+ notes: args.notes,
397
+ idempotencyKey: args.idempotencyKey,
398
+ }),
399
+ );
339
400
  case "relay_inbox_list":
340
401
  return text(await client.inbox());
341
402
  case "relay_acknowledge":
@@ -453,7 +453,10 @@ export function defaultStageRelayCompanionItem(notification) {
453
453
  return stageRelayCompanionItem(notification);
454
454
  }
455
455
 
456
- export function stagePlainRelayItem({ item, packet, attachmentUrls = {} }, { statePath = companionStatePath() } = {}) {
456
+ export function stagePlainRelayItem(
457
+ { item, packet, attachmentUrls = {} },
458
+ { statePath = companionStatePath(), forceUnread = false } = {},
459
+ ) {
457
460
  if (!item?.relayId) throw new Error("stagePlainRelayItem requires an inbox item with relayId");
458
461
  const state = readCompanionState(statePath);
459
462
  state.packets ||= {};
@@ -474,7 +477,8 @@ export function stagePlainRelayItem({ item, packet, attachmentUrls = {} }, { sta
474
477
  },
475
478
  };
476
479
  const contentPath = writeNotificationPacketContent({ id: item.relayId }, content, statePath);
477
- const readState = existing.state === "read" || item.state === "read" ? "read" : "unread";
480
+ const preserveSetupUnread = existing.setupImportedUnread === true && existing.state === "unread";
481
+ const readState = forceUnread || preserveSetupUnread ? "unread" : existing.state === "read" || item.state === "read" ? "read" : "unread";
478
482
  const row = {
479
483
  ...existing,
480
484
  id: item.relayId,
@@ -504,6 +508,7 @@ export function stagePlainRelayItem({ item, packet, attachmentUrls = {} }, { sta
504
508
  attachmentUrls,
505
509
  materializationDeferredReason: "relay_pill",
506
510
  materializedSurfaces: existing.materializedSurfaces || { codex: false, claudeCode: false, claudeCowork: false },
511
+ setupImportedUnread: forceUnread || preserveSetupUnread || undefined,
507
512
  };
508
513
  state.packets[item.relayId] = row;
509
514
  writeCompanionState(statePath, state);
@@ -0,0 +1,83 @@
1
+ import { RelayClient } from "./client.js";
2
+ import { openRelay } from "./materializer.js";
3
+ import { stagePlainRelayItem } from "./notifications.js";
4
+
5
+ export function normalizeSetupHost(host) {
6
+ const clean = String(host || "").trim().toLowerCase();
7
+ return clean === "codex" ? "codex" : "claude";
8
+ }
9
+
10
+ export function setupOpenRelayToken(flags = {}) {
11
+ return String(flags["open-relay"] || flags.openRelay || flags.relay || "").trim() || null;
12
+ }
13
+
14
+ export function setupOpenStatus(opened, openUrlFn = () => false) {
15
+ if (!opened) return { opened: false, fallbackAttempted: false, url: null };
16
+ if (opened.openedInHost || opened.skipExternalOpen) {
17
+ return { opened: true, fallbackAttempted: false, url: opened.url || null };
18
+ }
19
+ if (!opened.url) return { opened: false, fallbackAttempted: false, url: null };
20
+ return { opened: Boolean(openUrlFn(opened.url)), fallbackAttempted: true, url: opened.url };
21
+ }
22
+
23
+
24
+ function inboxItemFromOpenRelay(relay) {
25
+ return {
26
+ relayId: relay.relayId,
27
+ state: "delivered",
28
+ createdAt: relay.createdAt,
29
+ updatedAt: relay.createdAt,
30
+ kind: relay.kind,
31
+ title: relay.title,
32
+ displayTitle: relay.title,
33
+ sender: relay.sender,
34
+ preview: relay.preview,
35
+ hasAttachments: Boolean(relay.attachments?.length),
36
+ };
37
+ }
38
+
39
+ export async function stageCurrentInbox({ client = new RelayClient(), stage = stagePlainRelayItem, forceUnread = false, log = () => {} } = {}) {
40
+ const inbox = await client.inbox();
41
+ const staged = [];
42
+ for (const item of inbox.items || []) {
43
+ try {
44
+ const fetched = await client.fetchRelay(item.relayId);
45
+ stage({ item, packet: fetched.packet, attachmentUrls: fetched.attachmentUrls || {} }, { forceUnread });
46
+ staged.push(item.relayId);
47
+ } catch (error) {
48
+ log(`could not stage relay ${item.relayId}: ${error instanceof Error ? error.message : String(error)}`);
49
+ }
50
+ }
51
+ return staged;
52
+ }
53
+
54
+ export async function finishSetupOpenRelay({
55
+ token,
56
+ host = "claude",
57
+ client = new RelayClient(),
58
+ stage = stagePlainRelayItem,
59
+ openRelayFn = openRelay,
60
+ log = () => {},
61
+ } = {}) {
62
+ const cleanToken = String(token || "").trim();
63
+ if (!cleanToken) return null;
64
+
65
+ const bound = await client.bindOpenRelay(cleanToken);
66
+ const relay = bound?.relay;
67
+ if (!relay?.relayId) {
68
+ throw new Error("Relay setup paired this device, but the relay link could not be attached to the account.");
69
+ }
70
+
71
+ const stagedIds = await stageCurrentInbox({ client, stage, forceUnread: true, log });
72
+ if (!stagedIds.includes(relay.relayId)) {
73
+ const fetched = await client.fetchRelay(relay.relayId);
74
+ stage({
75
+ item: inboxItemFromOpenRelay(relay),
76
+ packet: fetched.packet,
77
+ attachmentUrls: fetched.attachmentUrls || {},
78
+ }, { forceUnread: true });
79
+ }
80
+
81
+ const result = await openRelayFn({ id: relay.relayId, host: normalizeSetupHost(host), log });
82
+ return { relayId: relay.relayId, staged: true, opened: result };
83
+ }