import * as sidecar from '@@/sidecar-registry'; import { getNotifyServerUrl } from '../notify/router'; import { shouldNotify, type NotifyKind } from './notify-decision'; // The phone half of the bell: the same signal the browser socket listens to, delivered to APNs/FCM. // // ── Why this is a second subscriber and not a branch inside notify-delivery ── // // The two channels have nothing in common except the question "does this account want to hear about // this". That question lives in `notify-decision.ts` and both ask it; everything else differs. The // browser needs an open tab and sends the chat's real title over a loopback socket on the owner's own // tailnet. The phone needs no tab at all and must send NO title, because that hop goes through Apple. // // Folding both into one function would mean a `if (isPush)` inside every step of it, and the first thing // to go wrong would be a title leaking into a payload because the branch was missed in one place. /** Which code a kind maps to. Both live in `notifications/catalogue.ts` as `officer/core:chat.*`. */ const CODE_FOR: Record = { 'turn-ended': 'officer/core:chat.turn-ended', 'input-needed': 'officer/core:chat.input-needed', }; /** * Subscribe the push channel to chat's notify signal. * * Called explicitly from `server.tsx` rather than run on import: a module whose only purpose is a side * effect is one an unused-import sweep deletes, and the failure would be silent. */ export function registerChatPushNotifications(): void { sidecar.onClaudeNotify(async (sessionKey, userId, kind) => { try { if (!(await shouldNotify(userId, sessionKey, kind))) return; const base = getNotifyServerUrl(); // The sidecar has not reported in — either it is starting, or push is not running on this install. // Not an error: the browser bell still works, and this is the channel that is allowed to be absent. if (!base) return; // `id` is the session key so the device can deep-link straight to the conversation. It is an // opaque uuid, which is the only reason it may travel: NO title, and no message content, because // this payload is read by Apple and Google. See docs/push-notifications.md. await fetch(`${base}/_officer/notify`, { method: 'POST', headers: { 'content-type': 'application/json', 'X-Officer-User': String(userId) }, body: JSON.stringify({ code: CODE_FOR[kind], userId, id: sessionKey }), signal: AbortSignal.timeout(10_000), }); } catch (err) { // A notification that fails must never disturb the turn that produced it. console.error('[chat:push] failed to dispatch:', err instanceof Error ? err.message : err); } }); }