// Twin of workspace-state.ts for state with no file to watch: tmux sessions, dev-server panels, sockets, browsers, // spawned children. The daemon pushes a domain on the existing stream instead of the browser polling; keeping the // mapping here only prevents drift. A key belongs to whoever queries it, so one entry can refresh several surfaces. // A segment per level, so a binding can name a nested key, not only a top-level one: the review's key is // ["git","changes"], distinct from the commit log's ["git","log"]. export type QueryKeyPath = readonly string[]; export interface RuntimeDomainBinding { // What moved, in the daemon's words; wire data riding the runtimeChanged frame. readonly domain: string; // Reaches only queries something is observing, so pushing a domain eagerly costs unwatched tabs nothing. readonly invalidates: readonly QueryKeyPath[]; } // Declared `as const` so domain names survive as literal types (RuntimeDomain below). const RUNTIME_DOMAINS = [ // Every attachable tmux session: the tab strip, activity badge and process rows; sampled once, daemon-side. { domain: "terminals", invalidates: [["terminals"]] }, // A dev server's state (running, healthy, preview URL); apps shares it, same underlying managed-process fact. { domain: "panels", invalidates: [["panels"], ["apps"]] }, // Every listening socket and which are forwarded publicly; only the LISTEN set is sampled cheaply. { domain: "ports", invalidates: [["ports"]] }, // The agent's Chromiums and open pages, daemon-held, minted from its own browser tool-call hooks. { domain: "browsers", invalidates: [["browsers"]] }, // The sandbox's own desktop: up, and how many windows are open on it, watched off its window manager's client list. { domain: "desktop", invalidates: [["desktop"]] }, // A connection's status as its last probe found it: pushed when a probe behind a served answer finds another one, and // when a browser sign-in is saved or cleared, which is what moves an account's card off "log in". { domain: "capabilities", invalidates: [["capabilities"]] }, // Daemon-held; changes continuously (tokens, tool uses), so it's rate-limited rather than pushed per mutation. { domain: "subagents", invalidates: [["subagents"]] }, // Machines on the other end of a socket (devices, browsers, phones, runners); "online" is the socket itself, never // sampled. hosts/webext/phones land on capabilities since a pairing card's state is literally the hub's online answer, // and each on the roster its dialogs and rows read (`hosts`, `webext`, `phones`; the editor's PAIRED_* families). // `hosts` also carries a device reading that landed too late for the reader waiting on it (hosts/device-reports.ts), // and a desktop-sync enrollment made or revoked, which is what moves `sync-health` (GET /system/sync). { domain: "hosts", invalidates: [["capabilities"], ["devices"], ["hosts"], ["sync-health"]] }, { domain: "webext", invalidates: [["capabilities"], ["webext"]] }, { domain: "phones", invalidates: [["capabilities"], ["phones"]] }, { domain: "runners", invalidates: [["runners"]] }, // Watched because rows act on their own while nobody's watching (coming due, a turn writing back). { domain: "approvals", invalidates: [["approvals"]] }, // Commit subject drafted after landing; arrives too late for the turn-end refresh, needs its own push. { domain: "landings", invalidates: [["git", "changes"]] }, // A pipeline run or one of its jobs moved: every webhook delivery, in flight or ended (2026-10-20), or the REST poll // for a repo with no hook. The board's own poll is the freshness floor, not the answer: somebody watching a run // must not wait out a poll interval. Runs only: the frame doesn't name which run moved, and re-reading every open // row's job graph would cost a vendor call per row. An open in-flight row re-reads its own graph when the board's // runs land (useRunJobs.ts); the daemon's CI-shaped watches recheck on it too (watchers.ts). { domain: "ci", invalidates: [["ci-runs"]] }, // An engine install starting or ending, whoever started it; the card's only feed, since a read costs registry calls. { domain: "engines", invalidates: [["engines"]] }, // The audit log grew or a gateway's status moved or aged out; the log lives outside /work, so no file push carries it. { domain: "activity", invalidates: [["activity"], ["activity-status"]] }, // An automatic update started waiting, changed what it waits on, began counting down or handed itself to the // machine: what every page needs to hear before the sandbox goes quiet under it, wherever the reader is. { domain: "update", invalidates: [["update"]] }, // A standing yes written or taken back outside a need's answer: a permission card's pass, a release, a purge. They // live under the auth root, which no file push watches, and the Allowed list must move with the card that wrote one. { domain: "grants", invalidates: [["grants"]] }, // Somebody's composer draft was written, cleared or sent. The frame names no conversation and carries no words, since // every member hears it: each window reads back its own caller's drafts (agents.drafts). The key is the editor's draft // store's, which reads them back itself rather than through a cached query (models/conversations/drafts/). { domain: "drafts", invalidates: [["drafts"]] }, ] as const satisfies readonly RuntimeDomainBinding[]; export const RUNTIME_DOMAIN_BINDINGS: readonly RuntimeDomainBinding[] = RUNTIME_DOMAINS; // Every declared domain as a type, so a rename is a compile error rather than a silently dropped frame. export type RuntimeDomain = (typeof RUNTIME_DOMAINS)[number]["domain"]; // Dedupes by joined segments: two domains can feed the same key, but each path is its own array so a Set won't catch // it. Exact, since a segment is always this file's own identifier, never a caller's string. const dedupe = (keys: readonly QueryKeyPath[]): readonly QueryKeyPath[] => [...new Map(keys.map((key) => [key.join(`/`), key])).values()]; // The query keys a pushed domain set makes stale, deduped; called by the browser's /events handler so daemon and // browser share one table. An unknown domain is dropped rather than throwing, for forward compatibility. export const staleRuntimeQueryKeys = (domains: readonly string[]): readonly QueryKeyPath[] => dedupe(RUNTIME_DOMAIN_BINDINGS.filter((binding) => domains.includes(binding.domain)).flatMap((binding) => binding.invalidates));