UNPKG

relay-companion

Version:

Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.

566 lines (529 loc) • 20.8 kB
import { createWorkConversation, hydrateWorkConversation, normalizeActivity, reduceWorkEvent, workPresentationSnapshot, } from "./work-conversation.js"; const DEFAULT_MAX_FEEDS = 64; const DEFAULT_MAX_PENDING_EVENTS = 2_048; const DEFAULT_MAX_SUBSCRIBERS = 16; const DEFAULT_MAX_ENVELOPE_BYTES = 2 * 1024 * 1024; const FORBIDDEN_RENDERER_KEYS = new Set([ "raw", "rawEvent", "reasoningContent", "encryptedContent", "arguments", "args", "input", "output", "stdout", "stderr", "environment", "env", "headers", "authorization", "apiKey", "token", "password", "secret", ]); function cleanId(value, label) { const id = String(value || "").trim(); if (!id) throw new TypeError(`${label} is required`); return id; } function finitePositive(value, fallback) { const number = Number(value); return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback; } function byteLength(value) { return Buffer.byteLength(JSON.stringify(value), "utf8"); } /** * Defense in depth around the renderer projection. The canonical presenter is * responsible for deciding what the UI needs; this pass guarantees provider * payloads and common secret-bearing fields cannot accidentally cross IPC. */ export function rendererSafeClone(value, { maxDepth = 16, maxArray = 1_000, maxString = 64 * 1_024 } = {}, depth = 0) { if (value == null || typeof value === "boolean" || typeof value === "number") return value; if (typeof value === "string") return value.length > maxString ? `${value.slice(0, maxString)}…` : value; if (depth >= maxDepth) return null; if (Array.isArray(value)) { return value.slice(0, maxArray).map((entry) => rendererSafeClone(entry, { maxDepth, maxArray, maxString }, depth + 1)); } if (typeof value !== "object") return null; const out = {}; for (const [key, entry] of Object.entries(value)) { if (FORBIDDEN_RENDERER_KEYS.has(key) || /(?:^|_)(?:secret|password|token|authorization|api[_-]?key)$/i.test(key)) continue; out[key] = rendererSafeClone(entry, { maxDepth, maxArray, maxString }, depth + 1); } return out; } function normalizeHydration(value) { if (Array.isArray(value)) return { events: value }; if (!value || typeof value !== "object") return { events: [] }; return { state: value.state, events: Array.isArray(value.events) ? value.events : [], provider: String(value.provider || "").trim() || null, }; } function defaultAlive() { return true; } /** * Owns one canonical Work conversation per Relay/native-session pair. * * The native subscription is attached before async hydration starts. Events * arriving during hydration are queued, then replayed after the persisted * history. The reducer's own event-id watermarks provide overlap dedupe. * Renderer subscribers receive only monotonic, bounded presentation snapshots. */ export function createWorkPushBridge({ createState, reduceEvent, present, hydrate, subscribeNative, presentItemDetail = null, presentAttachment = null, authorizeDetail = null, maxFeeds = DEFAULT_MAX_FEEDS, maxPendingEvents = DEFAULT_MAX_PENDING_EVENTS, maxSubscribers = DEFAULT_MAX_SUBSCRIBERS, maxEnvelopeBytes = DEFAULT_MAX_ENVELOPE_BYTES, now = () => Date.now(), } = {}) { if (typeof createState !== "function") throw new TypeError("createState must be a function"); if (typeof reduceEvent !== "function") throw new TypeError("reduceEvent must be a function"); if (typeof present !== "function") throw new TypeError("present must be a function"); if (typeof hydrate !== "function") throw new TypeError("hydrate must be a function"); if (typeof subscribeNative !== "function") throw new TypeError("subscribeNative must be a function"); maxFeeds = finitePositive(maxFeeds, DEFAULT_MAX_FEEDS); maxPendingEvents = finitePositive(maxPendingEvents, DEFAULT_MAX_PENDING_EVENTS); maxSubscribers = finitePositive(maxSubscribers, DEFAULT_MAX_SUBSCRIBERS); maxEnvelopeBytes = finitePositive(maxEnvelopeBytes, DEFAULT_MAX_ENVELOPE_BYTES); const feeds = new Map(); function keyFor(relayId, sessionId) { return `${cleanId(relayId, "relayId")}\u0000${cleanId(sessionId, "sessionId")}`; } function touch(feed) { feed.lastUsedAt = now(); feeds.delete(feed.key); feeds.set(feed.key, feed); } function stopNative(feed) { if (!feed.nativeUnsubscribe) return; const unsubscribe = feed.nativeUnsubscribe; feed.nativeUnsubscribe = null; feed.nativeDetached = false; try { unsubscribe(); } catch {} } function suspendIdleFeed(feed) { if (feed.subscribers.size !== 0) return; stopNative(feed); // Native notifications can occur while nobody is watching. A later // subscriber must therefore hydrate the authoritative bounded log again, // rather than inheriting a snapshot with an invisible event gap. feed.epoch += 1; feed.hydrated = false; feed.hydratePromise = null; feed.pending.length = 0; feed.needsRehydrate = false; } function removeFeed(feed) { stopNative(feed); feed.epoch += 1; feed.subscribers.clear(); feeds.delete(feed.key); } function evict() { if (feeds.size <= maxFeeds) return; for (const feed of feeds.values()) { if (feeds.size <= maxFeeds) break; if (feed.subscribers.size === 0) removeFeed(feed); } } function makeEnvelope(feed) { const projection = rendererSafeClone(present(feed.state, now())); const envelope = { relayId: feed.relayId, sessionId: feed.sessionId, provider: String(feed.state?.provider || projection?.provider || ""), revision: feed.revision, presentation: projection, }; if (byteLength(envelope) > maxEnvelopeBytes) { throw Object.assign(new Error("Work presentation exceeded the IPC size limit"), { code: "WORK_PRESENTATION_TOO_LARGE" }); } return envelope; } function publish(feed) { let envelope; try { envelope = makeEnvelope(feed); } catch (error) { feed.lastError = error; return; } for (const [id, subscriber] of feed.subscribers) { if (!subscriber.isAlive()) { feed.subscribers.delete(id); continue; } try { subscriber.send(envelope); } catch { feed.subscribers.delete(id); } } suspendIdleFeed(feed); } function apply(feed, event) { const next = reduceEvent(feed.state, event); if (next !== undefined) feed.state = next; feed.revision += 1; } function onNative(feed, epoch, event) { if (feed.epoch !== epoch || !feeds.has(feed.key)) return; touch(feed); if (!feed.hydrated) { if (feed.pending.length >= maxPendingEvents) { // Dropping a native delta would make the view dishonest. Invalidate the // generation and perform a fresh bounded hydration instead. feed.pending.length = 0; feed.needsRehydrate = true; return; } feed.pending.push(event); return; } apply(feed, event); publish(feed); } function attachNative(feed) { if (feed.nativeUnsubscribe || feed.subscribers.size === 0) return; const epoch = feed.epoch; const unsubscribe = subscribeNative( { relayId: feed.relayId, sessionId: feed.sessionId }, (event) => onNative(feed, epoch, event), ); feed.nativeUnsubscribe = typeof unsubscribe === "function" ? unsubscribe : () => {}; feed.nativeDetached = Boolean(unsubscribe?.detached); } function failDetachedActiveTurn(feed) { if (!feed.nativeDetached) return; const projected = present(feed.state, now()); const activeTurn = Array.isArray(projected?.turns) ? projected.turns.findLast((turn) => turn?.active || turn?.status === "inProgress") : null; if (!activeTurn) return; const emittedAtMs = now(); apply(feed, { method: "relay/connectionClosed", eventId: `detached:${feed.epoch}:${emittedAtMs}`, emittedAtMs, params: { turnId: activeTurn.id || activeTurn.key, message: "The provider process is no longer connected. This run did not finish.", }, }); } async function hydrateFeed(feed) { if (feed.hydratePromise) return feed.hydratePromise; const epoch = feed.epoch; feed.hydratePromise = (async () => { do { feed.needsRehydrate = false; const hydrated = normalizeHydration(await hydrate({ relayId: feed.relayId, sessionId: feed.sessionId })); if (feed.epoch !== epoch || !feeds.has(feed.key)) return null; feed.provider = hydrated.provider || hydrated.state?.provider || feed.provider || null; feed.state = hydrated.state ?? createState({ relayId: feed.relayId, sessionId: feed.sessionId, provider: feed.provider, }); for (const event of hydrated.events) apply(feed, event); const pending = feed.pending.splice(0); for (const event of pending) apply(feed, event); failDetachedActiveTurn(feed); } while (feed.needsRehydrate); feed.hydrated = true; feed.revision += 1; publish(feed); return makeEnvelope(feed); })().catch((error) => { if (feed.epoch === epoch) feed.lastError = error; throw error; }).finally(() => { if (feed.epoch === epoch) feed.hydratePromise = null; }); return feed.hydratePromise; } function getOrCreate(relayId, sessionId) { const key = keyFor(relayId, sessionId); let feed = feeds.get(key); if (!feed) { // Retire idle feeds first. Active feeds are owned by visible surfaces and // must never be silently evicted; fail honestly when every slot is live. if (feeds.size >= maxFeeds) { for (const candidate of [...feeds.values()]) { if (feeds.size < maxFeeds) break; if (candidate.subscribers.size === 0) removeFeed(candidate); } } if (feeds.size >= maxFeeds) { throw Object.assign(new Error("Too many active Work feeds"), { code: "WORK_TOO_MANY_FEEDS" }); } feed = { key, relayId: cleanId(relayId, "relayId"), sessionId: cleanId(sessionId, "sessionId"), provider: null, state: createState({ relayId, sessionId }), hydrated: false, hydratePromise: null, pending: [], needsRehydrate: false, nativeUnsubscribe: null, nativeDetached: false, subscribers: new Map(), revision: 0, epoch: 1, lastError: null, lastUsedAt: now(), }; feeds.set(key, feed); evict(); } touch(feed); return feed; } async function subscribe({ relayId, sessionId, subscriberId, send, isAlive = defaultAlive }) { if (typeof send !== "function") throw new TypeError("send must be a function"); if (typeof isAlive !== "function") throw new TypeError("isAlive must be a function"); const id = cleanId(subscriberId, "subscriberId"); const feed = getOrCreate(relayId, sessionId); if (!feed.subscribers.has(id) && feed.subscribers.size >= maxSubscribers) { throw Object.assign(new Error("Too many Work subscribers"), { code: "WORK_TOO_MANY_SUBSCRIBERS" }); } feed.subscribers.set(id, { send, isAlive }); try { attachNative(feed); // subscribe first: hydration cannot open an event gap if (feed.hydrated) send(makeEnvelope(feed)); else await hydrateFeed(feed); } catch (error) { // The caller never received an unsubscribe handle. Retire its provisional // registration here so failed hydration cannot leak a native listener. feed.subscribers.delete(id); suspendIdleFeed(feed); throw error; } return () => unsubscribe({ relayId, sessionId, subscriberId: id }); } function unsubscribe({ relayId, sessionId, subscriberId }) { const feed = feeds.get(keyFor(relayId, sessionId)); if (!feed) return false; const removed = feed.subscribers.delete(String(subscriberId || "")); suspendIdleFeed(feed); return removed; } async function reconnect({ relayId, sessionId }) { const feed = getOrCreate(relayId, sessionId); stopNative(feed); feed.epoch += 1; feed.hydrated = false; feed.hydratePromise = null; feed.pending.length = 0; feed.needsRehydrate = false; attachNative(feed); return hydrateFeed(feed); } async function itemDetail({ relayId, sessionId, subscriberId, turnId = "", itemId }) { if (typeof presentItemDetail !== "function") return { ok: false, error: "Detail is unavailable." }; const feed = feeds.get(keyFor(relayId, sessionId)); const subscriber = feed?.subscribers.get(String(subscriberId || "")); if (!feed || !subscriber || !subscriber.isAlive()) return { ok: false, error: "Not subscribed." }; if (authorizeDetail && !await authorizeDetail({ relayId: feed.relayId, sessionId: feed.sessionId, subscriberId, turnId, itemId })) { return { ok: false, error: "Not authorized." }; } const detail = rendererSafeClone(await presentItemDetail(feed.state, cleanId(itemId, "itemId"), { turnId: String(turnId || "") })); const payload = { ok: true, relayId: feed.relayId, sessionId: feed.sessionId, turnId: String(turnId || ""), itemId: String(itemId), revision: feed.revision, detail }; if (byteLength(payload) > maxEnvelopeBytes) return { ok: false, error: "Detail is too large." }; return payload; } async function attachment({ relayId, sessionId, subscriberId, turnId = "", itemId, attachmentId = "", attachmentIndex = -1, }) { if (typeof presentAttachment !== "function") return { ok: false, error: "Attachment preview is unavailable." }; const feed = feeds.get(keyFor(relayId, sessionId)); const subscriber = feed?.subscribers.get(String(subscriberId || "")); if (!feed || !subscriber || !subscriber.isAlive()) return { ok: false, error: "Not subscribed." }; const resolved = await presentAttachment(feed.state, cleanId(itemId, "itemId"), { turnId: String(turnId || ""), attachmentId: String(attachmentId || ""), attachmentIndex: Number.isInteger(attachmentIndex) ? attachmentIndex : -1, }); if (!resolved) return { ok: false, error: "Attachment is not part of this Work item." }; const payload = { ok: true, relayId: feed.relayId, sessionId: feed.sessionId, turnId: String(turnId || ""), itemId: String(itemId), revision: feed.revision, ...rendererSafeClone(resolved), }; if (byteLength(payload) > maxEnvelopeBytes) return { ok: false, error: "Attachment preview is too large." }; return payload; } function close() { for (const feed of [...feeds.values()]) removeFeed(feed); } return { subscribe, unsubscribe, reconnect, itemDetail, attachment, close, stats: () => [...feeds.values()].map((feed) => ({ relayId: feed.relayId, sessionId: feed.sessionId, revision: feed.revision, hydrated: feed.hydrated, pending: feed.pending.length, subscribers: feed.subscribers.size, nativeAttached: Boolean(feed.nativeUnsubscribe), })), }; } function canonicalItemDetail(state, requestedId, { turnId: requestedTurnId = "" } = {}) { const itemId = String(requestedId || ""); for (const turnId of state?.turnOrder || []) { if (requestedTurnId && turnId !== requestedTurnId) continue; const turn = state.turns?.[turnId]; const item = turn?.items?.[itemId]; if (!item) continue; if (item.type === "reasoning") { // Private chain of thought is never a renderer detail. The reducer's // short provider-authored summary is already present in the overview. return { id: itemId, type: "reasoning", available: false }; } const activity = normalizeActivity(item); const raw = item.raw || {}; const tool = String(raw.tool || raw.name || activity?.tool || "").replace(/^mcp__.*?__/, ""); const jsArguments = tool === "js" && raw.arguments && typeof raw.arguments === "object" ? raw.arguments : tool === "js" && raw.input && typeof raw.input === "object" ? raw.input : null; const command = item.type === "commandExecution" ? String(raw.command || raw.action?.command || raw.action?.cmd || "") : ""; return { id: itemId, turnId, type: String(item.type || "activity"), status: String(item.status || ""), ...(activity ? { activity } : {}), ...(jsArguments ? { execution: { title: String(jsArguments.title || raw.title || "JavaScript"), script: String(jsArguments.code || ""), result: item.result ?? null, }, } : {}), ...(item.type === "commandExecution" ? { execution: { title: String(activity?.fullObject || activity?.object || "Command"), command, transcript: String(item.output || ""), }, } : {}), ...(item.type === "fileChange" ? { changes: (item.raw?.changes || []).slice(0, 200).map((change) => ({ path: String(change?.path || change?.filePath || ""), kind: String(change?.kind || change?.type || ""), })), } : {}), }; } return null; } function attachmentCandidates(raw) { const direct = Array.isArray(raw?.attachments) ? raw.attachments : []; const blocks = Array.isArray(raw?.content) ? raw.content.filter((entry) => entry && /image|file|attachment/i.test(String(entry.type || ""))) : []; return [...direct, ...blocks].slice(0, 20); } /** * Resolve a renderer selector against the exact canonical reducer item. Paths, * URLs and hashes are taken only from main-process state; no renderer-supplied * source string participates in this lookup. */ export function canonicalAttachmentReference(state, requestedItemId, { turnId: requestedTurnId = "", attachmentId: requestedAttachmentId = "", attachmentIndex = -1, } = {}) { const itemId = String(requestedItemId || ""); const wantedAttachmentId = String(requestedAttachmentId || ""); for (const turnId of state?.turnOrder || []) { if (requestedTurnId && turnId !== requestedTurnId) continue; const item = state.turns?.[turnId]?.items?.[itemId]; if (!item) continue; const candidates = attachmentCandidates(item.raw); const candidate = Number.isInteger(attachmentIndex) && attachmentIndex >= 0 ? candidates[attachmentIndex] : candidates.find((entry, index) => String(entry?.id || entry?.attachmentId || `${itemId}:${index}`) === wantedAttachmentId); if (!candidate || typeof candidate !== "object") return null; const canonicalId = String(candidate.id || candidate.attachmentId || `${itemId}:${candidates.indexOf(candidate)}`); if (wantedAttachmentId && canonicalId !== wantedAttachmentId) return null; const sourcePath = String(candidate.localPath || candidate.path || candidate.filePath || "").trim(); const sourceUrl = String(candidate.url || candidate.image_url || candidate.file_url || "").trim(); const sha256 = String(candidate.sha256 || candidate.digest || "").replace(/^sha256:/i, "").trim(); const size = Number(candidate.size ?? candidate.bytes); return { id: canonicalId, path: sourcePath, url: /^https:\/\//i.test(sourceUrl) ? sourceUrl : "", name: String(candidate.name || candidate.fileName || candidate.filename || "Image"), mimeType: String(candidate.mimeType || candidate.contentType || candidate.mediaType || ""), size: Number.isSafeInteger(size) && size >= 0 ? size : null, sha256, }; } return null; } /** Canonical provider-native Work composition used by Task Work and AI Preview. */ export function createCanonicalWorkPushBridge({ hydrate, subscribeNative, authorizeDetail, ...limits } = {}) { return createWorkPushBridge({ ...limits, hydrate: async (identity) => { const value = normalizeHydration(await hydrate(identity)); return { state: value.state ? hydrateWorkConversation(value.state) : createWorkConversation({ provider: value.provider || identity.provider || "codex", sessionId: identity.sessionId, }), events: value.events, provider: value.provider || value.state?.provider || identity.provider || "codex", }; }, subscribeNative, authorizeDetail, createState: ({ sessionId, provider }) => createWorkConversation({ provider: provider || "codex", sessionId }), reduceEvent: reduceWorkEvent, present: (state, time) => workPresentationSnapshot(state, time), presentItemDetail: canonicalItemDetail, presentAttachment: limits.presentAttachment, }); }