relay-companion 0.1.41 → 0.1.43

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.
@@ -190,6 +190,16 @@
190
190
  transition:background-color .14s var(--settle);
191
191
  }
192
192
  .row:hover { background:rgba(31,26,23,.025); }
193
+ .row-delete {
194
+ appearance:none; position:absolute; top:9px; right:9px; width:25px; height:25px;
195
+ display:flex; align-items:center; justify-content:center; border:0; border-radius:7px;
196
+ background:rgba(255,255,255,.86); color:var(--muted-3); cursor:pointer; opacity:0;
197
+ transition:opacity .14s var(--settle), color .14s var(--settle), background .14s var(--settle);
198
+ }
199
+ .row:hover .row-delete, .row-delete:focus-visible { opacity:1; }
200
+ .row-delete:hover { color:#a83a32; background:#fff1ef; }
201
+ .row-delete:disabled { opacity:.45; cursor:default; }
202
+ .row.deletable .rk-time { margin-right:22px; }
193
203
  @keyframes rowEnter { from { opacity:0; transform:translateY(-12px); } to { opacity:1; transform:none; } }
194
204
  .row.enter { animation:rowEnter .52s var(--soft) both; }
195
205
 
@@ -1248,8 +1258,11 @@
1248
1258
  const body = r.bodyMarkdown || r.body || "";
1249
1259
  const preview = kind === "human_question" ? "" : bodyPreview(body, subject, 110);
1250
1260
  const previewHtml = preview ? `<div class="rk-preview">${esc(preview)}</div>` : "";
1261
+ const canDelete = kind === "plain_relay" || kind === "human_question" || kind === "task_completed" || kind === "task_cancelled" || kind === "task_failed";
1262
+ const deleteButton = canDelete ? `<button class="row-delete" type="button" data-delete="${esc(r.id)}" data-stop="1" aria-label="Move to Recently Deleted" title="Move to Recently Deleted"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"/></svg></button>` : "";
1251
1263
  return `
1252
- <div class="row ${r.unread ? "unread" : ""}${fresh ? " enter" : ""}" data-id="${esc(r.id)}" data-opening-id="${esc(r.id)}">
1264
+ <div class="row ${r.unread ? "unread" : ""}${fresh ? " enter" : ""}${canDelete ? " deletable" : ""}" data-id="${esc(r.id)}" data-opening-id="${esc(r.id)}">
1265
+ ${deleteButton}
1253
1266
  <div class="rk-top">
1254
1267
  <span class="rk-dot"></span>
1255
1268
  <span class="rk-from">From</span>
@@ -1354,6 +1367,25 @@
1354
1367
  ta.addEventListener("input", () => { ta.style.height = "auto"; ta.style.height = Math.min(84, ta.scrollHeight) + "px"; });
1355
1368
  ta.addEventListener("mousedown", () => setInteractive(true));
1356
1369
  }
1370
+ for (const btn of relaysListEl.querySelectorAll("[data-delete]")) {
1371
+ const id = btn.getAttribute("data-delete");
1372
+ btn.addEventListener("click", async (event) => {
1373
+ event.stopPropagation();
1374
+ if (btn.disabled) return;
1375
+ btn.disabled = true;
1376
+ showRowError(id, "");
1377
+ try {
1378
+ const res = await window.relay.deleteRelay(id);
1379
+ if (!res || res.ok === false) {
1380
+ showRowError(id, (res && res.error) || "Could not move this relay to Recently Deleted.");
1381
+ btn.disabled = false;
1382
+ }
1383
+ } catch (err) {
1384
+ showRowError(id, (err && err.message) || "Could not move this relay to Recently Deleted.");
1385
+ btn.disabled = false;
1386
+ }
1387
+ });
1388
+ }
1357
1389
  // accept/reject are hidden with the Tasks surface. Only connector_reauth keeps a
1358
1390
  // [data-review] "Reconnect" affordance here.
1359
1391
  for (const btn of relaysListEl.querySelectorAll("[data-review]")) {
package/overlay/main.cjs CHANGED
@@ -656,6 +656,32 @@ function ackPacket(packetId) {
656
656
  pushInbox(true).catch(() => {});
657
657
  }
658
658
 
659
+ async function deletePacket(packetId) {
660
+ if (!packetId) return { ok: false, error: "Missing relay id." };
661
+ const row = rowById(packetId);
662
+ if (!row) return { ok: true };
663
+ try {
664
+ // Server first: a transient network/API error must never make a recoverable
665
+ // item disappear locally without actually entering Recently Deleted.
666
+ const client = await relayClient();
667
+ await client.deleteInboxItem(packetId, { idempotencyKey: idempotencyKey("inbox_delete") });
668
+ withJsonLock(STATE_PATH, () => {
669
+ const store = readStore();
670
+ if (store.packets) delete store.packets[packetId];
671
+ writeStateAtomic(store);
672
+ });
673
+ presentedRelayIds.delete(packetId);
674
+ activeAttentionIds.delete(packetId);
675
+ writeOverlayPrefs();
676
+ await pushInbox(true);
677
+ return { ok: true };
678
+ } catch (error) {
679
+ const message = error && error.message ? error.message : String(error);
680
+ console.error("[overlay] relay delete failed:", message);
681
+ return { ok: false, error: message };
682
+ }
683
+ }
684
+
659
685
  // ---- mutations -----------------------------------------------------------
660
686
 
661
687
  function idempotencyKey(prefix) {
@@ -1598,6 +1624,7 @@ ipcMain.on("relay:openSent", (_e, id) => {
1598
1624
  ipcMain.on("relay:openTask", (_e, taskId) => openTaskDetail(taskId));
1599
1625
  ipcMain.on("relay:openUrl", (_e, url) => openUrlTarget(url));
1600
1626
  ipcMain.on("relay:ack", (_e, id) => ackPacket(id));
1627
+ ipcMain.handle("relay:delete", (_e, id) => deletePacket(id));
1601
1628
  ipcMain.handle("relay:refreshSent", async () => {
1602
1629
  await refreshSent();
1603
1630
  await pushInbox(true);
@@ -15,6 +15,7 @@ contextBridge.exposeInMainWorld("relay", {
15
15
  open: (id) => ipcRenderer.send("relay:open", id),
16
16
  openSent: (id) => ipcRenderer.send("relay:openSent", id),
17
17
  ack: (id) => ipcRenderer.send("relay:ack", id),
18
+ deleteRelay: (id) => ipcRenderer.invoke("relay:delete", id),
18
19
  openUrl: (url) => ipcRenderer.send("relay:openUrl", url),
19
20
  refreshSent: () => ipcRenderer.invoke("relay:refreshSent"),
20
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-companion",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
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
@@ -73,6 +73,18 @@ export class RelayClient {
73
73
  return this.#req("POST", `/v1/relays/${encodeURIComponent(id)}/read`, payload);
74
74
  }
75
75
 
76
+ deleteInboxItem(itemId, payload = {}) {
77
+ return this.#req("POST", `/v1/inbox-items/${encodeURIComponent(itemId)}/delete`, payload);
78
+ }
79
+
80
+ recentlyDeleted() {
81
+ return this.#req("GET", "/v1/recently-deleted");
82
+ }
83
+
84
+ restoreInboxItem(itemId, payload = {}) {
85
+ return this.#req("POST", `/v1/recently-deleted/${encodeURIComponent(itemId)}/restore`, payload);
86
+ }
87
+
76
88
  openRelay(token) {
77
89
  return this.#req("GET", `/v1/open/${encodeURIComponent(token)}`, undefined, { auth: false });
78
90
  }
@@ -43,7 +43,11 @@ function readMarker(markerPath) {
43
43
  }
44
44
 
45
45
  function markerSurfacePaths(marker) {
46
- return [marker?.appPath, marker?.pillPlistPath, marker?.daemonPlistPath];
46
+ const appExecutablePath =
47
+ typeof marker?.appPath === "string" && marker.appPath
48
+ ? path.join(marker.appPath, "Contents", "MacOS", "applet")
49
+ : null;
50
+ return [marker?.appPath, appExecutablePath, marker?.pillPlistPath, marker?.daemonPlistPath];
47
51
  }
48
52
 
49
53
  export function desktopMigrationMarkerIsCurrent(
package/src/install.js CHANGED
@@ -194,21 +194,44 @@ export function installRelayMacApp({
194
194
  appPaths: [RELAY_MAC_APP_NAME, RELAY_MAC_APP_FALLBACK_NAME].map((name) => path.join(applicationsDir, name)),
195
195
  };
196
196
  }
197
- const tmpPath = `${appPath}.${process.pid}.${Date.now()}.tmp`;
197
+ // Keep the staging bundle's final extension as .app. osacompile decides whether
198
+ // to produce an application bundle from the output extension; a generic .tmp
199
+ // path would produce a flat compiled script instead. Nest it under a hidden,
200
+ // same-volume directory so Spotlight cannot transiently expose a duplicate app.
201
+ const stagingDir = path.join(applicationsDir, `.relay-app-staging-${process.pid}-${Date.now()}`);
202
+ const tmpPath = path.join(stagingDir, RELAY_MAC_APP_NAME);
198
203
  const contentsDir = path.join(tmpPath, "Contents");
199
204
  const macosDir = path.join(contentsDir, "MacOS");
200
205
  const resourcesDir = path.join(contentsDir, "Resources");
201
- const launcherPath = path.join(macosDir, "Relay");
206
+ const appletExecutablePath = path.join(macosDir, "applet");
207
+ const launcherPath = path.join(resourcesDir, "relay-launcher.sh");
202
208
  const logPath = path.join(homeDir, ".relay", "pill.log");
203
209
  const pillPlistPath = path.join(homeDir, "Library", "LaunchAgents", `${PILL_LAUNCH_LABEL}.plist`);
204
210
  const version = packageVersion(packageRoot);
205
211
 
206
212
  try {
207
- fs.rmSync(tmpPath, { recursive: true, force: true });
208
- fs.mkdirSync(macosDir, { recursive: true });
209
- fs.mkdirSync(resourcesDir, { recursive: true });
213
+ fs.rmSync(stagingDir, { recursive: true, force: true });
214
+ fs.mkdirSync(applicationsDir, { recursive: true });
215
+ fs.mkdirSync(stagingDir, { recursive: true });
210
216
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
211
217
 
218
+ // LaunchServices rejects a shell script used directly as CFBundleExecutable
219
+ // (NSOSStatusErrorDomain -10669 on current macOS), even when it has a valid
220
+ // shebang and executable mode. Generate the system AppleScript applet wrapper
221
+ // so the bundle has a native universal Mach-O executable. The applet only runs
222
+ // our resource shell script; it does not automate or inspect another app.
223
+ const appletSource = [
224
+ "on run",
225
+ ' set launcherPath to POSIX path of (path to resource "relay-launcher.sh")',
226
+ ' do shell script "/bin/sh " & quoted form of launcherPath',
227
+ "end run",
228
+ ].join("\n");
229
+ const compiled = runCommand("/usr/bin/osacompile", ["-e", appletSource, "-o", tmpPath]);
230
+ if (!compiled.ok || !fs.existsSync(appletExecutablePath)) {
231
+ throw new Error(`could not compile native Relay app launcher${compiled.out ? `: ${compiled.out}` : ""}`);
232
+ }
233
+ fs.mkdirSync(resourcesDir, { recursive: true });
234
+
212
235
  const launcher = [
213
236
  "#!/bin/sh",
214
237
  'nonce="app-$$-$(date +%s)"',
@@ -247,35 +270,61 @@ export function installRelayMacApp({
247
270
  <dict>
248
271
  <key>CFBundleName</key><string>Relay</string>
249
272
  <key>CFBundleDisplayName</key><string>Relay</string>
250
- <key>CFBundleExecutable</key><string>Relay</string>
273
+ <key>CFBundleExecutable</key><string>applet</string>
251
274
  <key>CFBundleIdentifier</key><string>${RELAY_MAC_BUNDLE_IDENTIFIER}</string>
275
+ <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
252
276
  <key>CFBundlePackageType</key><string>APPL</string>
277
+ <key>CFBundleSignature</key><string>aplt</string>
253
278
  <key>CFBundleShortVersionString</key><string>${plistEscape(version)}</string>
254
279
  <key>CFBundleVersion</key><string>${plistEscape(version)}</string>
255
280
  ${iconEntry} <key>LSUIElement</key><true/>
256
281
  <key>NSHighResolutionCapable</key><true/>
282
+ <key>OSAAppletShowStartupScreen</key><false/>
257
283
  </dict>
258
284
  </plist>
259
285
  `;
260
286
  fs.writeFileSync(path.join(contentsDir, "Info.plist"), info);
261
287
  fs.writeFileSync(path.join(contentsDir, "PkgInfo"), "APPL????");
262
288
 
289
+ // osacompile signs the initial applet, so adding our launcher, icon, and final
290
+ // Info.plist invalidates that temporary signature. Re-sign the completed bundle
291
+ // and verify it before exposing it to LaunchServices; otherwise a bundle can be
292
+ // indexed by Spotlight yet still fail when the user clicks it.
293
+ const signed = runCommand("/usr/bin/codesign", ["--force", "--deep", "--sign", "-", tmpPath]);
294
+ if (!signed.ok) {
295
+ throw new Error(`could not sign native Relay app launcher${signed.out ? `: ${signed.out}` : ""}`);
296
+ }
297
+ const verified = runCommand("/usr/bin/codesign", ["--verify", "--deep", "--strict", tmpPath]);
298
+ if (!verified.ok) {
299
+ throw new Error(`could not verify native Relay app launcher${verified.out ? `: ${verified.out}` : ""}`);
300
+ }
301
+
263
302
  // Replace the Relay-owned bundle atomically enough that Spotlight never indexes
264
303
  // a half-written app. LaunchServices/Spotlight refreshes are best-effort; merely
265
304
  // placing a valid .app under ~/Applications is sufficient on a normal macOS host.
266
- fs.mkdirSync(applicationsDir, { recursive: true });
305
+ // Re-check ownership at the destructive boundary in case another process placed
306
+ // or replaced an unrelated app at the chosen path while the bundle was compiling.
307
+ if (fs.existsSync(appPath) && !isRelayOwnedMacApp(appPath)) {
308
+ throw new Error("Relay app target changed during installation");
309
+ }
267
310
  fs.rmSync(appPath, { recursive: true, force: true });
268
311
  fs.renameSync(tmpPath, appPath);
312
+ fs.rmSync(stagingDir, { recursive: true, force: true });
269
313
  runCommand("/usr/bin/touch", [appPath]);
270
314
  runCommand(
271
315
  "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
272
316
  ["-f", appPath]
273
317
  );
274
318
  runCommand("/usr/bin/mdimport", [appPath]);
275
- return { ok: true, appPath, launcherPath: path.join(appPath, "Contents", "MacOS", "Relay") };
319
+ return {
320
+ ok: true,
321
+ appPath,
322
+ launcherPath: path.join(appPath, "Contents", "Resources", "relay-launcher.sh"),
323
+ appletExecutablePath: path.join(appPath, "Contents", "MacOS", "applet"),
324
+ };
276
325
  } catch (error) {
277
326
  try {
278
- fs.rmSync(tmpPath, { recursive: true, force: true });
327
+ fs.rmSync(stagingDir, { recursive: true, force: true });
279
328
  } catch {}
280
329
  return {
281
330
  ok: false,
package/src/mcp.js CHANGED
@@ -94,6 +94,45 @@ export const TOOLS = [
94
94
  "List ordinary Relay messages addressed to this human. This does not list task coordination relays; use relay_task_status for task state.",
95
95
  inputSchema: { type: "object", properties: {} },
96
96
  },
97
+ {
98
+ name: "relay_inbox_delete",
99
+ description:
100
+ "Move one Relay inbox item to Recently Deleted for this human. This immediately removes it from the Relay website inbox and companion pill, but does not erase the sender's Sent history and does not cancel, answer, or otherwise change an underlying Relay task. The item remains recoverable for exactly 30 days, then its recovery snapshot is permanently erased and restoration is impossible. For an ordinary relay, pass the relayId returned by relay_inbox_list. For a task question shown in the pill/site, pass human_question:<messageId>. For a terminal task notification, pass task_completed:<taskId>, task_cancelled:<taskId>, or task_failed:<taskId>. Do not guess an id or delete merely to acknowledge/mark handled; use relay_acknowledge for that ordinary-relay workflow. A pending human question may leave its agent task paused even after the inbox item is deleted, because deletion is only inbox organization. This operation is idempotent for a given item while it remains in Recently Deleted.",
101
+ inputSchema: {
102
+ type: "object",
103
+ properties: {
104
+ itemId: {
105
+ type: "string",
106
+ description:
107
+ "Exact stable inbox item id. Use relayId for ordinary relays, human_question:<messageId> for a task question, or task_<terminal-state>:<taskId> for a terminal task notification.",
108
+ },
109
+ idempotencyKey: { type: "string", description: "A unique key of at least 8 characters for this deletion request." },
110
+ },
111
+ required: ["itemId", "idempotencyKey"],
112
+ },
113
+ },
114
+ {
115
+ name: "relay_recently_deleted_list",
116
+ description:
117
+ "Fetch this human's complete Recently Deleted Relay inbox. Use this before attempting a restore whenever the human describes an item by sender, title, content, or approximate time instead of giving an exact itemId. Results are ordered newest deletion first and include the exact itemId required by relay_recently_deleted_restore, source type, sender/title/body snapshot, deletion time, permanentlyDeletesAt, and daysRemaining. Recently Deleted exists only on the Relay website and through these MCP tools; it is intentionally never shown in the companion pill. Items remain here for 30 days from their most recent deletion. After permanentlyDeletesAt the recovery snapshot is erased, the item no longer appears here, and neither an agent nor Relay support can restore it. Listing is read-only and does not extend the 30-day deadline.",
118
+ inputSchema: { type: "object", properties: {} },
119
+ },
120
+ {
121
+ name: "relay_recently_deleted_restore",
122
+ description:
123
+ "Restore one exact inbox item from this human's Recently Deleted folder. First call relay_recently_deleted_list unless the human supplied an exact current itemId; match cautiously using sender, title, body, sourceType, and timestamps, and ask the human if multiple items could match. Never invent or infer an id. Restoration is only possible before permanentlyDeletesAt. A successful restore removes the item from Recently Deleted and makes it eligible to reappear in the website inbox and companion pill; the companion treats the restore as fresh delivery so an older item can notify again. Restoring a task question does not answer it, and may re-expose a still-paused run; restoring a task-status notification does not reopen the completed/cancelled/failed task. Restoring an ordinary relay does not alter the sender's Sent record. This operation is idempotent for the same already-restored item.",
124
+ inputSchema: {
125
+ type: "object",
126
+ properties: {
127
+ itemId: {
128
+ type: "string",
129
+ description: "The exact itemId returned by relay_recently_deleted_list. Do not use a title, relay subject, or guessed task/message id.",
130
+ },
131
+ idempotencyKey: { type: "string", description: "A unique key of at least 8 characters for this restore request." },
132
+ },
133
+ required: ["itemId", "idempotencyKey"],
134
+ },
135
+ },
97
136
  {
98
137
  name: "relay_acknowledge",
99
138
  description:
@@ -456,6 +495,12 @@ export async function handleCall(client, name, args) {
456
495
  );
457
496
  case "relay_inbox_list":
458
497
  return text(await client.inbox());
498
+ case "relay_inbox_delete":
499
+ return text(await client.deleteInboxItem(args.itemId, { idempotencyKey: args.idempotencyKey }));
500
+ case "relay_recently_deleted_list":
501
+ return text(await client.recentlyDeleted());
502
+ case "relay_recently_deleted_restore":
503
+ return text(await client.restoreInboxItem(args.itemId, { idempotencyKey: args.idempotencyKey }));
459
504
  case "relay_acknowledge":
460
505
  return text(await client.acknowledge(args.relayId, { idempotencyKey: args.idempotencyKey }));
461
506
  case "relay_task_create":
@@ -113,7 +113,7 @@ function taskNotification(task, kind, title, body, url, extra = {}) {
113
113
  };
114
114
  }
115
115
 
116
- export function buildHumanNotifications({ user, tasks = [], relays = [], connectors = [] } = {}) {
116
+ export function buildHumanNotifications({ user, tasks = [], relays = [], connectors = [], inboxState = {} } = {}) {
117
117
  const notifications = [];
118
118
  for (const task of tasks) {
119
119
  const participant = participantForUser(task, user);
@@ -193,12 +193,22 @@ export function buildHumanNotifications({ user, tasks = [], relays = [], connect
193
193
  };
194
194
  notifications.push({ ...notification, action: actionForNotification(notification) });
195
195
  }
196
- return notifications;
196
+ const suppressed = new Set(inboxState.suppressedItemIds || []);
197
+ const restoredAtByItemId = inboxState.restoredAtByItemId || {};
198
+ return notifications
199
+ .filter((notification) => !suppressed.has(notification.id))
200
+ .map((notification) => ({
201
+ ...notification,
202
+ deliveryVersion: restoredAtByItemId[notification.id] || "",
203
+ }));
197
204
  }
198
205
 
199
206
  export function freshNotifications(ledger, notifications) {
200
207
  const seen = ledger.notifications || {};
201
- return notifications.filter((notification) => !seen[notification.id]);
208
+ return notifications.filter((notification) => {
209
+ const previous = seen[notification.id];
210
+ return !previous || (notification.deliveryVersion || "") !== (previous.deliveryVersion || "");
211
+ });
202
212
  }
203
213
 
204
214
  export function markNotificationsProcessed(ledger, notifications) {
@@ -207,6 +217,7 @@ export function markNotificationsProcessed(ledger, notifications) {
207
217
  for (const notification of notifications) {
208
218
  ledger.notifications[notification.id] = {
209
219
  kind: notification.kind,
220
+ deliveryVersion: notification.deliveryVersion || "",
210
221
  processedAt,
211
222
  };
212
223
  }
@@ -176,6 +176,7 @@ async function pollHumanNotifications({ client, ledger, stageCompanionItem, stag
176
176
  tasks: tasks.tasks || [],
177
177
  relays: relays.relays || [],
178
178
  connectors: connectors.connectors || [],
179
+ inboxState: relays.inboxState || {},
179
180
  });
180
181
  const fresh = freshNotifications(ledger, notifications);
181
182
  const staged = [];