relay-companion 0.1.0
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 +262 -0
- package/overlay/inbox.html +1398 -0
- package/overlay/main.cjs +762 -0
- package/overlay/preload.cjs +37 -0
- package/overlay/sounds/tink.wav +0 -0
- package/package.json +25 -0
- package/src/claude-materializer.js +85 -0
- package/src/claude-session-writer.js +629 -0
- package/src/client.js +168 -0
- package/src/codex-app-server.js +120 -0
- package/src/codex-desktop.js +276 -0
- package/src/codex-session-writer.js +170 -0
- package/src/codex-state.js +114 -0
- package/src/config.js +62 -0
- package/src/host-json.js +14 -0
- package/src/host-paths.js +67 -0
- package/src/install.js +142 -0
- package/src/materializer.js +378 -0
- package/src/mcp.js +419 -0
- package/src/notifications.js +412 -0
- package/src/pinning.js +43 -0
- package/src/relay-briefing.js +344 -0
- package/src/runtime.js +1141 -0
- package/src/task-daemon.js +216 -0
package/overlay/main.cjs
ADDED
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
// Relay companion pill — a small floating, always-on-top Relay window that lists
|
|
2
|
+
// incoming Relay attention items + tasks, and lazy-opens them in Claude Code / Codex.
|
|
3
|
+
//
|
|
4
|
+
// Motion principle: the OS window is a FIXED-SIZE, transparent, click-through canvas.
|
|
5
|
+
// It is NEVER resized while animating. All expand/collapse motion is GPU-composited CSS
|
|
6
|
+
// on the card inside it, so it stays perfectly smooth. The empty area around the card is
|
|
7
|
+
// click-through, so the overlay never blocks the app behind it.
|
|
8
|
+
//
|
|
9
|
+
// Data:
|
|
10
|
+
// - Relay attention rows come from ${RELAY_HOME:-~/.relay-companion}/state.json under
|
|
11
|
+
// `state.packets` (staged by the companion daemon). Read it directly + fs.watch it.
|
|
12
|
+
// - Tasks come live from RelayClient.listTasks(), refreshed on a timer and on demand.
|
|
13
|
+
// - Contacts come live from RelayClient.listContacts() / upsertContact().
|
|
14
|
+
// - Mutations (accept/reject/approve/decline/answer) call RelayClient with device-token
|
|
15
|
+
// auth. ack/mark-read writes state.json directly (atomic temp+rename).
|
|
16
|
+
|
|
17
|
+
const { app, BrowserWindow, ipcMain, shell, screen } = require("electron");
|
|
18
|
+
const fs = require("node:fs");
|
|
19
|
+
const os = require("node:os");
|
|
20
|
+
const path = require("node:path");
|
|
21
|
+
const { execFile, spawn, pathToFileURL } = (() => {
|
|
22
|
+
const cp = require("node:child_process");
|
|
23
|
+
const url = require("node:url");
|
|
24
|
+
return { execFile: cp.execFile, spawn: cp.spawn, pathToFileURL: url.pathToFileURL };
|
|
25
|
+
})();
|
|
26
|
+
|
|
27
|
+
const RELAY_HOME = process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion");
|
|
28
|
+
const STATE_PATH = path.join(RELAY_HOME, "state.json");
|
|
29
|
+
// The companion CLI entrypoint (same path the overlay resolves its ESM modules
|
|
30
|
+
// from). Spawned with ELECTRON_RUN_AS_NODE so Electron runs it as plain Node.
|
|
31
|
+
const RELAY_CLI = path.resolve(__dirname, "..", "bin", "relay.js");
|
|
32
|
+
|
|
33
|
+
// Fixed transparent canvas, large enough to contain the inbox sheet + spring/shadow room.
|
|
34
|
+
const WIN = { width: 520, height: 840 };
|
|
35
|
+
const MARGIN = 8;
|
|
36
|
+
const DEFAULT_WEB_BASE = "https://sendrelays.com";
|
|
37
|
+
|
|
38
|
+
let win = null;
|
|
39
|
+
let hostRunning = false; // Claude or Codex desktop app is running
|
|
40
|
+
let lastHost = null; // most-recently foregrounded host: "claude" | "codex"
|
|
41
|
+
let lastHostSeenAt = 0;
|
|
42
|
+
let lastSig = "";
|
|
43
|
+
|
|
44
|
+
// The ESM companion modules (config/client) are loaded lazily via dynamic import,
|
|
45
|
+
// because this overlay runs as CommonJS under Electron while the package is ESM.
|
|
46
|
+
let relayModules = null;
|
|
47
|
+
let relayModulesPromise = null;
|
|
48
|
+
function loadRelayModules() {
|
|
49
|
+
if (relayModules) return Promise.resolve(relayModules);
|
|
50
|
+
if (relayModulesPromise) return relayModulesPromise;
|
|
51
|
+
const clientUrl = pathToFileURL(path.join(__dirname, "..", "src", "client.js")).href;
|
|
52
|
+
const configUrl = pathToFileURL(path.join(__dirname, "..", "src", "config.js")).href;
|
|
53
|
+
relayModulesPromise = Promise.all([import(clientUrl), import(configUrl)])
|
|
54
|
+
.then(([client, config]) => {
|
|
55
|
+
relayModules = { RelayClient: client.RelayClient, config };
|
|
56
|
+
return relayModules;
|
|
57
|
+
})
|
|
58
|
+
.catch((error) => {
|
|
59
|
+
console.error("[overlay] failed to load relay modules:", error && error.message);
|
|
60
|
+
relayModulesPromise = null;
|
|
61
|
+
throw error;
|
|
62
|
+
});
|
|
63
|
+
return relayModulesPromise;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function relayClient() {
|
|
67
|
+
const { RelayClient } = await loadRelayModules();
|
|
68
|
+
return new RelayClient();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
app.setName("Relay");
|
|
72
|
+
|
|
73
|
+
// ---- config / account ----------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function readConfigFile() {
|
|
76
|
+
const configPath =
|
|
77
|
+
process.env.RELAY_CONFIG ||
|
|
78
|
+
path.join(process.env.RELAY_CONFIG_DIR || path.join(os.homedir(), ".relay"), "config.json");
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(fs.readFileSync(configPath, "utf8")) || {};
|
|
81
|
+
} catch {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function deviceToken() {
|
|
87
|
+
if (process.env.RELAY_DEVICE_TOKEN) return process.env.RELAY_DEVICE_TOKEN;
|
|
88
|
+
return readConfigFile().deviceToken || "";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function webBase() {
|
|
92
|
+
const cfg = readConfigFile();
|
|
93
|
+
const base = process.env.RELAY_WEB_URL || cfg.webUrl || DEFAULT_WEB_BASE;
|
|
94
|
+
return String(base).replace(/\/+$/, "");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function account() {
|
|
98
|
+
const cfg = readConfigFile();
|
|
99
|
+
const user = cfg.user || {};
|
|
100
|
+
return {
|
|
101
|
+
paired: Boolean(deviceToken()),
|
|
102
|
+
email: user.email || "",
|
|
103
|
+
name: user.name || "",
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Turn an actionUrl (absolute https, or a relative path) into an absolute URL.
|
|
108
|
+
function absoluteUrl(pathOrUrl) {
|
|
109
|
+
const value = String(pathOrUrl || "").trim();
|
|
110
|
+
if (!value) return webBase();
|
|
111
|
+
if (/^https?:\/\//i.test(value)) return value;
|
|
112
|
+
const suffix = value.startsWith("/") ? value : `/${value}`;
|
|
113
|
+
return `${webBase()}${suffix}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function taskWebUrl(taskId) {
|
|
117
|
+
return `${webBase()}/app/tasks/${encodeURIComponent(taskId)}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- state.json reading (Relay attention rows) ---------------------------
|
|
121
|
+
|
|
122
|
+
function readStore() {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(fs.readFileSync(STATE_PATH, "utf8")) || {};
|
|
125
|
+
} catch {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function writeStateAtomic(store) {
|
|
131
|
+
try {
|
|
132
|
+
fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true });
|
|
133
|
+
const tmp = `${STATE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
|
134
|
+
fs.writeFileSync(tmp, `${JSON.stringify(store, null, 2)}\n`);
|
|
135
|
+
fs.renameSync(tmp, STATE_PATH);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error("[overlay] state write failed:", error && error.message);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The inbound Relay attention rows, newest + unread first.
|
|
142
|
+
function readRelays() {
|
|
143
|
+
const store = readStore();
|
|
144
|
+
const packets = (store && store.packets) || {};
|
|
145
|
+
return Object.entries(packets)
|
|
146
|
+
.map(([id, p]) => ({ id, ...(p || {}) }))
|
|
147
|
+
.filter((p) => p.direction === "inbound")
|
|
148
|
+
.map((p) => ({
|
|
149
|
+
id: p.id,
|
|
150
|
+
direction: "inbound",
|
|
151
|
+
state: p.state === "read" ? "read" : "unread",
|
|
152
|
+
unread: p.state !== "read",
|
|
153
|
+
relayNotificationKind: p.relayNotificationKind || "task_completed",
|
|
154
|
+
urgency: p.urgency || "normal",
|
|
155
|
+
senderName: p.senderName || "Relay",
|
|
156
|
+
title: p.title || p.displayTitle || "Relay",
|
|
157
|
+
displayTitle: p.displayTitle || p.title || "Relay",
|
|
158
|
+
bodyMarkdown: p.bodyMarkdown || "",
|
|
159
|
+
briefingMarkdown: p.briefingMarkdown || "",
|
|
160
|
+
createdAt: p.createdAt || "",
|
|
161
|
+
updatedAt: p.updatedAt || "",
|
|
162
|
+
actionUrl: p.actionUrl || "",
|
|
163
|
+
action: p.action || {},
|
|
164
|
+
quickReply: Boolean(p.quickReply),
|
|
165
|
+
taskId: p.taskId || null,
|
|
166
|
+
participantId: p.participantId || null,
|
|
167
|
+
messageId: p.messageId || null,
|
|
168
|
+
approvalId:
|
|
169
|
+
(p.action && (p.action.approvalId || (Array.isArray(p.action.actions) ? null : null))) || p.approvalId || null,
|
|
170
|
+
provider: p.provider || null,
|
|
171
|
+
contentPath: p.contentPath || null,
|
|
172
|
+
expectedVersion: p.expectedVersion || null,
|
|
173
|
+
}))
|
|
174
|
+
.sort((a, b) => {
|
|
175
|
+
if (a.unread !== b.unread) return a.unread ? -1 : 1; // unread always on top
|
|
176
|
+
return String(b.createdAt).localeCompare(String(a.createdAt)); // then newest
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---- tasks (live) --------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
let tasksCache = [];
|
|
183
|
+
let tasksLoadedOnce = null; // a promise that resolves after the first task load
|
|
184
|
+
async function refreshTasks() {
|
|
185
|
+
try {
|
|
186
|
+
const client = await relayClient();
|
|
187
|
+
const res = await client.listTasks();
|
|
188
|
+
tasksCache = Array.isArray(res && res.tasks) ? res.tasks : [];
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// Keep the last good cache; surface the error in the log but don't crash.
|
|
191
|
+
console.error("[overlay] listTasks failed:", error && error.message);
|
|
192
|
+
}
|
|
193
|
+
return tasksCache;
|
|
194
|
+
}
|
|
195
|
+
// Ensure the live task list is loaded at least once before the first payload is
|
|
196
|
+
// served, so the renderer's initial paint already includes Tasks.
|
|
197
|
+
function ensureTasksLoaded() {
|
|
198
|
+
if (!tasksLoadedOnce) tasksLoadedOnce = refreshTasks().catch(() => tasksCache);
|
|
199
|
+
return tasksLoadedOnce;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---- contacts (live) -----------------------------------------------------
|
|
203
|
+
|
|
204
|
+
async function readContacts() {
|
|
205
|
+
try {
|
|
206
|
+
const client = await relayClient();
|
|
207
|
+
const res = await client.listContacts();
|
|
208
|
+
const contacts = Array.isArray(res && res.contacts) ? res.contacts : [];
|
|
209
|
+
return contacts
|
|
210
|
+
.map((c) => {
|
|
211
|
+
const emails = Array.isArray(c.emails) ? c.emails.filter(Boolean) : c.email ? [c.email] : [];
|
|
212
|
+
return {
|
|
213
|
+
id: c.id || "",
|
|
214
|
+
name: c.name || emails[0] || "",
|
|
215
|
+
email: emails[0] || c.email || "",
|
|
216
|
+
emails,
|
|
217
|
+
updatedAt: c.updatedAt || null,
|
|
218
|
+
};
|
|
219
|
+
})
|
|
220
|
+
.sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error("[overlay] listContacts failed:", error && error.message);
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function saveContact(input) {
|
|
228
|
+
const name = String((input && input.name) || "").trim();
|
|
229
|
+
const emails = Array.isArray(input && input.emails)
|
|
230
|
+
? input.emails.map((e) => String(e || "").trim()).filter(Boolean)
|
|
231
|
+
: input && input.email
|
|
232
|
+
? [String(input.email).trim()].filter(Boolean)
|
|
233
|
+
: [];
|
|
234
|
+
if (!name) throw new Error("A name is required");
|
|
235
|
+
for (const e of emails) if (!e.includes("@")) throw new Error("That email looks off.");
|
|
236
|
+
const client = await relayClient();
|
|
237
|
+
await client.upsertContact({ name, emails, email: emails[0] || "" });
|
|
238
|
+
return readContacts();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---- payload assembly + push to renderer ---------------------------------
|
|
242
|
+
|
|
243
|
+
async function buildPayload() {
|
|
244
|
+
await ensureTasksLoaded();
|
|
245
|
+
const [relays, contacts] = [readRelays(), await readContacts()];
|
|
246
|
+
return {
|
|
247
|
+
account: account(),
|
|
248
|
+
relays,
|
|
249
|
+
tasks: tasksCache,
|
|
250
|
+
contacts,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let knownIds = null; // null until first load, so we never "notify" for rows already present
|
|
255
|
+
async function pushInbox(force) {
|
|
256
|
+
if (!win || win.isDestroyed()) return;
|
|
257
|
+
const payload = await buildPayload();
|
|
258
|
+
const rows = payload.relays;
|
|
259
|
+
if (knownIds === null) {
|
|
260
|
+
knownIds = new Set(rows.map((r) => r.id));
|
|
261
|
+
} else {
|
|
262
|
+
const fresh = rows.filter((r) => r.unread && !knownIds.has(r.id));
|
|
263
|
+
knownIds = new Set(rows.map((r) => r.id));
|
|
264
|
+
if (fresh.length && win && !win.isDestroyed()) win.webContents.send("newRelay", fresh[0]); // newest arrival drives the peek
|
|
265
|
+
}
|
|
266
|
+
const sig = JSON.stringify({
|
|
267
|
+
relays: rows.map((r) => [r.id, r.relayNotificationKind, r.unread, r.title, r.senderName, r.urgency]),
|
|
268
|
+
tasks: payload.tasks.map((t) => [
|
|
269
|
+
t.id,
|
|
270
|
+
t.state,
|
|
271
|
+
t.updatedAt,
|
|
272
|
+
t.requiresViewerAction,
|
|
273
|
+
t.pendingApprovalCount,
|
|
274
|
+
Array.isArray(t.participants) ? t.participants.length : 0,
|
|
275
|
+
]),
|
|
276
|
+
contacts: payload.contacts.map((c) => [c.id, c.name, c.email]),
|
|
277
|
+
account: [payload.account.paired, payload.account.email],
|
|
278
|
+
});
|
|
279
|
+
if (!force && sig === lastSig) {
|
|
280
|
+
if (!win || win.isDestroyed()) return;
|
|
281
|
+
}
|
|
282
|
+
lastSig = sig;
|
|
283
|
+
if (win && !win.isDestroyed()) win.webContents.send("inbox", payload);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Refresh state-derived rows only (fast path used by fs.watch + the safety poll).
|
|
287
|
+
function pushInboxQuiet() {
|
|
288
|
+
pushInbox(false).catch((error) => console.error("[overlay] pushInbox failed:", error && error.message));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---- mark-read (write state.json directly) -------------------------------
|
|
292
|
+
|
|
293
|
+
function ackPacket(packetId) {
|
|
294
|
+
if (!packetId) return;
|
|
295
|
+
const store = readStore();
|
|
296
|
+
if (!store.packets || !store.packets[packetId]) return;
|
|
297
|
+
store.packets[packetId] = {
|
|
298
|
+
...store.packets[packetId],
|
|
299
|
+
state: "read",
|
|
300
|
+
updatedAt: new Date().toISOString(),
|
|
301
|
+
};
|
|
302
|
+
writeStateAtomic(store);
|
|
303
|
+
pushInbox(true).catch(() => {});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- mutations -----------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
function idempotencyKey(prefix) {
|
|
309
|
+
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function rowById(packetId) {
|
|
313
|
+
return readRelays().find((r) => r.id === packetId) || null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Find an approvalId for a share_approval row. The staged row may not carry it,
|
|
317
|
+
// in which case the renderer routes to the web detail page (Review) instead.
|
|
318
|
+
function approvalIdForRow(row) {
|
|
319
|
+
if (!row) return null;
|
|
320
|
+
if (row.approvalId) return row.approvalId;
|
|
321
|
+
const action = row.action || {};
|
|
322
|
+
if (action.approvalId) return action.approvalId;
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function runMutation(label, fn) {
|
|
327
|
+
try {
|
|
328
|
+
await fn();
|
|
329
|
+
await refreshTasks();
|
|
330
|
+
await pushInbox(true);
|
|
331
|
+
return { ok: true };
|
|
332
|
+
} catch (error) {
|
|
333
|
+
const message = error && error.message ? error.message : String(error);
|
|
334
|
+
const conflict = error && (error.status === 409 || /stale|conflict|version/i.test(message));
|
|
335
|
+
console.error(`[overlay] ${label} failed:`, message);
|
|
336
|
+
await refreshTasks().catch(() => {});
|
|
337
|
+
await pushInbox(true).catch(() => {});
|
|
338
|
+
return { ok: false, error: message, conflict: Boolean(conflict) };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function acceptTask(taskId, participantId) {
|
|
343
|
+
if (!taskId || !participantId) return { ok: false, error: "Missing task or participant id." };
|
|
344
|
+
const expectedVersion = taskVersion(taskId);
|
|
345
|
+
return runMutation("accept", async () => {
|
|
346
|
+
const client = await relayClient();
|
|
347
|
+
await client.acceptTask(taskId, participantId, {
|
|
348
|
+
idempotencyKey: idempotencyKey("accept"),
|
|
349
|
+
...(expectedVersion != null ? { expectedVersion } : {}),
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function rejectTask(taskId, participantId) {
|
|
355
|
+
if (!taskId || !participantId) return { ok: false, error: "Missing task or participant id." };
|
|
356
|
+
const expectedVersion = taskVersion(taskId);
|
|
357
|
+
return runMutation("reject", async () => {
|
|
358
|
+
const client = await relayClient();
|
|
359
|
+
await client.rejectTask(taskId, participantId, {
|
|
360
|
+
idempotencyKey: idempotencyKey("reject"),
|
|
361
|
+
...(expectedVersion != null ? { expectedVersion } : {}),
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function approveShare(taskId, approvalId) {
|
|
367
|
+
if (!taskId || !approvalId) return { ok: false, error: "Missing task or approval id." };
|
|
368
|
+
return runMutation("approve", async () => {
|
|
369
|
+
const client = await relayClient();
|
|
370
|
+
await client.approveShare(taskId, approvalId, { idempotencyKey: idempotencyKey("approve") });
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function declineShare(taskId, approvalId) {
|
|
375
|
+
if (!taskId || !approvalId) return { ok: false, error: "Missing task or approval id." };
|
|
376
|
+
return runMutation("decline", async () => {
|
|
377
|
+
const client = await relayClient();
|
|
378
|
+
await client.declineShare(taskId, approvalId, { idempotencyKey: idempotencyKey("decline") });
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function answerQuestion(taskId, messageId, text) {
|
|
383
|
+
const answerMarkdown = String(text || "").trim();
|
|
384
|
+
if (!taskId || !messageId) return { ok: false, error: "Missing task or message id." };
|
|
385
|
+
if (!answerMarkdown) return { ok: false, error: "Write an answer first." };
|
|
386
|
+
return runMutation("answer", async () => {
|
|
387
|
+
const client = await relayClient();
|
|
388
|
+
await client.answerHumanQuestion(taskId, messageId, {
|
|
389
|
+
answerMarkdown,
|
|
390
|
+
idempotencyKey: idempotencyKey("answer"),
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function taskVersion(taskId) {
|
|
396
|
+
const task = tasksCache.find((t) => t.id === taskId);
|
|
397
|
+
return task && task.version != null ? task.version : null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---- click-to-open a Relay row -------------------------------------------
|
|
401
|
+
|
|
402
|
+
// Best-effort frontmost-app bundle id (no permission prompt; uses lsappinfo).
|
|
403
|
+
function frontmostBundleId(cb) {
|
|
404
|
+
execFile("/usr/bin/lsappinfo", ["front"], (e1, asn) => {
|
|
405
|
+
const a = String(asn || "").trim();
|
|
406
|
+
if (e1 || !a) return cb(null);
|
|
407
|
+
execFile("/usr/bin/lsappinfo", ["info", "-only", "bundleid", a], (e2, out) => {
|
|
408
|
+
const m = String(out || "").match(/"CFBundleIdentifier"\s*=\s*"([^"]+)"/);
|
|
409
|
+
cb(e2 ? null : m ? m[1] : null);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function hostFromBundle(bundle) {
|
|
415
|
+
if (bundle === "com.anthropic.claudefordesktop") return "claude";
|
|
416
|
+
if (bundle === "com.openai.codex") return "codex";
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function rememberForegroundHost(host) {
|
|
421
|
+
if (!host) return;
|
|
422
|
+
lastHost = host;
|
|
423
|
+
lastHostSeenAt = Date.now();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function appInstalled(appName) {
|
|
427
|
+
const name = String(appName || "").replace(/[^A-Za-z0-9 ._-]/g, "");
|
|
428
|
+
if (!name) return false;
|
|
429
|
+
const candidates = [
|
|
430
|
+
path.join("/Applications", `${name}.app`),
|
|
431
|
+
path.join(os.homedir(), "Applications", `${name}.app`),
|
|
432
|
+
];
|
|
433
|
+
return candidates.some((candidate) => fs.existsSync(candidate));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function defaultClickHost() {
|
|
437
|
+
// Spec: neither foregrounded -> prefer Codex if installed, otherwise Claude Code.
|
|
438
|
+
if (appInstalled("Codex")) return "codex";
|
|
439
|
+
if (appInstalled("Claude")) return "claude";
|
|
440
|
+
return "codex";
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function isRelayBundle(bundle) {
|
|
444
|
+
const clean = String(bundle || "").toLowerCase();
|
|
445
|
+
return clean.includes("relay") || clean.includes("electron");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Click-to-open a Relay row. A relay materializes a REAL native agent session
|
|
449
|
+
// inside the already-running foregrounded host (Claude Desktop or Codex) via the
|
|
450
|
+
// companion CLI's `open` command, so it appears in that app's recents rail. The
|
|
451
|
+
// CLI prints { url, skipExternalOpen, openedInHost }; when the host wasn't driven
|
|
452
|
+
// directly (skipExternalOpen false) the overlay fires shell.openExternal(url) as
|
|
453
|
+
// the fallback. The web view is only used if the CLI itself fails. Connector
|
|
454
|
+
// reauth is a web OAuth flow, so it intentionally opens the connectors page.
|
|
455
|
+
function openPacket(packetId) {
|
|
456
|
+
if (!packetId) return;
|
|
457
|
+
const row = rowById(packetId);
|
|
458
|
+
if (win && !win.isDestroyed()) win.webContents.send("opening", packetId);
|
|
459
|
+
const finish = () => {
|
|
460
|
+
ackPacket(packetId);
|
|
461
|
+
if (win && !win.isDestroyed()) win.webContents.send("openDone", packetId); // stop the row spinner
|
|
462
|
+
};
|
|
463
|
+
if (row && row.relayNotificationKind === "connector_reauth") {
|
|
464
|
+
shell
|
|
465
|
+
.openExternal(`${webBase()}/app/connectors`)
|
|
466
|
+
.catch((error) => console.error("[overlay] open failed:", error && error.message));
|
|
467
|
+
return finish();
|
|
468
|
+
}
|
|
469
|
+
frontmostBundleId((bundle) => {
|
|
470
|
+
const frontHost = hostFromBundle(bundle);
|
|
471
|
+
if (frontHost) rememberForegroundHost(frontHost);
|
|
472
|
+
const capturedHostIsFresh = lastHost && Date.now() - lastHostSeenAt < 2500;
|
|
473
|
+
const host = frontHost || ((!bundle || isRelayBundle(bundle)) && capturedHostIsFresh ? lastHost : defaultClickHost());
|
|
474
|
+
const env = {
|
|
475
|
+
...process.env,
|
|
476
|
+
ELECTRON_RUN_AS_NODE: "1",
|
|
477
|
+
RELAY_HOME,
|
|
478
|
+
CODEX_CLI_PATH: process.env.CODEX_CLI_PATH || "/Applications/Codex.app/Contents/Resources/codex",
|
|
479
|
+
};
|
|
480
|
+
if (host === "claude") {
|
|
481
|
+
// Let the overlay own the actual deep-link launch. Otherwise the helper can open Claude first,
|
|
482
|
+
// then keep the row spinner alive while it does post-import title repair.
|
|
483
|
+
env.RELAY_IMPORT_CLAUDE_DESKTOP = "0";
|
|
484
|
+
}
|
|
485
|
+
const child = spawn(process.execPath, [RELAY_CLI, "open", packetId, "--host", host], {
|
|
486
|
+
env,
|
|
487
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
488
|
+
});
|
|
489
|
+
let out = "";
|
|
490
|
+
let err = "";
|
|
491
|
+
child.stdout.on("data", (data) => (out += data));
|
|
492
|
+
child.stderr.on("data", (data) => (err += data));
|
|
493
|
+
child.on("exit", (code) => {
|
|
494
|
+
let url = null;
|
|
495
|
+
let skipExternalOpen = false;
|
|
496
|
+
try {
|
|
497
|
+
const parsed = JSON.parse(out.trim());
|
|
498
|
+
url = parsed.url || null;
|
|
499
|
+
skipExternalOpen = Boolean(parsed.skipExternalOpen);
|
|
500
|
+
} catch {}
|
|
501
|
+
if (url || skipExternalOpen) {
|
|
502
|
+
if (url && !skipExternalOpen) {
|
|
503
|
+
shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
|
|
504
|
+
}
|
|
505
|
+
} else {
|
|
506
|
+
console.error(
|
|
507
|
+
"[overlay] open failed for",
|
|
508
|
+
packetId,
|
|
509
|
+
"host",
|
|
510
|
+
host,
|
|
511
|
+
"code",
|
|
512
|
+
code,
|
|
513
|
+
"stderr:",
|
|
514
|
+
err.trim().split("\n").slice(-2).join(" | "),
|
|
515
|
+
);
|
|
516
|
+
// Last-resort web fallback so the click is never a dead end.
|
|
517
|
+
const target = row && row.taskId
|
|
518
|
+
? taskWebUrl(row.taskId)
|
|
519
|
+
: row && row.actionUrl
|
|
520
|
+
? absoluteUrl(row.actionUrl)
|
|
521
|
+
: webBase();
|
|
522
|
+
shell.openExternal(target).catch((e) => console.error("[overlay] web fallback failed:", e && e.message));
|
|
523
|
+
}
|
|
524
|
+
finish();
|
|
525
|
+
});
|
|
526
|
+
child.on("error", (error) => {
|
|
527
|
+
console.error("[overlay] open spawn error:", error && error.message);
|
|
528
|
+
finish();
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Click-to-open a task row. Mirrors openPacket: materialize the task into a REAL
|
|
534
|
+
// native agent session inside the foregrounded host (Claude Desktop or Codex) via
|
|
535
|
+
// `relay open --task <taskId> --host <host>`, so it appears in that app's recents
|
|
536
|
+
// rail seeded with the task's objective + state + an instruction to call
|
|
537
|
+
// relay_task_status for live detail. The web view is only used if the CLI fails.
|
|
538
|
+
function openTaskDetail(taskId) {
|
|
539
|
+
if (!taskId) return;
|
|
540
|
+
frontmostBundleId((bundle) => {
|
|
541
|
+
const frontHost = hostFromBundle(bundle);
|
|
542
|
+
if (frontHost) rememberForegroundHost(frontHost);
|
|
543
|
+
const capturedHostIsFresh = lastHost && Date.now() - lastHostSeenAt < 2500;
|
|
544
|
+
const host = frontHost || ((!bundle || isRelayBundle(bundle)) && capturedHostIsFresh ? lastHost : defaultClickHost());
|
|
545
|
+
const env = {
|
|
546
|
+
...process.env,
|
|
547
|
+
ELECTRON_RUN_AS_NODE: "1",
|
|
548
|
+
RELAY_HOME,
|
|
549
|
+
CODEX_CLI_PATH: process.env.CODEX_CLI_PATH || "/Applications/Codex.app/Contents/Resources/codex",
|
|
550
|
+
};
|
|
551
|
+
if (host === "claude") {
|
|
552
|
+
// Let the overlay own the actual deep-link launch (see openPacket).
|
|
553
|
+
env.RELAY_IMPORT_CLAUDE_DESKTOP = "0";
|
|
554
|
+
}
|
|
555
|
+
const child = spawn(process.execPath, [RELAY_CLI, "open", "--task", taskId, "--host", host], {
|
|
556
|
+
env,
|
|
557
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
558
|
+
});
|
|
559
|
+
let out = "";
|
|
560
|
+
let err = "";
|
|
561
|
+
child.stdout.on("data", (data) => (out += data));
|
|
562
|
+
child.stderr.on("data", (data) => (err += data));
|
|
563
|
+
child.on("exit", (code) => {
|
|
564
|
+
let url = null;
|
|
565
|
+
let skipExternalOpen = false;
|
|
566
|
+
try {
|
|
567
|
+
const parsed = JSON.parse(out.trim());
|
|
568
|
+
url = parsed.url || null;
|
|
569
|
+
skipExternalOpen = Boolean(parsed.skipExternalOpen);
|
|
570
|
+
} catch {}
|
|
571
|
+
if (url || skipExternalOpen) {
|
|
572
|
+
if (url && !skipExternalOpen) {
|
|
573
|
+
shell.openExternal(url).catch((error) => console.error("[overlay] openExternal failed:", error && error.message));
|
|
574
|
+
}
|
|
575
|
+
} else {
|
|
576
|
+
console.error(
|
|
577
|
+
"[overlay] task open failed for",
|
|
578
|
+
taskId,
|
|
579
|
+
"host",
|
|
580
|
+
host,
|
|
581
|
+
"code",
|
|
582
|
+
code,
|
|
583
|
+
"stderr:",
|
|
584
|
+
err.trim().split("\n").slice(-2).join(" | "),
|
|
585
|
+
);
|
|
586
|
+
// Last-resort web fallback so the click is never a dead end.
|
|
587
|
+
shell
|
|
588
|
+
.openExternal(taskWebUrl(taskId))
|
|
589
|
+
.catch((e) => console.error("[overlay] task web fallback failed:", e && e.message));
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
child.on("error", (error) => {
|
|
593
|
+
console.error("[overlay] task open spawn error:", error && error.message);
|
|
594
|
+
shell
|
|
595
|
+
.openExternal(taskWebUrl(taskId))
|
|
596
|
+
.catch((e) => console.error("[overlay] task web fallback failed:", e && e.message));
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function openUrlTarget(url) {
|
|
602
|
+
const target = absoluteUrl(url);
|
|
603
|
+
shell.openExternal(target).catch((error) => console.error("[overlay] open failed:", error && error.message));
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ---- window placement / visibility ---------------------------------------
|
|
607
|
+
|
|
608
|
+
function anchorTopRight() {
|
|
609
|
+
const display = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()) || screen.getPrimaryDisplay();
|
|
610
|
+
const wa = display.workArea;
|
|
611
|
+
return { x: wa.x + wa.width - WIN.width - MARGIN, y: wa.y + MARGIN, width: WIN.width, height: WIN.height };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function maybeShow() {
|
|
615
|
+
if (!win || win.isDestroyed()) return;
|
|
616
|
+
if (hostRunning) {
|
|
617
|
+
if (!win.isVisible()) win.showInactive();
|
|
618
|
+
} else if (win.isVisible()) {
|
|
619
|
+
win.hide();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function pollHosts() {
|
|
624
|
+
// Show whenever EITHER host app is running; track the foregrounded one for click routing.
|
|
625
|
+
execFile("/bin/ps", ["-Ao", "comm"], (err, stdout) => {
|
|
626
|
+
const s = String(stdout || "");
|
|
627
|
+
const claude = !err && /\/Claude\.app\/Contents\/MacOS\/Claude(\n|$)/.test(s);
|
|
628
|
+
const codex = !err && /\/Codex\.app\/Contents\/MacOS\/Codex(\n|$)/.test(s);
|
|
629
|
+
hostRunning = claude || codex;
|
|
630
|
+
maybeShow();
|
|
631
|
+
});
|
|
632
|
+
frontmostBundleId((bundle) => {
|
|
633
|
+
rememberForegroundHost(hostFromBundle(bundle));
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function createWindow() {
|
|
638
|
+
win = new BrowserWindow({
|
|
639
|
+
...anchorTopRight(),
|
|
640
|
+
frame: false,
|
|
641
|
+
transparent: true,
|
|
642
|
+
backgroundColor: "#00000000",
|
|
643
|
+
resizable: false,
|
|
644
|
+
movable: false,
|
|
645
|
+
show: false,
|
|
646
|
+
hasShadow: false,
|
|
647
|
+
skipTaskbar: true,
|
|
648
|
+
fullscreenable: false,
|
|
649
|
+
maximizable: false,
|
|
650
|
+
minimizable: false,
|
|
651
|
+
focusable: false, // never steal keyboard focus from Claude/Codex
|
|
652
|
+
title: "Relay",
|
|
653
|
+
webPreferences: {
|
|
654
|
+
preload: path.join(__dirname, "preload.cjs"),
|
|
655
|
+
contextIsolation: true,
|
|
656
|
+
nodeIntegration: false,
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
win.setAlwaysOnTop(true, "floating");
|
|
661
|
+
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
662
|
+
// Empty canvas is click-through; the renderer flips this on when the pointer is over the card.
|
|
663
|
+
win.setIgnoreMouseEvents(true, { forward: true });
|
|
664
|
+
win.loadFile(path.join(__dirname, "inbox.html"));
|
|
665
|
+
// surface renderer + preload errors to the overlay log so failures are visible
|
|
666
|
+
win.webContents.on("console-message", (...a) => {
|
|
667
|
+
const d =
|
|
668
|
+
a[1] && typeof a[1] === "object"
|
|
669
|
+
? `${a[1].level} ${a[1].message} @ ${a[1].sourceId}:${a[1].lineNumber}`
|
|
670
|
+
: `${a[1]} ${a[2]} @ ${a[4]}:${a[3]}`;
|
|
671
|
+
console.error("[renderer]", d);
|
|
672
|
+
});
|
|
673
|
+
win.webContents.on("preload-error", (_e, p, err) => console.error("[preload-error]", p, err && err.message));
|
|
674
|
+
win.webContents.on("render-process-gone", (_e, det) => console.error("[render-gone]", JSON.stringify(det)));
|
|
675
|
+
win.once("ready-to-show", () => {
|
|
676
|
+
// buildPayload() awaits the first task load, so a single push paints all tabs.
|
|
677
|
+
pushInbox(true).catch((error) => console.error("[overlay] initial push failed:", error && error.message));
|
|
678
|
+
pollHosts();
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
try {
|
|
682
|
+
fs.watchFile(STATE_PATH, { interval: 800 }, () => pushInboxQuiet());
|
|
683
|
+
} catch {}
|
|
684
|
+
try {
|
|
685
|
+
fs.watch(RELAY_HOME, { persistent: true }, (_evt, file) => {
|
|
686
|
+
if (!file || String(file).startsWith("state.json")) pushInboxQuiet();
|
|
687
|
+
});
|
|
688
|
+
} catch {}
|
|
689
|
+
setInterval(() => pushInboxQuiet(), 2500); // safety net for state.json
|
|
690
|
+
setInterval(() => {
|
|
691
|
+
refreshTasks()
|
|
692
|
+
.then(() => pushInbox(false))
|
|
693
|
+
.catch(() => {});
|
|
694
|
+
}, 5000); // live task refresh
|
|
695
|
+
setInterval(pollHosts, 1500);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// ---- ipc -----------------------------------------------------------------
|
|
699
|
+
|
|
700
|
+
ipcMain.handle("relay:get", () => buildPayload());
|
|
701
|
+
ipcMain.on("relay:open", (_e, id) => openPacket(id));
|
|
702
|
+
ipcMain.on("relay:openTask", (_e, taskId) => openTaskDetail(taskId));
|
|
703
|
+
ipcMain.on("relay:openUrl", (_e, url) => openUrlTarget(url));
|
|
704
|
+
ipcMain.on("relay:ack", (_e, id) => ackPacket(id));
|
|
705
|
+
ipcMain.handle("relay:refreshTasks", async () => {
|
|
706
|
+
await refreshTasks();
|
|
707
|
+
await pushInbox(true);
|
|
708
|
+
return tasksCache;
|
|
709
|
+
});
|
|
710
|
+
ipcMain.handle("relay:accept", (_e, taskId, participantId) => acceptTask(taskId, participantId));
|
|
711
|
+
ipcMain.handle("relay:reject", (_e, taskId, participantId) => rejectTask(taskId, participantId));
|
|
712
|
+
ipcMain.handle("relay:approve", (_e, taskId, approvalId) => approveShare(taskId, approvalId));
|
|
713
|
+
ipcMain.handle("relay:decline", (_e, taskId, approvalId) => declineShare(taskId, approvalId));
|
|
714
|
+
ipcMain.handle("relay:answer", (_e, taskId, messageId, text) => answerQuestion(taskId, messageId, text));
|
|
715
|
+
ipcMain.handle("relay:contacts", () => readContacts());
|
|
716
|
+
ipcMain.handle("relay:contactSave", (_e, input) => saveContact(input));
|
|
717
|
+
|
|
718
|
+
// The overlay is normally focusable:false so it never steals keyboard focus from
|
|
719
|
+
// Claude/Codex. Text fields (the contact form, quick reply) need focus, so the renderer
|
|
720
|
+
// asks us to grant it while a field is open and revoke it the moment the form closes.
|
|
721
|
+
ipcMain.on("relay:setFocusable", (_e, focusable) => {
|
|
722
|
+
if (!win || win.isDestroyed()) return;
|
|
723
|
+
win.setFocusable(Boolean(focusable));
|
|
724
|
+
if (focusable) win.focus();
|
|
725
|
+
});
|
|
726
|
+
ipcMain.on("relay:interactive", (_e, interactive) => {
|
|
727
|
+
// Capture the foreground host the instant the pointer reaches the overlay — BEFORE the click can
|
|
728
|
+
// momentarily activate this accessory window. Without this, clicking a relay while a host is merely
|
|
729
|
+
// windowed (not fullscreen) makes the at-click frontmost detection return the overlay/Electron.
|
|
730
|
+
if (interactive) {
|
|
731
|
+
frontmostBundleId((bundle) => {
|
|
732
|
+
rememberForegroundHost(hostFromBundle(bundle));
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
if (win && !win.isDestroyed()) win.setIgnoreMouseEvents(!interactive, { forward: true });
|
|
736
|
+
});
|
|
737
|
+
ipcMain.on("relay:setPos", (_e, x, y) => {
|
|
738
|
+
if (win && !win.isDestroyed() && Number.isFinite(x) && Number.isFinite(y)) win.setPosition(Math.round(x), Math.round(y));
|
|
739
|
+
});
|
|
740
|
+
ipcMain.handle("relay:soundBytes", (_e, name) => {
|
|
741
|
+
const clean = String(name).replace(/[^A-Za-z]/g, "");
|
|
742
|
+
const candidates = [
|
|
743
|
+
path.join(__dirname, "sounds", `${clean.toLowerCase()}.wav`), // bundled WAV (Chromium decodes reliably)
|
|
744
|
+
`/System/Library/Sounds/${clean}.aiff`, // fallback to the system sound
|
|
745
|
+
];
|
|
746
|
+
for (const file of candidates) {
|
|
747
|
+
try {
|
|
748
|
+
const b = fs.readFileSync(file);
|
|
749
|
+
return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
|
|
750
|
+
} catch {}
|
|
751
|
+
}
|
|
752
|
+
return null; // gracefully null when no sound is available
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
app.whenReady().then(() => {
|
|
756
|
+
if (process.platform === "darwin" && app.dock) app.dock.hide(); // accessory: no dock icon, no NC banners
|
|
757
|
+
createWindow();
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
app.on("window-all-closed", () => {
|
|
761
|
+
/* keep running as a background agent; relaunched by launchd KeepAlive if it dies */
|
|
762
|
+
});
|