relay-companion
Version:
Install Relay for Claude Code, Cowork, and Codex, then sign in from the Relay pill.
799 lines (762 loc) • 33.9 kB
JavaScript
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { currentUser, readConfig, taskLedgerPath, webUrl } from "./config.js";
import { migrateContentValue } from "./content-field-migration.js";
import { withJsonLock, withJsonLockStrict } from "./state-lock.cjs";
import atomicJson from "./atomic-json.cjs";
import localTrace from "./local-trace.cjs";
import inboxItemIds from "./inbox-item-id.cjs";
const { canonicalInboxItemId } = inboxItemIds;
const { atomicWriteJsonSync } = atomicJson;
const { appendLocalTrace } = localTrace;
const DEFAULT_COMPANION_STATE = {
version: 1,
profile: {
name: "",
handle: "",
email: "",
inboxDir: "",
contactCardRoots: [],
transport: { type: "filesystem" },
},
contacts: [],
packets: {},
meetingNotes: {},
setup: {},
emailThreads: {},
chats: {},
};
function absoluteActionUrl(pathOrUrl) {
if (!pathOrUrl) return webUrl();
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
const path = pathOrUrl.startsWith("/") ? pathOrUrl : `/${pathOrUrl}`;
return `${webUrl()}${path}`;
}
export function notificationActionUrl(notification) {
return notification.actionUrl || absoluteActionUrl(notification.url);
}
function actionForNotification(notification) {
if (notification.kind === "task_request") {
return {
type: "task_request",
taskId: notification.taskId,
participantId: notification.participantId,
url: notificationActionUrl(notification),
actions: [
{ id: "accept", label: "Accept", method: "accept_task_invitation" },
{ id: "reject", label: "Reject", method: "reject_task_invitation" },
],
};
}
if (notification.kind === "share_approval") {
return {
type: "review_share_approval",
taskId: notification.taskId,
url: notificationActionUrl(notification),
actions: [
{ id: "send", label: "Send", method: "approve_share" },
{ id: "decline", label: "Decline", method: "decline_share" },
],
};
}
return {
type: "open",
url: notificationActionUrl(notification),
};
}
function participantForUser(task, user) {
return (task.participants || []).find((participant) => (
participant.userId === user?.id || participant.email === user?.email
));
}
function taskNotification(task, kind, title, body, url, extra = {}) {
// Identity is stable on the CONDITION, not the volatile updatedAt (which re-minted
// every poll and staged duplicate rows). `idDiscriminator` lets a caller re-notify
// when the condition genuinely changes (e.g. a rising approval count) without
// reintroducing timestamp spam. Never leaks into the row payload.
const { idDiscriminator, ...rest } = extra;
const base = {
id: idDiscriminator != null ? `${kind}:${task.id}:${idDiscriminator}` : `${kind}:${task.id}`,
kind,
title,
body,
url,
taskId: task.id,
createdAt: task.updatedAt || task.createdAt || null,
...rest,
};
return {
...base,
action: actionForNotification(base),
};
}
export function buildHumanNotifications({ user, tasks = [], relays = [], connectors = [], inboxState = {} } = {}) {
const notifications = [];
for (const task of tasks) {
const participant = participantForUser(task, user);
if (
task.viewerRole === "participant" &&
participant &&
["created", "delivered"].includes(participant.invitationState) &&
task.state === "inviting"
) {
notifications.push(taskNotification(
task,
"task_request",
"Relay Task",
`${task.title}: ${task.objective}`,
`/app/tasks/${task.id}`,
{ participantId: participant.id },
));
}
if (task.pendingApprovalCount > 0) {
notifications.push(taskNotification(
task,
"share_approval",
"Relay approval needed",
`${task.pendingApprovalCount} outbound share approval${task.pendingApprovalCount === 1 ? "" : "s"} waiting for ${task.title}.`,
`/app/tasks/${task.id}`,
// A rising approval count is genuinely new work, so it must re-notify — but a
// steady count must NOT re-stage every poll. Fold the count into the id.
{ idDiscriminator: task.pendingApprovalCount },
));
}
if (task.state === "completed") {
notifications.push(taskNotification(task, "task_completed", "Relay task completed", task.title, `/app/tasks/${task.id}`));
}
if (task.state === "cancelled" || task.state === "failed") {
notifications.push(taskNotification(task, `task_${task.state}`, `Relay task ${task.state}`, task.title, `/app/tasks/${task.id}`));
}
}
for (const connector of connectors) {
if (!["expired", "reconnect_required", "admin_consent_required", "error"].includes(connector.status)) continue;
const notification = {
// Stable on (provider, status) — the connector is re-polled every cycle, so a
// lastCheckedAt suffix minted a fresh id (and a duplicate unread pill row) on
// EVERY poll while the connector stayed broken.
id: `connector:${connector.provider}:${connector.status}`,
kind: "connector_reauth",
title: `${connector.displayName} needs attention`,
body: connector.errorMessage || `${connector.displayName} is ${connector.status.replaceAll("_", " ")}.`,
url: "/app/connectors",
provider: connector.provider,
createdAt: connector.lastCheckedAt || null,
};
notifications.push({ ...notification, action: actionForNotification(notification) });
}
const suppressed = new Set(inboxState.suppressedItemIds || []);
const restoredAtByItemId = inboxState.restoredAtByItemId || {};
const readAllAtMs = inboxState.readAllAt ? new Date(inboxState.readAllAt).getTime() : Number.NaN;
return notifications
.filter((notification) => !suppressed.has(notification.id))
.map((notification) => {
const deliveryVersion = restoredAtByItemId[notification.id] || "";
const deliveryReferenceAt = deliveryVersion || notification.createdAt || "";
const deliveryMs = new Date(deliveryReferenceAt).getTime();
return {
...notification,
deliveryVersion,
deliveryReferenceAt,
read: Number.isFinite(readAllAtMs) && Number.isFinite(deliveryMs) && deliveryMs <= readAllAtMs,
};
});
}
export function freshNotifications(ledger, notifications) {
const seen = ledger.notifications || {};
return notifications.filter((notification) => {
const previous = seen[notification.id];
return !previous || (notification.deliveryVersion || "") !== (previous.deliveryVersion || "");
});
}
export function markNotificationsProcessed(ledger, notifications) {
ledger.notifications ||= {};
const processedAt = new Date().toISOString();
for (const notification of notifications) {
ledger.notifications[notification.id] = {
kind: notification.kind,
deliveryVersion: notification.deliveryVersion || "",
processedAt,
};
}
}
export function companionHome() {
return process.env.RELAY_HOME || process.env.RELAY_COMPANION_HOME || path.join(os.homedir(), ".relay-companion");
}
export function companionStatePath(dir = companionHome()) {
return path.join(dir, "state.json");
}
function readCompanionState(statePath) {
try {
return {
...structuredClone(DEFAULT_COMPANION_STATE),
...JSON.parse(fs.readFileSync(statePath, "utf8")),
};
} catch {
return structuredClone(DEFAULT_COMPANION_STATE);
}
}
function writeCompanionState(statePath, state) {
atomicWriteJsonSync(statePath, state, { mode: 0o600 });
}
/** Apply server tombstones to rows already staged by this companion. */
export function removeSuppressedCompanionItems(itemIds, { statePath = companionStatePath() } = {}) {
const suppressed = new Set((itemIds || []).filter(Boolean).map(String));
if (!suppressed.size) return 0;
return withJsonLock(statePath, () => {
const state = readCompanionState(statePath);
state.packets ||= {};
let removed = 0;
for (const [id, row] of Object.entries(state.packets)) {
if (!suppressed.has(canonicalInboxItemId(id, row))) continue;
delete state.packets[id];
removed += 1;
}
if (removed) writeCompanionState(statePath, state);
return removed;
});
}
/** Reconcile an account-wide mark-all-read watermark onto already-staged rows. */
export function markCompanionItemsReadThrough(readAllAt, { statePath = companionStatePath() } = {}) {
const cutoffMs = new Date(readAllAt || "").getTime();
if (!Number.isFinite(cutoffMs)) return 0;
return withJsonLock(statePath, () => {
const state = readCompanionState(statePath);
state.packets ||= {};
let changed = 0;
for (const row of Object.values(state.packets)) {
if (!row || row.direction !== "inbound" || row.state === "read") continue;
const reference = row.deliveryReferenceAt ||
(row.relayNotificationKind === "plain_relay" ? row.updatedAt : row.createdAt);
const referenceMs = new Date(reference || "").getTime();
if (!Number.isFinite(referenceMs) || referenceMs > cutoffMs) continue;
row.state = "read";
changed += 1;
}
if (changed) writeCompanionState(statePath, state);
return changed;
});
}
function accountMarker({ user = currentUser(), deviceId = readConfig().deviceId || "" } = {}) {
return {
userId: user?.id || "",
email: user?.email || "",
deviceId: deviceId || "",
};
}
function stateBelongsToAccount(state, marker) {
if (!marker.userId && !marker.email) return true;
const account = state.account || {};
if (!account.userId && !account.email) return false;
if (account.userId && marker.userId && account.userId !== marker.userId) return false;
if (account.email && marker.email && account.email !== marker.email) return false;
return true;
}
export function resetCompanionStateForAccount(
{ user = currentUser(), deviceId = readConfig().deviceId || "", force = false } = {},
{ statePath = companionStatePath() } = {},
) {
const marker = accountMarker({ user, deviceId });
return withJsonLock(statePath, () => {
const existing = readCompanionState(statePath);
if (!force && stateBelongsToAccount(existing, marker)) {
if (!existing.account) {
existing.account = { ...marker, resetAt: null };
writeCompanionState(statePath, existing);
}
return { reset: false, statePath };
}
const next = {
...structuredClone(DEFAULT_COMPANION_STATE),
account: { ...marker, resetAt: new Date().toISOString() },
profile: {
...structuredClone(DEFAULT_COMPANION_STATE.profile),
transport: { type: "relay_api" },
},
};
writeCompanionState(statePath, next);
// The staging ledger belongs to the store that was just destroyed. Left
// intact, its dedupe would permanently block re-staging every relay the
// reset wiped, leaving the inbox empty while the server still has them.
try {
const ledgerFile = taskLedgerPath();
const ledger = JSON.parse(fs.readFileSync(ledgerFile, "utf8")) || {};
ledger.plainRelays = {};
fs.writeFileSync(ledgerFile, `${JSON.stringify(ledger, null, 2)}\n`);
} catch {}
return { reset: true, statePath };
});
}
function notificationUrgency(kind) {
if (kind === "task_request" || kind === "share_approval") return "action_required";
if (kind === "connector_reauth") return "attention";
if (kind === "task_failed" || kind === "task_cancelled") return "problem";
return "normal";
}
function senderForNotification(notification) {
// Always prefer the real sender carried on the notification (e.g. the question's
// TaskMessage.senderLabel or the other person's name). Never surface the brand word
// "Relay" as if it were a person.
if (notification.senderName) return notification.senderName;
// task_request / share_approval are workflow notices, not people; label them as the
// automation, not as "Relay" the person. (These are filtered out of the Relays list
// anyway via RELAY_HIDDEN_KINDS, but the staged senderName must still be coherent.)
if (notification.kind === "task_request") return "Your agent";
if (notification.kind === "share_approval") return "Your agent";
// Task lifecycle notices (completed/cancelled/failed) are the agent run reporting
// back, not a person — label them as the automation, never "Someone".
if (notification.kind === "task_completed") return "Your agent";
if (notification.kind === "task_cancelled") return "Your agent";
if (notification.kind === "task_failed") return "Your agent";
if (notification.kind === "connector_reauth") return "Relay connectors";
return "Someone";
}
function subjectForNotification(notification) {
if (notification.kind === "task_request" && notification.body) return notification.body;
if (notification.kind === "share_approval" && notification.body) return notification.body;
return notification.title || notification.body || "Relay";
}
function notificationSender(notification) {
return {
name: senderForNotification(notification),
handle: "relay",
email: "",
host: "relay",
};
}
function notificationRecipient() {
return {
name: "You",
handle: "you",
email: "",
ai: "codex",
};
}
function packetMarkdownForNotification(notification) {
const lines = [
notification.body || notification.title || "Relay needs your attention.",
"",
"---",
`Relay attention item: ${notification.kind || "relay"}`,
];
if (notification.taskId) lines.push(`Task id: \`${notification.taskId}\``);
if (notification.messageId) lines.push(`Message id: \`${notification.messageId}\``);
if (notification.participantId) lines.push(`Participant id: \`${notification.participantId}\``);
if (notification.provider) lines.push(`Connector: \`${notification.provider}\``);
if (notification.kind === "task_request") {
lines.push("");
lines.push("This is a Relay Task. The human must accept or reject before their agent participates.");
} else if (notification.kind === "share_approval") {
lines.push("");
lines.push("This is a Relay share approval. No cross-person information has been sent yet.");
}
return lines.join("\n");
}
// Notification ids carry a colon by construction — `share_approval:task_9`,
// `connector:claude:degraded` — and a colon cannot appear in
// a Windows filename: it is the alternate-data-stream separator. Writing the id
// straight through meant every packet write on Windows died with EINVAL, so no
// notification could ever be staged there.
//
// Clean the name for the filesystem and keep a short digest of the EXACT id, so two
// ids that clean to the same string can never share a file. Same id always yields
// the same name, which is what lets a re-stage overwrite in place. Readers follow
// the contentPath recorded in state, so this only has to be stable, not pretty.
function packetFileName(id) {
const raw = String(id || "");
const clean = raw.replace(/[^0-9A-Za-z._-]+/g, "-").replace(/^[.]+/, "").slice(0, 80) || "packet";
return `${clean}.${createHash("sha256").update(raw).digest("hex").slice(0, 8)}.json`;
}
function writeNotificationPacketContent(notification, packet, statePath) {
const dir = path.join(path.dirname(statePath), "packets");
const contentPath = path.join(dir, packetFileName(packet.id));
atomicWriteJsonSync(contentPath, packet, { mode: 0o600 });
return contentPath;
}
function relayPacketFromNotification(notification, { now = new Date().toISOString() } = {}) {
const subject = subjectForNotification(notification);
const sender = notificationSender(notification);
return {
schemaVersion: 1,
id: notification.id,
createdAt: notification.createdAt || now,
updatedAt: now,
state: notification.read ? "read" : "unread",
sender,
recipient: notificationRecipient(),
source: {
host: "relay",
surface: "relay_companion",
threadId: null,
cwd: process.cwd(),
},
kind: "message",
title: subject,
displayTitle: subject,
// THE AGENT LANE. This was hardcoded empty, so every inbound relay lost its
// second document at staging and the reader's folder — "For you" / "For
// <their agent app>" — could never appear, however carefully the sender
// split it (David, live: "where's the folder stuff?"). The tabs are gated
// on this string being non-empty.
forAgent: String(notification.forAgent || ""), // notification path keeps its own source
includeDefaultTranscript: false,
briefingMarkdown: packetMarkdownForNotification(notification),
attachments: [],
delivery: {
transport: "relay_task",
targetSurfaces: ["codex", "claude_code"],
recipientInboxDir: "",
outboxPath: null,
inboxPath: null,
},
};
}
export function relayCompanionRowFromNotification(
notification,
{ now = new Date().toISOString(), contentPath = null } = {},
) {
const actionUrl = notificationActionUrl(notification);
const subject = subjectForNotification(notification);
return {
id: notification.id,
direction: "inbound",
state: notification.read ? "read" : "unread",
kind: "message",
// Default unknown notification kinds to a neutral "message", never "task_completed"
// (which would falsely mark an unknown row as a finished task). main.cjs readRelays
// also defaults to "task_completed"; staging a real kind here keeps that fallback unused.
relayNotificationKind: notification.kind || "message",
urgency: notificationUrgency(notification.kind),
senderName: senderForNotification(notification),
title: subject,
displayTitle: subject,
briefingMarkdown: packetMarkdownForNotification(notification),
forHuman: notification.body || notification.title || "",
createdAt: notification.createdAt || now,
deliveryReferenceAt: notification.deliveryReferenceAt || notification.createdAt || now,
updatedAt: now,
stagedAt: now,
actionUrl,
action: notification.action || actionForNotification(notification),
taskId: notification.taskId || null,
participantId: notification.participantId || null,
messageId: notification.messageId || null,
provider: notification.provider || null,
contentPath,
filePath: contentPath,
attachments: [],
materializationDeferredReason: "relay_pill",
materializedSurfaces: { codex: false, claudeCode: false, claudeCowork: false },
};
}
export function stageRelayCompanionItem(notification, { statePath = companionStatePath() } = {}) {
// Locked read-modify-write: the pill acks rows into the same file concurrently.
return withJsonLock(statePath, () => {
const state = readCompanionState(statePath);
state.packets ||= {};
state.chats ||= {};
const existing = state.packets[notification.id] || {};
const packet = relayPacketFromNotification(notification);
const contentPath = writeNotificationPacketContent(notification, packet, statePath);
const item = {
...existing,
...relayCompanionRowFromNotification(notification, { contentPath }),
state: existing.state === "read" || packet.state === "read" ? "read" : "unread",
};
state.packets[item.id] = item;
writeCompanionState(statePath, state);
return {
ok: true,
transport: "relay_companion",
statePath,
itemId: item.id,
actionUrl: item.actionUrl,
contentPath,
};
});
}
export function defaultStageRelayCompanionItem(notification) {
return stageRelayCompanionItem(notification);
}
/**
* Patch a staged row's attachments in place (the ingest prefetch writes the
* acquired localPath values back after download). Deliberately writes ONLY the
* attachments array — never the materialization-signature keys, which are
* open-time state owned by the materializer.
*/
export function updateStagedRelayAttachments(relayId, attachments, { statePath = companionStatePath() } = {}) {
if (!relayId || !Array.isArray(attachments)) return false;
return withJsonLock(statePath, () => {
const state = readCompanionState(statePath);
const row = state.packets && state.packets[relayId];
if (!row) return false;
row.attachments = attachments;
writeCompanionState(statePath, state);
return true;
});
}
export function stagePlainRelayItem(
{ item, packet, attachmentUrls = {} },
{ statePath = companionStatePath(), forceUnread = false } = {},
) {
if (!item?.relayId) throw new Error("stagePlainRelayItem requires an inbox item with relayId");
// A rolling API deploy may briefly return a pre-v3 packet to a v3+ client.
// Normalize at the wire boundary on every ingest so retired field names can
// never overwrite the canonical two-document store after its one-time sweep.
packet = packet && typeof packet === "object" ? structuredClone(packet) : {};
migrateContentValue(packet);
// STRICT lock: an unlocked stage racing a pill write can be clobbered by the
// last-writer-wins rename, and the daemon's ledger would never re-stage the
// relay — a permanently invisible message. Failing the poll and retrying in
// 4s is strictly better. Throwing keeps the caller's no-ledger-on-error path.
const strict = withJsonLockStrict(statePath, () => {
const state = readCompanionState(statePath);
state.packets ||= {};
state.chats ||= {};
const now = new Date().toISOString();
const existing = state.packets[item.relayId] || {};
const content = {
...(packet || {}),
id: item.relayId,
briefingMarkdown: packet?.forHuman || item.preview || "",
attachmentUrls,
delivery: {
transport: "relay_api",
targetSurfaces: packet?.targetSurfaces || ["codex", "claude_code"],
recipientInboxDir: "",
outboxPath: null,
inboxPath: null,
},
};
const contentPath = writeNotificationPacketContent({ id: item.relayId }, content, statePath);
const preserveSetupUnread = existing.setupImportedUnread === true && existing.state === "unread";
const readState = forceUnread || preserveSetupUnread
? "unread"
: existing.state === "read" || item.state === "read" || item.state === "acknowledged"
? "read"
: "unread";
const row = {
...existing,
id: item.relayId,
direction: "inbound",
state: readState,
kind: item.kind || packet?.kind || "message",
// A task is still an ordinary relay on the wire; the notification kind is
// what gives it the Task chip and the Start verb in the pill. Distinct from
// the legacy (hidden) "task_request" protocol kind on purpose.
relayNotificationKind: (item.kind || packet?.kind) === "task" ? "task" : "plain_relay",
urgency: "normal",
// Task receipt timestamps, present only on kind "task" items. The server
// is authoritative once it has them; until then keep a locally stamped
// value (the preview writes one at Start) instead of reverting to null.
taskState: item.taskState || existing.taskState || ((item.kind || packet?.kind) === "task" ? "requested" : null),
taskAcceptedAt: item.taskAcceptedAt || existing.taskAcceptedAt || null,
taskStartedAt: item.taskStartedAt || existing.taskStartedAt || null,
taskRunOwner: item.taskRunOwner || existing.taskRunOwner || null,
taskCompletedAt: item.taskCompletedAt || existing.taskCompletedAt || null,
// Channel Task ownership is live projection state, not packet content.
taskClaim: item.taskClaim || existing.taskClaim || null,
// The completion species has to survive staging or the pill cannot tell an
// agent's report from correspondence, and it lands in the chat with a
// person (David, twice). packet.type is the sender's declared type.
type: packet?.type || item.type || existing.type || null,
senderName: item.sender?.name || packet?.sender?.name || "Someone",
// The address is what lets the pill show a contact-book name instead of an
// account handle: a Relay account is named by whatever the person signed up
// with, which is usually their email prefix.
senderEmail: item.sender?.email || packet?.sender?.email || "",
// An untitled relay is a typed text: staging must NOT manufacture a title
// ("Relay", or the preview promoted to a subject) or every downstream
// body-first fallback is defeated and the pill shows the word "Relay"
// instead of the message.
title: item.title || packet?.title || "",
displayTitle: item.title || packet?.title || item.displayTitle || "",
briefingMarkdown: packet?.forHuman || item.preview || "",
forHuman: packet?.forHuman || item.preview || "",
// THE AGENT LANE — the relay's second document. It rides the PACKET (the
// inbox list item carries only a preview), and staging never copied it, so
// the reader's folder — "For you" / "For <their agent app>" — could not
// render however carefully the sender split the relay (David, live:
// "where's the folder stuff?"). The tabs are gated on this being non-empty.
forAgent: packet?.forAgent || "",
createdAt: item.createdAt || packet?.createdAt || now,
updatedAt: item.updatedAt || now,
editedAt: item.editedAt || packet?.editedAt || null,
deletedAt: item.deletedAt || packet?.deletedAt || null,
// Thread identity travels with the staged row so the pill can group an
// exchange and keep its unread counting stable per thread.
inReplyToRelayId: item.inReplyToRelayId || packet?.inReplyToRelayId || null,
threadId: item.threadId || packet?.threadId || item.relayId,
// The conversation's name, carried on every item by the API so the pill can
// label a thread without fetching it.
threadTitle: item.threadTitle || packet?.threadTitle || null,
// Stable group facts must survive local staging. Without these, a group
// with only one person speaking looked like a 1:1 chat with that person.
groupSendId: item.groupSendId || packet?.groupSendId || null,
recipientGroupId: item.recipientGroupId || packet?.recipientGroupId || null,
recipientGroupName: item.recipientGroupName || packet?.recipientGroupName || null,
// This marker is created only after authenticated local MLS decryption. It
// drives a visible trust label; the server cannot manufacture it in a
// plaintext inbox response.
e2ee: packet?.e2ee || item.e2ee || null,
historyImported: packet?.historyImported === true || item.historyImported === true,
// A restoration is a fresh inbox delivery even when the underlying relay was
// read before deletion. Use that timestamp so an older account read-all
// cutoff cannot immediately collapse the restored row back to read.
deliveryReferenceAt: item.restoredAt || item.updatedAt || item.createdAt || now,
stagedAt: now,
actionUrl: "/app/relays",
action: { type: "open", url: "/app/relays" },
taskId: null,
participantId: null,
messageId: null,
provider: null,
// The WORKSPACE PASSPORT rides with the row: it is how the open path routes
// a relay to the repo it belongs to (cwd-select.js). Dropping it here is why
// every relay landed in the default folder — the sender's passport survived
// the API in packet.source and died at staging (David's live report).
source: packet?.source || item.source || null,
contentPath,
filePath: contentPath,
attachments: packet?.attachments || [],
attachmentUrls,
materializationDeferredReason: "relay_pill",
materializedSurfaces: existing.materializedSurfaces || { codex: false, claudeCode: false, claudeCowork: false },
setupImportedUnread: forceUnread || preserveSetupUnread || undefined,
};
state.packets[item.relayId] = row;
writeCompanionState(statePath, state);
// Verified read-back before the caller may ledger this item: the row must
// actually be on disk, or the ledger would permanently swallow the relay.
const persisted = readCompanionState(statePath);
if (!persisted.packets || !persisted.packets[item.relayId]) {
throw new Error(`staged relay ${item.relayId} missing on read-back`);
}
return {
ok: true,
transport: "relay_companion",
statePath,
itemId: item.relayId,
stagedState: row.state,
actionUrl: row.actionUrl,
contentPath,
};
});
if (!strict.ok) throw new Error(`state lock unavailable (${strict.reason}); relay ${item.relayId} not staged`);
appendLocalTrace({
event: "relay_staged",
relayId: item.relayId,
surface: "relay_pill",
state: strict.value?.stagedState || "unknown",
restored: Boolean(forceUnread || item.restoredAt),
}, { home: path.dirname(statePath) });
return strict.value;
}
// Sent relays normally live only in the API-backed Sent list. Stage a durable,
// non-inbox row on first open so the existing lazy materializer can create (and
// subsequently reuse) the same native Claude/Codex session it creates for an
// inbound relay. `direction: "outbound"` deliberately keeps this synthetic row
// out of the Relays column while preserving its materialization metadata.
export function stageSentRelayItem(
{ item, sender = {} },
{ statePath = companionStatePath() } = {},
) {
if (!item?.relayId) throw new Error("stageSentRelayItem requires a sent item with relayId");
return withJsonLock(statePath, () => {
const state = readCompanionState(statePath);
state.packets ||= {};
state.chats ||= {};
// A self-sent relay can also exist as an inbound packet under item.relayId.
// Use a distinct, filename-safe key so opening Sent never overwrites or
// reorients the copy shown in the Relays column.
const materializationId = `sent_${item.relayId}`;
const existing = state.packets[materializationId] || {};
const now = new Date().toISOString();
const existingAttachments = new Map(
(Array.isArray(existing.attachments) ? existing.attachments : [])
.filter((attachment) => attachment?.id)
.map((attachment) => [attachment.id, attachment]),
);
const attachments = (Array.isArray(item.attachments) ? item.attachments : []).map((attachment) => {
const prior = existingAttachments.get(attachment?.id);
return prior?.localPath ? { ...attachment, localPath: prior.localPath } : attachment;
});
const attachmentUrls = Object.fromEntries(
attachments
.filter((attachment) => attachment?.id && attachment?.openUrl)
.map((attachment) => [attachment.id, attachment.openUrl]),
);
const senderName = String(sender.name || sender.email || "You").trim() || "You";
const recipient = item.recipient || { name: "Recipient" };
// Same rule as inbound staging: an untitled sent row is a typed text, and
// manufacturing a subject here defeats every body-first fallback downstream.
const title = item.title || item.displayTitle || "";
const forHuman = item.forHuman || item.preview || "";
const forAgent = item.forAgent || "";
const content = {
id: materializationId,
sourceRelayId: item.relayId,
createdAt: item.createdAt || now,
editedAt: item.editedAt || null,
deletedAt: item.deletedAt || null,
kind: item.kind || "message",
title,
displayTitle: title,
forHuman,
// `/v1/sent` carries the complete second Relay document. Keep it in the
// durable packet as well as the projected row so both providers receive
// the same two-document handoff when a sent Relay is opened later.
forAgent,
source: item.source || { host: "unknown" },
sender: { name: senderName, ...(sender.email ? { email: sender.email } : {}) },
recipient,
attachments,
attachmentUrls,
// Thread identity so opening a sent message inside a conversation seeds
// the whole exchange (materializer attachThreadTranscript), same as the
// inbound side. An item without one must not clobber a previously staged
// id (this row is re-staged on every open).
threadId: item.threadId || existing.threadId || null,
threadTitle: item.threadTitle || existing.threadTitle || null,
groupSendId: item.groupSendId || existing.groupSendId || null,
recipientGroupId: item.recipientGroupId || existing.recipientGroupId || null,
recipientGroupName: item.recipientGroupName || existing.recipientGroupName || null,
inReplyToRelayId: item.inReplyToRelayId || existing.inReplyToRelayId || null,
// A sibling of recipient, not a property of it, so the field-by-field
// allowlist above drops it unless it is named here. Without this line the
// sender's own seed can never tell a minted link from an ordinary send.
shareLink: item.shareLink || existing.shareLink || null,
};
const contentPath = writeNotificationPacketContent({ id: materializationId }, content, statePath);
const row = {
...existing,
...content,
direction: "outbound",
state: "read",
relayNotificationKind: "sent_relay",
senderName,
recipient,
briefingMarkdown: forHuman,
updatedAt: item.updatedAt || now,
stagedAt: existing.stagedAt || now,
contentPath,
filePath: contentPath,
attachmentUrls,
sentDelivery: item.delivery || null,
materializationDeferredReason: "sent_relay_pill",
materializedSurfaces:
existing.materializedSurfaces || { codex: false, claudeCode: false, claudeCowork: false },
};
state.packets[materializationId] = row;
writeCompanionState(statePath, state);
return {
ok: true,
transport: "relay_companion",
statePath,
itemId: materializationId,
relayId: item.relayId,
contentPath,
};
});
}