relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
256 lines (245 loc) • 16.3 kB
JavaScript
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("relay", {
// Sandboxed harness overlays share the user's screen and speakers. The
// renderer needs to know it is one so it can stay silent.
isTestOverlay: process.env.RELAY_OVERLAY_TEST === "1" || process.env.RELAY_OVERLAY_PERF === "1",
// streams from main -> renderer
onInbox: (cb) => ipcRenderer.on("inbox", (_e, payload) => cb(payload)),
onNewRelay: (cb) => ipcRenderer.on("newRelay", (_e, relay, opts) => cb(relay, opts || {})),
onOpenDone: (cb) => ipcRenderer.on("openDone", (_e, id) => cb(id)),
// "Open in current chat" staged an injection: the UI confirms it so the
// click has visible feedback (delivery is checkpoint-based, not instant).
onInjected: (cb) => ipcRenderer.on("injected", (_e, id, info) => cb(id, info || {})),
// The staged injection was actually CONSUMED by the target session — flip the
// waiting note to a delivered one (main watches the consume-once file).
onInjectionDelivered: (cb) => ipcRenderer.on("injectionDelivered", (_e, id) => cb(id)),
// The current chat stayed idle through the grace window; the injection was
// reclaimed and the relay is opening in a fresh chat instead.
onInjectionAutoFresh: (cb) => ipcRenderer.on("injectionAutoFresh", (_e, id) => cb(id)),
// The open failed (CLI error / helper wouldn't spawn). The row stays unread, so
// say why inline instead of letting the spinner just stop.
onOpenError: (cb) => ipcRenderer.on("openError", (_e, id, message) => cb(id, message || "")),
onOpenFull: (cb) => ipcRenderer.on("openFull", () => cb()), // status-area icon clicked
onOpenRelay: (cb) => ipcRenderer.on("relay:openReader", (_e, input) => cb(input || {})),
onShown: (cb) => ipcRenderer.on("shown", () => cb()),
// pull the full payload on demand
refresh: () => ipcRenderer.invoke("relay:get"),
// Relay rows
open: (id, host) => ipcRenderer.send("relay:open", id, host),
openSent: (id, host) => ipcRenderer.send("relay:openSent", id, host),
// Sent rows get the same open-actions menu as received relays.
openSentInCurrent: (id, host) => ipcRenderer.send("relay:openSentInCurrent", id, host),
openSentFresh: (id, host) => ipcRenderer.send("relay:openSentFresh", id, host),
// expand-card actions: inject into the live Claude session / force a fresh one
preview: (id) => ipcRenderer.send("relay:preview", id),
openInCurrent: (id, host) => ipcRenderer.send("relay:openInCurrent", id, host),
openFresh: (id, host, note) => ipcRenderer.send("relay:openFresh", id, host, note),
openRunSession: (id) => ipcRenderer.invoke("relay:openRunSession", String(id || "")),
// The pill tray's Start task: the full start flow with the default runtime.
taskStart: (id, route) => ipcRenderer.invoke("relay:taskStart", String(id || ""), route || null),
taskClaim: (id, expectedVersion) => ipcRenderer.invoke("relay:taskClaim", String(id || ""), expectedVersion),
taskUnclaim: (id, expectedVersion) => ipcRenderer.invoke("relay:taskUnclaim", String(id || ""), expectedVersion),
taskStop: (id) => ipcRenderer.invoke("relay:taskStop", String(id || "")),
requestReviewSafety: (id) => ipcRenderer.invoke("relay:requestReviewSafety", String(id || "")),
requestCompletionSend: (id) => ipcRenderer.invoke("relay:requestCompletionSend", String(id || "")),
relayWorkStart: (id, route) => ipcRenderer.invoke("relay:relayWorkStart", String(id || ""), route || null),
chatAgentWorkStop: (id) => ipcRenderer.invoke("relay:chatAgentWorkStop", String(id || "")),
chatAgentWorkRetry: (id) => ipcRenderer.invoke("relay:chatAgentWorkRetry", String(id || "")),
runFeed: (id) => ipcRenderer.invoke("relay:runFeed", String(id || "")),
watchRunFeed: (id) => ipcRenderer.invoke("relay:runFeed:watch", String(id || "")),
unwatchRunFeed: (id) => ipcRenderer.send("relay:runFeed:unwatch", String(id || "")),
onRunFeedUpdate: (cb) => {
const listener = (_event, envelope) => cb(envelope);
ipcRenderer.on("relay:runFeed:update", listener);
return () => ipcRenderer.removeListener("relay:runFeed:update", listener);
},
runFeedDetail: (input) => ipcRenderer.invoke("relay:runFeed:detail", {
relayId:String(input?.relayId || ""), sessionId:String(input?.sessionId || ""),
turnId:String(input?.turnId || ""), itemId:String(input?.itemId || ""), attachmentId:String(input?.attachmentId || ""),
}),
runFeedAttachment: (input) => ipcRenderer.invoke("relay:runFeed:attachment", {
relayId:String(input?.relayId || ""), sessionId:String(input?.sessionId || ""),
turnId:String(input?.turnId || ""), itemId:String(input?.itemId || ""),
attachmentId:String(input?.attachmentId || ""),
attachmentIndex:Number.isInteger(input?.attachmentIndex) ? input.attachmentIndex : -1,
}),
runSteer: (relayId, body, options = {}) => ipcRenderer.invoke("relay:runSteer", {
relayId: String(relayId || ""),
body: String(body || ""),
newTurn: options && options.newTurn === true,
model: String(options && options.model || ""),
effort: String(options && options.effort || ""),
files: Array.isArray(options && options.files) ? options.files : [],
}),
schedules: () => ipcRenderer.invoke("relay:schedules"),
scheduleSave: (input) => ipcRenderer.invoke("relay:scheduleSave", input || {}),
ack: (id) => ipcRenderer.send("relay:ack", id),
ackMany: (ids) => ipcRenderer.invoke("relay:ackMany", Array.isArray(ids) ? ids : []),
chatReadActivity: () => ipcRenderer.send("relay:chatReadActivity"),
// A REAL send: {text, inReplyToRelayId?, recipient?, files:[{path}|{name,contentBase64,contentType}]}
// Answers as soon as the message is committed to this device's outbox, not
// when the server has it — the composer never waits on the network.
sendReply: (payload) => ipcRenderer.invoke("relay:sendReply", payload),
// Deliberate acts on a message the server refused: send it again, or bin it.
outboxRetry: (id) => ipcRenderer.invoke("relay:outboxRetry", id),
outboxDiscard: (id) => ipcRenderer.invoke("relay:outboxDiscard", id),
// `window.online` is the only connectivity signal either process actually
// has; the renderer forwards it so a regained network flushes the queue at
// once instead of waiting out the last failure's backoff.
networkOnline: () => ipcRenderer.invoke("relay:networkOnline"),
react: (id, emoji, action) => ipcRenderer.invoke("relay:react", { id, emoji, action }),
editMessage: (id, forHuman, expectedUpdatedAt) => ipcRenderer.invoke("relay:messageEdit", {
id: String(id || ""),
forHuman: String(forHuman || ""),
expectedUpdatedAt: String(expectedUpdatedAt || ""),
}),
deleteMessage: (id, expectedUpdatedAt) => ipcRenderer.invoke("relay:messageDelete", {
id: String(id || ""),
expectedUpdatedAt: String(expectedUpdatedAt || ""),
}),
// Electron>=32 removed File.path — the preload's webUtils is the only way
// a renderer file picker/drop can yield a real path for the send pipeline.
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
deleteRelay: (id) => ipcRenderer.invoke("relay:delete", id),
markAllRead: () => ipcRenderer.invoke("relay:markAllRead"),
openUrl: (url) => ipcRenderer.send("relay:openUrl", url),
// Attachment chip click: main resolves the local copy (or downloads it) and
// opens it; the renderer never sees paths or URLs. Returns { ok, error? }.
openAttachment: (relayId, attachmentId) => ipcRenderer.invoke("relay:openAttachment", relayId, attachmentId),
// Image plates use the same canonical attachment lookup, but receive only
// bounded, signature-checked image bytes. Paths and signed URLs stay in main.
previewAttachment: (relayId, attachmentId) => ipcRenderer.invoke(
"relay:previewAttachment",
String(relayId || ""),
String(attachmentId || ""),
),
refreshSent: () => ipcRenderer.invoke("relay:refreshSent"),
// task mutations (return { ok, error?, conflict? })
accept: (taskId, participantId) => ipcRenderer.invoke("relay:accept", taskId, participantId),
reject: (taskId, participantId) => ipcRenderer.invoke("relay:reject", taskId, participantId),
approve: (taskId, approvalId) => ipcRenderer.invoke("relay:approve", taskId, approvalId),
decline: (taskId, approvalId) => ipcRenderer.invoke("relay:decline", taskId, approvalId),
// tasks view
refreshTasks: () => ipcRenderer.invoke("relay:refreshTasks"),
openTask: (taskId) => ipcRenderer.send("relay:openTask", taskId),
taskStatus: (taskId) => ipcRenderer.invoke("relay:taskStatus", taskId),
// contacts
capabilities: () => ipcRenderer.invoke("relay:capabilities"),
contacts: () => ipcRenderer.invoke("relay:contacts"),
contactsSearch: (q) => ipcRenderer.invoke("relay:contactsSearch", q),
// Resolve a contact's canonical conversation for the pill's chat surface.
openChatWith: (email, name) =>
ipcRenderer.invoke("relay:openChatWith", { email: String(email || ""), name: String(name || "") }),
canonicalChat: (chatId, options = {}) => ipcRenderer.invoke("relay:canonicalChat", {
chatId: String(chatId || ""),
surface: options && options.surface === "slack" ? "slack" : "relay",
includeSlack: Boolean(options && options.includeSlack),
}),
canonicalChatRead: (chatId, options = {}) => ipcRenderer.invoke("relay:canonicalChatRead", {
chatId: String(chatId || ""),
surface: options && options.surface === "slack" ? "slack" : "relay",
includeSlack: Boolean(options && options.includeSlack),
}),
// contact groups (saved rosters; a group relays to every member as one thread)
groups: () => ipcRenderer.invoke("relay:groups"),
groupCreate: (name) => ipcRenderer.invoke("relay:groupCreate", name),
groupRename: (id, name) => ipcRenderer.invoke("relay:groupRename", id, name),
groupDelete: (id) => ipcRenderer.invoke("relay:groupDelete", id),
groupAddMember: (id, contactId) => ipcRenderer.invoke("relay:groupAddMember", id, contactId),
groupRemoveMember: (id, contactId) => ipcRenderer.invoke("relay:groupRemoveMember", id, contactId),
groupLeave: (id) => ipcRenderer.invoke("relay:groupLeave", id),
contactSave: (input) => ipcRenderer.invoke("relay:contactSave", input),
contactDelete: (input) => ipcRenderer.invoke("relay:contactDelete", input),
// settings / account (switch + sign-out relaunch the pill on success)
accountInfo: () => ipcRenderer.invoke("relay:accountInfo"),
slackConnection: () => ipcRenderer.invoke("relay:slackConnection"),
slackConnect: (input = {}) => ipcRenderer.invoke("relay:slackConnect", input || {}),
slackDisconnect: () => ipcRenderer.invoke("relay:slackDisconnect"),
credentialRetry: () => ipcRenderer.invoke("relay:credentialRetry"),
chatAgentPreferences: () => ipcRenderer.invoke("relay:chatAgentPreferences"),
saveChatAgentPreferences: (input) => ipcRenderer.invoke("relay:chatAgentPreferencesSave", input || {}),
connectChatGPT: () => ipcRenderer.invoke("relay:connectChatGPT"),
connectClaude: () => ipcRenderer.invoke("relay:connectClaude"),
completeSetupTutorial: () => ipcRenderer.invoke("relay:completeSetupTutorial"),
e2eeDeviceApprovals: () => ipcRenderer.invoke("relay:e2eeDeviceApprovals"),
approveE2eeDevice: (deviceId) => ipcRenderer.invoke("relay:approveE2eeDevice", String(deviceId || "")),
// Agent-installed first run. Main/core owns the installation authorization,
// its client secret, activation token and PKCE verifier. The renderer sees
// only status + verified account summary and can request the next human act.
installationAuthState: () => ipcRenderer.invoke("relay:installationAuthState"),
installationAuthBegin: () => ipcRenderer.invoke("relay:installationAuthBegin"),
installationAuthResume: () => ipcRenderer.invoke("relay:installationAuthResume"),
// Restart is an explicit human act. Main deletes only the one-time
// installation-authorization namespace, and does so before minting again.
installationAuthRestart: () => ipcRenderer.invoke("relay:installationAuthRestart"),
installationAuthGoogle: (options = {}) => ipcRenderer.invoke("relay:installationAuthGoogle", {
forceAccountSelection: options?.forceAccountSelection === true,
}),
installationAuthEmailStart: (email) => ipcRenderer.invoke("relay:installationAuthEmailStart", { email: String(email || "") }),
installationAuthEmailVerify: (code) => ipcRenderer.invoke("relay:installationAuthEmailVerify", { code: String(code || "") }),
installationAuthApprove: () => ipcRenderer.invoke("relay:installationAuthApprove"),
// Cancelling abandons the local capability immediately; the unapproved
// server record carries no identity and expires on its own.
installationAuthCancel: () => ipcRenderer.invoke("relay:installationAuthCancel"),
pairWithCode: (input) => ipcRenderer.invoke("relay:pairWithCode", input),
signOut: () => ipcRenderer.invoke("relay:signOut"),
providerAuthStatus: () => ipcRenderer.invoke("relay:providerAuthStatus"),
providerInventory: (input = {}) => ipcRenderer.invoke("relay:providerInventory", {
refresh: input?.refresh === true,
force: input?.force === true,
}),
providerAuthConnect: (provider) => ipcRenderer.invoke("relay:providerAuthConnect", String(provider || "")),
providerAuthSetEnabled: (provider, enabled) => ipcRenderer.invoke("relay:providerAuthSetEnabled", {
provider: String(provider || ""),
enabled: Boolean(enabled),
}),
// Manual update backstop, when the auto-updater has not refreshed this machine.
updateNow: () => ipcRenderer.invoke("relay:updateNow"),
// Quiet mode (Settings). Durable per-device preferences, not snoozes: current
// values ride back on accountInfo() so an open Settings tab paints the truth.
setPillHidden: (v) => ipcRenderer.invoke("relay:setPillHidden", Boolean(v)),
setSoundsMuted: (v) => ipcRenderer.invoke("relay:setSoundsMuted", Boolean(v)),
// window plumbing
engage: () => ipcRenderer.send("relay:engage"),
// The native window tracks the visible card's dimensions. `prepare` reserves
// growth before paint; `settled` commits shrink after the spring completes.
cardSize: (w, h, motion = {}) => {
const safeMotion = {
phase: motion.phase === "settled" ? "settled" : "prepare",
motionId: Number.isSafeInteger(motion.motionId) ? motion.motionId : 0,
motionSessionId: typeof motion.motionSessionId === "string"
? motion.motionSessionId.slice(0, 64)
: "",
};
// Final visual state waits for this acknowledgement. It resolves only
// after main has synchronously committed the new AppKit bounds, preventing
// a folded face from painting once in the previous expanded surface.
if (safeMotion.phase === "settled") {
return ipcRenderer.invoke("relay:cardSizeSettled", w, h, safeMotion);
}
ipcRenderer.send("relay:cardSize", w, h, safeMotion);
return undefined;
},
// Grow before revealing a larger reader so content is never clipped.
prepareCardSize: (w, h) => ipcRenderer.invoke("relay:prepareCardSize", w, h),
setTheme: (t) => ipcRenderer.send("relay:theme", t), // preview window wears the same sheet
setPos: (x, y) => ipcRenderer.send("relay:setPos", x, y),
soundBytes: (name) => ipcRenderer.invoke("relay:soundBytes", name),
// dismissal (the card's ✕ / the status-area mark / ghost notifications)
dismiss: () => ipcRenderer.send("relay:dismiss"),
undismiss: () => ipcRenderer.send("relay:undismiss"),
notifDone: () => ipcRenderer.send("relay:notifDone"),
// Legacy array form = bare id list; object form carries dwell/interaction
// evidence for the attention queue's confirmed-seen protocol.
attentionDone: (payload) =>
ipcRenderer.send(
"relay:attentionDone",
Array.isArray(payload) ? payload : payload && typeof payload === "object" ? payload : { ids: [] },
),
// Fired as the renderer script's last statement: every listener above is
// registered, so main may deliver arrivals without dropping them.
rendererReady: () => ipcRenderer.send("relay:rendererReady"),
// Self-diagnosis: the renderer reports its own hitches so a real freeze on a
// real machine writes evidence instead of needing a live reproduction.
reportStall: (info) => ipcRenderer.send("relay:stall", info || {}),
});