@mulmoclaude/core 4.7.0 → 4.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -633,6 +633,23 @@ var isSeedableBlob = (blob) => {
633
633
  return false;
634
634
  }
635
635
  };
636
+ var FIREBASE_KEY_NAMESPACE = "firebase";
637
+ var FIREBASE_KEY_SEGMENTS = 4;
638
+ var rekeyForApp = (key, appName) => {
639
+ const segments = key.split(":");
640
+ if (segments.length !== FIREBASE_KEY_SEGMENTS || segments[0] !== FIREBASE_KEY_NAMESPACE) return key;
641
+ return [...segments.slice(0, 3), appName].join(":");
642
+ };
643
+ var resolveKeys = (keys, appName) => {
644
+ if (!appName) return new Map(keys.map((key) => [key, key]));
645
+ const targets = keys.map((key) => rekeyForApp(key, appName));
646
+ const contested = new Set(targets.filter((target, index) => targets.indexOf(target) !== index));
647
+ return keys.reduce((resolved, key, index) => {
648
+ const target = targets[index];
649
+ if (target !== void 0 && !contested.has(target)) resolved.set(key, target);
650
+ return resolved;
651
+ }, /* @__PURE__ */ new Map());
652
+ };
636
653
  var makeHostPersistenceClass = (store, notify) => {
637
654
  class HostAuthPersistence {
638
655
  static type = "LOCAL";
@@ -672,11 +689,15 @@ var createHostSessionPersistence = () => {
672
689
  listeners.forEach((listener) => listener(blob));
673
690
  };
674
691
  const persistence = makeHostPersistenceClass(store, notify);
675
- const seed = (blob) => {
692
+ const seed = (blob, appName) => {
676
693
  const parsed = JSON.parse(blob);
677
694
  if (!isRecord(parsed)) throw new Error("host session blob must be a JSON object");
678
695
  store.clear();
679
- for (const [key, value] of Object.entries(parsed)) if (isPersistenceValue(value)) store.set(key, value);
696
+ const resolved = resolveKeys(Object.keys(parsed), appName);
697
+ for (const [key, value] of Object.entries(parsed)) {
698
+ const target = resolved.get(key);
699
+ if (target !== void 0 && isPersistenceValue(value)) store.set(target, value);
700
+ }
680
701
  };
681
702
  const onChange = (listener) => {
682
703
  listeners.add(listener);
@@ -756,10 +777,11 @@ var createRemoteHostSession = (config) => {
756
777
  const previousApp = app;
757
778
  const previousBlob = store.exportBlob();
758
779
  appSeq += 1;
780
+ const appName = `remote-host-${appSeq}`;
759
781
  try {
760
782
  store.clear();
761
- if (seedBlob) store.seed(seedBlob);
762
- const { app: nextApp, handles } = await openFreshApp(config, store, `remote-host-${appSeq}`);
783
+ if (seedBlob) store.seed(seedBlob, appName);
784
+ const { app: nextApp, handles } = await openFreshApp(config, store, appName);
763
785
  await validateOrRollback(nextApp, handles, validate);
764
786
  app = nextApp;
765
787
  if (previousApp) await (0, firebase_app.deleteApp)(previousApp).catch(() => void 0);
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../../../src/remote-host/server/firestoreSafeResult.ts","../../../src/remote-host/server/monotonicClock.ts","../../../src/remote-host/server/presenceBeat.ts","../../../src/remote-host/server/hostRunner.ts","../../../src/remote-host/server/resilientRunner.ts","../../../src/remote-host/server/presenceProbe.ts","../../../src/remote-host/server/lifecycle.ts","../../../src/remote-host/server/auth.ts","../../../src/remote-host/server/sessionPersistence.ts","../../../src/remote-host/server/firebase.ts"],"sourcesContent":["// A handler's reply, made safe to hand to Firestore (#2634).\n//\n// Firestore rejects `undefined` at ANY depth, and the runner writes a handler's\n// return value straight into the command document. So one `undefined` — anywhere\n// in a reply, however deep — takes down the WHOLE reply: `updateDoc` throws,\n// `status: \"done\"` never lands, and the remote waits until it times out. The\n// symptom is not \"one field missing\" but \"nothing works\", which is what happened\n// when a `work: undefined` reached MulmoTerminal's session list\n// (receptron/mulmoterminal#1042).\n//\n// Strip rather than throw: a missing optional field costs one row's worth of\n// detail, while a throw costs the user the entire reply — the very outcome this\n// exists to prevent. The report is what keeps it from being silent, because an\n// undeclared stripped key IS a bug on the sending side, and Firestore's own error\n// names the document rather than the field.\n//\n// NOT `ignoreUndefinedProperties` on the Firestore instance: that setting makes\n// this class of bug disappear into \"the value just doesn't arrive\", with nothing\n// logged and nothing to grep for.\n\n/** Path reported for a reply that is itself `undefined` — it has no field name. */\nexport const ROOT_PATH = \"(root)\";\n\n// Only arrays and plain objects are walked. A class instance is left ALONE, because\n// rebuilding it from its entries destroys it: Firestore accepts `Date`, `Timestamp`,\n// `GeoPoint`, `DocumentReference` and its own sentinels as values, and\n// `Object.fromEntries(Object.entries(new Date()))` is `{}`. Silently turning a\n// timestamp into an empty object would be a worse bug than the one being fixed.\nconst isPlainObject = (value: object): boolean => {\n // Typed explicitly: `getPrototypeOf` is declared to return `any`, which would\n // spread an unchecked value through the comparison below.\n const proto: unknown = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n};\n\nconst isWalkable = (value: unknown): value is object => typeof value === \"object\" && value !== null && (Array.isArray(value) || isPlainObject(value));\n\nconst childPath = (prefix: string, key: string | number): string => (prefix ? `${prefix}.${key}` : String(key));\n\n// A reply that points back at itself has no bottom, so the walks below would recurse\n// until the stack gives out. `RangeError: Maximum call stack size exceeded` is caught\n// by the runner and written back as the command's error, so nothing crashes — but it\n// names neither the cause nor the field, which is the one thing this module exists to\n// provide. Ancestors only: a value referenced twice from different branches is\n// ordinary JSON, so each branch removes itself on the way out. One home for the rule\n// because the two walks are structurally twins — applied separately, a later edit to\n// the safety logic would land in only one of them.\nconst withCycleGuard = <T>(value: object, ancestors: WeakSet<object>, path: string, walk: () => T): T => {\n if (ancestors.has(value)) throw new Error(`circular reference at ${path || ROOT_PATH} — Firestore cannot serialise it`);\n ancestors.add(value);\n try {\n return walk();\n } finally {\n ancestors.delete(value);\n }\n};\n\nconst pathsIn = (value: unknown, prefix: string, ancestors: WeakSet<object>): string[] => {\n if (value === undefined) return [prefix || ROOT_PATH];\n if (!isWalkable(value)) return [];\n return withCycleGuard(value, ancestors, prefix, () =>\n // Array.from, not flatMap: a SPARSE array's holes are skipped by flatMap/map, so\n // `[1, , 3]` would be reported clean and then written with a hole Firestore rejects.\n Array.isArray(value)\n ? Array.from(value).flatMap((item, index) => pathsIn(item, childPath(prefix, index), ancestors))\n : Object.entries(value).flatMap(([key, item]) => pathsIn(item, childPath(prefix, key), ancestors)),\n );\n};\n\n/** Every path holding `undefined`, in `a.b.0.c` form. Empty when the value is safe to write.\n * Throws on a circular reply, naming the path it closed the loop at. */\nexport const undefinedPaths = (value: unknown, prefix = \"\"): string[] => pathsIn(value, prefix, new WeakSet());\n\nconst strippedIn = (value: unknown, prefix: string, ancestors: WeakSet<object>): unknown => {\n // The whole reply can be undefined (a handler with no explicit return); `null` is\n // what the runner already substitutes for a missing result.\n if (value === undefined) return null;\n // Anything that is not an array or a plain object is handed back untouched — see\n // isPlainObject: a Date rebuilt from its entries is an empty object.\n if (!isWalkable(value)) return value;\n return withCycleGuard(value, ancestors, prefix, () =>\n Array.isArray(value)\n ? Array.from(value, (item, index) => strippedIn(item, childPath(prefix, index), ancestors))\n : Object.fromEntries(\n Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, strippedIn(item, childPath(prefix, key), ancestors)]])),\n ),\n );\n};\n\n/**\n * The same value with every `undefined` removed — object keys dropped, array holes\n * turned into `null` so the surrounding indexes still line up with what the sender\n * meant. Returns `unknown` because a stripped object is no longer the input type.\n * Throws on a circular reply, same as `undefinedPaths`.\n */\nexport const stripUndefined = (value: unknown): unknown => strippedIn(value, \"\", new WeakSet());\n\n/** Does `path` match `pattern`, where `*` stands for exactly one segment?\n * Segment-wise rather than by regex: no escaping of user-supplied dots, and no\n * pattern can turn into a catastrophic backtrack. */\nexport const matchesPathPattern = (path: string, pattern: string): boolean => {\n const segments = path.split(\".\");\n const wanted = pattern.split(\".\");\n return segments.length === wanted.length && wanted.every((segment, index) => segment === \"*\" || segment === segments[index]);\n};\n\n/**\n * The paths worth reporting: everything the caller has NOT declared as\n * legitimately-optional (`\"sessions.*.work\"`).\n *\n * Takes paths rather than the value so a caller walks the reply once — the walk is\n * also what tells it whether stripping (a full copy) is needed at all.\n *\n * Both kinds have to be stripped — that is Firestore's rule, not a judgment — but\n * only one kind is a defect. Reporting both means reporting one on every reply,\n * which is how a warning stops being read.\n */\nexport const unexpectedPaths = (paths: readonly string[], expected: readonly string[] = []): string[] =>\n paths.filter((path) => !expected.some((pattern) => matchesPathPattern(path, pattern)));\n","// The clock the runner measures its OWN elapsed time with (#2845).\n//\n// `Date.now()` is the wall clock, and the wall clock moves for reasons that have\n// nothing to do with this process: an NTP step, a manual change, a VM syncing its\n// time after a suspend. A runner that measures \"how long have we been failing\"\n// against it reads those jumps as an outage — and one report arrived as\n// \"no presence write acknowledged for 459s\" from a host whose network was fine.\n//\n// Server-side only, which is where every caller lives.\nimport { performance } from \"node:perf_hooks\";\n\n/** Milliseconds from an arbitrary origin, never moving backwards. Only differences\n * between two readings mean anything — never compare one to a wall-clock time. */\nexport const monotonicNowMs = (): number => performance.now();\n","// The presence heartbeat, and the one question the host could never answer about\n// itself: are my beats still landing? (#2633)\n//\n// The phone decides whether a host is reachable from the freshness of the presence\n// document. The host decided whether it was connected from listener errors — a\n// different path entirely, so a presence write could fail on every beat while\n// onSnapshot stayed quiet and the host kept reporting itself green.\n//\n// The sensor is the age of the last ACKNOWLEDGED write, not a count of failed ones.\n// Firestore does not reject a write it cannot deliver; it queues it and leaves the\n// promise pending (\"if the client is offline, the returned Promise will not resolve\n// for a potentially-long time\" — @firebase/firestore typings). A rejection counter\n// would therefore read zero throughout the outage it exists to catch.\n//\n// The write is injected so this file needs no Firestore: hostRunner binds it to\n// setDoc, tests bind it to a promise they control.\n//\n// Beats are COUNTED, not timed (#2845). Counting how much time passed answers a\n// different question than the one being asked: three beats' worth of wall clock\n// also elapses while the machine is asleep, while the event loop is blocked, and\n// when NTP steps the clock — in every one of those the beat never ran, so nothing\n// was ever attempted, let alone failed. Counting only the beats that RAN cannot\n// confuse the two, and needs no clock at all.\nimport { monotonicNowMs } from \"./monotonicClock.js\";\n\n// Beats that may RUN unacknowledged before the host stops claiming to be online.\n// One missed beat is a blip; three means the phone has had nothing fresh to read\n// for as long as it takes to notice.\nexport const PRESENCE_STALE_BEATS = 3;\n\nexport interface PresenceBeatDeps {\n /** Announce online/offline. Resolves when the backend has the write. */\n write: (online: boolean) => Promise<void>;\n /** `staleAfterBeats` beats have run with nothing acknowledged — the phone cannot see\n * this host. `silentMs` is how long that took, for the report only. */\n onStale: (silentMs: number) => void;\n /** A write was refused (auth, rules, quota). Carries the reason, which used to be dropped. */\n onError: (message: string) => void;\n staleAfterBeats: number;\n now?: () => number;\n}\n\nexport interface PresenceBeat {\n /** Write presence unconditionally — startup and the goodbye on teardown. */\n announce: (online: boolean) => void;\n /** One heartbeat: report staleness if the last ack is too old, otherwise announce. */\n beat: () => void;\n}\n\nconst errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error));\n\n// The callbacks belong to the host, and a heartbeat must not be the thing that\n// takes the process down: an observer that throws would otherwise surface as an\n// uncaught exception from a timer, or as an unhandled rejection from the write\n// chain. There is nowhere to report it — `onError` is the reporter — so the only\n// honest move is to keep beating.\nconst notify = (report: () => void, onThrow?: (error: unknown) => void): void => {\n try {\n report();\n } catch (error) {\n // Without an `onThrow` there is nowhere left to report to — the reporter is\n // what just threw. A throwing observer is the host's bug; losing the command\n // channel over it would be ours.\n onThrow?.(error);\n }\n};\n\nexport const createPresenceBeat = (deps: PresenceBeatDeps): PresenceBeat => {\n const now = deps.now ?? monotonicNowMs;\n // Starts at \"just acknowledged\" so a host is given a full window to land its\n // first beat rather than being declared stale before it has written anything.\n // `lastAckMs` is carried for the report; `beatsSinceAck` is what decides.\n const state = { lastAckMs: now(), beatsSinceAck: 0 };\n\n const announce = (online: boolean): void => {\n const fail = (error: unknown) => notify(() => deps.onError(errorText(error)));\n const ack = () =>\n notify(() => {\n state.lastAckMs = now();\n state.beatsSinceAck = 0;\n });\n // A write can also fail SYNCHRONOUSLY — Firestore validates the payload before\n // it returns a promise (that is how one bad field throws before anything is\n // sent) — and that throw would otherwise escape through the heartbeat timer.\n notify(() => {\n deps.write(online).then(ack, fail);\n }, fail);\n };\n\n const beat = (): void => {\n state.beatsSinceAck += 1;\n // Deliberately no write here: another mutation would only queue up behind the\n // ones already stuck, and the answer for this beat is already known.\n if (state.beatsSinceAck >= deps.staleAfterBeats) {\n notify(() => deps.onStale(now() - state.lastAckMs));\n return;\n }\n announce(true);\n };\n\n return { announce, beat };\n};\n","// Host side of the command channel: claim queued commands, run handlers, write\n// results back, and announce presence via heartbeat.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/hostRunner.ts (itself\n// ported from ../mulmoserver). The only signature change vs. that copy: the\n// `firestore` instance is a parameter (each host supplies its own Firebase init),\n// and the heartbeat interval is an option (defaults to one minute).\nimport {\n DocumentReference,\n Firestore,\n FirestoreError,\n Query,\n QuerySnapshot,\n deleteDoc,\n onSnapshot,\n query,\n runTransaction,\n serverTimestamp,\n setDoc,\n updateDoc,\n where,\n} from \"firebase/firestore\";\n\nimport { isRecord } from \"@mulmoclaude/common\";\n\nimport { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport {\n Channel,\n Command,\n CommandHandler,\n CommandHandlers,\n buildHostPresence,\n byCreatedAt,\n coerceJsonObject,\n commandsCollection,\n hostDoc,\n isExpired,\n} from \"../index.js\";\nimport { stripUndefined, undefinedPaths, unexpectedPaths } from \"./firestoreSafeResult.js\";\nimport { PRESENCE_STALE_BEATS, createPresenceBeat, type PresenceBeat } from \"./presenceBeat.js\";\nimport { monotonicNowMs } from \"./monotonicClock.js\";\n// Listener retry policy: `../../firestore/listen.ts`. It is true of ANY\n// Firestore listener, and the shared-collection store has one too. Two copies\n// would drift SILENTLY — one subsystem retrying a revoked grant forever while\n// the other gives up on a network blip.\nimport { backoffDelayMs, classifyListenerError, LISTEN_RETRY_WINDOW_MS, shouldGiveUpListening } from \"../../firestore/listen.js\";\n\n// Exported so a host that judges presence freshness from the outside (a probe that\n// reads the doc back) measures against the same beat the runner writes on.\nexport const DEFAULT_HEARTBEAT_MS = 60_000;\n\n// Re-exported under their original names so this module's published surface —\n// which MulmoTerminal imports — is unchanged by the move.\nexport { backoffDelayMs, classifyListenerError, LISTEN_RETRY_WINDOW_MS, shouldGiveUpListening };\n\nexport interface HostEvent {\n phase: \"received\" | \"done\" | \"error\";\n method: string;\n message?: string;\n}\n\nexport interface HostRunnerOptions {\n onEvent?: (event: HostEvent) => void;\n // Called once when the listener dies fatally (after presence has been set\n // offline), so the lifecycle owner can reconcile its own state — e.g. clear\n // the runner handle so status() no longer reports connected. NOT called on a\n // normal stop().\n onClosed?: () => void;\n // Called when a command is dropped for being past its `expiresAt`, BEFORE the\n // doc is deleted, so the host can clean up out-of-band resources the command\n // referenced (e.g. staged attachment uploads in Storage). `uid` is THIS runner's\n // session uid (channel.uid) — passed in rather than read from a global so a\n // concurrent reconnect as a different account can't point cleanup at the wrong\n // user's Storage path. Best-effort: a throw is logged via onEvent and does NOT\n // block the doc deletion. Absent ⇒ the expired doc is simply deleted.\n onExpire?: ((command: Command, uid: string) => void | Promise<void>) | undefined;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number | undefined;\n // Paths in a handler's reply where `undefined` is expected rather than a bug,\n // keyed by method name — `{ listSessions: [\"sessions.*.work\"] }`, `*` matching\n // exactly one segment. Firestore refuses `undefined` either way, so these are\n // still stripped; declaring them only silences the report, which is what keeps\n // it worth reading (#2634).\n expectedUndefined?: Record<string, readonly string[]>;\n}\n\nexport interface Claim {\n method: string;\n // Still unproved JSON here: the doc is remote-written, so the walk that earns\n // `JsonObject` runs at the handler boundary, outside the claim transaction.\n params: Record<string, unknown>;\n}\n\nconst noop = () => undefined;\n\n// The remote may have deleted the doc on timeout, so ignore write-after-delete.\nconst writeError = (ref: DocumentReference, code: string, message: string) =>\n updateDoc(ref, { status: \"error\", error: { code, message }, updatedAt: serverTimestamp() }).catch(noop);\n\n// What a queued command doc contributes to a claim, rebuilt from the two\n// members that were checked rather than asserted off a `DocumentData`.\n// `null` means \"not ours to take\" — the doc is gone or someone else moved it\n// out of `queued`.\n//\n// Anything else DEGRADES instead of bailing: a non-string method becomes \"\" so\n// the dispatch falls through to the unknown-method reply, and non-object params\n// become an empty set so the handler's own required-field check answers. Both\n// write an error back to the doc. Returning null for them instead would leave\n// the command claimed as `processing` with nothing ever written back — the one\n// outcome the remote cannot distinguish from a dead host.\nexport const readClaim = (data: unknown): Claim | null => {\n if (!isRecord(data) || data.status !== \"queued\") return null;\n return { method: typeof data.method === \"string\" ? data.method : \"\", params: isRecord(data.params) ? data.params : {} };\n};\n\n// Atomically move a command queued -> processing so it is handled exactly once.\n// Returns the method/params to run, or null if another handler already took it.\nconst claimCommand = (firestore: Firestore, ref: DocumentReference): Promise<Claim | null> =>\n runTransaction(firestore, async (txn) => {\n const claim = readClaim((await txn.get(ref)).data());\n if (!claim) {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return claim;\n });\n\n// Own-property lookup: a bare `handlers[method]` with a method name written by a\n// remote terminal resolves `constructor` / `toString` to an Object.prototype\n// function, which is truthy and slips past the unknown-method check (#2319).\nexport const resolveCommandHandler = (handlers: CommandHandlers, method: string): CommandHandler | undefined =>\n Object.hasOwn(handlers, method) ? handlers[method] : undefined;\n\n// Firestore refuses a write containing `undefined` at any depth, so one stray\n// value would cost the whole reply — `status: \"done\"` never lands and the remote\n// waits out its timeout. Strip instead, and name the paths: Firestore's own error\n// points at the document, never at the field, which is where the debugging time\n// goes. Paths the caller declared as legitimately-optional are stripped silently.\nconst reportStripped = (dropped: string[], claim: Claim, options: HostRunnerOptions): void => {\n if (dropped.length === 0) return;\n const paths = dropped.map((path) => `result.${path}`).join(\", \");\n options.onEvent?.({ phase: \"error\", method: claim.method, message: `undefined dropped at ${paths} — Firestore would have refused the whole reply` });\n};\n\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler, options: HostRunnerOptions): Promise<HostEvent> => {\n try {\n // Inside the try on purpose: a params value JSON cannot carry throws\n // naming its path, and that lands in the same `handler_error` reply the\n // handler's own failures do, rather than stalling the command.\n const returned = await handler(coerceJsonObject(claim.params));\n const dropped = undefinedPaths(returned);\n reportStripped(unexpectedPaths(dropped, options.expectedUndefined?.[claim.method]), claim, options);\n // The walk above already answered \"is there anything to strip\", so a clean\n // reply — every reply, normally — is written without copying it first.\n await updateDoc(ref, { status: \"done\", result: dropped.length === 0 ? returned : stripUndefined(returned), updatedAt: serverTimestamp() });\n return { phase: \"done\", method: claim.method };\n } catch (error) {\n const message = errorMessage(error);\n await writeError(ref, \"handler_error\", message);\n return { phase: \"error\", method: claim.method, message };\n }\n};\n\n// A command past its deadline is removed entirely rather than run: give the host\n// a chance to clean up out-of-band resources (staged attachments), then delete\n// the doc so it is neither reprocessed nor left as a stale error. Both steps are\n// best-effort/idempotent, so a snapshot replay surfacing the same expired doc\n// twice is harmless (no claim transaction needed — see plan edge #3).\nconst expireCommand = async (ref: DocumentReference, command: Command, options: HostRunnerOptions, uid: string) => {\n try {\n await options.onExpire?.(command, uid);\n } catch (error) {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `onExpire failed: ${errorMessage(error)}` });\n }\n // Surface a delete failure (permissions / transient network) the same way the\n // onExpire failure above is surfaced — otherwise the expired doc lingers as\n // \"queued\" with no signal as to why cleanup didn't happen.\n await deleteDoc(ref).catch((error) => {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `expire delete failed: ${errorMessage(error)}` });\n });\n options.onEvent?.({ phase: \"done\", method: command.method, message: \"expired\" });\n};\n\n// Per-runner constants bundled into one context so processCommand stays under the\n// max-params cap: firestore, the handler table, options, and the session uid are\n// all fixed for the runner's lifetime; only ref/command/now vary per command.\ninterface RunnerContext {\n firestore: Firestore;\n handlers: CommandHandlers;\n options: HostRunnerOptions;\n uid: string;\n}\n\nconst processCommand = async (ctx: RunnerContext, ref: DocumentReference, command: Command, now: number) => {\n const { handlers, options } = ctx;\n // Drop an expired command before claiming it — it must never reach a handler.\n if (isExpired(command, now)) {\n await expireCommand(ref, command, options, ctx.uid);\n return;\n }\n const claim = await claimCommand(ctx.firestore, ref);\n if (!claim) {\n return;\n }\n options.onEvent?.({ phase: \"received\", method: claim.method });\n const handler = resolveCommandHandler(handlers, claim.method);\n if (!handler) {\n await writeError(ref, \"unknown_method\", `No handler for method: ${claim.method}`);\n options.onEvent?.({ phase: \"error\", method: claim.method, message: \"unknown method\" });\n return;\n }\n options.onEvent?.(await runHandler(ref, claim, handler, options));\n};\n\n// A resilient command listener: its mutable retry state plus the fixed collaborators.\ninterface ListenerRun {\n queuedCommands: Query;\n ctx: RunnerContext;\n goOffline: () => void;\n stopped: boolean;\n unsubscribe: () => void;\n retryTimer: ReturnType<typeof setTimeout> | null;\n attempt: number;\n // Start of the current outage, or null while healthy. `attempt` still drives the\n // backoff ladder; only the give-up decision reads the clock — a MONOTONIC one, so\n // a clock step cannot spend this window on its own.\n downSinceMs: number | null;\n}\n\n// Best-effort oldest-first DISPATCH only — commands run concurrently and may\n// finish out of order (chat is asynchronous). We sort in memory rather than\n// orderBy(\"createdAt\") because a Firestore orderBy silently EXCLUDES docs missing\n// the field, dropping every pre-offline-queue command.\nconst dispatchAddedCommands = (ctx: RunnerContext, snapshot: QuerySnapshot): void => {\n // Wall clock on purpose, unlike the outage timers below: this is compared with a\n // command's `expiresAt`, which the phone stamped from its own clock.\n const now = Date.now();\n snapshot\n .docChanges()\n .filter((change) => change.type === \"added\")\n // Cast kept (#2692). The runner itself reads only `createdAt`, `expiresAt`\n // and `method` here, but the doc rides verbatim into the host's `onExpire`,\n // whose published signature takes a whole `Command`. Rebuilding one would\n // have to invent the members nothing on this path reads — `status`,\n // `result`, `error`, `createdBy` — and hand a second host's cleanup\n // callback values the document never carried, which is worse than the\n // assertion. Removing it means narrowing `onExpire`'s parameter, a\n // breaking change to a published contract MulmoTerminal also implements.\n .map((change) => ({ ref: change.doc.ref, command: change.doc.data() as Command }))\n .sort((left, right) => byCreatedAt(left.command, right.command))\n .forEach(({ ref, command }) => {\n processCommand(ctx, ref, command, now).catch(noop);\n });\n};\n\n// Re-subscribe after a transient error, backing off exponentially.\nfunction scheduleResubscribe(run: ListenerRun): void {\n run.retryTimer = setTimeout(() => subscribeCommands(run), backoffDelayMs(run.attempt));\n run.attempt += 1;\n}\n\n// A Firestore onSnapshot error terminates THIS listener and never recovers on its\n// own. Transient → re-subscribe with bounded backoff (presence stays online);\n// fatal, or failing for longer than the retry window → go offline.\nfunction handleListenError(run: ListenerRun, error: FirestoreError): void {\n run.ctx.options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n if (run.stopped) return;\n const now = monotonicNowMs();\n run.downSinceMs ??= now;\n if (classifyListenerError(error) === \"fatal\" || shouldGiveUpListening(run.downSinceMs, now)) {\n run.goOffline();\n return;\n }\n scheduleResubscribe(run);\n}\n\nfunction subscribeCommands(run: ListenerRun): void {\n run.retryTimer = null;\n if (run.stopped) return;\n run.unsubscribe = onSnapshot(\n run.queuedCommands,\n (snapshot) => {\n // A healthy snapshot proves the listener recovered: the ladder and the\n // outage clock both start fresh for whatever comes next.\n run.attempt = 0;\n run.downSinceMs = null;\n dispatchAddedCommands(run.ctx, snapshot);\n },\n (error) => handleListenError(run, error),\n );\n}\n\n// Subscribe to the queued-command stream; re-subscribe on transient listener\n// errors with bounded backoff, go offline on a fatal one. Returns a stop that\n// cancels any pending retry and detaches the listener.\nconst listenForCommands = (queuedCommands: Query, ctx: RunnerContext, goOffline: () => void): (() => void) => {\n const run: ListenerRun = { queuedCommands, ctx, goOffline, stopped: false, unsubscribe: noop, retryTimer: null, attempt: 0, downSinceMs: null };\n subscribeCommands(run);\n return () => {\n run.stopped = true;\n if (run.retryTimer) clearTimeout(run.retryTimer);\n run.unsubscribe();\n };\n};\n\n// One running host, as far as shutting it down is concerned. `closed` guards the\n// teardown: a presence failure and a fatal listener error can arrive together, and\n// each of them ends the runner.\ninterface HostRun {\n beat: ReturnType<typeof setInterval> | null;\n stopListening: () => void;\n closed: boolean;\n}\n\nconst heartbeatMs = (options: HostRunnerOptions): number => options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;\n\n// How old an acknowledged presence write may be before this runner stops claiming\n// to be online. Exported because a host that judges the same freshness from the\n// OUTSIDE (a probe reading the doc back) has to apply the runner's threshold, not\n// a second copy of it — pass that host's own runner options and the two cannot\n// drift when `heartbeatMs` is customised.\nexport const presenceStaleAfterMs = (options: HostRunnerOptions = {}): number => heartbeatMs(options) * PRESENCE_STALE_BEATS;\n\n// Advertise online/offline + the capability set (method names + protocol version)\n// on the same doc the remote already listens to for presence, and watch whether\n// those writes are landing — see presenceBeat.ts for why the sensor counts the\n// beats that ran rather than the time that passed.\nconst buildPresenceBeat = (\n firestore: Firestore,\n channel: Channel,\n handlers: CommandHandlers,\n options: HostRunnerOptions,\n onStale: () => void,\n): PresenceBeat => {\n const presence = hostDoc(firestore, channel);\n const report = (message: string) => options.onEvent?.({ phase: \"error\", method: \"presence\", message });\n return createPresenceBeat({\n write: (online) => setDoc(presence, { ...buildHostPresence(channel, handlers, online), updatedAt: serverTimestamp() }),\n onError: (message) => report(`presence write failed: ${message}`),\n onStale: (silentMs) => {\n report(`no presence write acknowledged across ${PRESENCE_STALE_BEATS} beats (${Math.round(silentMs / 1_000)}s) — the remote cannot see this host`);\n onStale();\n },\n staleAfterBeats: PRESENCE_STALE_BEATS,\n });\n};\n\n// Stop beating, say goodbye, detach the listener. Announcing offline is\n// best-effort — if the channel is the thing that broke, this write goes nowhere,\n// which is exactly why the remote judges presence by age rather than by the flag.\nconst shutDown = (run: HostRun, presence: PresenceBeat): void => {\n if (run.closed) return;\n run.closed = true;\n if (run.beat) clearInterval(run.beat);\n run.beat = null;\n presence.announce(false);\n run.stopListening();\n};\n\n// startHostRunner subscribes to queued commands for the given channel and runs\n// each one through the supplied handler table. It also announces presence (a\n// heartbeat on users/{uid}/hosts/{hostId}) so the remote can tell it is online.\n// Returns a stop function that goes offline and detaches the listener.\nexport const startHostRunner = (firestore: Firestore, channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions = {}): (() => void) => {\n const run: HostRun = { beat: null, stopListening: noop, closed: false };\n const presence = buildPresenceBeat(firestore, channel, handlers, options, goOffline);\n\n // The channel is gone and re-subscribing is not going to bring it back: hand\n // over to the lifecycle owner, which can re-auth and start a fresh runner.\n function goOffline(): void {\n if (run.closed) return;\n shutDown(run, presence);\n options.onClosed?.();\n }\n\n presence.announce(true);\n run.beat = setInterval(presence.beat, heartbeatMs(options));\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const ctx: RunnerContext = { firestore, handlers, options, uid: channel.uid };\n run.stopListening = listenForCommands(queuedCommands, ctx, goOffline);\n\n return () => shutDown(run, presence);\n};\n","// Keeps the Firestore command channel alive across outages — the ring OUTSIDE\n// `startHostRunner` (#2633, moved into core by #2643).\n//\n// The inner ring recovers in place: it re-subscribes its listener with backoff and\n// notices when its own presence writes stop being acknowledged. When even that runs\n// out it calls `onClosed` and stops. This ring starts a WHOLE new runner (fresh\n// listener, fresh presence write), and only when relaunching stops helping does it\n// pass the closure through, so the client can escalate to a full re-auth from its\n// parked blob — the only path that fixes an actually-dead credential.\n//\n// The give-up rule is TIME, not a count of relaunches, so a long outage does not\n// burn through a budget while nothing can possibly succeed. Time this process was\n// not RUNNING is excluded from it, which is what `after` is for (#2845).\n//\n// It lives here rather than in each host because its correctness is a relation\n// between constants: SETTLE_MS must outlast `LISTEN_RETRY_WINDOW_MS`'s reporting\n// delay, PROBE_INTERVAL_MS must sit above `presenceStaleAfterMs()`, and\n// RESUME_GAP_MS must sit above ordinary timer jitter yet below GIVE_UP_MS — above,\n// or every late timer restarts the budget and nothing ever escalates; below, or a\n// gap can still spend the whole budget undetected. While the two hosts each kept a\n// copy, raising LISTEN_RETRY_WINDOW_MS from ~31s to 5 minutes silently disabled\n// `giveUp` in the copy that had not been told (#2643).\nimport type { RunnerHealth, RunnerHealthState } from \"../health.js\";\nimport type { HostRunnerOptions } from \"./hostRunner.js\";\nimport type { RemoteHostLogger } from \"./lifecycle.js\";\nimport type { Liveness } from \"./presenceProbe.js\";\nimport { monotonicNowMs } from \"./monotonicClock.js\";\n\nconst ONE_SECOND_MS = 1_000;\nconst ONE_MINUTE_MS = 60 * ONE_SECOND_MS;\n\nconst RECONNECT_BASE_MS = ONE_SECOND_MS;\nconst RECONNECT_MAX_MS = ONE_MINUTE_MS;\n// When to ask, after a launch, whether the new runner actually works.\nconst SETTLE_MS = ONE_MINUTE_MS;\n// Past this, retrying in place cannot help: an expired credential needs the\n// browser's parked blob, which only the client can replay.\nconst GIVE_UP_MS = 5 * ONE_MINUTE_MS;\n// How often to ask whether the phone can still see us. Slower than the one-minute\n// heartbeat, because the question is \"are the beats landing\", not \"did this one\".\nconst PROBE_INTERVAL_MS = 90 * ONE_SECOND_MS;\n// A task firing this much later than it asked for did not merely run late: the\n// process was not running at all (system sleep, a blocked event loop). Ordinary\n// load costs a timer milliseconds, not a minute.\nconst RESUME_GAP_MS = ONE_MINUTE_MS;\n\nexport const reconnectDelayMs = (attempt: number): number => Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);\n\n/** Cancels a scheduled task. Handing back a closure rather than a timer handle keeps\n * the seam free of platform timer types, so a test clock is an ordinary function. */\nexport type CancelTimer = () => void;\n\nexport interface ResilientHostRunnerDeps {\n /** Starts the host runner, returning its stop function. Throws if the session is gone. */\n start: (options: HostRunnerOptions) => () => void;\n /** The options the lifecycle handed us; `onClosed` is passed on only once we give up. */\n options: HostRunnerOptions;\n /** Positive liveness check (`createPresenceProbe`). Without one, recovery falls back\n * to trusting silence — which is what let a dead channel report itself green. */\n checkAlive?: () => Promise<Liveness>;\n /** State changes, for a host that renders channel health. Optional: a host with no\n * such UI should not have to pass an empty function. */\n onHealth?: (health: RunnerHealth) => void;\n log: Pick<RemoteHostLogger, \"info\" | \"warn\">;\n schedule?: (task: () => void, delayMs: number) => CancelTimer;\n /** Measures how long things have taken. MONOTONIC by default — never render one\n * of its readings, and never compare it with a wall-clock time. */\n now?: () => number;\n /** Wall-clock epoch ms, and used for `RunnerHealth.changedAt` alone, which the UI\n * renders as a time. Separate from `now` on purpose: an epoch that quietly became\n * \"ms since this process started\" reads as 1970 in the UI. */\n wallNow?: () => number;\n}\n\ninterface RunnerContext {\n deps: ResilientHostRunnerDeps;\n schedule: (task: () => void, delayMs: number) => CancelTimer;\n now: () => number;\n wallNow: () => number;\n stopUnderlying: (() => void) | null;\n cancelTimer: CancelTimer | null;\n attempt: number;\n downSinceMs: number | null;\n lastError: string | null;\n state: RunnerHealthState;\n stopped: boolean;\n cancelProbe: CancelTimer | null;\n}\n\nconst errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error));\n\nconst scheduleWithTimeout = (task: () => void, delayMs: number): CancelTimer => {\n const timer = setTimeout(task, delayMs);\n return () => clearTimeout(timer);\n};\n\n// The observer belongs to the host, and reporting a state must not be able to\n// prevent reaching it. A throwing `onHealth` inside `giveUp` would swallow the\n// `onClosed` that follows it — losing the one escalation that gets a dead\n// credential re-authenticated, over a bug in a toolbar indicator. Same reasoning\n// as `presenceBeat`'s `notify`, and the log is where there is left to say it.\nconst setState = (ctx: RunnerContext, next: RunnerHealthState): void => {\n ctx.state = next;\n try {\n ctx.deps.onHealth?.({ state: next, lastError: ctx.lastError, changedAt: ctx.wallNow() });\n } catch (error) {\n ctx.deps.log.warn(`host runner health observer threw: ${errorText(error)}`);\n }\n};\n\nconst clearTimer = (ctx: RunnerContext): void => {\n ctx.cancelTimer?.();\n ctx.cancelTimer = null;\n};\n\nconst clearProbe = (ctx: RunnerContext): void => {\n ctx.cancelProbe?.();\n ctx.cancelProbe = null;\n};\n\n// Schedule, and notice when the process was not running for part of the wait.\n//\n// Time this host spent frozen is not time the channel spent failing, but the outage\n// budget cannot tell them apart on its own: a gap longer than GIVE_UP_MS spends the\n// whole budget while nothing was ever attempted, and the ring then escalates to the\n// client without one try against the network as it now stands (#2845). So a gap\n// restarts the budget and the backoff ladder, and the task runs as the first attempt\n// after it.\nconst after = (ctx: RunnerContext, delayMs: number, task: () => void): CancelTimer => {\n const dueMs = ctx.now() + delayMs;\n return ctx.schedule(() => {\n const overshootMs = ctx.now() - dueMs;\n if (overshootMs > RESUME_GAP_MS) {\n // Says only what is true in both cases: this fires while healthy too, where\n // there is no outage for the budget to be \"restarting\".\n ctx.deps.log.warn(`host runner resumed after a ${Math.round(overshootMs / ONE_SECOND_MS)}s gap; that time does not count as outage`);\n ctx.downSinceMs = null;\n ctx.attempt = 0;\n }\n task();\n }, delayMs);\n};\n\n// Only worth asking while we believe we are up: during a reconnect the answer is\n// already known, and the recovery it would trigger is the one already running.\nfunction scheduleProbe(ctx: RunnerContext): void {\n if (ctx.stopped || !ctx.deps.checkAlive) return;\n clearProbe(ctx);\n ctx.cancelProbe = after(ctx, PROBE_INTERVAL_MS, () => void runProbe(ctx));\n}\n\n// `alive: null` (no probe wired, or nothing judgeable yet) is not an answer — the\n// caller must read it as neither alive nor dead. `reason` describes a \"no\".\ninterface LivenessAnswer {\n alive: Liveness;\n reason: string;\n}\n\nconst STALE_REASON = \"presence went stale — the phone can no longer see this host\";\n\nasync function askAlive(ctx: RunnerContext): Promise<LivenessAnswer> {\n try {\n return { alive: (await ctx.deps.checkAlive?.()) ?? null, reason: STALE_REASON };\n } catch (error) {\n // The read itself could not reach the server, which answers the question it\n // was asking.\n return { alive: false, reason: `presence probe failed: ${errorText(error)}` };\n }\n}\n\nasync function runProbe(ctx: RunnerContext): Promise<void> {\n ctx.cancelProbe = null;\n if (ctx.stopped || ctx.state !== \"online\") return;\n const answer = await askAlive(ctx);\n // A state change while the read was in flight means someone else is already on it.\n if (ctx.stopped || ctx.state !== \"online\") return;\n if (answer.alive === false) {\n ctx.lastError = answer.reason;\n ctx.deps.log.warn(`host runner ${answer.reason}`);\n onUnderlyingClosed(ctx);\n return;\n }\n scheduleProbe(ctx);\n}\n\n// The inner runner leaves its (already dead) snapshot registration in place when it\n// goes offline, so release it before starting another one — otherwise every\n// reconnect cycle adds one more.\nconst releaseUnderlying = (ctx: RunnerContext): void => {\n const stop = ctx.stopUnderlying;\n ctx.stopUnderlying = null;\n try {\n stop?.();\n } catch (error) {\n ctx.deps.log.warn(`host runner teardown failed: ${errorText(error)}`);\n }\n};\n\n// The channel is genuinely back, so the outage budget and the backoff ladder start\n// fresh for the next one.\nconst markRecovered = (ctx: RunnerContext): void => {\n ctx.downSinceMs = null;\n ctx.attempt = 0;\n // The incident is over, so its error stops describing the channel: kept, it\n // would be reported as the cause of whatever outage comes next.\n ctx.lastError = null;\n if (ctx.state === \"online\") return;\n ctx.deps.log.info(\"host runner re-subscribed\");\n setState(ctx, \"online\");\n scheduleProbe(ctx);\n};\n\n// One settle window after a launch, ask whether the new runner actually works. A\n// \"no\" keeps the outage clock running, which is what eventually escalates to the\n// client; anything else (a \"yes\", or no probe to ask) accepts the recovery.\nasync function settle(ctx: RunnerContext): Promise<void> {\n ctx.cancelTimer = null;\n if (ctx.stopped) return;\n const answer = await askAlive(ctx);\n if (ctx.stopped) return;\n if (answer.alive === false) {\n ctx.lastError = answer.reason;\n ctx.deps.log.warn(`host runner is up but still unreachable — ${answer.reason}`);\n onUnderlyingClosed(ctx);\n return;\n }\n markRecovered(ctx);\n}\n\nconst giveUp = (ctx: RunnerContext): void => {\n ctx.stopped = true;\n clearProbe(ctx);\n ctx.deps.log.warn(`host runner stayed down for ${Math.round(GIVE_UP_MS / ONE_SECOND_MS)}s, giving up (${ctx.lastError ?? \"no error reported\"})`);\n setState(ctx, \"offline\");\n ctx.deps.options.onClosed?.();\n};\n\nfunction scheduleRelaunch(ctx: RunnerContext): void {\n const delayMs = reconnectDelayMs(ctx.attempt);\n ctx.attempt += 1;\n ctx.deps.log.warn(`host runner closed (${ctx.lastError ?? \"no error reported\"}); re-subscribing in ${Math.round(delayMs / ONE_SECOND_MS)}s`);\n // Re-announcing on every relaunch would restamp `changedAt`, and a UI reading it\n // as \"down since\" would reset to zero each cycle of the outage it is reporting.\n if (ctx.state !== \"reconnecting\") setState(ctx, \"reconnecting\");\n ctx.cancelTimer = after(ctx, delayMs, () => launch(ctx));\n}\n\nfunction onUnderlyingClosed(ctx: RunnerContext): void {\n if (ctx.stopped) return;\n releaseUnderlying(ctx);\n clearTimer(ctx);\n clearProbe(ctx);\n ctx.downSinceMs ??= ctx.now();\n if (ctx.now() - ctx.downSinceMs >= GIVE_UP_MS) giveUp(ctx);\n else scheduleRelaunch(ctx);\n}\n\n// Which failures describe the CHANNEL. A handler that threw is worth logging but\n// says nothing about reachability, and remembering it would let it be quoted as the\n// cause of an unrelated outage minutes later.\nconst CHANNEL_METHODS = new Set([\"listen\", \"presence\"]);\n\nconst runnerOptions = (ctx: RunnerContext): HostRunnerOptions => ({\n ...ctx.deps.options,\n onEvent: (event) => {\n // The error code is the one thing that says whether the credential or the\n // network is at fault, so keep the latest for the log lines above.\n if (event.phase === \"error\") {\n const text = `${event.method}: ${event.message ?? \"no detail\"}`;\n if (CHANNEL_METHODS.has(event.method)) ctx.lastError = text;\n ctx.deps.log.warn(`host runner event error — ${text}`);\n }\n ctx.deps.options.onEvent?.(event);\n },\n onClosed: () => onUnderlyingClosed(ctx),\n});\n\nfunction launch(ctx: RunnerContext): void {\n ctx.cancelTimer = null;\n if (ctx.stopped) return;\n try {\n ctx.stopUnderlying = ctx.deps.start(runnerOptions(ctx));\n } catch (error) {\n // The session was torn down under us (currentFirestore throws when disconnected).\n ctx.lastError = errorText(error);\n onUnderlyingClosed(ctx);\n return;\n }\n ctx.cancelTimer = after(ctx, SETTLE_MS, () => void settle(ctx));\n}\n\nexport function startResilientHostRunner(deps: ResilientHostRunnerDeps): () => void {\n const ctx: RunnerContext = {\n deps,\n schedule: deps.schedule ?? scheduleWithTimeout,\n now: deps.now ?? monotonicNowMs,\n wallNow: deps.wallNow ?? Date.now,\n stopUnderlying: null,\n cancelTimer: null,\n attempt: 0,\n downSinceMs: null,\n lastError: null,\n state: \"online\",\n stopped: false,\n cancelProbe: null,\n };\n // Announce the starting state rather than assuming the owner knows it: a\n // (re)connect is also what clears the notice left behind by the previous outage.\n setState(ctx, \"online\");\n launch(ctx);\n scheduleProbe(ctx);\n\n return () => {\n ctx.stopped = true;\n clearTimer(ctx);\n clearProbe(ctx);\n releaseUnderlying(ctx);\n };\n}\n","// Can the phone still see this host? Asked from the phone's own vantage point,\n// rather than inferred from this side (#2633).\n//\n// `createPresenceBeat` tracks whether this host's own presence writes are being\n// acknowledged, which is the same question one step earlier. This reads the\n// document back FROM THE SERVER and judges it by age — the exact test the remote\n// applies. Both answers are useful: a read that throws means the connection is\n// genuinely gone, and a read that returns a stale document means the beats are\n// not landing.\n//\n// Lives beside the runner it feeds (#2643) so the probe interval, the heartbeat,\n// and `presenceStaleAfterMs` can only be changed against each other.\nimport { getDocFromServer } from \"firebase/firestore\";\nimport type { Firestore } from \"firebase/firestore\";\n\nimport { hostDoc, type Channel } from \"../index.js\";\nimport { presenceStaleAfterMs } from \"./hostRunner.js\";\n\nconst ONE_SECOND_MS = 1_000;\n\n// The runner's own threshold, taken from the runner rather than recomputed: a\n// laptop waking up gets three beats to write again before anyone calls it dead.\n// Being wrong here costs a reconnect cycle, so it leans towards patience.\nexport const PRESENCE_STALE_MS = presenceStaleAfterMs();\n\n// A read that never settles would leave the probe un-rearmed — a sensor dying\n// quietly, which is the very failure this module exists to catch. Firestore takes\n// no abort signal, so the deadline has to be a race.\nconst PROBE_TIMEOUT_MS = 30 * ONE_SECOND_MS;\n\n/** `null` = cannot be judged, which is NOT a failure: the document may simply not\n * exist yet (a runner that has never announced), and treating \"no answer\" as\n * \"dead\" would spin a reconnect loop against a host that is merely new. */\nexport type Liveness = boolean | null;\n\nconst hasToMillis = (value: object): value is { toMillis: () => number } => \"toMillis\" in value && typeof Reflect.get(value, \"toMillis\") === \"function\";\n\nconst asMillis = (value: unknown): number | null => {\n if (typeof value === \"number\") return value;\n // Firestore hands back a Timestamp for serverTimestamp() fields.\n if (typeof value === \"object\" && value !== null && hasToMillis(value)) return value.toMillis();\n return null;\n};\n\n/** Judge a presence document that was read successfully. Exported for its own test:\n * the freshness rule is the part worth pinning, and it needs no Firestore. */\nexport const presenceIsFresh = (data: Record<string, unknown> | undefined, now: number, staleAfterMs: number = PRESENCE_STALE_MS): Liveness => {\n if (!data) return null;\n const updatedAt = asMillis(data.updatedAt);\n // A pending serverTimestamp() reads as null until the write is acknowledged;\n // that is a write in flight, not a stale one.\n if (updatedAt === null) return null;\n // `online: false` is the runner's own goodbye, written on teardown. A truthful\n // state, not a broken one — it is meant to be down.\n if (data.online === false) return null;\n return now - updatedAt < staleAfterMs;\n};\n\n/** Reject once `timeoutMs` has passed, so a stalled read answers instead of hanging. */\nexport const withTimeout = async <T>(work: Promise<T>, timeoutMs: number): Promise<T> => {\n const timer: { handle: ReturnType<typeof setTimeout> | null } = { handle: null };\n const deadline = new Promise<never>((_, reject) => {\n timer.handle = setTimeout(() => reject(new Error(`presence read did not answer within ${Math.round(timeoutMs / ONE_SECOND_MS)}s`)), timeoutMs);\n });\n try {\n return await Promise.race([work, deadline]);\n } finally {\n if (timer.handle) clearTimeout(timer.handle);\n }\n};\n\nexport interface PresenceProbeDeps {\n firestore: () => Firestore;\n channel: Channel;\n /** The runner's own staleness threshold. Pass `presenceStaleAfterMs(options)` when\n * the runner uses a custom `heartbeatMs`, so the two judgments cannot diverge. */\n staleAfterMs?: number;\n timeoutMs?: number;\n now?: () => number;\n}\n\n/** Reads the host's own presence document from the server and reports whether it is fresh. */\nexport const createPresenceProbe = (deps: PresenceProbeDeps): (() => Promise<Liveness>) => {\n const now = deps.now ?? Date.now;\n return async () => {\n // From the server, never the cache: a cached copy of our own last write answers\n // \"fresh\" precisely when the connection that should have carried it is dead.\n const snapshot = await withTimeout(getDocFromServer(hostDoc(deps.firestore(), deps.channel)), deps.timeoutMs ?? PROBE_TIMEOUT_MS);\n return presenceIsFresh(snapshot.data(), now(), deps.staleAfterMs ?? PRESENCE_STALE_MS);\n };\n};\n","// Remote-host lifecycle: wire a Firebase session to the Firestore host runner so\n// connecting starts the command loop + presence heartbeat, and disconnecting\n// stops both.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/index.ts. The factory\n// takes injected collaborators — real hosts bind them to Firebase; tests pass\n// fakes to exercise the invariants (non-destructive connect, serialized\n// transitions, status/liveness reconciliation on fatal listener death) without a\n// network. `hostId` and the logger are injected too, so this file imports no\n// Firebase and no host logger and stays trivially unit-testable.\n//\n// Single-account, single-host per instance, in-memory session: a host restart\n// drops the session and needs a re-connect.\nimport type { Channel, Command, CommandHandlers } from \"../index.js\";\nimport type { HostEvent, HostRunnerOptions } from \"./hostRunner.js\";\n\nexport interface RemoteHostStatus {\n connected: boolean;\n uid: string | null;\n}\n\n// Minimal logger the factory calls; each host adapts its own logger to this\n// shape (or omits it to run silently, as the tests do).\nexport interface RemoteHostLogger {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n debug: (msg: string) => void;\n}\n\n// Injectable collaborators — a real host binds these to Firebase + its own\n// hostId; tests pass fakes to exercise the lifecycle without a network.\n// `startRunner` is the host's `startHostRunner` pre-bound with its Firestore\n// instance, so this module needs no Firebase of its own.\nexport interface RemoteHostDeps {\n hostId: string;\n signIn: (idToken: string) => Promise<string>;\n // Restore a session from a browser-parked blob (case A', mulmoserver#50) and\n // resolve to its uid — no popup. MUST reject when the blob yields no valid\n // session so `reconnect` stays non-destructive. Absent ⇒ `reconnect` throws.\n restore?: (sessionBlob: string) => Promise<string>;\n signOut: () => Promise<void>;\n currentUid: () => string | null;\n startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;\n handlers: CommandHandlers;\n // Optional host-specific cleanup for a command the runner drops as expired\n // (e.g. delete its staged attachment uploads). Threaded verbatim into the\n // runner's `onExpire`, which supplies the session `uid` so cleanup targets the\n // right user's Storage path even across a reconnect; absent ⇒ an expired doc is\n // just deleted.\n onExpire?: ((command: Command, uid: string) => void | Promise<void>) | undefined;\n log?: RemoteHostLogger;\n}\n\nexport interface RemoteHostLifecycle {\n connect: (idToken: string) => Promise<RemoteHostStatus>;\n // Restore a browser-parked session and start the runner — popup-free re-attach\n // after a host restart. Non-destructive like `connect`. Rejects if `deps.restore`\n // isn't wired, or if restore fails (blob expired/invalid → caller falls back to connect).\n reconnect: (sessionBlob: string) => Promise<RemoteHostStatus>;\n disconnect: () => Promise<RemoteHostStatus>;\n status: () => RemoteHostStatus;\n}\n\nconst noop = () => undefined;\nconst silentLogger: RemoteHostLogger = { info: noop, warn: noop, debug: noop };\n\n// The message carries the Firestore error code — the only thing that says whether\n// a dead channel is the credential or the network. Logging just the phase and the\n// method threw that away at the last step (#2633).\nconst eventText = (event: HostEvent): string => (event.message ? `${event.phase} ${event.method} — ${event.message}` : `${event.phase} ${event.method}`);\n\nexport const createRemoteHost = (deps: RemoteHostDeps): RemoteHostLifecycle => {\n const log = deps.log ?? silentLogger;\n\n // The running host runner's stop() handle, or null when disconnected. Keeps\n // the single-host invariant — one runner at a time.\n let stopRunner: (() => void) | null = null;\n\n // Serialize connect/disconnect so overlapping requests can't both mutate\n // stopRunner (which would leak a second runner) or race auth against teardown.\n // Each transition runs only after the previous one settles; a failed\n // transition does not block the next (both handlers run the next op).\n let transition: Promise<unknown> = Promise.resolve();\n\n const serialize = <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n\n const stopIfRunning = () => {\n if (stopRunner) {\n stopRunner();\n stopRunner = null;\n }\n };\n\n const status = (): RemoteHostStatus => ({ connected: stopRunner !== null, uid: deps.currentUid() });\n\n // Swap the running runner for one bound to `uid`. Callers authenticate FIRST\n // (before this teardown) so a failed connect/reconnect keeps a healthy session.\n const attach = (uid: string, verb: string): RemoteHostStatus => {\n stopIfRunning();\n const runner = deps.startRunner({ uid, hostId: deps.hostId }, deps.handlers, {\n onEvent: (event) => log.debug(`host event: ${eventText(event)}`),\n onExpire: deps.onExpire,\n // Fatal listener death: drop the handle so status() stops reporting\n // connected — but only if it still points at THIS runner (a later\n // reconnect may have replaced it).\n onClosed: () => {\n if (stopRunner === runner) {\n stopRunner = null;\n log.warn(\"host runner listener died; marked disconnected\");\n }\n },\n });\n stopRunner = runner;\n log.info(`${verb} as ${uid}, host runner started (hostId=${deps.hostId})`);\n return status();\n };\n\n const connect = (idToken: string): Promise<RemoteHostStatus> => serialize(async () => attach(await deps.signIn(idToken), \"connected\"));\n\n const reconnect = (sessionBlob: string): Promise<RemoteHostStatus> =>\n serialize(async () => {\n const { restore } = deps;\n if (!restore) throw new Error(\"remote-host reconnect requires a `restore` dependency\");\n return attach(await restore(sessionBlob), \"reconnected\");\n });\n\n const disconnect = (): Promise<RemoteHostStatus> =>\n serialize(async () => {\n stopIfRunning();\n await deps.signOut();\n log.info(\"disconnected, host runner stopped\");\n return status();\n });\n\n return { connect, reconnect, disconnect, status };\n};\n","// Firebase credential exchange for the remote-host runner.\n//\n// The host authenticates to Firestore *as the user* (Option B) using the\n// Firebase JS SDK's signInWithCredential with a browser-minted Google OAuth ID\n// token — no Admin SDK, no project service account. Security rules keep the host\n// scoped to that user's own users/{uid}/… subtree.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/auth.ts. The `auth`\n// instance is a parameter so each host binds its own Firebase init; the factory\n// returns the low-level credential primitives (the connect/disconnect lifecycle\n// that starts/stops the host runner lives in createRemoteHost).\nimport { Auth, GoogleAuthProvider, signInWithCredential, signOut } from \"firebase/auth\";\n\nexport interface RemoteHostAuth {\n // Establish the Firebase session from a browser-minted Google OAuth ID token.\n // The token is used once here; the JS SDK then holds its own refresh token for\n // the process lifetime. Resolves to the authenticated uid.\n signInHost: (idToken: string) => Promise<string>;\n // Tear down the Firebase session (in-memory persistence → re-login on restart).\n signOutHost: () => Promise<void>;\n // The currently signed-in uid, or null when disconnected.\n currentUid: () => string | null;\n}\n\nexport const createRemoteHostAuth = (auth: Auth): RemoteHostAuth => ({\n signInHost: async (idToken: string): Promise<string> => {\n const credential = GoogleAuthProvider.credential(idToken);\n const userCredential = await signInWithCredential(auth, credential);\n return userCredential.user.uid;\n },\n signOutHost: (): Promise<void> => signOut(auth),\n currentUid: (): string | null => auth.currentUser?.uid ?? null,\n});\n","// A custom Firebase Auth persistence that keeps the signed-in session in memory\n// but can be seeded from — and exported back to — an opaque blob, so a host's\n// Firebase session survives a process restart by being parked in the browser's\n// localStorage (mulmoserver#50, \"case A'\"). The blob is whatever the SDK wrote\n// into persistence, round-tripped through JSON; we NEVER interpret its fields,\n// so we don't couple to the SDK's serialized-user format across versions.\n//\n// **Persistence is a CLASS, not an instance.** `initializeAuth(app, { persistence })`\n// hands the value to the SDK's `_getInstance(cls)`, which asserts `cls instanceof\n// Function` (\"Expected a class definition\") and then `new cls()`. Passing a plain\n// object throws — the SDK's own `inMemoryPersistence` is likewise a class. The\n// class is defined per factory call so its instances share this call's store;\n// `type: \"LOCAL\"` makes the SDK treat it as durable and persist the user.\n//\n// Seed BEFORE `initializeAuth` — the SDK reads persistence once at init. `onChange`\n// fires when the SDK writes/removes a key (a token update that rotates the stored\n// blob, or a sign-out) — the signal to re-sync the browser copy.\nimport type { Persistence } from \"firebase/auth\";\n\n// The SDK stores values as JSON objects (a serialized user) or strings. We treat\n// them opaquely — a value is either.\ntype PersistenceValue = Record<string, unknown> | string;\n\ntype StorageListener = (value: PersistenceValue | null) => void;\n\n// The instance the SDK builds via `new` and then drives with these `_`-methods.\nexport interface HostAuthPersistenceInstance extends Persistence {\n type: \"LOCAL\";\n _isAvailable: () => Promise<boolean>;\n _set: (key: string, value: PersistenceValue) => Promise<void>;\n _get: (key: string) => Promise<PersistenceValue | null>;\n _remove: (key: string) => Promise<void>;\n _addListener: (key: string, listener: StorageListener) => void;\n _removeListener: (key: string, listener: StorageListener) => void;\n}\n\n// The class value passed to `initializeAuth`. The static `type` makes it\n// assignable to the SDK's `Persistence` (a class-valued-typed-as-instance, the\n// same trick the SDK uses for `inMemoryPersistence`); `new ()` lets the SDK — and\n// the tests — instantiate it.\nexport interface HostAuthPersistenceClass extends Persistence {\n type: \"LOCAL\";\n new (): HostAuthPersistenceInstance;\n}\n\nexport interface HostSessionPersistence {\n /** Pass to `initializeAuth(app, { persistence })`. It's a class the SDK `new`s. */\n persistence: HostAuthPersistenceClass;\n /** Load a previously exported blob. Call BEFORE `initializeAuth`. */\n seed: (blob: string) => void;\n /** Serialize the current contents, or `null` when empty (no session). */\n exportBlob: () => string | null;\n /** Notified with the fresh blob (or `null`) whenever the SDK writes/removes. */\n onChange: (listener: (blob: string | null) => void) => () => void;\n /** Drop all contents (used when tearing down the Firebase app). */\n clear: () => void;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nconst isPersistenceValue = (value: unknown): value is PersistenceValue => typeof value === \"string\" || isRecord(value);\n\n// True when `blob` is a JSON object `seed` can load. A blob that fails this can\n// never restore a session (corrupt localStorage, wrong shape), so callers treat\n// it as an expired/invalid session rather than a transient error — the client\n// then drops it instead of retrying the same doomed blob forever.\nexport const isSeedableBlob = (blob: string): boolean => {\n try {\n return isRecord(JSON.parse(blob));\n } catch {\n return false;\n }\n};\n\n// A class (constructor) so the SDK's `_getInstance` accepts it (it asserts\n// `cls instanceof Function`, then `new cls()`); instances hold the shared\n// `store`/`notify`, so a fresh `new` still sees the seeded/live data. `static\n// type` is for the `Persistence` type bridge; the instance `type` is what the\n// SDK reads after `new`.\nconst makeHostPersistenceClass = (store: Map<string, PersistenceValue>, notify: () => void): HostAuthPersistenceClass => {\n class HostAuthPersistence {\n static readonly type = \"LOCAL\" as const;\n readonly type = \"LOCAL\" as const;\n private readonly store = store;\n private readonly notify = notify;\n private readonly external = new Set<StorageListener>();\n _isAvailable(): Promise<boolean> {\n return Promise.resolve(this.store instanceof Map);\n }\n async _set(key: string, value: PersistenceValue): Promise<void> {\n this.store.set(key, value);\n this.notify();\n }\n _get(key: string): Promise<PersistenceValue | null> {\n return Promise.resolve(this.store.get(key) ?? null);\n }\n async _remove(key: string): Promise<void> {\n this.store.delete(key);\n this.notify();\n }\n // Node has no cross-tab storage events to deliver, so registered listeners\n // never fire; we still track them so add/remove stay symmetric.\n _addListener(_key: string, listener: StorageListener): void {\n this.external.add(listener);\n }\n _removeListener(_key: string, listener: StorageListener): void {\n this.external.delete(listener);\n }\n }\n return HostAuthPersistence;\n};\n\nexport const createHostSessionPersistence = (): HostSessionPersistence => {\n const store = new Map<string, PersistenceValue>();\n const listeners = new Set<(blob: string | null) => void>();\n\n const exportBlob = (): string | null => (store.size === 0 ? null : JSON.stringify(Object.fromEntries(store)));\n\n const notify = (): void => {\n const blob = exportBlob();\n listeners.forEach((listener) => listener(blob));\n };\n\n const persistence = makeHostPersistenceClass(store, notify);\n\n const seed = (blob: string): void => {\n const parsed: unknown = JSON.parse(blob);\n if (!isRecord(parsed)) throw new Error(\"host session blob must be a JSON object\");\n store.clear();\n for (const [key, value] of Object.entries(parsed)) {\n if (isPersistenceValue(value)) store.set(key, value);\n }\n };\n\n const onChange = (listener: (blob: string | null) => void): (() => void) => {\n listeners.add(listener);\n return () => listeners.delete(listener);\n };\n\n const clear = (): void => {\n store.clear();\n };\n\n return { persistence, seed, exportBlob, onChange, clear };\n};\n","// Firebase init for a remote-host runner.\n//\n// A host acts as a *host*: it signs in to Firebase as the user (via\n// signInWithCredential, see auth.ts) and listens to that user's command queue in\n// Firestore. The modular firebase/firestore + firebase/auth SDKs run in Node, so\n// this mirrors a browser init but also exposes Firestore (default database,\n// which must be in Native mode) and Storage.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/firebase.ts. The\n// public web config is a parameter so each host supplies its own (both hosts\n// reuse the shared mulmoserver project).\nimport { deleteApp, FirebaseApp, FirebaseOptions, initializeApp } from \"firebase/app\";\nimport { Auth, getAuth, initializeAuth } from \"firebase/auth\";\nimport { Firestore, getFirestore } from \"firebase/firestore\";\nimport { FirebaseStorage, getStorage } from \"firebase/storage\";\n\nimport { createHostSessionPersistence, type HostSessionPersistence } from \"./sessionPersistence.js\";\n\nexport interface RemoteHostFirebase {\n app: FirebaseApp;\n auth: Auth;\n firestore: Firestore;\n // Storage carries the full-res attachment bytes the command channel can't (a\n // Firestore command doc caps at ~1 MiB). The host, signed in as the user,\n // pulls each staged upload from `users/{uid}/uploads/{id}` and deletes it\n // after ingest.\n storage: FirebaseStorage;\n}\n\nexport const createRemoteHostFirebase = (config: FirebaseOptions): RemoteHostFirebase => {\n const app = initializeApp(config);\n return { app, auth: getAuth(app), firestore: getFirestore(app), storage: getStorage(app) };\n};\n\nexport interface RemoteHostSessionHandles {\n auth: Auth;\n firestore: Firestore;\n storage: FirebaseStorage;\n // The signed-in uid restored from a seed blob, or null when opened fresh\n // (before `signInHost`) or when the blob held no valid session.\n uid: string | null;\n}\n\n// A restartable Firebase session for a host, backed by the export/seed-able\n// persistence (mulmoserver#50, \"case A'\"). Because `initializeAuth` reads\n// persistence once and can run only once per app, each `open` spins up a FRESH\n// app (unique name) with the persistence seeded first, then tears down the\n// previous app — so a reconnect can restore a browser-parked session, and a\n// fresh connect starts clean. `exportSession`/`onSessionChange` expose the blob\n// the browser stores and the signal to re-sync it.\n// Runs on the freshly opened handles BEFORE the previous session is torn down.\n// Reject to keep the open non-destructive: the fresh app is rolled back and the\n// previous session is preserved (so a failed sign-in or an expired blob doesn't\n// replace a healthy live session).\nexport type RemoteHostSessionValidate = (handles: RemoteHostSessionHandles) => Promise<void>;\n\nexport interface RemoteHostSession {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => Promise<RemoteHostSessionHandles>;\n close: () => Promise<void>;\n exportSession: () => string | null;\n onSessionChange: (cb: (blob: string | null) => void) => () => void;\n}\n\nconst noop = (): void => undefined;\n\n// Run operations one-at-a-time in submission order. `open`/`close` mutate shared\n// store/app state, so overlapping calls must not interleave (they would leak an\n// app or leave `app` pointing at a torn-down instance). Mirrors the lifecycle's\n// serialization; a failed op doesn't block the next.\nconst makeSerializer = () => {\n let transition: Promise<unknown> = Promise.resolve();\n return <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n};\n\n// Build a fresh app from the (already seeded) persistence and wait for the\n// restored auth state to settle. Persistence restore is async, so `uid`\n// reflects a seeded session only after `authStateReady()` (null when the blob\n// was empty/invalid, or on a fresh connect before `signInHost`).\nconst openFreshApp = async (\n config: FirebaseOptions,\n store: HostSessionPersistence,\n name: string,\n): Promise<{ app: FirebaseApp; handles: RemoteHostSessionHandles }> => {\n // `initializeApp` registers the app in the SDK's global registry, so if init\n // then throws we must delete it — otherwise repeated reconnect failures leak\n // a registered app each time.\n const app = initializeApp(config, name);\n try {\n const auth = initializeAuth(app, { persistence: store.persistence });\n await auth.authStateReady();\n return { app, handles: { auth, firestore: getFirestore(app), storage: getStorage(app), uid: auth.currentUser?.uid ?? null } };\n } catch (error) {\n await deleteApp(app).catch(() => undefined);\n throw error;\n }\n};\n\nexport const createRemoteHostSession = (config: FirebaseOptions): RemoteHostSession => {\n const store = createHostSessionPersistence();\n let app: FirebaseApp | null = null;\n let appSeq = 0;\n const serialize = makeSerializer();\n\n const closeInner = async (): Promise<void> => {\n const previous = app;\n app = null;\n store.clear();\n if (previous) await deleteApp(previous);\n };\n\n // Run the caller's check on the fresh handles; on rejection, delete the fresh\n // app so only the caller-visible error escapes and the previous session (never\n // touched here) stays live.\n const validateOrRollback = async (nextApp: FirebaseApp, handles: RemoteHostSessionHandles, validate?: RemoteHostSessionValidate): Promise<void> => {\n if (!validate) return;\n try {\n await validate(handles);\n } catch (error) {\n await deleteApp(nextApp).catch(() => undefined);\n throw error;\n }\n };\n\n const openInner = async (seedBlob?: string, validate?: RemoteHostSessionValidate): Promise<RemoteHostSessionHandles> => {\n // Non-destructive: keep the current session intact until the fresh app is\n // proven to come up AND pass `validate` (sign-in / uid). A bad seed blob, a\n // failed init, or a rejected validation must not tear down a healthy session\n // — the reconnect contract (mulmoserver#50). So we tear the previous app\n // down only AFTER success, and roll the store back on failure.\n const previousApp = app;\n const previousBlob = store.exportBlob();\n appSeq += 1;\n try {\n store.clear();\n if (seedBlob) store.seed(seedBlob);\n const { app: nextApp, handles } = await openFreshApp(config, store, `remote-host-${appSeq}`);\n await validateOrRollback(nextApp, handles, validate);\n app = nextApp;\n if (previousApp) await deleteApp(previousApp).catch(() => undefined);\n return handles;\n } catch (error) {\n store.clear();\n if (previousBlob) store.seed(previousBlob);\n throw error;\n }\n };\n\n return {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => serialize(() => openInner(seedBlob, validate)),\n close: () => serialize(closeInner),\n exportSession: store.exportBlob,\n onSessionChange: store.onChange,\n };\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../../src/remote-host/server/firestoreSafeResult.ts","../../../src/remote-host/server/monotonicClock.ts","../../../src/remote-host/server/presenceBeat.ts","../../../src/remote-host/server/hostRunner.ts","../../../src/remote-host/server/resilientRunner.ts","../../../src/remote-host/server/presenceProbe.ts","../../../src/remote-host/server/lifecycle.ts","../../../src/remote-host/server/auth.ts","../../../src/remote-host/server/sessionPersistence.ts","../../../src/remote-host/server/firebase.ts"],"sourcesContent":["// A handler's reply, made safe to hand to Firestore (#2634).\n//\n// Firestore rejects `undefined` at ANY depth, and the runner writes a handler's\n// return value straight into the command document. So one `undefined` — anywhere\n// in a reply, however deep — takes down the WHOLE reply: `updateDoc` throws,\n// `status: \"done\"` never lands, and the remote waits until it times out. The\n// symptom is not \"one field missing\" but \"nothing works\", which is what happened\n// when a `work: undefined` reached MulmoTerminal's session list\n// (receptron/mulmoterminal#1042).\n//\n// Strip rather than throw: a missing optional field costs one row's worth of\n// detail, while a throw costs the user the entire reply — the very outcome this\n// exists to prevent. The report is what keeps it from being silent, because an\n// undeclared stripped key IS a bug on the sending side, and Firestore's own error\n// names the document rather than the field.\n//\n// NOT `ignoreUndefinedProperties` on the Firestore instance: that setting makes\n// this class of bug disappear into \"the value just doesn't arrive\", with nothing\n// logged and nothing to grep for.\n\n/** Path reported for a reply that is itself `undefined` — it has no field name. */\nexport const ROOT_PATH = \"(root)\";\n\n// Only arrays and plain objects are walked. A class instance is left ALONE, because\n// rebuilding it from its entries destroys it: Firestore accepts `Date`, `Timestamp`,\n// `GeoPoint`, `DocumentReference` and its own sentinels as values, and\n// `Object.fromEntries(Object.entries(new Date()))` is `{}`. Silently turning a\n// timestamp into an empty object would be a worse bug than the one being fixed.\nconst isPlainObject = (value: object): boolean => {\n // Typed explicitly: `getPrototypeOf` is declared to return `any`, which would\n // spread an unchecked value through the comparison below.\n const proto: unknown = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n};\n\nconst isWalkable = (value: unknown): value is object => typeof value === \"object\" && value !== null && (Array.isArray(value) || isPlainObject(value));\n\nconst childPath = (prefix: string, key: string | number): string => (prefix ? `${prefix}.${key}` : String(key));\n\n// A reply that points back at itself has no bottom, so the walks below would recurse\n// until the stack gives out. `RangeError: Maximum call stack size exceeded` is caught\n// by the runner and written back as the command's error, so nothing crashes — but it\n// names neither the cause nor the field, which is the one thing this module exists to\n// provide. Ancestors only: a value referenced twice from different branches is\n// ordinary JSON, so each branch removes itself on the way out. One home for the rule\n// because the two walks are structurally twins — applied separately, a later edit to\n// the safety logic would land in only one of them.\nconst withCycleGuard = <T>(value: object, ancestors: WeakSet<object>, path: string, walk: () => T): T => {\n if (ancestors.has(value)) throw new Error(`circular reference at ${path || ROOT_PATH} — Firestore cannot serialise it`);\n ancestors.add(value);\n try {\n return walk();\n } finally {\n ancestors.delete(value);\n }\n};\n\nconst pathsIn = (value: unknown, prefix: string, ancestors: WeakSet<object>): string[] => {\n if (value === undefined) return [prefix || ROOT_PATH];\n if (!isWalkable(value)) return [];\n return withCycleGuard(value, ancestors, prefix, () =>\n // Array.from, not flatMap: a SPARSE array's holes are skipped by flatMap/map, so\n // `[1, , 3]` would be reported clean and then written with a hole Firestore rejects.\n Array.isArray(value)\n ? Array.from(value).flatMap((item, index) => pathsIn(item, childPath(prefix, index), ancestors))\n : Object.entries(value).flatMap(([key, item]) => pathsIn(item, childPath(prefix, key), ancestors)),\n );\n};\n\n/** Every path holding `undefined`, in `a.b.0.c` form. Empty when the value is safe to write.\n * Throws on a circular reply, naming the path it closed the loop at. */\nexport const undefinedPaths = (value: unknown, prefix = \"\"): string[] => pathsIn(value, prefix, new WeakSet());\n\nconst strippedIn = (value: unknown, prefix: string, ancestors: WeakSet<object>): unknown => {\n // The whole reply can be undefined (a handler with no explicit return); `null` is\n // what the runner already substitutes for a missing result.\n if (value === undefined) return null;\n // Anything that is not an array or a plain object is handed back untouched — see\n // isPlainObject: a Date rebuilt from its entries is an empty object.\n if (!isWalkable(value)) return value;\n return withCycleGuard(value, ancestors, prefix, () =>\n Array.isArray(value)\n ? Array.from(value, (item, index) => strippedIn(item, childPath(prefix, index), ancestors))\n : Object.fromEntries(\n Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, strippedIn(item, childPath(prefix, key), ancestors)]])),\n ),\n );\n};\n\n/**\n * The same value with every `undefined` removed — object keys dropped, array holes\n * turned into `null` so the surrounding indexes still line up with what the sender\n * meant. Returns `unknown` because a stripped object is no longer the input type.\n * Throws on a circular reply, same as `undefinedPaths`.\n */\nexport const stripUndefined = (value: unknown): unknown => strippedIn(value, \"\", new WeakSet());\n\n/** Does `path` match `pattern`, where `*` stands for exactly one segment?\n * Segment-wise rather than by regex: no escaping of user-supplied dots, and no\n * pattern can turn into a catastrophic backtrack. */\nexport const matchesPathPattern = (path: string, pattern: string): boolean => {\n const segments = path.split(\".\");\n const wanted = pattern.split(\".\");\n return segments.length === wanted.length && wanted.every((segment, index) => segment === \"*\" || segment === segments[index]);\n};\n\n/**\n * The paths worth reporting: everything the caller has NOT declared as\n * legitimately-optional (`\"sessions.*.work\"`).\n *\n * Takes paths rather than the value so a caller walks the reply once — the walk is\n * also what tells it whether stripping (a full copy) is needed at all.\n *\n * Both kinds have to be stripped — that is Firestore's rule, not a judgment — but\n * only one kind is a defect. Reporting both means reporting one on every reply,\n * which is how a warning stops being read.\n */\nexport const unexpectedPaths = (paths: readonly string[], expected: readonly string[] = []): string[] =>\n paths.filter((path) => !expected.some((pattern) => matchesPathPattern(path, pattern)));\n","// The clock the runner measures its OWN elapsed time with (#2845).\n//\n// `Date.now()` is the wall clock, and the wall clock moves for reasons that have\n// nothing to do with this process: an NTP step, a manual change, a VM syncing its\n// time after a suspend. A runner that measures \"how long have we been failing\"\n// against it reads those jumps as an outage — and one report arrived as\n// \"no presence write acknowledged for 459s\" from a host whose network was fine.\n//\n// Server-side only, which is where every caller lives.\nimport { performance } from \"node:perf_hooks\";\n\n/** Milliseconds from an arbitrary origin, never moving backwards. Only differences\n * between two readings mean anything — never compare one to a wall-clock time. */\nexport const monotonicNowMs = (): number => performance.now();\n","// The presence heartbeat, and the one question the host could never answer about\n// itself: are my beats still landing? (#2633)\n//\n// The phone decides whether a host is reachable from the freshness of the presence\n// document. The host decided whether it was connected from listener errors — a\n// different path entirely, so a presence write could fail on every beat while\n// onSnapshot stayed quiet and the host kept reporting itself green.\n//\n// The sensor is the age of the last ACKNOWLEDGED write, not a count of failed ones.\n// Firestore does not reject a write it cannot deliver; it queues it and leaves the\n// promise pending (\"if the client is offline, the returned Promise will not resolve\n// for a potentially-long time\" — @firebase/firestore typings). A rejection counter\n// would therefore read zero throughout the outage it exists to catch.\n//\n// The write is injected so this file needs no Firestore: hostRunner binds it to\n// setDoc, tests bind it to a promise they control.\n//\n// Beats are COUNTED, not timed (#2845). Counting how much time passed answers a\n// different question than the one being asked: three beats' worth of wall clock\n// also elapses while the machine is asleep, while the event loop is blocked, and\n// when NTP steps the clock — in every one of those the beat never ran, so nothing\n// was ever attempted, let alone failed. Counting only the beats that RAN cannot\n// confuse the two, and needs no clock at all.\nimport { monotonicNowMs } from \"./monotonicClock.js\";\n\n// Beats that may RUN unacknowledged before the host stops claiming to be online.\n// One missed beat is a blip; three means the phone has had nothing fresh to read\n// for as long as it takes to notice.\nexport const PRESENCE_STALE_BEATS = 3;\n\nexport interface PresenceBeatDeps {\n /** Announce online/offline. Resolves when the backend has the write. */\n write: (online: boolean) => Promise<void>;\n /** `staleAfterBeats` beats have run with nothing acknowledged — the phone cannot see\n * this host. `silentMs` is how long that took, for the report only. */\n onStale: (silentMs: number) => void;\n /** A write was refused (auth, rules, quota). Carries the reason, which used to be dropped. */\n onError: (message: string) => void;\n staleAfterBeats: number;\n now?: () => number;\n}\n\nexport interface PresenceBeat {\n /** Write presence unconditionally — startup and the goodbye on teardown. */\n announce: (online: boolean) => void;\n /** One heartbeat: report staleness if the last ack is too old, otherwise announce. */\n beat: () => void;\n}\n\nconst errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error));\n\n// The callbacks belong to the host, and a heartbeat must not be the thing that\n// takes the process down: an observer that throws would otherwise surface as an\n// uncaught exception from a timer, or as an unhandled rejection from the write\n// chain. There is nowhere to report it — `onError` is the reporter — so the only\n// honest move is to keep beating.\nconst notify = (report: () => void, onThrow?: (error: unknown) => void): void => {\n try {\n report();\n } catch (error) {\n // Without an `onThrow` there is nowhere left to report to — the reporter is\n // what just threw. A throwing observer is the host's bug; losing the command\n // channel over it would be ours.\n onThrow?.(error);\n }\n};\n\nexport const createPresenceBeat = (deps: PresenceBeatDeps): PresenceBeat => {\n const now = deps.now ?? monotonicNowMs;\n // Starts at \"just acknowledged\" so a host is given a full window to land its\n // first beat rather than being declared stale before it has written anything.\n // `lastAckMs` is carried for the report; `beatsSinceAck` is what decides.\n const state = { lastAckMs: now(), beatsSinceAck: 0 };\n\n const announce = (online: boolean): void => {\n const fail = (error: unknown) => notify(() => deps.onError(errorText(error)));\n const ack = () =>\n notify(() => {\n state.lastAckMs = now();\n state.beatsSinceAck = 0;\n });\n // A write can also fail SYNCHRONOUSLY — Firestore validates the payload before\n // it returns a promise (that is how one bad field throws before anything is\n // sent) — and that throw would otherwise escape through the heartbeat timer.\n notify(() => {\n deps.write(online).then(ack, fail);\n }, fail);\n };\n\n const beat = (): void => {\n state.beatsSinceAck += 1;\n // Deliberately no write here: another mutation would only queue up behind the\n // ones already stuck, and the answer for this beat is already known.\n if (state.beatsSinceAck >= deps.staleAfterBeats) {\n notify(() => deps.onStale(now() - state.lastAckMs));\n return;\n }\n announce(true);\n };\n\n return { announce, beat };\n};\n","// Host side of the command channel: claim queued commands, run handlers, write\n// results back, and announce presence via heartbeat.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/hostRunner.ts (itself\n// ported from ../mulmoserver). The only signature change vs. that copy: the\n// `firestore` instance is a parameter (each host supplies its own Firebase init),\n// and the heartbeat interval is an option (defaults to one minute).\nimport {\n DocumentReference,\n Firestore,\n FirestoreError,\n Query,\n QuerySnapshot,\n deleteDoc,\n onSnapshot,\n query,\n runTransaction,\n serverTimestamp,\n setDoc,\n updateDoc,\n where,\n} from \"firebase/firestore\";\n\nimport { isRecord } from \"@mulmoclaude/common\";\n\nimport { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport {\n Channel,\n Command,\n CommandHandler,\n CommandHandlers,\n buildHostPresence,\n byCreatedAt,\n coerceJsonObject,\n commandsCollection,\n hostDoc,\n isExpired,\n} from \"../index.js\";\nimport { stripUndefined, undefinedPaths, unexpectedPaths } from \"./firestoreSafeResult.js\";\nimport { PRESENCE_STALE_BEATS, createPresenceBeat, type PresenceBeat } from \"./presenceBeat.js\";\nimport { monotonicNowMs } from \"./monotonicClock.js\";\n// Listener retry policy: `../../firestore/listen.ts`. It is true of ANY\n// Firestore listener, and the shared-collection store has one too. Two copies\n// would drift SILENTLY — one subsystem retrying a revoked grant forever while\n// the other gives up on a network blip.\nimport { backoffDelayMs, classifyListenerError, LISTEN_RETRY_WINDOW_MS, shouldGiveUpListening } from \"../../firestore/listen.js\";\n\n// Exported so a host that judges presence freshness from the outside (a probe that\n// reads the doc back) measures against the same beat the runner writes on.\nexport const DEFAULT_HEARTBEAT_MS = 60_000;\n\n// Re-exported under their original names so this module's published surface —\n// which MulmoTerminal imports — is unchanged by the move.\nexport { backoffDelayMs, classifyListenerError, LISTEN_RETRY_WINDOW_MS, shouldGiveUpListening };\n\nexport interface HostEvent {\n phase: \"received\" | \"done\" | \"error\";\n method: string;\n message?: string;\n}\n\nexport interface HostRunnerOptions {\n onEvent?: (event: HostEvent) => void;\n // Called once when the listener dies fatally (after presence has been set\n // offline), so the lifecycle owner can reconcile its own state — e.g. clear\n // the runner handle so status() no longer reports connected. NOT called on a\n // normal stop().\n onClosed?: () => void;\n // Called when a command is dropped for being past its `expiresAt`, BEFORE the\n // doc is deleted, so the host can clean up out-of-band resources the command\n // referenced (e.g. staged attachment uploads in Storage). `uid` is THIS runner's\n // session uid (channel.uid) — passed in rather than read from a global so a\n // concurrent reconnect as a different account can't point cleanup at the wrong\n // user's Storage path. Best-effort: a throw is logged via onEvent and does NOT\n // block the doc deletion. Absent ⇒ the expired doc is simply deleted.\n onExpire?: ((command: Command, uid: string) => void | Promise<void>) | undefined;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number | undefined;\n // Paths in a handler's reply where `undefined` is expected rather than a bug,\n // keyed by method name — `{ listSessions: [\"sessions.*.work\"] }`, `*` matching\n // exactly one segment. Firestore refuses `undefined` either way, so these are\n // still stripped; declaring them only silences the report, which is what keeps\n // it worth reading (#2634).\n expectedUndefined?: Record<string, readonly string[]>;\n}\n\nexport interface Claim {\n method: string;\n // Still unproved JSON here: the doc is remote-written, so the walk that earns\n // `JsonObject` runs at the handler boundary, outside the claim transaction.\n params: Record<string, unknown>;\n}\n\nconst noop = () => undefined;\n\n// The remote may have deleted the doc on timeout, so ignore write-after-delete.\nconst writeError = (ref: DocumentReference, code: string, message: string) =>\n updateDoc(ref, { status: \"error\", error: { code, message }, updatedAt: serverTimestamp() }).catch(noop);\n\n// What a queued command doc contributes to a claim, rebuilt from the two\n// members that were checked rather than asserted off a `DocumentData`.\n// `null` means \"not ours to take\" — the doc is gone or someone else moved it\n// out of `queued`.\n//\n// Anything else DEGRADES instead of bailing: a non-string method becomes \"\" so\n// the dispatch falls through to the unknown-method reply, and non-object params\n// become an empty set so the handler's own required-field check answers. Both\n// write an error back to the doc. Returning null for them instead would leave\n// the command claimed as `processing` with nothing ever written back — the one\n// outcome the remote cannot distinguish from a dead host.\nexport const readClaim = (data: unknown): Claim | null => {\n if (!isRecord(data) || data.status !== \"queued\") return null;\n return { method: typeof data.method === \"string\" ? data.method : \"\", params: isRecord(data.params) ? data.params : {} };\n};\n\n// Atomically move a command queued -> processing so it is handled exactly once.\n// Returns the method/params to run, or null if another handler already took it.\nconst claimCommand = (firestore: Firestore, ref: DocumentReference): Promise<Claim | null> =>\n runTransaction(firestore, async (txn) => {\n const claim = readClaim((await txn.get(ref)).data());\n if (!claim) {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return claim;\n });\n\n// Own-property lookup: a bare `handlers[method]` with a method name written by a\n// remote terminal resolves `constructor` / `toString` to an Object.prototype\n// function, which is truthy and slips past the unknown-method check (#2319).\nexport const resolveCommandHandler = (handlers: CommandHandlers, method: string): CommandHandler | undefined =>\n Object.hasOwn(handlers, method) ? handlers[method] : undefined;\n\n// Firestore refuses a write containing `undefined` at any depth, so one stray\n// value would cost the whole reply — `status: \"done\"` never lands and the remote\n// waits out its timeout. Strip instead, and name the paths: Firestore's own error\n// points at the document, never at the field, which is where the debugging time\n// goes. Paths the caller declared as legitimately-optional are stripped silently.\nconst reportStripped = (dropped: string[], claim: Claim, options: HostRunnerOptions): void => {\n if (dropped.length === 0) return;\n const paths = dropped.map((path) => `result.${path}`).join(\", \");\n options.onEvent?.({ phase: \"error\", method: claim.method, message: `undefined dropped at ${paths} — Firestore would have refused the whole reply` });\n};\n\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler, options: HostRunnerOptions): Promise<HostEvent> => {\n try {\n // Inside the try on purpose: a params value JSON cannot carry throws\n // naming its path, and that lands in the same `handler_error` reply the\n // handler's own failures do, rather than stalling the command.\n const returned = await handler(coerceJsonObject(claim.params));\n const dropped = undefinedPaths(returned);\n reportStripped(unexpectedPaths(dropped, options.expectedUndefined?.[claim.method]), claim, options);\n // The walk above already answered \"is there anything to strip\", so a clean\n // reply — every reply, normally — is written without copying it first.\n await updateDoc(ref, { status: \"done\", result: dropped.length === 0 ? returned : stripUndefined(returned), updatedAt: serverTimestamp() });\n return { phase: \"done\", method: claim.method };\n } catch (error) {\n const message = errorMessage(error);\n await writeError(ref, \"handler_error\", message);\n return { phase: \"error\", method: claim.method, message };\n }\n};\n\n// A command past its deadline is removed entirely rather than run: give the host\n// a chance to clean up out-of-band resources (staged attachments), then delete\n// the doc so it is neither reprocessed nor left as a stale error. Both steps are\n// best-effort/idempotent, so a snapshot replay surfacing the same expired doc\n// twice is harmless (no claim transaction needed — see plan edge #3).\nconst expireCommand = async (ref: DocumentReference, command: Command, options: HostRunnerOptions, uid: string) => {\n try {\n await options.onExpire?.(command, uid);\n } catch (error) {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `onExpire failed: ${errorMessage(error)}` });\n }\n // Surface a delete failure (permissions / transient network) the same way the\n // onExpire failure above is surfaced — otherwise the expired doc lingers as\n // \"queued\" with no signal as to why cleanup didn't happen.\n await deleteDoc(ref).catch((error) => {\n options.onEvent?.({ phase: \"error\", method: command.method, message: `expire delete failed: ${errorMessage(error)}` });\n });\n options.onEvent?.({ phase: \"done\", method: command.method, message: \"expired\" });\n};\n\n// Per-runner constants bundled into one context so processCommand stays under the\n// max-params cap: firestore, the handler table, options, and the session uid are\n// all fixed for the runner's lifetime; only ref/command/now vary per command.\ninterface RunnerContext {\n firestore: Firestore;\n handlers: CommandHandlers;\n options: HostRunnerOptions;\n uid: string;\n}\n\nconst processCommand = async (ctx: RunnerContext, ref: DocumentReference, command: Command, now: number) => {\n const { handlers, options } = ctx;\n // Drop an expired command before claiming it — it must never reach a handler.\n if (isExpired(command, now)) {\n await expireCommand(ref, command, options, ctx.uid);\n return;\n }\n const claim = await claimCommand(ctx.firestore, ref);\n if (!claim) {\n return;\n }\n options.onEvent?.({ phase: \"received\", method: claim.method });\n const handler = resolveCommandHandler(handlers, claim.method);\n if (!handler) {\n await writeError(ref, \"unknown_method\", `No handler for method: ${claim.method}`);\n options.onEvent?.({ phase: \"error\", method: claim.method, message: \"unknown method\" });\n return;\n }\n options.onEvent?.(await runHandler(ref, claim, handler, options));\n};\n\n// A resilient command listener: its mutable retry state plus the fixed collaborators.\ninterface ListenerRun {\n queuedCommands: Query;\n ctx: RunnerContext;\n goOffline: () => void;\n stopped: boolean;\n unsubscribe: () => void;\n retryTimer: ReturnType<typeof setTimeout> | null;\n attempt: number;\n // Start of the current outage, or null while healthy. `attempt` still drives the\n // backoff ladder; only the give-up decision reads the clock — a MONOTONIC one, so\n // a clock step cannot spend this window on its own.\n downSinceMs: number | null;\n}\n\n// Best-effort oldest-first DISPATCH only — commands run concurrently and may\n// finish out of order (chat is asynchronous). We sort in memory rather than\n// orderBy(\"createdAt\") because a Firestore orderBy silently EXCLUDES docs missing\n// the field, dropping every pre-offline-queue command.\nconst dispatchAddedCommands = (ctx: RunnerContext, snapshot: QuerySnapshot): void => {\n // Wall clock on purpose, unlike the outage timers below: this is compared with a\n // command's `expiresAt`, which the phone stamped from its own clock.\n const now = Date.now();\n snapshot\n .docChanges()\n .filter((change) => change.type === \"added\")\n // Cast kept (#2692). The runner itself reads only `createdAt`, `expiresAt`\n // and `method` here, but the doc rides verbatim into the host's `onExpire`,\n // whose published signature takes a whole `Command`. Rebuilding one would\n // have to invent the members nothing on this path reads — `status`,\n // `result`, `error`, `createdBy` — and hand a second host's cleanup\n // callback values the document never carried, which is worse than the\n // assertion. Removing it means narrowing `onExpire`'s parameter, a\n // breaking change to a published contract MulmoTerminal also implements.\n .map((change) => ({ ref: change.doc.ref, command: change.doc.data() as Command }))\n .sort((left, right) => byCreatedAt(left.command, right.command))\n .forEach(({ ref, command }) => {\n processCommand(ctx, ref, command, now).catch(noop);\n });\n};\n\n// Re-subscribe after a transient error, backing off exponentially.\nfunction scheduleResubscribe(run: ListenerRun): void {\n run.retryTimer = setTimeout(() => subscribeCommands(run), backoffDelayMs(run.attempt));\n run.attempt += 1;\n}\n\n// A Firestore onSnapshot error terminates THIS listener and never recovers on its\n// own. Transient → re-subscribe with bounded backoff (presence stays online);\n// fatal, or failing for longer than the retry window → go offline.\nfunction handleListenError(run: ListenerRun, error: FirestoreError): void {\n run.ctx.options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n if (run.stopped) return;\n const now = monotonicNowMs();\n run.downSinceMs ??= now;\n if (classifyListenerError(error) === \"fatal\" || shouldGiveUpListening(run.downSinceMs, now)) {\n run.goOffline();\n return;\n }\n scheduleResubscribe(run);\n}\n\nfunction subscribeCommands(run: ListenerRun): void {\n run.retryTimer = null;\n if (run.stopped) return;\n run.unsubscribe = onSnapshot(\n run.queuedCommands,\n (snapshot) => {\n // A healthy snapshot proves the listener recovered: the ladder and the\n // outage clock both start fresh for whatever comes next.\n run.attempt = 0;\n run.downSinceMs = null;\n dispatchAddedCommands(run.ctx, snapshot);\n },\n (error) => handleListenError(run, error),\n );\n}\n\n// Subscribe to the queued-command stream; re-subscribe on transient listener\n// errors with bounded backoff, go offline on a fatal one. Returns a stop that\n// cancels any pending retry and detaches the listener.\nconst listenForCommands = (queuedCommands: Query, ctx: RunnerContext, goOffline: () => void): (() => void) => {\n const run: ListenerRun = { queuedCommands, ctx, goOffline, stopped: false, unsubscribe: noop, retryTimer: null, attempt: 0, downSinceMs: null };\n subscribeCommands(run);\n return () => {\n run.stopped = true;\n if (run.retryTimer) clearTimeout(run.retryTimer);\n run.unsubscribe();\n };\n};\n\n// One running host, as far as shutting it down is concerned. `closed` guards the\n// teardown: a presence failure and a fatal listener error can arrive together, and\n// each of them ends the runner.\ninterface HostRun {\n beat: ReturnType<typeof setInterval> | null;\n stopListening: () => void;\n closed: boolean;\n}\n\nconst heartbeatMs = (options: HostRunnerOptions): number => options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;\n\n// How old an acknowledged presence write may be before this runner stops claiming\n// to be online. Exported because a host that judges the same freshness from the\n// OUTSIDE (a probe reading the doc back) has to apply the runner's threshold, not\n// a second copy of it — pass that host's own runner options and the two cannot\n// drift when `heartbeatMs` is customised.\nexport const presenceStaleAfterMs = (options: HostRunnerOptions = {}): number => heartbeatMs(options) * PRESENCE_STALE_BEATS;\n\n// Advertise online/offline + the capability set (method names + protocol version)\n// on the same doc the remote already listens to for presence, and watch whether\n// those writes are landing — see presenceBeat.ts for why the sensor counts the\n// beats that ran rather than the time that passed.\nconst buildPresenceBeat = (\n firestore: Firestore,\n channel: Channel,\n handlers: CommandHandlers,\n options: HostRunnerOptions,\n onStale: () => void,\n): PresenceBeat => {\n const presence = hostDoc(firestore, channel);\n const report = (message: string) => options.onEvent?.({ phase: \"error\", method: \"presence\", message });\n return createPresenceBeat({\n write: (online) => setDoc(presence, { ...buildHostPresence(channel, handlers, online), updatedAt: serverTimestamp() }),\n onError: (message) => report(`presence write failed: ${message}`),\n onStale: (silentMs) => {\n report(`no presence write acknowledged across ${PRESENCE_STALE_BEATS} beats (${Math.round(silentMs / 1_000)}s) — the remote cannot see this host`);\n onStale();\n },\n staleAfterBeats: PRESENCE_STALE_BEATS,\n });\n};\n\n// Stop beating, say goodbye, detach the listener. Announcing offline is\n// best-effort — if the channel is the thing that broke, this write goes nowhere,\n// which is exactly why the remote judges presence by age rather than by the flag.\nconst shutDown = (run: HostRun, presence: PresenceBeat): void => {\n if (run.closed) return;\n run.closed = true;\n if (run.beat) clearInterval(run.beat);\n run.beat = null;\n presence.announce(false);\n run.stopListening();\n};\n\n// startHostRunner subscribes to queued commands for the given channel and runs\n// each one through the supplied handler table. It also announces presence (a\n// heartbeat on users/{uid}/hosts/{hostId}) so the remote can tell it is online.\n// Returns a stop function that goes offline and detaches the listener.\nexport const startHostRunner = (firestore: Firestore, channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions = {}): (() => void) => {\n const run: HostRun = { beat: null, stopListening: noop, closed: false };\n const presence = buildPresenceBeat(firestore, channel, handlers, options, goOffline);\n\n // The channel is gone and re-subscribing is not going to bring it back: hand\n // over to the lifecycle owner, which can re-auth and start a fresh runner.\n function goOffline(): void {\n if (run.closed) return;\n shutDown(run, presence);\n options.onClosed?.();\n }\n\n presence.announce(true);\n run.beat = setInterval(presence.beat, heartbeatMs(options));\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const ctx: RunnerContext = { firestore, handlers, options, uid: channel.uid };\n run.stopListening = listenForCommands(queuedCommands, ctx, goOffline);\n\n return () => shutDown(run, presence);\n};\n","// Keeps the Firestore command channel alive across outages — the ring OUTSIDE\n// `startHostRunner` (#2633, moved into core by #2643).\n//\n// The inner ring recovers in place: it re-subscribes its listener with backoff and\n// notices when its own presence writes stop being acknowledged. When even that runs\n// out it calls `onClosed` and stops. This ring starts a WHOLE new runner (fresh\n// listener, fresh presence write), and only when relaunching stops helping does it\n// pass the closure through, so the client can escalate to a full re-auth from its\n// parked blob — the only path that fixes an actually-dead credential.\n//\n// The give-up rule is TIME, not a count of relaunches, so a long outage does not\n// burn through a budget while nothing can possibly succeed. Time this process was\n// not RUNNING is excluded from it, which is what `after` is for (#2845).\n//\n// It lives here rather than in each host because its correctness is a relation\n// between constants: SETTLE_MS must outlast `LISTEN_RETRY_WINDOW_MS`'s reporting\n// delay, PROBE_INTERVAL_MS must sit above `presenceStaleAfterMs()`, and\n// RESUME_GAP_MS must sit above ordinary timer jitter yet below GIVE_UP_MS — above,\n// or every late timer restarts the budget and nothing ever escalates; below, or a\n// gap can still spend the whole budget undetected. While the two hosts each kept a\n// copy, raising LISTEN_RETRY_WINDOW_MS from ~31s to 5 minutes silently disabled\n// `giveUp` in the copy that had not been told (#2643).\nimport type { RunnerHealth, RunnerHealthState } from \"../health.js\";\nimport type { HostRunnerOptions } from \"./hostRunner.js\";\nimport type { RemoteHostLogger } from \"./lifecycle.js\";\nimport type { Liveness } from \"./presenceProbe.js\";\nimport { monotonicNowMs } from \"./monotonicClock.js\";\n\nconst ONE_SECOND_MS = 1_000;\nconst ONE_MINUTE_MS = 60 * ONE_SECOND_MS;\n\nconst RECONNECT_BASE_MS = ONE_SECOND_MS;\nconst RECONNECT_MAX_MS = ONE_MINUTE_MS;\n// When to ask, after a launch, whether the new runner actually works.\nconst SETTLE_MS = ONE_MINUTE_MS;\n// Past this, retrying in place cannot help: an expired credential needs the\n// browser's parked blob, which only the client can replay.\nconst GIVE_UP_MS = 5 * ONE_MINUTE_MS;\n// How often to ask whether the phone can still see us. Slower than the one-minute\n// heartbeat, because the question is \"are the beats landing\", not \"did this one\".\nconst PROBE_INTERVAL_MS = 90 * ONE_SECOND_MS;\n// A task firing this much later than it asked for did not merely run late: the\n// process was not running at all (system sleep, a blocked event loop). Ordinary\n// load costs a timer milliseconds, not a minute.\nconst RESUME_GAP_MS = ONE_MINUTE_MS;\n\nexport const reconnectDelayMs = (attempt: number): number => Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);\n\n/** Cancels a scheduled task. Handing back a closure rather than a timer handle keeps\n * the seam free of platform timer types, so a test clock is an ordinary function. */\nexport type CancelTimer = () => void;\n\nexport interface ResilientHostRunnerDeps {\n /** Starts the host runner, returning its stop function. Throws if the session is gone. */\n start: (options: HostRunnerOptions) => () => void;\n /** The options the lifecycle handed us; `onClosed` is passed on only once we give up. */\n options: HostRunnerOptions;\n /** Positive liveness check (`createPresenceProbe`). Without one, recovery falls back\n * to trusting silence — which is what let a dead channel report itself green. */\n checkAlive?: () => Promise<Liveness>;\n /** State changes, for a host that renders channel health. Optional: a host with no\n * such UI should not have to pass an empty function. */\n onHealth?: (health: RunnerHealth) => void;\n log: Pick<RemoteHostLogger, \"info\" | \"warn\">;\n schedule?: (task: () => void, delayMs: number) => CancelTimer;\n /** Measures how long things have taken. MONOTONIC by default — never render one\n * of its readings, and never compare it with a wall-clock time. */\n now?: () => number;\n /** Wall-clock epoch ms, and used for `RunnerHealth.changedAt` alone, which the UI\n * renders as a time. Separate from `now` on purpose: an epoch that quietly became\n * \"ms since this process started\" reads as 1970 in the UI. */\n wallNow?: () => number;\n}\n\ninterface RunnerContext {\n deps: ResilientHostRunnerDeps;\n schedule: (task: () => void, delayMs: number) => CancelTimer;\n now: () => number;\n wallNow: () => number;\n stopUnderlying: (() => void) | null;\n cancelTimer: CancelTimer | null;\n attempt: number;\n downSinceMs: number | null;\n lastError: string | null;\n state: RunnerHealthState;\n stopped: boolean;\n cancelProbe: CancelTimer | null;\n}\n\nconst errorText = (error: unknown): string => (error instanceof Error ? error.message : String(error));\n\nconst scheduleWithTimeout = (task: () => void, delayMs: number): CancelTimer => {\n const timer = setTimeout(task, delayMs);\n return () => clearTimeout(timer);\n};\n\n// The observer belongs to the host, and reporting a state must not be able to\n// prevent reaching it. A throwing `onHealth` inside `giveUp` would swallow the\n// `onClosed` that follows it — losing the one escalation that gets a dead\n// credential re-authenticated, over a bug in a toolbar indicator. Same reasoning\n// as `presenceBeat`'s `notify`, and the log is where there is left to say it.\nconst setState = (ctx: RunnerContext, next: RunnerHealthState): void => {\n ctx.state = next;\n try {\n ctx.deps.onHealth?.({ state: next, lastError: ctx.lastError, changedAt: ctx.wallNow() });\n } catch (error) {\n ctx.deps.log.warn(`host runner health observer threw: ${errorText(error)}`);\n }\n};\n\nconst clearTimer = (ctx: RunnerContext): void => {\n ctx.cancelTimer?.();\n ctx.cancelTimer = null;\n};\n\nconst clearProbe = (ctx: RunnerContext): void => {\n ctx.cancelProbe?.();\n ctx.cancelProbe = null;\n};\n\n// Schedule, and notice when the process was not running for part of the wait.\n//\n// Time this host spent frozen is not time the channel spent failing, but the outage\n// budget cannot tell them apart on its own: a gap longer than GIVE_UP_MS spends the\n// whole budget while nothing was ever attempted, and the ring then escalates to the\n// client without one try against the network as it now stands (#2845). So a gap\n// restarts the budget and the backoff ladder, and the task runs as the first attempt\n// after it.\nconst after = (ctx: RunnerContext, delayMs: number, task: () => void): CancelTimer => {\n const dueMs = ctx.now() + delayMs;\n return ctx.schedule(() => {\n const overshootMs = ctx.now() - dueMs;\n if (overshootMs > RESUME_GAP_MS) {\n // Says only what is true in both cases: this fires while healthy too, where\n // there is no outage for the budget to be \"restarting\".\n ctx.deps.log.warn(`host runner resumed after a ${Math.round(overshootMs / ONE_SECOND_MS)}s gap; that time does not count as outage`);\n ctx.downSinceMs = null;\n ctx.attempt = 0;\n }\n task();\n }, delayMs);\n};\n\n// Only worth asking while we believe we are up: during a reconnect the answer is\n// already known, and the recovery it would trigger is the one already running.\nfunction scheduleProbe(ctx: RunnerContext): void {\n if (ctx.stopped || !ctx.deps.checkAlive) return;\n clearProbe(ctx);\n ctx.cancelProbe = after(ctx, PROBE_INTERVAL_MS, () => void runProbe(ctx));\n}\n\n// `alive: null` (no probe wired, or nothing judgeable yet) is not an answer — the\n// caller must read it as neither alive nor dead. `reason` describes a \"no\".\ninterface LivenessAnswer {\n alive: Liveness;\n reason: string;\n}\n\nconst STALE_REASON = \"presence went stale — the phone can no longer see this host\";\n\nasync function askAlive(ctx: RunnerContext): Promise<LivenessAnswer> {\n try {\n return { alive: (await ctx.deps.checkAlive?.()) ?? null, reason: STALE_REASON };\n } catch (error) {\n // The read itself could not reach the server, which answers the question it\n // was asking.\n return { alive: false, reason: `presence probe failed: ${errorText(error)}` };\n }\n}\n\nasync function runProbe(ctx: RunnerContext): Promise<void> {\n ctx.cancelProbe = null;\n if (ctx.stopped || ctx.state !== \"online\") return;\n const answer = await askAlive(ctx);\n // A state change while the read was in flight means someone else is already on it.\n if (ctx.stopped || ctx.state !== \"online\") return;\n if (answer.alive === false) {\n ctx.lastError = answer.reason;\n ctx.deps.log.warn(`host runner ${answer.reason}`);\n onUnderlyingClosed(ctx);\n return;\n }\n scheduleProbe(ctx);\n}\n\n// The inner runner leaves its (already dead) snapshot registration in place when it\n// goes offline, so release it before starting another one — otherwise every\n// reconnect cycle adds one more.\nconst releaseUnderlying = (ctx: RunnerContext): void => {\n const stop = ctx.stopUnderlying;\n ctx.stopUnderlying = null;\n try {\n stop?.();\n } catch (error) {\n ctx.deps.log.warn(`host runner teardown failed: ${errorText(error)}`);\n }\n};\n\n// The channel is genuinely back, so the outage budget and the backoff ladder start\n// fresh for the next one.\nconst markRecovered = (ctx: RunnerContext): void => {\n ctx.downSinceMs = null;\n ctx.attempt = 0;\n // The incident is over, so its error stops describing the channel: kept, it\n // would be reported as the cause of whatever outage comes next.\n ctx.lastError = null;\n if (ctx.state === \"online\") return;\n ctx.deps.log.info(\"host runner re-subscribed\");\n setState(ctx, \"online\");\n scheduleProbe(ctx);\n};\n\n// One settle window after a launch, ask whether the new runner actually works. A\n// \"no\" keeps the outage clock running, which is what eventually escalates to the\n// client; anything else (a \"yes\", or no probe to ask) accepts the recovery.\nasync function settle(ctx: RunnerContext): Promise<void> {\n ctx.cancelTimer = null;\n if (ctx.stopped) return;\n const answer = await askAlive(ctx);\n if (ctx.stopped) return;\n if (answer.alive === false) {\n ctx.lastError = answer.reason;\n ctx.deps.log.warn(`host runner is up but still unreachable — ${answer.reason}`);\n onUnderlyingClosed(ctx);\n return;\n }\n markRecovered(ctx);\n}\n\nconst giveUp = (ctx: RunnerContext): void => {\n ctx.stopped = true;\n clearProbe(ctx);\n ctx.deps.log.warn(`host runner stayed down for ${Math.round(GIVE_UP_MS / ONE_SECOND_MS)}s, giving up (${ctx.lastError ?? \"no error reported\"})`);\n setState(ctx, \"offline\");\n ctx.deps.options.onClosed?.();\n};\n\nfunction scheduleRelaunch(ctx: RunnerContext): void {\n const delayMs = reconnectDelayMs(ctx.attempt);\n ctx.attempt += 1;\n ctx.deps.log.warn(`host runner closed (${ctx.lastError ?? \"no error reported\"}); re-subscribing in ${Math.round(delayMs / ONE_SECOND_MS)}s`);\n // Re-announcing on every relaunch would restamp `changedAt`, and a UI reading it\n // as \"down since\" would reset to zero each cycle of the outage it is reporting.\n if (ctx.state !== \"reconnecting\") setState(ctx, \"reconnecting\");\n ctx.cancelTimer = after(ctx, delayMs, () => launch(ctx));\n}\n\nfunction onUnderlyingClosed(ctx: RunnerContext): void {\n if (ctx.stopped) return;\n releaseUnderlying(ctx);\n clearTimer(ctx);\n clearProbe(ctx);\n ctx.downSinceMs ??= ctx.now();\n if (ctx.now() - ctx.downSinceMs >= GIVE_UP_MS) giveUp(ctx);\n else scheduleRelaunch(ctx);\n}\n\n// Which failures describe the CHANNEL. A handler that threw is worth logging but\n// says nothing about reachability, and remembering it would let it be quoted as the\n// cause of an unrelated outage minutes later.\nconst CHANNEL_METHODS = new Set([\"listen\", \"presence\"]);\n\nconst runnerOptions = (ctx: RunnerContext): HostRunnerOptions => ({\n ...ctx.deps.options,\n onEvent: (event) => {\n // The error code is the one thing that says whether the credential or the\n // network is at fault, so keep the latest for the log lines above.\n if (event.phase === \"error\") {\n const text = `${event.method}: ${event.message ?? \"no detail\"}`;\n if (CHANNEL_METHODS.has(event.method)) ctx.lastError = text;\n ctx.deps.log.warn(`host runner event error — ${text}`);\n }\n ctx.deps.options.onEvent?.(event);\n },\n onClosed: () => onUnderlyingClosed(ctx),\n});\n\nfunction launch(ctx: RunnerContext): void {\n ctx.cancelTimer = null;\n if (ctx.stopped) return;\n try {\n ctx.stopUnderlying = ctx.deps.start(runnerOptions(ctx));\n } catch (error) {\n // The session was torn down under us (currentFirestore throws when disconnected).\n ctx.lastError = errorText(error);\n onUnderlyingClosed(ctx);\n return;\n }\n ctx.cancelTimer = after(ctx, SETTLE_MS, () => void settle(ctx));\n}\n\nexport function startResilientHostRunner(deps: ResilientHostRunnerDeps): () => void {\n const ctx: RunnerContext = {\n deps,\n schedule: deps.schedule ?? scheduleWithTimeout,\n now: deps.now ?? monotonicNowMs,\n wallNow: deps.wallNow ?? Date.now,\n stopUnderlying: null,\n cancelTimer: null,\n attempt: 0,\n downSinceMs: null,\n lastError: null,\n state: \"online\",\n stopped: false,\n cancelProbe: null,\n };\n // Announce the starting state rather than assuming the owner knows it: a\n // (re)connect is also what clears the notice left behind by the previous outage.\n setState(ctx, \"online\");\n launch(ctx);\n scheduleProbe(ctx);\n\n return () => {\n ctx.stopped = true;\n clearTimer(ctx);\n clearProbe(ctx);\n releaseUnderlying(ctx);\n };\n}\n","// Can the phone still see this host? Asked from the phone's own vantage point,\n// rather than inferred from this side (#2633).\n//\n// `createPresenceBeat` tracks whether this host's own presence writes are being\n// acknowledged, which is the same question one step earlier. This reads the\n// document back FROM THE SERVER and judges it by age — the exact test the remote\n// applies. Both answers are useful: a read that throws means the connection is\n// genuinely gone, and a read that returns a stale document means the beats are\n// not landing.\n//\n// Lives beside the runner it feeds (#2643) so the probe interval, the heartbeat,\n// and `presenceStaleAfterMs` can only be changed against each other.\nimport { getDocFromServer } from \"firebase/firestore\";\nimport type { Firestore } from \"firebase/firestore\";\n\nimport { hostDoc, type Channel } from \"../index.js\";\nimport { presenceStaleAfterMs } from \"./hostRunner.js\";\n\nconst ONE_SECOND_MS = 1_000;\n\n// The runner's own threshold, taken from the runner rather than recomputed: a\n// laptop waking up gets three beats to write again before anyone calls it dead.\n// Being wrong here costs a reconnect cycle, so it leans towards patience.\nexport const PRESENCE_STALE_MS = presenceStaleAfterMs();\n\n// A read that never settles would leave the probe un-rearmed — a sensor dying\n// quietly, which is the very failure this module exists to catch. Firestore takes\n// no abort signal, so the deadline has to be a race.\nconst PROBE_TIMEOUT_MS = 30 * ONE_SECOND_MS;\n\n/** `null` = cannot be judged, which is NOT a failure: the document may simply not\n * exist yet (a runner that has never announced), and treating \"no answer\" as\n * \"dead\" would spin a reconnect loop against a host that is merely new. */\nexport type Liveness = boolean | null;\n\nconst hasToMillis = (value: object): value is { toMillis: () => number } => \"toMillis\" in value && typeof Reflect.get(value, \"toMillis\") === \"function\";\n\nconst asMillis = (value: unknown): number | null => {\n if (typeof value === \"number\") return value;\n // Firestore hands back a Timestamp for serverTimestamp() fields.\n if (typeof value === \"object\" && value !== null && hasToMillis(value)) return value.toMillis();\n return null;\n};\n\n/** Judge a presence document that was read successfully. Exported for its own test:\n * the freshness rule is the part worth pinning, and it needs no Firestore. */\nexport const presenceIsFresh = (data: Record<string, unknown> | undefined, now: number, staleAfterMs: number = PRESENCE_STALE_MS): Liveness => {\n if (!data) return null;\n const updatedAt = asMillis(data.updatedAt);\n // A pending serverTimestamp() reads as null until the write is acknowledged;\n // that is a write in flight, not a stale one.\n if (updatedAt === null) return null;\n // `online: false` is the runner's own goodbye, written on teardown. A truthful\n // state, not a broken one — it is meant to be down.\n if (data.online === false) return null;\n return now - updatedAt < staleAfterMs;\n};\n\n/** Reject once `timeoutMs` has passed, so a stalled read answers instead of hanging. */\nexport const withTimeout = async <T>(work: Promise<T>, timeoutMs: number): Promise<T> => {\n const timer: { handle: ReturnType<typeof setTimeout> | null } = { handle: null };\n const deadline = new Promise<never>((_, reject) => {\n timer.handle = setTimeout(() => reject(new Error(`presence read did not answer within ${Math.round(timeoutMs / ONE_SECOND_MS)}s`)), timeoutMs);\n });\n try {\n return await Promise.race([work, deadline]);\n } finally {\n if (timer.handle) clearTimeout(timer.handle);\n }\n};\n\nexport interface PresenceProbeDeps {\n firestore: () => Firestore;\n channel: Channel;\n /** The runner's own staleness threshold. Pass `presenceStaleAfterMs(options)` when\n * the runner uses a custom `heartbeatMs`, so the two judgments cannot diverge. */\n staleAfterMs?: number;\n timeoutMs?: number;\n now?: () => number;\n}\n\n/** Reads the host's own presence document from the server and reports whether it is fresh. */\nexport const createPresenceProbe = (deps: PresenceProbeDeps): (() => Promise<Liveness>) => {\n const now = deps.now ?? Date.now;\n return async () => {\n // From the server, never the cache: a cached copy of our own last write answers\n // \"fresh\" precisely when the connection that should have carried it is dead.\n const snapshot = await withTimeout(getDocFromServer(hostDoc(deps.firestore(), deps.channel)), deps.timeoutMs ?? PROBE_TIMEOUT_MS);\n return presenceIsFresh(snapshot.data(), now(), deps.staleAfterMs ?? PRESENCE_STALE_MS);\n };\n};\n","// Remote-host lifecycle: wire a Firebase session to the Firestore host runner so\n// connecting starts the command loop + presence heartbeat, and disconnecting\n// stops both.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/index.ts. The factory\n// takes injected collaborators — real hosts bind them to Firebase; tests pass\n// fakes to exercise the invariants (non-destructive connect, serialized\n// transitions, status/liveness reconciliation on fatal listener death) without a\n// network. `hostId` and the logger are injected too, so this file imports no\n// Firebase and no host logger and stays trivially unit-testable.\n//\n// Single-account, single-host per instance, in-memory session: a host restart\n// drops the session and needs a re-connect.\nimport type { Channel, Command, CommandHandlers } from \"../index.js\";\nimport type { HostEvent, HostRunnerOptions } from \"./hostRunner.js\";\n\nexport interface RemoteHostStatus {\n connected: boolean;\n uid: string | null;\n}\n\n// Minimal logger the factory calls; each host adapts its own logger to this\n// shape (or omits it to run silently, as the tests do).\nexport interface RemoteHostLogger {\n info: (msg: string) => void;\n warn: (msg: string) => void;\n debug: (msg: string) => void;\n}\n\n// Injectable collaborators — a real host binds these to Firebase + its own\n// hostId; tests pass fakes to exercise the lifecycle without a network.\n// `startRunner` is the host's `startHostRunner` pre-bound with its Firestore\n// instance, so this module needs no Firebase of its own.\nexport interface RemoteHostDeps {\n hostId: string;\n signIn: (idToken: string) => Promise<string>;\n // Restore a session from a browser-parked blob (case A', mulmoserver#50) and\n // resolve to its uid — no popup. MUST reject when the blob yields no valid\n // session so `reconnect` stays non-destructive. Absent ⇒ `reconnect` throws.\n restore?: (sessionBlob: string) => Promise<string>;\n signOut: () => Promise<void>;\n currentUid: () => string | null;\n startRunner: (channel: Channel, handlers: CommandHandlers, options: HostRunnerOptions) => () => void;\n handlers: CommandHandlers;\n // Optional host-specific cleanup for a command the runner drops as expired\n // (e.g. delete its staged attachment uploads). Threaded verbatim into the\n // runner's `onExpire`, which supplies the session `uid` so cleanup targets the\n // right user's Storage path even across a reconnect; absent ⇒ an expired doc is\n // just deleted.\n onExpire?: ((command: Command, uid: string) => void | Promise<void>) | undefined;\n log?: RemoteHostLogger;\n}\n\nexport interface RemoteHostLifecycle {\n connect: (idToken: string) => Promise<RemoteHostStatus>;\n // Restore a browser-parked session and start the runner — popup-free re-attach\n // after a host restart. Non-destructive like `connect`. Rejects if `deps.restore`\n // isn't wired, or if restore fails (blob expired/invalid → caller falls back to connect).\n reconnect: (sessionBlob: string) => Promise<RemoteHostStatus>;\n disconnect: () => Promise<RemoteHostStatus>;\n status: () => RemoteHostStatus;\n}\n\nconst noop = () => undefined;\nconst silentLogger: RemoteHostLogger = { info: noop, warn: noop, debug: noop };\n\n// The message carries the Firestore error code — the only thing that says whether\n// a dead channel is the credential or the network. Logging just the phase and the\n// method threw that away at the last step (#2633).\nconst eventText = (event: HostEvent): string => (event.message ? `${event.phase} ${event.method} — ${event.message}` : `${event.phase} ${event.method}`);\n\nexport const createRemoteHost = (deps: RemoteHostDeps): RemoteHostLifecycle => {\n const log = deps.log ?? silentLogger;\n\n // The running host runner's stop() handle, or null when disconnected. Keeps\n // the single-host invariant — one runner at a time.\n let stopRunner: (() => void) | null = null;\n\n // Serialize connect/disconnect so overlapping requests can't both mutate\n // stopRunner (which would leak a second runner) or race auth against teardown.\n // Each transition runs only after the previous one settles; a failed\n // transition does not block the next (both handlers run the next op).\n let transition: Promise<unknown> = Promise.resolve();\n\n const serialize = <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n\n const stopIfRunning = () => {\n if (stopRunner) {\n stopRunner();\n stopRunner = null;\n }\n };\n\n const status = (): RemoteHostStatus => ({ connected: stopRunner !== null, uid: deps.currentUid() });\n\n // Swap the running runner for one bound to `uid`. Callers authenticate FIRST\n // (before this teardown) so a failed connect/reconnect keeps a healthy session.\n const attach = (uid: string, verb: string): RemoteHostStatus => {\n stopIfRunning();\n const runner = deps.startRunner({ uid, hostId: deps.hostId }, deps.handlers, {\n onEvent: (event) => log.debug(`host event: ${eventText(event)}`),\n onExpire: deps.onExpire,\n // Fatal listener death: drop the handle so status() stops reporting\n // connected — but only if it still points at THIS runner (a later\n // reconnect may have replaced it).\n onClosed: () => {\n if (stopRunner === runner) {\n stopRunner = null;\n log.warn(\"host runner listener died; marked disconnected\");\n }\n },\n });\n stopRunner = runner;\n log.info(`${verb} as ${uid}, host runner started (hostId=${deps.hostId})`);\n return status();\n };\n\n const connect = (idToken: string): Promise<RemoteHostStatus> => serialize(async () => attach(await deps.signIn(idToken), \"connected\"));\n\n const reconnect = (sessionBlob: string): Promise<RemoteHostStatus> =>\n serialize(async () => {\n const { restore } = deps;\n if (!restore) throw new Error(\"remote-host reconnect requires a `restore` dependency\");\n return attach(await restore(sessionBlob), \"reconnected\");\n });\n\n const disconnect = (): Promise<RemoteHostStatus> =>\n serialize(async () => {\n stopIfRunning();\n await deps.signOut();\n log.info(\"disconnected, host runner stopped\");\n return status();\n });\n\n return { connect, reconnect, disconnect, status };\n};\n","// Firebase credential exchange for the remote-host runner.\n//\n// The host authenticates to Firestore *as the user* (Option B) using the\n// Firebase JS SDK's signInWithCredential with a browser-minted Google OAuth ID\n// token — no Admin SDK, no project service account. Security rules keep the host\n// scoped to that user's own users/{uid}/… subtree.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/auth.ts. The `auth`\n// instance is a parameter so each host binds its own Firebase init; the factory\n// returns the low-level credential primitives (the connect/disconnect lifecycle\n// that starts/stops the host runner lives in createRemoteHost).\nimport { Auth, GoogleAuthProvider, signInWithCredential, signOut } from \"firebase/auth\";\n\nexport interface RemoteHostAuth {\n // Establish the Firebase session from a browser-minted Google OAuth ID token.\n // The token is used once here; the JS SDK then holds its own refresh token for\n // the process lifetime. Resolves to the authenticated uid.\n signInHost: (idToken: string) => Promise<string>;\n // Tear down the Firebase session (in-memory persistence → re-login on restart).\n signOutHost: () => Promise<void>;\n // The currently signed-in uid, or null when disconnected.\n currentUid: () => string | null;\n}\n\nexport const createRemoteHostAuth = (auth: Auth): RemoteHostAuth => ({\n signInHost: async (idToken: string): Promise<string> => {\n const credential = GoogleAuthProvider.credential(idToken);\n const userCredential = await signInWithCredential(auth, credential);\n return userCredential.user.uid;\n },\n signOutHost: (): Promise<void> => signOut(auth),\n currentUid: (): string | null => auth.currentUser?.uid ?? null,\n});\n","// A custom Firebase Auth persistence that keeps the signed-in session in memory\n// but can be seeded from — and exported back to — an opaque blob, so a host's\n// Firebase session survives a process restart by being parked in the browser's\n// localStorage (mulmoserver#50, \"case A'\"). The blob is whatever the SDK wrote\n// into persistence, round-tripped through JSON; we never interpret its VALUES,\n// so we don't couple to the SDK's serialized-user format across versions. Key\n// NAMES we do read, but only their app-name segment — see `seed`.\n//\n// **Persistence is a CLASS, not an instance.** `initializeAuth(app, { persistence })`\n// hands the value to the SDK's `_getInstance(cls)`, which asserts `cls instanceof\n// Function` (\"Expected a class definition\") and then `new cls()`. Passing a plain\n// object throws — the SDK's own `inMemoryPersistence` is likewise a class. The\n// class is defined per factory call so its instances share this call's store;\n// `type: \"LOCAL\"` makes the SDK treat it as durable and persist the user.\n//\n// Seed BEFORE `initializeAuth` — the SDK reads persistence once at init. `onChange`\n// fires when the SDK writes/removes a key (a token update that rotates the stored\n// blob, or a sign-out) — the signal to re-sync the browser copy.\nimport type { Persistence } from \"firebase/auth\";\n\n// The SDK stores values as JSON objects (a serialized user) or strings. We treat\n// them opaquely — a value is either.\ntype PersistenceValue = Record<string, unknown> | string;\n\ntype StorageListener = (value: PersistenceValue | null) => void;\n\n// The instance the SDK builds via `new` and then drives with these `_`-methods.\nexport interface HostAuthPersistenceInstance extends Persistence {\n type: \"LOCAL\";\n _isAvailable: () => Promise<boolean>;\n _set: (key: string, value: PersistenceValue) => Promise<void>;\n _get: (key: string) => Promise<PersistenceValue | null>;\n _remove: (key: string) => Promise<void>;\n _addListener: (key: string, listener: StorageListener) => void;\n _removeListener: (key: string, listener: StorageListener) => void;\n}\n\n// The class value passed to `initializeAuth`. The static `type` makes it\n// assignable to the SDK's `Persistence` (a class-valued-typed-as-instance, the\n// same trick the SDK uses for `inMemoryPersistence`); `new ()` lets the SDK — and\n// the tests — instantiate it.\nexport interface HostAuthPersistenceClass extends Persistence {\n type: \"LOCAL\";\n new (): HostAuthPersistenceInstance;\n}\n\nexport interface HostSessionPersistence {\n /** Pass to `initializeAuth(app, { persistence })`. It's a class the SDK `new`s. */\n persistence: HostAuthPersistenceClass;\n /**\n * Load a previously exported blob. Call BEFORE `initializeAuth`. `appName`\n * re-keys the blob for the app about to be opened (see `rekeyForApp`); omit\n * it to restore keys verbatim, which is what a rollback to a still-live app\n * needs.\n */\n seed: (blob: string, appName?: string) => void;\n /** Serialize the current contents, or `null` when empty (no session). */\n exportBlob: () => string | null;\n /** Notified with the fresh blob (or `null`) whenever the SDK writes/removes. */\n onChange: (listener: (blob: string | null) => void) => () => void;\n /** Drop all contents (used when tearing down the Firebase app). */\n clear: () => void;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nconst isPersistenceValue = (value: unknown): value is PersistenceValue => typeof value === \"string\" || isRecord(value);\n\n// True when `blob` is a JSON object `seed` can load. A blob that fails this can\n// never restore a session (corrupt localStorage, wrong shape), so callers treat\n// it as an expired/invalid session rather than a transient error — the client\n// then drops it instead of retrying the same doomed blob forever.\nexport const isSeedableBlob = (blob: string): boolean => {\n try {\n return isRecord(JSON.parse(blob));\n } catch {\n return false;\n }\n};\n\n// Firebase Auth namespaces every persistence key with the app that wrote it:\n// `firebase:<key>:<apiKey>:<appName>` (@firebase/auth's `_persistenceKeyName`).\n// A host opens a fresh app per connect, so a blob parked while `remote-host-2`\n// was live is invisible to the `remote-host-1` app a restarted host opens first\n// — the SDK looks up its own key, misses, and settles with no user (#3089).\n// Rewriting the app-name segment as the blob is seeded makes a parked session\n// independent of the sequence number it happened to be saved under. Only the key\n// is touched: the serialized user stays opaque, and its own `appName` field is\n// never read back by the SDK (`UserImpl._fromJSON`, @firebase/auth 1.13.3).\n// A key of any other shape is passed through untouched.\nconst FIREBASE_KEY_NAMESPACE = \"firebase\";\nconst FIREBASE_KEY_SEGMENTS = 4;\n\nconst rekeyForApp = (key: string, appName: string): string => {\n const segments = key.split(\":\");\n if (segments.length !== FIREBASE_KEY_SEGMENTS || segments[0] !== FIREBASE_KEY_NAMESPACE) return key;\n return [...segments.slice(0, FIREBASE_KEY_SEGMENTS - 1), appName].join(\":\");\n};\n\n// Re-keying is only safe while it is unambiguous, and a blob CAN carry two app\n// names: `open` keeps the previous app alive until the fresh one has validated,\n// and that app shares this store, so a token refresh in the meantime writes its\n// own key back beside the new one. Collapsing both onto one target would pick a\n// winner by JSON order — the stale app writes last — and a host would come back\n// as the account the user had just signed out of. So an ambiguous target is\n// dropped: the restore finds no user, the client is told to sign in again, and\n// that is what happened before re-keying existed.\nconst resolveKeys = (keys: string[], appName: string | undefined): Map<string, string> => {\n if (!appName) return new Map(keys.map((key) => [key, key]));\n const targets = keys.map((key) => rekeyForApp(key, appName));\n const contested = new Set(targets.filter((target, index) => targets.indexOf(target) !== index));\n return keys.reduce((resolved, key, index) => {\n const target = targets[index];\n if (target !== undefined && !contested.has(target)) resolved.set(key, target);\n return resolved;\n }, new Map<string, string>());\n};\n\n// A class (constructor) so the SDK's `_getInstance` accepts it (it asserts\n// `cls instanceof Function`, then `new cls()`); instances hold the shared\n// `store`/`notify`, so a fresh `new` still sees the seeded/live data. `static\n// type` is for the `Persistence` type bridge; the instance `type` is what the\n// SDK reads after `new`.\nconst makeHostPersistenceClass = (store: Map<string, PersistenceValue>, notify: () => void): HostAuthPersistenceClass => {\n class HostAuthPersistence {\n static readonly type = \"LOCAL\" as const;\n readonly type = \"LOCAL\" as const;\n private readonly store = store;\n private readonly notify = notify;\n private readonly external = new Set<StorageListener>();\n _isAvailable(): Promise<boolean> {\n return Promise.resolve(this.store instanceof Map);\n }\n async _set(key: string, value: PersistenceValue): Promise<void> {\n this.store.set(key, value);\n this.notify();\n }\n _get(key: string): Promise<PersistenceValue | null> {\n return Promise.resolve(this.store.get(key) ?? null);\n }\n async _remove(key: string): Promise<void> {\n this.store.delete(key);\n this.notify();\n }\n // Node has no cross-tab storage events to deliver, so registered listeners\n // never fire; we still track them so add/remove stay symmetric.\n _addListener(_key: string, listener: StorageListener): void {\n this.external.add(listener);\n }\n _removeListener(_key: string, listener: StorageListener): void {\n this.external.delete(listener);\n }\n }\n return HostAuthPersistence;\n};\n\nexport const createHostSessionPersistence = (): HostSessionPersistence => {\n const store = new Map<string, PersistenceValue>();\n const listeners = new Set<(blob: string | null) => void>();\n\n const exportBlob = (): string | null => (store.size === 0 ? null : JSON.stringify(Object.fromEntries(store)));\n\n const notify = (): void => {\n const blob = exportBlob();\n listeners.forEach((listener) => listener(blob));\n };\n\n const persistence = makeHostPersistenceClass(store, notify);\n\n const seed = (blob: string, appName?: string): void => {\n const parsed: unknown = JSON.parse(blob);\n if (!isRecord(parsed)) throw new Error(\"host session blob must be a JSON object\");\n store.clear();\n const resolved = resolveKeys(Object.keys(parsed), appName);\n for (const [key, value] of Object.entries(parsed)) {\n const target = resolved.get(key);\n if (target !== undefined && isPersistenceValue(value)) store.set(target, value);\n }\n };\n\n const onChange = (listener: (blob: string | null) => void): (() => void) => {\n listeners.add(listener);\n return () => listeners.delete(listener);\n };\n\n const clear = (): void => {\n store.clear();\n };\n\n return { persistence, seed, exportBlob, onChange, clear };\n};\n","// Firebase init for a remote-host runner.\n//\n// A host acts as a *host*: it signs in to Firebase as the user (via\n// signInWithCredential, see auth.ts) and listens to that user's command queue in\n// Firestore. The modular firebase/firestore + firebase/auth SDKs run in Node, so\n// this mirrors a browser init but also exposes Firestore (default database,\n// which must be in Native mode) and Storage.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/firebase.ts. The\n// public web config is a parameter so each host supplies its own (both hosts\n// reuse the shared mulmoserver project).\nimport { deleteApp, FirebaseApp, FirebaseOptions, initializeApp } from \"firebase/app\";\nimport { Auth, getAuth, initializeAuth } from \"firebase/auth\";\nimport { Firestore, getFirestore } from \"firebase/firestore\";\nimport { FirebaseStorage, getStorage } from \"firebase/storage\";\n\nimport { createHostSessionPersistence, type HostSessionPersistence } from \"./sessionPersistence.js\";\n\nexport interface RemoteHostFirebase {\n app: FirebaseApp;\n auth: Auth;\n firestore: Firestore;\n // Storage carries the full-res attachment bytes the command channel can't (a\n // Firestore command doc caps at ~1 MiB). The host, signed in as the user,\n // pulls each staged upload from `users/{uid}/uploads/{id}` and deletes it\n // after ingest.\n storage: FirebaseStorage;\n}\n\nexport const createRemoteHostFirebase = (config: FirebaseOptions): RemoteHostFirebase => {\n const app = initializeApp(config);\n return { app, auth: getAuth(app), firestore: getFirestore(app), storage: getStorage(app) };\n};\n\nexport interface RemoteHostSessionHandles {\n auth: Auth;\n firestore: Firestore;\n storage: FirebaseStorage;\n // The signed-in uid restored from a seed blob, or null when opened fresh\n // (before `signInHost`) or when the blob held no valid session.\n uid: string | null;\n}\n\n// A restartable Firebase session for a host, backed by the export/seed-able\n// persistence (mulmoserver#50, \"case A'\"). Because `initializeAuth` reads\n// persistence once and can run only once per app, each `open` spins up a FRESH\n// app (unique name) with the persistence seeded first, then tears down the\n// previous app — so a reconnect can restore a browser-parked session, and a\n// fresh connect starts clean. `exportSession`/`onSessionChange` expose the blob\n// the browser stores and the signal to re-sync it.\n// Runs on the freshly opened handles BEFORE the previous session is torn down.\n// Reject to keep the open non-destructive: the fresh app is rolled back and the\n// previous session is preserved (so a failed sign-in or an expired blob doesn't\n// replace a healthy live session).\nexport type RemoteHostSessionValidate = (handles: RemoteHostSessionHandles) => Promise<void>;\n\nexport interface RemoteHostSession {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => Promise<RemoteHostSessionHandles>;\n close: () => Promise<void>;\n exportSession: () => string | null;\n onSessionChange: (cb: (blob: string | null) => void) => () => void;\n}\n\nconst noop = (): void => undefined;\n\n// Run operations one-at-a-time in submission order. `open`/`close` mutate shared\n// store/app state, so overlapping calls must not interleave (they would leak an\n// app or leave `app` pointing at a torn-down instance). Mirrors the lifecycle's\n// serialization; a failed op doesn't block the next.\nconst makeSerializer = () => {\n let transition: Promise<unknown> = Promise.resolve();\n return <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n};\n\n// Build a fresh app from the (already seeded) persistence and wait for the\n// restored auth state to settle. Persistence restore is async, so `uid`\n// reflects a seeded session only after `authStateReady()` (null when the blob\n// was empty/invalid, or on a fresh connect before `signInHost`).\nconst openFreshApp = async (\n config: FirebaseOptions,\n store: HostSessionPersistence,\n name: string,\n): Promise<{ app: FirebaseApp; handles: RemoteHostSessionHandles }> => {\n // `initializeApp` registers the app in the SDK's global registry, so if init\n // then throws we must delete it — otherwise repeated reconnect failures leak\n // a registered app each time.\n const app = initializeApp(config, name);\n try {\n const auth = initializeAuth(app, { persistence: store.persistence });\n await auth.authStateReady();\n return { app, handles: { auth, firestore: getFirestore(app), storage: getStorage(app), uid: auth.currentUser?.uid ?? null } };\n } catch (error) {\n await deleteApp(app).catch(() => undefined);\n throw error;\n }\n};\n\nexport const createRemoteHostSession = (config: FirebaseOptions): RemoteHostSession => {\n const store = createHostSessionPersistence();\n let app: FirebaseApp | null = null;\n let appSeq = 0;\n const serialize = makeSerializer();\n\n const closeInner = async (): Promise<void> => {\n const previous = app;\n app = null;\n store.clear();\n if (previous) await deleteApp(previous);\n };\n\n // Run the caller's check on the fresh handles; on rejection, delete the fresh\n // app so only the caller-visible error escapes and the previous session (never\n // touched here) stays live.\n const validateOrRollback = async (nextApp: FirebaseApp, handles: RemoteHostSessionHandles, validate?: RemoteHostSessionValidate): Promise<void> => {\n if (!validate) return;\n try {\n await validate(handles);\n } catch (error) {\n await deleteApp(nextApp).catch(() => undefined);\n throw error;\n }\n };\n\n const openInner = async (seedBlob?: string, validate?: RemoteHostSessionValidate): Promise<RemoteHostSessionHandles> => {\n // Non-destructive: keep the current session intact until the fresh app is\n // proven to come up AND pass `validate` (sign-in / uid). A bad seed blob, a\n // failed init, or a rejected validation must not tear down a healthy session\n // — the reconnect contract (mulmoserver#50). So we tear the previous app\n // down only AFTER success, and roll the store back on failure.\n const previousApp = app;\n const previousBlob = store.exportBlob();\n appSeq += 1;\n // `appSeq` restarts at 0 each process and counts failed opens too, so the\n // name a blob was parked under is meaningless to a later run. `seed` re-keys\n // the blob to the app we are opening now, which is what makes a parked\n // session restorable at all (#3089).\n const appName = `remote-host-${appSeq}`;\n try {\n store.clear();\n if (seedBlob) store.seed(seedBlob, appName);\n const { app: nextApp, handles } = await openFreshApp(config, store, appName);\n await validateOrRollback(nextApp, handles, validate);\n app = nextApp;\n if (previousApp) await deleteApp(previousApp).catch(() => undefined);\n return handles;\n } catch (error) {\n // Verbatim: the previous app is still live and reads the keys the blob\n // already carries, so re-keying here would hide its own session from it.\n store.clear();\n if (previousBlob) store.seed(previousBlob);\n throw error;\n }\n };\n\n return {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => serialize(() => openInner(seedBlob, validate)),\n close: () => serialize(closeInner),\n exportSession: store.exportBlob,\n onSessionChange: store.onChange,\n };\n};\n"],"mappings":";;;;;;;;;AA4BA,IAAM,iBAAiB,UAA2B;CAGhD,MAAM,QAAiB,OAAO,eAAe,KAAK;CAClD,OAAO,UAAU,OAAO,aAAa,UAAU;AACjD;AAEA,IAAM,cAAc,UAAoC,OAAO,UAAU,YAAY,UAAU,SAAS,MAAM,QAAQ,KAAK,KAAK,cAAc,KAAK;AAEnJ,IAAM,aAAa,QAAgB,QAAkC,SAAS,GAAG,OAAO,GAAG,QAAQ,OAAO,GAAG;AAU7G,IAAM,kBAAqB,OAAe,WAA4B,MAAc,SAAqB;CACvG,IAAI,UAAU,IAAI,KAAK,GAAG,MAAM,IAAI,MAAM,yBAAyB,QAAA,SAAkB,iCAAiC;CACtH,UAAU,IAAI,KAAK;CACnB,IAAI;EACF,OAAO,KAAK;CACd,UAAU;EACR,UAAU,OAAO,KAAK;CACxB;AACF;AAEA,IAAM,WAAW,OAAgB,QAAgB,cAAyC;CACxF,IAAI,UAAU,KAAA,GAAW,OAAO,CAAC,UAAA,QAAmB;CACpD,IAAI,CAAC,WAAW,KAAK,GAAG,OAAO,CAAC;CAChC,OAAO,eAAe,OAAO,WAAW,cAGtC,MAAM,QAAQ,KAAK,IACf,MAAM,KAAK,KAAK,CAAC,CAAC,SAAS,MAAM,UAAU,QAAQ,MAAM,UAAU,QAAQ,KAAK,GAAG,SAAS,CAAC,IAC7F,OAAO,QAAQ,KAAK,CAAC,CAAC,SAAS,CAAC,KAAK,UAAU,QAAQ,MAAM,UAAU,QAAQ,GAAG,GAAG,SAAS,CAAC,CACrG;AACF;;;AAIA,IAAa,kBAAkB,OAAgB,SAAS,OAAiB,QAAQ,OAAO,wBAAQ,IAAI,QAAQ,CAAC;AAE7G,IAAM,cAAc,OAAgB,QAAgB,cAAwC;CAG1F,IAAI,UAAU,KAAA,GAAW,OAAO;CAGhC,IAAI,CAAC,WAAW,KAAK,GAAG,OAAO;CAC/B,OAAO,eAAe,OAAO,WAAW,cACtC,MAAM,QAAQ,KAAK,IACf,MAAM,KAAK,QAAQ,MAAM,UAAU,WAAW,MAAM,UAAU,QAAQ,KAAK,GAAG,SAAS,CAAC,IACxF,OAAO,YACL,OAAO,QAAQ,KAAK,CAAC,CAAC,SAAS,CAAC,KAAK,UAAW,SAAS,KAAA,IAAY,CAAC,IAAI,CAAC,CAAC,KAAK,WAAW,MAAM,UAAU,QAAQ,GAAG,GAAG,SAAS,CAAC,CAAC,CAAE,CACzI,CACN;AACF;;;;;;;AAQA,IAAa,kBAAkB,UAA4B,WAAW,OAAO,oBAAI,IAAI,QAAQ,CAAC;;;;AAK9F,IAAa,sBAAsB,MAAc,YAA6B;CAC5E,MAAM,WAAW,KAAK,MAAM,GAAG;CAC/B,MAAM,SAAS,QAAQ,MAAM,GAAG;CAChC,OAAO,SAAS,WAAW,OAAO,UAAU,OAAO,OAAO,SAAS,UAAU,YAAY,OAAO,YAAY,SAAS,MAAM;AAC7H;;;;;;;;;;;;AAaA,IAAa,mBAAmB,OAA0B,WAA8B,CAAC,MACvF,MAAM,QAAQ,SAAS,CAAC,SAAS,MAAM,YAAY,mBAAmB,MAAM,OAAO,CAAC,CAAC;;;;;ACzGvF,IAAa,uBAA+B,gBAAA,YAAY,IAAI;;;ACe5D,IAAa,uBAAuB;AAqBpC,IAAM,eAAa,UAA4B,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAOpG,IAAM,UAAU,QAAoB,YAA6C;CAC/E,IAAI;EACF,OAAO;CACT,SAAS,OAAO;EAId,UAAU,KAAK;CACjB;AACF;AAEA,IAAa,sBAAsB,SAAyC;CAC1E,MAAM,MAAM,KAAK,OAAO;CAIxB,MAAM,QAAQ;EAAE,WAAW,IAAI;EAAG,eAAe;CAAE;CAEnD,MAAM,YAAY,WAA0B;EAC1C,MAAM,QAAQ,UAAmB,aAAa,KAAK,QAAQ,YAAU,KAAK,CAAC,CAAC;EAC5E,MAAM,YACJ,aAAa;GACX,MAAM,YAAY,IAAI;GACtB,MAAM,gBAAgB;EACxB,CAAC;EAIH,aAAa;GACX,KAAK,MAAM,MAAM,CAAC,CAAC,KAAK,KAAK,IAAI;EACnC,GAAG,IAAI;CACT;CAEA,MAAM,aAAmB;EACvB,MAAM,iBAAiB;EAGvB,IAAI,MAAM,iBAAiB,KAAK,iBAAiB;GAC/C,aAAa,KAAK,QAAQ,IAAI,IAAI,MAAM,SAAS,CAAC;GAClD;EACF;EACA,SAAS,IAAI;CACf;CAEA,OAAO;EAAE;EAAU;CAAK;AAC1B;;;ACpDA,IAAa,uBAAuB;AA4CpC,IAAM,eAAa,KAAA;AAGnB,IAAM,cAAc,KAAwB,MAAc,aAAA,GACxD,mBAAA,UAAA,CAAU,KAAK;CAAE,QAAQ;CAAS,OAAO;EAAE;EAAM;CAAQ;CAAG,YAAA,GAAW,mBAAA,gBAAA,CAAgB;AAAE,CAAC,CAAC,CAAC,MAAM,MAAI;AAaxG,IAAa,aAAa,SAAgC;CACxD,IAAI,CAAC,aAAA,SAAS,IAAI,KAAK,KAAK,WAAW,UAAU,OAAO;CACxD,OAAO;EAAE,QAAQ,OAAO,KAAK,WAAW,WAAW,KAAK,SAAS;EAAI,QAAQ,aAAA,SAAS,KAAK,MAAM,IAAI,KAAK,SAAS,CAAC;CAAE;AACxH;AAIA,IAAM,gBAAgB,WAAsB,SAAA,GAC1C,mBAAA,eAAA,CAAe,WAAW,OAAO,QAAQ;CACvC,MAAM,QAAQ,WAAW,MAAM,IAAI,IAAI,GAAG,EAAA,CAAG,KAAK,CAAC;CACnD,IAAI,CAAC,OACH,OAAO;CAET,IAAI,OAAO,KAAK;EAAE,QAAQ;EAAc,YAAA,GAAW,mBAAA,gBAAA,CAAgB;CAAE,CAAC;CACtE,OAAO;AACT,CAAC;AAKH,IAAa,yBAAyB,UAA2B,WAC/D,OAAO,OAAO,UAAU,MAAM,IAAI,SAAS,UAAU,KAAA;AAOvD,IAAM,kBAAkB,SAAmB,OAAc,YAAqC;CAC5F,IAAI,QAAQ,WAAW,GAAG;CAC1B,MAAM,QAAQ,QAAQ,KAAK,SAAS,UAAU,MAAM,CAAC,CAAC,KAAK,IAAI;CAC/D,QAAQ,UAAU;EAAE,OAAO;EAAS,QAAQ,MAAM;EAAQ,SAAS,wBAAwB,MAAM;CAAiD,CAAC;AACrJ;AAEA,IAAM,aAAa,OAAO,KAAwB,OAAc,SAAyB,YAAmD;CAC1I,IAAI;EAIF,MAAM,WAAW,MAAM,QAAQ,oBAAA,iBAAiB,MAAM,MAAM,CAAC;EAC7D,MAAM,UAAU,eAAe,QAAQ;EACvC,eAAe,gBAAgB,SAAS,QAAQ,oBAAoB,MAAM,OAAO,GAAG,OAAO,OAAO;EAGlG,OAAA,GAAM,mBAAA,UAAA,CAAU,KAAK;GAAE,QAAQ;GAAQ,QAAQ,QAAQ,WAAW,IAAI,WAAW,eAAe,QAAQ;GAAG,YAAA,GAAW,mBAAA,gBAAA,CAAgB;EAAE,CAAC;EACzI,OAAO;GAAE,OAAO;GAAQ,QAAQ,MAAM;EAAO;CAC/C,SAAS,OAAO;EACd,MAAM,UAAU,aAAA,aAAa,KAAK;EAClC,MAAM,WAAW,KAAK,iBAAiB,OAAO;EAC9C,OAAO;GAAE,OAAO;GAAS,QAAQ,MAAM;GAAQ;EAAQ;CACzD;AACF;AAOA,IAAM,gBAAgB,OAAO,KAAwB,SAAkB,SAA4B,QAAgB;CACjH,IAAI;EACF,MAAM,QAAQ,WAAW,SAAS,GAAG;CACvC,SAAS,OAAO;EACd,QAAQ,UAAU;GAAE,OAAO;GAAS,QAAQ,QAAQ;GAAQ,SAAS,oBAAoB,aAAA,aAAa,KAAK;EAAI,CAAC;CAClH;CAIA,OAAA,GAAM,mBAAA,UAAA,CAAU,GAAG,CAAC,CAAC,OAAO,UAAU;EACpC,QAAQ,UAAU;GAAE,OAAO;GAAS,QAAQ,QAAQ;GAAQ,SAAS,yBAAyB,aAAA,aAAa,KAAK;EAAI,CAAC;CACvH,CAAC;CACD,QAAQ,UAAU;EAAE,OAAO;EAAQ,QAAQ,QAAQ;EAAQ,SAAS;CAAU,CAAC;AACjF;AAYA,IAAM,iBAAiB,OAAO,KAAoB,KAAwB,SAAkB,QAAgB;CAC1G,MAAM,EAAE,UAAU,YAAY;CAE9B,IAAI,oBAAA,UAAU,SAAS,GAAG,GAAG;EAC3B,MAAM,cAAc,KAAK,SAAS,SAAS,IAAI,GAAG;EAClD;CACF;CACA,MAAM,QAAQ,MAAM,aAAa,IAAI,WAAW,GAAG;CACnD,IAAI,CAAC,OACH;CAEF,QAAQ,UAAU;EAAE,OAAO;EAAY,QAAQ,MAAM;CAAO,CAAC;CAC7D,MAAM,UAAU,sBAAsB,UAAU,MAAM,MAAM;CAC5D,IAAI,CAAC,SAAS;EACZ,MAAM,WAAW,KAAK,kBAAkB,0BAA0B,MAAM,QAAQ;EAChF,QAAQ,UAAU;GAAE,OAAO;GAAS,QAAQ,MAAM;GAAQ,SAAS;EAAiB,CAAC;EACrF;CACF;CACA,QAAQ,UAAU,MAAM,WAAW,KAAK,OAAO,SAAS,OAAO,CAAC;AAClE;AAqBA,IAAM,yBAAyB,KAAoB,aAAkC;CAGnF,MAAM,MAAM,KAAK,IAAI;CACrB,SACG,WAAW,CAAC,CACZ,QAAQ,WAAW,OAAO,SAAS,OAAO,CAAC,CAS3C,KAAK,YAAY;EAAE,KAAK,OAAO,IAAI;EAAK,SAAS,OAAO,IAAI,KAAK;CAAa,EAAE,CAAC,CACjF,MAAM,MAAM,UAAU,oBAAA,YAAY,KAAK,SAAS,MAAM,OAAO,CAAC,CAAC,CAC/D,SAAS,EAAE,KAAK,cAAc;EAC7B,eAAe,KAAK,KAAK,SAAS,GAAG,CAAC,CAAC,MAAM,MAAI;CACnD,CAAC;AACL;AAGA,SAAS,oBAAoB,KAAwB;CACnD,IAAI,aAAa,iBAAiB,kBAAkB,GAAG,GAAG,eAAA,eAAe,IAAI,OAAO,CAAC;CACrF,IAAI,WAAW;AACjB;AAKA,SAAS,kBAAkB,KAAkB,OAA6B;CACxE,IAAI,IAAI,QAAQ,UAAU;EAAE,OAAO;EAAS,QAAQ;EAAU,SAAS,MAAM;CAAQ,CAAC;CACtF,IAAI,IAAI,SAAS;CACjB,MAAM,MAAM,eAAe;CAC3B,IAAI,gBAAgB;CACpB,IAAI,eAAA,sBAAsB,KAAK,MAAM,WAAW,eAAA,sBAAsB,IAAI,aAAa,GAAG,GAAG;EAC3F,IAAI,UAAU;EACd;CACF;CACA,oBAAoB,GAAG;AACzB;AAEA,SAAS,kBAAkB,KAAwB;CACjD,IAAI,aAAa;CACjB,IAAI,IAAI,SAAS;CACjB,IAAI,eAAA,GAAc,mBAAA,WAAA,CAChB,IAAI,iBACH,aAAa;EAGZ,IAAI,UAAU;EACd,IAAI,cAAc;EAClB,sBAAsB,IAAI,KAAK,QAAQ;CACzC,IACC,UAAU,kBAAkB,KAAK,KAAK,CACzC;AACF;AAKA,IAAM,qBAAqB,gBAAuB,KAAoB,cAAwC;CAC5G,MAAM,MAAmB;EAAE;EAAgB;EAAK;EAAW,SAAS;EAAO,aAAa;EAAM,YAAY;EAAM,SAAS;EAAG,aAAa;CAAK;CAC9I,kBAAkB,GAAG;CACrB,aAAa;EACX,IAAI,UAAU;EACd,IAAI,IAAI,YAAY,aAAa,IAAI,UAAU;EAC/C,IAAI,YAAY;CAClB;AACF;AAWA,IAAM,eAAe,YAAuC,QAAQ,eAAA;AAOpE,IAAa,wBAAwB,UAA6B,CAAC,MAAc,YAAY,OAAO,IAAA;AAMpG,IAAM,qBACJ,WACA,SACA,UACA,SACA,YACiB;CACjB,MAAM,WAAW,oBAAA,QAAQ,WAAW,OAAO;CAC3C,MAAM,UAAU,YAAoB,QAAQ,UAAU;EAAE,OAAO;EAAS,QAAQ;EAAY;CAAQ,CAAC;CACrG,OAAO,mBAAmB;EACxB,QAAQ,YAAA,GAAW,mBAAA,OAAA,CAAO,UAAU;GAAE,GAAG,oBAAA,kBAAkB,SAAS,UAAU,MAAM;GAAG,YAAA,GAAW,mBAAA,gBAAA,CAAgB;EAAE,CAAC;EACrH,UAAU,YAAY,OAAO,0BAA0B,SAAS;EAChE,UAAU,aAAa;GACrB,OAAO,kDAAwE,KAAK,MAAM,WAAW,GAAK,EAAE,qCAAqC;GACjJ,QAAQ;EACV;EACA,iBAAA;CACF,CAAC;AACH;AAKA,IAAM,YAAY,KAAc,aAAiC;CAC/D,IAAI,IAAI,QAAQ;CAChB,IAAI,SAAS;CACb,IAAI,IAAI,MAAM,cAAc,IAAI,IAAI;CACpC,IAAI,OAAO;CACX,SAAS,SAAS,KAAK;CACvB,IAAI,cAAc;AACpB;AAMA,IAAa,mBAAmB,WAAsB,SAAkB,UAA2B,UAA6B,CAAC,MAAoB;CACnJ,MAAM,MAAe;EAAE,MAAM;EAAM,eAAe;EAAM,QAAQ;CAAM;CACtE,MAAM,WAAW,kBAAkB,WAAW,SAAS,UAAU,SAAS,SAAS;CAInF,SAAS,YAAkB;EACzB,IAAI,IAAI,QAAQ;EAChB,SAAS,KAAK,QAAQ;EACtB,QAAQ,WAAW;CACrB;CAEA,SAAS,SAAS,IAAI;CACtB,IAAI,OAAO,YAAY,SAAS,MAAM,YAAY,OAAO,CAAC;CAI1D,IAAI,gBAAgB,mBAFd,GAAiB,mBAAA,MAAA,CAAM,oBAAA,mBAAmB,WAAW,OAAO,IAAA,GAAG,mBAAA,MAAA,CAAM,UAAU,MAAM,QAAQ,CAE7D,GAAgB;EADzB;EAAW;EAAU;EAAS,KAAK,QAAQ;CAClB,GAAK,SAAS;CAEpE,aAAa,SAAS,KAAK,QAAQ;AACrC;;;ACnWA,IAAM,kBAAgB;AACtB,IAAM,gBAAgB,KAAK;AAE3B,IAAM,oBAAoB;AAC1B,IAAM,mBAAmB;AAEzB,IAAM,YAAY;AAGlB,IAAM,aAAa,IAAI;AAGvB,IAAM,oBAAoB,KAAK;AAI/B,IAAM,gBAAgB;AAEtB,IAAa,oBAAoB,YAA4B,KAAK,IAAI,kBAAkB,oBAAoB,KAAK,OAAO;AA2CxH,IAAM,aAAa,UAA4B,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AAEpG,IAAM,uBAAuB,MAAkB,YAAiC;CAC9E,MAAM,QAAQ,WAAW,MAAM,OAAO;CACtC,aAAa,aAAa,KAAK;AACjC;AAOA,IAAM,YAAY,KAAoB,SAAkC;CACtE,IAAI,QAAQ;CACZ,IAAI;EACF,IAAI,KAAK,WAAW;GAAE,OAAO;GAAM,WAAW,IAAI;GAAW,WAAW,IAAI,QAAQ;EAAE,CAAC;CACzF,SAAS,OAAO;EACd,IAAI,KAAK,IAAI,KAAK,sCAAsC,UAAU,KAAK,GAAG;CAC5E;AACF;AAEA,IAAM,cAAc,QAA6B;CAC/C,IAAI,cAAc;CAClB,IAAI,cAAc;AACpB;AAEA,IAAM,cAAc,QAA6B;CAC/C,IAAI,cAAc;CAClB,IAAI,cAAc;AACpB;AAUA,IAAM,SAAS,KAAoB,SAAiB,SAAkC;CACpF,MAAM,QAAQ,IAAI,IAAI,IAAI;CAC1B,OAAO,IAAI,eAAe;EACxB,MAAM,cAAc,IAAI,IAAI,IAAI;EAChC,IAAI,cAAc,eAAe;GAG/B,IAAI,KAAK,IAAI,KAAK,+BAA+B,KAAK,MAAM,cAAc,eAAa,EAAE,0CAA0C;GACnI,IAAI,cAAc;GAClB,IAAI,UAAU;EAChB;EACA,KAAK;CACP,GAAG,OAAO;AACZ;AAIA,SAAS,cAAc,KAA0B;CAC/C,IAAI,IAAI,WAAW,CAAC,IAAI,KAAK,YAAY;CACzC,WAAW,GAAG;CACd,IAAI,cAAc,MAAM,KAAK,yBAAyB,KAAK,SAAS,GAAG,CAAC;AAC1E;AASA,IAAM,eAAe;AAErB,eAAe,SAAS,KAA6C;CACnE,IAAI;EACF,OAAO;GAAE,OAAQ,MAAM,IAAI,KAAK,aAAa,KAAM;GAAM,QAAQ;EAAa;CAChF,SAAS,OAAO;EAGd,OAAO;GAAE,OAAO;GAAO,QAAQ,0BAA0B,UAAU,KAAK;EAAI;CAC9E;AACF;AAEA,eAAe,SAAS,KAAmC;CACzD,IAAI,cAAc;CAClB,IAAI,IAAI,WAAW,IAAI,UAAU,UAAU;CAC3C,MAAM,SAAS,MAAM,SAAS,GAAG;CAEjC,IAAI,IAAI,WAAW,IAAI,UAAU,UAAU;CAC3C,IAAI,OAAO,UAAU,OAAO;EAC1B,IAAI,YAAY,OAAO;EACvB,IAAI,KAAK,IAAI,KAAK,eAAe,OAAO,QAAQ;EAChD,mBAAmB,GAAG;EACtB;CACF;CACA,cAAc,GAAG;AACnB;AAKA,IAAM,qBAAqB,QAA6B;CACtD,MAAM,OAAO,IAAI;CACjB,IAAI,iBAAiB;CACrB,IAAI;EACF,OAAO;CACT,SAAS,OAAO;EACd,IAAI,KAAK,IAAI,KAAK,gCAAgC,UAAU,KAAK,GAAG;CACtE;AACF;AAIA,IAAM,iBAAiB,QAA6B;CAClD,IAAI,cAAc;CAClB,IAAI,UAAU;CAGd,IAAI,YAAY;CAChB,IAAI,IAAI,UAAU,UAAU;CAC5B,IAAI,KAAK,IAAI,KAAK,2BAA2B;CAC7C,SAAS,KAAK,QAAQ;CACtB,cAAc,GAAG;AACnB;AAKA,eAAe,OAAO,KAAmC;CACvD,IAAI,cAAc;CAClB,IAAI,IAAI,SAAS;CACjB,MAAM,SAAS,MAAM,SAAS,GAAG;CACjC,IAAI,IAAI,SAAS;CACjB,IAAI,OAAO,UAAU,OAAO;EAC1B,IAAI,YAAY,OAAO;EACvB,IAAI,KAAK,IAAI,KAAK,6CAA6C,OAAO,QAAQ;EAC9E,mBAAmB,GAAG;EACtB;CACF;CACA,cAAc,GAAG;AACnB;AAEA,IAAM,UAAU,QAA6B;CAC3C,IAAI,UAAU;CACd,WAAW,GAAG;CACd,IAAI,KAAK,IAAI,KAAK,+BAA+B,KAAK,MAAM,aAAa,eAAa,EAAE,gBAAgB,IAAI,aAAa,oBAAoB,EAAE;CAC/I,SAAS,KAAK,SAAS;CACvB,IAAI,KAAK,QAAQ,WAAW;AAC9B;AAEA,SAAS,iBAAiB,KAA0B;CAClD,MAAM,UAAU,iBAAiB,IAAI,OAAO;CAC5C,IAAI,WAAW;CACf,IAAI,KAAK,IAAI,KAAK,uBAAuB,IAAI,aAAa,oBAAoB,uBAAuB,KAAK,MAAM,UAAU,eAAa,EAAE,EAAE;CAG3I,IAAI,IAAI,UAAU,gBAAgB,SAAS,KAAK,cAAc;CAC9D,IAAI,cAAc,MAAM,KAAK,eAAe,OAAO,GAAG,CAAC;AACzD;AAEA,SAAS,mBAAmB,KAA0B;CACpD,IAAI,IAAI,SAAS;CACjB,kBAAkB,GAAG;CACrB,WAAW,GAAG;CACd,WAAW,GAAG;CACd,IAAI,gBAAgB,IAAI,IAAI;CAC5B,IAAI,IAAI,IAAI,IAAI,IAAI,eAAe,YAAY,OAAO,GAAG;MACpD,iBAAiB,GAAG;AAC3B;AAKA,IAAM,kCAAkB,IAAI,IAAI,CAAC,UAAU,UAAU,CAAC;AAEtD,IAAM,iBAAiB,SAA2C;CAChE,GAAG,IAAI,KAAK;CACZ,UAAU,UAAU;EAGlB,IAAI,MAAM,UAAU,SAAS;GAC3B,MAAM,OAAO,GAAG,MAAM,OAAO,IAAI,MAAM,WAAW;GAClD,IAAI,gBAAgB,IAAI,MAAM,MAAM,GAAG,IAAI,YAAY;GACvD,IAAI,KAAK,IAAI,KAAK,6BAA6B,MAAM;EACvD;EACA,IAAI,KAAK,QAAQ,UAAU,KAAK;CAClC;CACA,gBAAgB,mBAAmB,GAAG;AACxC;AAEA,SAAS,OAAO,KAA0B;CACxC,IAAI,cAAc;CAClB,IAAI,IAAI,SAAS;CACjB,IAAI;EACF,IAAI,iBAAiB,IAAI,KAAK,MAAM,cAAc,GAAG,CAAC;CACxD,SAAS,OAAO;EAEd,IAAI,YAAY,UAAU,KAAK;EAC/B,mBAAmB,GAAG;EACtB;CACF;CACA,IAAI,cAAc,MAAM,KAAK,iBAAiB,KAAK,OAAO,GAAG,CAAC;AAChE;AAEA,SAAgB,yBAAyB,MAA2C;CAClF,MAAM,MAAqB;EACzB;EACA,UAAU,KAAK,YAAY;EAC3B,KAAK,KAAK,OAAO;EACjB,SAAS,KAAK,WAAW,KAAK;EAC9B,gBAAgB;EAChB,aAAa;EACb,SAAS;EACT,aAAa;EACb,WAAW;EACX,OAAO;EACP,SAAS;EACT,aAAa;CACf;CAGA,SAAS,KAAK,QAAQ;CACtB,OAAO,GAAG;CACV,cAAc,GAAG;CAEjB,aAAa;EACX,IAAI,UAAU;EACd,WAAW,GAAG;EACd,WAAW,GAAG;EACd,kBAAkB,GAAG;CACvB;AACF;;;AC5SA,IAAM,gBAAgB;AAKtB,IAAa,oBAAoB,qBAAqB;AAKtD,IAAM,mBAAmB,KAAK;AAO9B,IAAM,eAAe,UAAuD,cAAc,SAAS,OAAO,QAAQ,IAAI,OAAO,UAAU,MAAM;AAE7I,IAAM,YAAY,UAAkC;CAClD,IAAI,OAAO,UAAU,UAAU,OAAO;CAEtC,IAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,YAAY,KAAK,GAAG,OAAO,MAAM,SAAS;CAC7F,OAAO;AACT;;;AAIA,IAAa,mBAAmB,MAA2C,KAAa,eAAuB,sBAAgC;CAC7I,IAAI,CAAC,MAAM,OAAO;CAClB,MAAM,YAAY,SAAS,KAAK,SAAS;CAGzC,IAAI,cAAc,MAAM,OAAO;CAG/B,IAAI,KAAK,WAAW,OAAO,OAAO;CAClC,OAAO,MAAM,YAAY;AAC3B;;AAGA,IAAa,cAAc,OAAU,MAAkB,cAAkC;CACvF,MAAM,QAA0D,EAAE,QAAQ,KAAK;CAC/E,MAAM,WAAW,IAAI,SAAgB,GAAG,WAAW;EACjD,MAAM,SAAS,iBAAiB,uBAAO,IAAI,MAAM,uCAAuC,KAAK,MAAM,YAAY,aAAa,EAAE,EAAE,CAAC,GAAG,SAAS;CAC/I,CAAC;CACD,IAAI;EACF,OAAO,MAAM,QAAQ,KAAK,CAAC,MAAM,QAAQ,CAAC;CAC5C,UAAU;EACR,IAAI,MAAM,QAAQ,aAAa,MAAM,MAAM;CAC7C;AACF;;AAaA,IAAa,uBAAuB,SAAuD;CACzF,MAAM,MAAM,KAAK,OAAO,KAAK;CAC7B,OAAO,YAAY;EAIjB,OAAO,iBAAgB,MADA,aAAA,GAAY,mBAAA,iBAAA,CAAiB,oBAAA,QAAQ,KAAK,UAAU,GAAG,KAAK,OAAO,CAAC,GAAG,KAAK,aAAa,gBAAgB,EACzG,CAAS,KAAK,GAAG,IAAI,GAAG,KAAK,gBAAgB,iBAAiB;CACvF;AACF;;;AC3BA,IAAM,eAAa,KAAA;AACnB,IAAM,eAAiC;CAAE,MAAM;CAAM,MAAM;CAAM,OAAO;AAAK;AAK7E,IAAM,aAAa,UAA8B,MAAM,UAAU,GAAG,MAAM,MAAM,GAAG,MAAM,OAAO,KAAK,MAAM,YAAY,GAAG,MAAM,MAAM,GAAG,MAAM;AAE/I,IAAa,oBAAoB,SAA8C;CAC7E,MAAM,MAAM,KAAK,OAAO;CAIxB,IAAI,aAAkC;CAMtC,IAAI,aAA+B,QAAQ,QAAQ;CAEnD,MAAM,aAAgB,cAA4C;EAChE,MAAM,OAAO,WAAW,KAAK,WAAW,SAAS;EACjD,aAAa,KAAK,KAAK,QAAM,MAAI;EACjC,OAAO;CACT;CAEA,MAAM,sBAAsB;EAC1B,IAAI,YAAY;GACd,WAAW;GACX,aAAa;EACf;CACF;CAEA,MAAM,gBAAkC;EAAE,WAAW,eAAe;EAAM,KAAK,KAAK,WAAW;CAAE;CAIjG,MAAM,UAAU,KAAa,SAAmC;EAC9D,cAAc;EACd,MAAM,SAAS,KAAK,YAAY;GAAE;GAAK,QAAQ,KAAK;EAAO,GAAG,KAAK,UAAU;GAC3E,UAAU,UAAU,IAAI,MAAM,eAAe,UAAU,KAAK,GAAG;GAC/D,UAAU,KAAK;GAIf,gBAAgB;IACd,IAAI,eAAe,QAAQ;KACzB,aAAa;KACb,IAAI,KAAK,gDAAgD;IAC3D;GACF;EACF,CAAC;EACD,aAAa;EACb,IAAI,KAAK,GAAG,KAAK,MAAM,IAAI,gCAAgC,KAAK,OAAO,EAAE;EACzE,OAAO,OAAO;CAChB;CAEA,MAAM,WAAW,YAA+C,UAAU,YAAY,OAAO,MAAM,KAAK,OAAO,OAAO,GAAG,WAAW,CAAC;CAErI,MAAM,aAAa,gBACjB,UAAU,YAAY;EACpB,MAAM,EAAE,YAAY;EACpB,IAAI,CAAC,SAAS,MAAM,IAAI,MAAM,uDAAuD;EACrF,OAAO,OAAO,MAAM,QAAQ,WAAW,GAAG,aAAa;CACzD,CAAC;CAEH,MAAM,mBACJ,UAAU,YAAY;EACpB,cAAc;EACd,MAAM,KAAK,QAAQ;EACnB,IAAI,KAAK,mCAAmC;EAC5C,OAAO,OAAO;CAChB,CAAC;CAEH,OAAO;EAAE;EAAS;EAAW;EAAY;CAAO;AAClD;;;ACnHA,IAAa,wBAAwB,UAAgC;CACnE,YAAY,OAAO,YAAqC;EACtD,MAAM,aAAa,cAAA,mBAAmB,WAAW,OAAO;EAExD,QAAO,OAAA,GADsB,cAAA,qBAAA,CAAqB,MAAM,UAAU,EAAA,CAC5C,KAAK;CAC7B;CACA,oBAAA,GAAkC,cAAA,QAAA,CAAQ,IAAI;CAC9C,kBAAiC,KAAK,aAAa,OAAO;AAC5D;;;ACgCA,IAAM,YAAY,UAAqD,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAE1I,IAAM,sBAAsB,UAA8C,OAAO,UAAU,YAAY,SAAS,KAAK;AAMrH,IAAa,kBAAkB,SAA0B;CACvD,IAAI;EACF,OAAO,SAAS,KAAK,MAAM,IAAI,CAAC;CAClC,QAAQ;EACN,OAAO;CACT;AACF;AAYA,IAAM,yBAAyB;AAC/B,IAAM,wBAAwB;AAE9B,IAAM,eAAe,KAAa,YAA4B;CAC5D,MAAM,WAAW,IAAI,MAAM,GAAG;CAC9B,IAAI,SAAS,WAAW,yBAAyB,SAAS,OAAO,wBAAwB,OAAO;CAChG,OAAO,CAAC,GAAG,SAAS,MAAM,GAAG,CAAyB,GAAG,OAAO,CAAC,CAAC,KAAK,GAAG;AAC5E;AAUA,IAAM,eAAe,MAAgB,YAAqD;CACxF,IAAI,CAAC,SAAS,OAAO,IAAI,IAAI,KAAK,KAAK,QAAQ,CAAC,KAAK,GAAG,CAAC,CAAC;CAC1D,MAAM,UAAU,KAAK,KAAK,QAAQ,YAAY,KAAK,OAAO,CAAC;CAC3D,MAAM,YAAY,IAAI,IAAI,QAAQ,QAAQ,QAAQ,UAAU,QAAQ,QAAQ,MAAM,MAAM,KAAK,CAAC;CAC9F,OAAO,KAAK,QAAQ,UAAU,KAAK,UAAU;EAC3C,MAAM,SAAS,QAAQ;EACvB,IAAI,WAAW,KAAA,KAAa,CAAC,UAAU,IAAI,MAAM,GAAG,SAAS,IAAI,KAAK,MAAM;EAC5E,OAAO;CACT,mBAAG,IAAI,IAAoB,CAAC;AAC9B;AAOA,IAAM,4BAA4B,OAAsC,WAAiD;CACvH,MAAM,oBAAoB;EACxB,OAAgB,OAAO;EACvB,OAAgB;EAChB,QAAyB;EACzB,SAA0B;EAC1B,2BAA4B,IAAI,IAAqB;EACrD,eAAiC;GAC/B,OAAO,QAAQ,QAAQ,KAAK,iBAAiB,GAAG;EAClD;EACA,MAAM,KAAK,KAAa,OAAwC;GAC9D,KAAK,MAAM,IAAI,KAAK,KAAK;GACzB,KAAK,OAAO;EACd;EACA,KAAK,KAA+C;GAClD,OAAO,QAAQ,QAAQ,KAAK,MAAM,IAAI,GAAG,KAAK,IAAI;EACpD;EACA,MAAM,QAAQ,KAA4B;GACxC,KAAK,MAAM,OAAO,GAAG;GACrB,KAAK,OAAO;EACd;EAGA,aAAa,MAAc,UAAiC;GAC1D,KAAK,SAAS,IAAI,QAAQ;EAC5B;EACA,gBAAgB,MAAc,UAAiC;GAC7D,KAAK,SAAS,OAAO,QAAQ;EAC/B;CACF;CACA,OAAO;AACT;AAEA,IAAa,qCAA6D;CACxE,MAAM,wBAAQ,IAAI,IAA8B;CAChD,MAAM,4BAAY,IAAI,IAAmC;CAEzD,MAAM,mBAAmC,MAAM,SAAS,IAAI,OAAO,KAAK,UAAU,OAAO,YAAY,KAAK,CAAC;CAE3G,MAAM,eAAqB;EACzB,MAAM,OAAO,WAAW;EACxB,UAAU,SAAS,aAAa,SAAS,IAAI,CAAC;CAChD;CAEA,MAAM,cAAc,yBAAyB,OAAO,MAAM;CAE1D,MAAM,QAAQ,MAAc,YAA2B;EACrD,MAAM,SAAkB,KAAK,MAAM,IAAI;EACvC,IAAI,CAAC,SAAS,MAAM,GAAG,MAAM,IAAI,MAAM,yCAAyC;EAChF,MAAM,MAAM;EACZ,MAAM,WAAW,YAAY,OAAO,KAAK,MAAM,GAAG,OAAO;EACzD,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,GAAG;GACjD,MAAM,SAAS,SAAS,IAAI,GAAG;GAC/B,IAAI,WAAW,KAAA,KAAa,mBAAmB,KAAK,GAAG,MAAM,IAAI,QAAQ,KAAK;EAChF;CACF;CAEA,MAAM,YAAY,aAA0D;EAC1E,UAAU,IAAI,QAAQ;EACtB,aAAa,UAAU,OAAO,QAAQ;CACxC;CAEA,MAAM,cAAoB;EACxB,MAAM,MAAM;CACd;CAEA,OAAO;EAAE;EAAa;EAAM;EAAY;EAAU;CAAM;AAC1D;;;ACjKA,IAAa,4BAA4B,WAAgD;CACvF,MAAM,OAAA,GAAM,aAAA,cAAA,CAAc,MAAM;CAChC,OAAO;EAAE;EAAK,OAAA,GAAM,cAAA,QAAA,CAAQ,GAAG;EAAG,YAAA,GAAW,mBAAA,aAAA,CAAa,GAAG;EAAG,UAAA,GAAS,iBAAA,WAAA,CAAW,GAAG;CAAE;AAC3F;AA+BA,IAAM,aAAmB,KAAA;AAMzB,IAAM,uBAAuB;CAC3B,IAAI,aAA+B,QAAQ,QAAQ;CACnD,QAAW,cAA4C;EACrD,MAAM,OAAO,WAAW,KAAK,WAAW,SAAS;EACjD,aAAa,KAAK,KAAK,MAAM,IAAI;EACjC,OAAO;CACT;AACF;AAMA,IAAM,eAAe,OACnB,QACA,OACA,SACqE;CAIrE,MAAM,OAAA,GAAM,aAAA,cAAA,CAAc,QAAQ,IAAI;CACtC,IAAI;EACF,MAAM,QAAA,GAAO,cAAA,eAAA,CAAe,KAAK,EAAE,aAAa,MAAM,YAAY,CAAC;EACnE,MAAM,KAAK,eAAe;EAC1B,OAAO;GAAE;GAAK,SAAS;IAAE;IAAM,YAAA,GAAW,mBAAA,aAAA,CAAa,GAAG;IAAG,UAAA,GAAS,iBAAA,WAAA,CAAW,GAAG;IAAG,KAAK,KAAK,aAAa,OAAO;GAAK;EAAE;CAC9H,SAAS,OAAO;EACd,OAAA,GAAM,aAAA,UAAA,CAAU,GAAG,CAAC,CAAC,YAAY,KAAA,CAAS;EAC1C,MAAM;CACR;AACF;AAEA,IAAa,2BAA2B,WAA+C;CACrF,MAAM,QAAQ,6BAA6B;CAC3C,IAAI,MAA0B;CAC9B,IAAI,SAAS;CACb,MAAM,YAAY,eAAe;CAEjC,MAAM,aAAa,YAA2B;EAC5C,MAAM,WAAW;EACjB,MAAM;EACN,MAAM,MAAM;EACZ,IAAI,UAAU,OAAA,GAAM,aAAA,UAAA,CAAU,QAAQ;CACxC;CAKA,MAAM,qBAAqB,OAAO,SAAsB,SAAmC,aAAwD;EACjJ,IAAI,CAAC,UAAU;EACf,IAAI;GACF,MAAM,SAAS,OAAO;EACxB,SAAS,OAAO;GACd,OAAA,GAAM,aAAA,UAAA,CAAU,OAAO,CAAC,CAAC,YAAY,KAAA,CAAS;GAC9C,MAAM;EACR;CACF;CAEA,MAAM,YAAY,OAAO,UAAmB,aAA4E;EAMtH,MAAM,cAAc;EACpB,MAAM,eAAe,MAAM,WAAW;EACtC,UAAU;EAKV,MAAM,UAAU,eAAe;EAC/B,IAAI;GACF,MAAM,MAAM;GACZ,IAAI,UAAU,MAAM,KAAK,UAAU,OAAO;GAC1C,MAAM,EAAE,KAAK,SAAS,YAAY,MAAM,aAAa,QAAQ,OAAO,OAAO;GAC3E,MAAM,mBAAmB,SAAS,SAAS,QAAQ;GACnD,MAAM;GACN,IAAI,aAAa,OAAA,GAAM,aAAA,UAAA,CAAU,WAAW,CAAC,CAAC,YAAY,KAAA,CAAS;GACnE,OAAO;EACT,SAAS,OAAO;GAGd,MAAM,MAAM;GACZ,IAAI,cAAc,MAAM,KAAK,YAAY;GACzC,MAAM;EACR;CACF;CAEA,OAAO;EACL,OAAO,UAAmB,aAAyC,gBAAgB,UAAU,UAAU,QAAQ,CAAC;EAChH,aAAa,UAAU,UAAU;EACjC,eAAe,MAAM;EACrB,iBAAiB,MAAM;CACzB;AACF"}
@@ -632,6 +632,23 @@ var isSeedableBlob = (blob) => {
632
632
  return false;
633
633
  }
634
634
  };
635
+ var FIREBASE_KEY_NAMESPACE = "firebase";
636
+ var FIREBASE_KEY_SEGMENTS = 4;
637
+ var rekeyForApp = (key, appName) => {
638
+ const segments = key.split(":");
639
+ if (segments.length !== FIREBASE_KEY_SEGMENTS || segments[0] !== FIREBASE_KEY_NAMESPACE) return key;
640
+ return [...segments.slice(0, 3), appName].join(":");
641
+ };
642
+ var resolveKeys = (keys, appName) => {
643
+ if (!appName) return new Map(keys.map((key) => [key, key]));
644
+ const targets = keys.map((key) => rekeyForApp(key, appName));
645
+ const contested = new Set(targets.filter((target, index) => targets.indexOf(target) !== index));
646
+ return keys.reduce((resolved, key, index) => {
647
+ const target = targets[index];
648
+ if (target !== void 0 && !contested.has(target)) resolved.set(key, target);
649
+ return resolved;
650
+ }, /* @__PURE__ */ new Map());
651
+ };
635
652
  var makeHostPersistenceClass = (store, notify) => {
636
653
  class HostAuthPersistence {
637
654
  static type = "LOCAL";
@@ -671,11 +688,15 @@ var createHostSessionPersistence = () => {
671
688
  listeners.forEach((listener) => listener(blob));
672
689
  };
673
690
  const persistence = makeHostPersistenceClass(store, notify);
674
- const seed = (blob) => {
691
+ const seed = (blob, appName) => {
675
692
  const parsed = JSON.parse(blob);
676
693
  if (!isRecord(parsed)) throw new Error("host session blob must be a JSON object");
677
694
  store.clear();
678
- for (const [key, value] of Object.entries(parsed)) if (isPersistenceValue(value)) store.set(key, value);
695
+ const resolved = resolveKeys(Object.keys(parsed), appName);
696
+ for (const [key, value] of Object.entries(parsed)) {
697
+ const target = resolved.get(key);
698
+ if (target !== void 0 && isPersistenceValue(value)) store.set(target, value);
699
+ }
679
700
  };
680
701
  const onChange = (listener) => {
681
702
  listeners.add(listener);
@@ -755,10 +776,11 @@ var createRemoteHostSession = (config) => {
755
776
  const previousApp = app;
756
777
  const previousBlob = store.exportBlob();
757
778
  appSeq += 1;
779
+ const appName = `remote-host-${appSeq}`;
758
780
  try {
759
781
  store.clear();
760
- if (seedBlob) store.seed(seedBlob);
761
- const { app: nextApp, handles } = await openFreshApp(config, store, `remote-host-${appSeq}`);
782
+ if (seedBlob) store.seed(seedBlob, appName);
783
+ const { app: nextApp, handles } = await openFreshApp(config, store, appName);
762
784
  await validateOrRollback(nextApp, handles, validate);
763
785
  app = nextApp;
764
786
  if (previousApp) await deleteApp(previousApp).catch(() => void 0);