// Decides when to push. A thread notifies when its phase becomes one the user cares about // (waiting for them, finished, failed), and again only after it moves on. A new approval on a // thread already waiting counts as a change, so each request notifies once. Nothing notifies while // a host loads its first snapshot, so a restart never replays old news. import { isSnoozed, threadPhase, type Phase } from "./awareness.ts"; import type { PendingRequest, PushCategory, PushPayload } from "./contract.ts"; import type { HostRow } from "./db.ts"; import type { T3Host, ThreadChange } from "./t3/host.ts"; import { trimText, type SummaryCache, type ThreadSummary } from "./views.ts"; export interface Push { title: string; subtitle?: string; body: string; category: PushCategory; /** True for a subagent's request, which watches can mute apart from their own threads'. */ fromSubagent?: boolean; data: PushPayload; /** Replaces an earlier push about the same thing on the watch. */ collapseId: string; } export type SendPush = (push: Push) => Promise; const NOTIFYING: ReadonlySet = new Set(["waiting_for_approval", "waiting_for_input", "completed", "failed"]); const BODY_CHARS = 180; const TITLE_CHARS = 80; /** An option value longer than this can't be sent whole, so the question goes without buttons. */ const OPTION_VALUE_CHARS = 200; const SUMMARY_WAIT_MS = 3_000; const TOKEN_WARNING_MS = 7 * 24 * 60 * 60_000; export class Notifier { /** The last phase key seen per thread, `phase:requestId`. */ private readonly keys = new Map(); /** Hosts already warned about, so each warning goes out once a day at most. */ private readonly warned = new Map(); constructor( private readonly send: SendPush, private readonly cache: SummaryCache, private readonly log: (message: string) => void = () => {}, ) {} onThreadChange = ({ host, previous, next, initial }: ThreadChange) => { const id = `${host.id}:${(next ?? previous)?.id}`; if (!next) { this.keys.delete(id); return; } const phase = threadPhase(next); const key = `${phase}:${next.pendingRuntimeRequest?.id ?? ""}`; const before = this.keys.get(id); this.keys.set(id, key); if (initial || phase === null || !NOTIFYING.has(phase) || key === before) return; // T3 keeps a snoozed thread asleep through background wakes, so its finishing again is not news. if (phase === "completed" && isSnoozed(next)) return; void this.notify(host, next, phase).catch((error: unknown) => this.log(`push for ${id} failed: ${error instanceof Error ? error.message : String(error)}`), ); }; /** Warns once when a host starts rejecting the gateway's token. */ onHostStatus = (host: T3Host) => { if (host.status !== "auth_invalid") return; this.warnHost(host.id, host.label, `T3 rejected the gateway's token. Pair ${host.label} again.`); }; /** Warns about tokens that expire within a week. Run it daily. */ checkTokenExpiry(rows: HostRow[], now = Date.now()) { for (const row of rows) { if (!row.token_expires_at) continue; const left = Date.parse(row.token_expires_at) - now; if (left > TOKEN_WARNING_MS) continue; const days = Math.max(0, Math.ceil(left / (24 * 60 * 60_000))); this.warnHost(row.id, row.label, `The gateway's T3 token expires in ${days} day${days === 1 ? "" : "s"}. Pair ${row.label} again.`); } } private warnHost(hostId: string, label: string, body: string) { const last = this.warned.get(hostId) ?? 0; if (Date.now() - last < 24 * 60 * 60_000) return; this.warned.set(hostId, Date.now()); void this.send({ title: label, body, category: "host", data: { hostId }, collapseId: `host:${hostId}`, }).catch(() => {}); } private async notify(host: T3Host, thread: NonNullable, phase: Phase) { const summary = await Promise.race([ this.cache.get(host, thread).catch(() => undefined), Bun.sleep(SUMMARY_WAIT_MS).then(() => undefined), ]); const project = host.projects.get(thread.projectId)?.title; const requestId = thread.pendingRuntimeRequest?.id; // A subagent's push is about the thread that delegated to it, which is the one the user knows. const parent = thread.delegatedFrom?.title; const text = body(phase, thread.lastError ?? null, summary); await this.send({ title: trimText(parent ?? thread.title, TITLE_CHARS), subtitle: project ? `${host.label} · ${project}` : host.label, body: parent ? `Subagent ${trimText(thread.title, TITLE_CHARS)}: ${text}` : text, category: category(phase), fromSubagent: parent !== undefined, data: { hostId: host.id, threadId: thread.id, ...(requestId ? { requestId } : {}), ...pushQuestion(phase, summary) }, collapseId: `${host.id}:${thread.id}`.slice(0, 64), }); } } /** * A lone single-choice question's options, so the notification can offer them as buttons. Labels * are trimmed and the list capped to keep the payload under APNs' 4 KB. */ function pushQuestion(phase: Phase, summary: ThreadSummary | undefined): Pick { const questions = summary?.request?.questions ?? []; const question = questions[0]; if (phase !== "waiting_for_input" || questions.length !== 1 || !question || question.multiSelect) return {}; if (question.options.length === 0 && !question.allowCustomAnswer) return {}; // A cut value would answer with a string that matches no option. if (question.options.some(({ value }) => value.length > OPTION_VALUE_CHARS)) return {}; return { question: { id: question.id, options: question.options.slice(0, 4).map(({ label, value }) => ({ label: trimText(label, 40), value })), allowCustomAnswer: question.allowCustomAnswer, }, }; } function category(phase: Phase): PushCategory { switch (phase) { case "waiting_for_approval": return "approval"; case "waiting_for_input": return "input"; case "failed": return "failed"; default: return "finished"; } } /** * An approval push's body: the files a file-change approval would edit, as "Wants to edit * compose.yml +9 −6" or "Wants to edit 3 files: a.ts, b.ts, c.ts", or else the command it wants to * run. The watch's cards word it the same way. */ export function approvalBody(request: PendingRequest | null): string { const files = request?.kind === "file-change" ? request.files : []; const [file] = files; if (files.length === 1 && file) { const counts = [ file.additions === null ? null : `+${file.additions}`, file.deletions === null ? null : `\u2212${file.deletions}`, ].filter((count) => count !== null); return trimText(["Wants to edit", file.name, ...counts].join(" "), BODY_CHARS); } if (files.length > 1) { return trimText(`Wants to edit ${files.length} files: ${files.map((file) => file.name).join(", ")}`, BODY_CHARS); } const prompt = request?.prompt; return prompt ? `Wants to run: ${trimText(prompt, BODY_CHARS)}` : "Needs your approval."; } function body(phase: Phase, lastError: string | null, summary: ThreadSummary | undefined): string { switch (phase) { case "waiting_for_approval": return approvalBody(summary?.request ?? null); case "waiting_for_input": { const question = summary?.request?.questions[0]?.question; return question ? trimText(question, BODY_CHARS) : "Has a question for you."; } case "failed": return lastError ? trimText(lastError, BODY_CHARS) : "The run failed."; default: return summary?.lastAssistantMessage ? trimText(summary.lastAssistantMessage, BODY_CHARS) : "Finished."; } }