@mulmoclaude/core 1.9.0 → 1.11.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.
Files changed (49) hide show
  1. package/assets/helps/error-recovery.md +85 -0
  2. package/assets/helps/google-calendar-collection.md +41 -18
  3. package/dist/collection/core/schemaZ.d.ts +10 -1
  4. package/dist/collection/registry/server/index.cjs +2 -2
  5. package/dist/collection/registry/server/index.js +2 -2
  6. package/dist/collection/server/index.cjs +2 -2
  7. package/dist/collection/server/index.js +2 -2
  8. package/dist/collection-watchers/index.cjs +2 -2
  9. package/dist/collection-watchers/index.js +2 -2
  10. package/dist/{discovery-_fjsluLx.js → discovery-B4CZvrXR.js} +11 -3
  11. package/dist/{discovery-_fjsluLx.js.map → discovery-B4CZvrXR.js.map} +1 -1
  12. package/dist/{discovery-DYcR83qx.cjs → discovery-NRy3tyUA.cjs} +11 -3
  13. package/dist/{discovery-DYcR83qx.cjs.map → discovery-NRy3tyUA.cjs.map} +1 -1
  14. package/dist/feeds/server/index.cjs +2 -2
  15. package/dist/feeds/server/index.js +2 -2
  16. package/dist/google/calendar.d.ts +9 -0
  17. package/dist/google/calendarLock.d.ts +16 -0
  18. package/dist/google/calendarPushState.d.ts +1 -1
  19. package/dist/google/collectionProjection.d.ts +18 -0
  20. package/dist/google/collectionPush.d.ts +62 -0
  21. package/dist/google/collectionSync.d.ts +51 -23
  22. package/dist/google/index.cjs +833 -567
  23. package/dist/google/index.cjs.map +1 -1
  24. package/dist/google/index.d.ts +4 -2
  25. package/dist/google/index.js +825 -568
  26. package/dist/google/index.js.map +1 -1
  27. package/dist/google/pushPlan.d.ts +1 -1
  28. package/dist/remote-host/health.d.ts +13 -0
  29. package/dist/remote-host/index.cjs +11 -30
  30. package/dist/remote-host/index.d.ts +2 -0
  31. package/dist/remote-host/index.js +2 -24
  32. package/dist/remote-host/server/index.cjs +223 -8
  33. package/dist/remote-host/server/index.cjs.map +1 -1
  34. package/dist/remote-host/server/index.d.ts +4 -0
  35. package/dist/remote-host/server/index.js +214 -5
  36. package/dist/remote-host/server/index.js.map +1 -1
  37. package/dist/remote-host/server/presenceProbe.d.ts +23 -0
  38. package/dist/remote-host/server/resilientRunner.d.ts +24 -0
  39. package/dist/remote-host-D_BRFHcI.js +36 -0
  40. package/dist/remote-host-D_BRFHcI.js.map +1 -0
  41. package/dist/remote-host-DkDVxNim.cjs +95 -0
  42. package/dist/remote-host-DkDVxNim.cjs.map +1 -0
  43. package/dist/{server-l4TZ5Lbf.cjs → server-7U-3DE2e.cjs} +2 -2
  44. package/dist/{server-l4TZ5Lbf.cjs.map → server-7U-3DE2e.cjs.map} +1 -1
  45. package/dist/{server-BlIrtGKl.js → server-DdyIvFhz.js} +2 -2
  46. package/dist/{server-BlIrtGKl.js.map → server-DdyIvFhz.js.map} +1 -1
  47. package/package.json +5 -5
  48. package/dist/remote-host/index.cjs.map +0 -1
  49. package/dist/remote-host/index.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../src/remote-host/server/firestoreSafeResult.ts","../../../src/remote-host/server/presenceBeat.ts","../../../src/remote-host/server/hostRunner.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 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 a write may go 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 /** No write has been acknowledged for `silentMs` — the phone cannot see this host. */\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 staleAfterMs: 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 ?? Date.now;\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 const state = { lastAckMs: now() };\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 });\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 const silentMs = now() - state.lastAckMs;\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 (silentMs >= deps.staleAfterMs) {\n notify(() => deps.onStale(silentMs));\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 { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport {\n Channel,\n Command,\n CommandHandler,\n CommandHandlers,\n JsonObject,\n buildHostPresence,\n byCreatedAt,\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\";\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// Firestore listen errors worth re-subscribing for: network / backend blips, plus\n// `unauthenticated` — the SDK refreshes tokens on its own, so an expired one is\n// fixed by trying again, and stopping the host at the first expiry was far too\n// strong (#2633). Everything else — permission-denied and any unrecognized code —\n// is fatal: re-listening can't restore a revoked grant, and an open-ended retry on\n// an unknown code would loop forever. Retrying is bounded by LISTEN_RETRY_WINDOW_MS\n// either way, so even a doomed retry ends in an escalation rather than a spin.\nconst TRANSIENT_LISTEN_ERROR_CODES = new Set([\"aborted\", \"cancelled\", \"deadline-exceeded\", \"internal\", \"resource-exhausted\", \"unauthenticated\", \"unavailable\"]);\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null;\nconst listenErrorCode = (error: unknown): string => (isRecord(error) && typeof error.code === \"string\" ? error.code : \"\");\n\nexport const classifyListenerError = (error: unknown): \"transient\" | \"fatal\" =>\n TRANSIENT_LISTEN_ERROR_CODES.has(listenErrorCode(error)) ? \"transient\" : \"fatal\";\n\nconst BASE_LISTEN_RETRY_MS = 1_000;\nconst MAX_LISTEN_RETRY_MS = 30_000;\n// How long a listener may keep failing before the runner stops retrying in place\n// and escalates to the lifecycle owner (which can re-auth). Bounding this by a\n// RETRY COUNT instead made it ~31s of wall clock — shorter than any laptop sleep\n// or network move, after which the host never re-subscribed (#2633).\nexport const LISTEN_RETRY_WINDOW_MS = 5 * 60_000;\n\n// The outage is measured from its first failure, not from the last attempt: a\n// backoff ladder that keeps failing must not extend its own deadline.\nexport const shouldGiveUpListening = (downSinceMs: number, now: number, windowMs: number = LISTEN_RETRY_WINDOW_MS): boolean => now - downSinceMs >= windowMs;\n\n// Exponential backoff, capped: attempt 0 → 1s, 1 → 2s, … saturating at 30s.\nexport const backoffDelayMs = (attempt: number): number => Math.min(MAX_LISTEN_RETRY_MS, BASE_LISTEN_RETRY_MS * 2 ** attempt);\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>;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number;\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\ninterface Claim {\n method: string;\n params: JsonObject;\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// 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 data = (await txn.get(ref)).data() as Command | undefined;\n if (!data || data.status !== \"queued\") {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return { method: data.method, params: data.params ?? {} };\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 const returned = await handler(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.\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 const now = Date.now();\n snapshot\n .docChanges()\n .filter((change) => change.type === \"added\")\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 = Date.now();\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 is the age of\n// the last acknowledgement rather than a count of failures.\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 for ${Math.round(silentMs / 1_000)}s — the remote cannot see this host`);\n onStale();\n },\n staleAfterMs: presenceStaleAfterMs(options),\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","// 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>;\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.js","names":[],"sources":["../../../src/remote-host/server/firestoreSafeResult.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 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 a write may go 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 /** No write has been acknowledged for `silentMs` — the phone cannot see this host. */\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 staleAfterMs: 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 ?? Date.now;\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 const state = { lastAckMs: now() };\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 });\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 const silentMs = now() - state.lastAckMs;\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 (silentMs >= deps.staleAfterMs) {\n notify(() => deps.onStale(silentMs));\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 { errorMessage } from \"../../collection/core/errorMessage.js\";\nimport {\n Channel,\n Command,\n CommandHandler,\n CommandHandlers,\n JsonObject,\n buildHostPresence,\n byCreatedAt,\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\";\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// Firestore listen errors worth re-subscribing for: network / backend blips, plus\n// `unauthenticated` — the SDK refreshes tokens on its own, so an expired one is\n// fixed by trying again, and stopping the host at the first expiry was far too\n// strong (#2633). Everything else — permission-denied and any unrecognized code —\n// is fatal: re-listening can't restore a revoked grant, and an open-ended retry on\n// an unknown code would loop forever. Retrying is bounded by LISTEN_RETRY_WINDOW_MS\n// either way, so even a doomed retry ends in an escalation rather than a spin.\nconst TRANSIENT_LISTEN_ERROR_CODES = new Set([\"aborted\", \"cancelled\", \"deadline-exceeded\", \"internal\", \"resource-exhausted\", \"unauthenticated\", \"unavailable\"]);\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null;\nconst listenErrorCode = (error: unknown): string => (isRecord(error) && typeof error.code === \"string\" ? error.code : \"\");\n\nexport const classifyListenerError = (error: unknown): \"transient\" | \"fatal\" =>\n TRANSIENT_LISTEN_ERROR_CODES.has(listenErrorCode(error)) ? \"transient\" : \"fatal\";\n\nconst BASE_LISTEN_RETRY_MS = 1_000;\nconst MAX_LISTEN_RETRY_MS = 30_000;\n// How long a listener may keep failing before the runner stops retrying in place\n// and escalates to the lifecycle owner (which can re-auth). Bounding this by a\n// RETRY COUNT instead made it ~31s of wall clock — shorter than any laptop sleep\n// or network move, after which the host never re-subscribed (#2633).\nexport const LISTEN_RETRY_WINDOW_MS = 5 * 60_000;\n\n// The outage is measured from its first failure, not from the last attempt: a\n// backoff ladder that keeps failing must not extend its own deadline.\nexport const shouldGiveUpListening = (downSinceMs: number, now: number, windowMs: number = LISTEN_RETRY_WINDOW_MS): boolean => now - downSinceMs >= windowMs;\n\n// Exponential backoff, capped: attempt 0 → 1s, 1 → 2s, … saturating at 30s.\nexport const backoffDelayMs = (attempt: number): number => Math.min(MAX_LISTEN_RETRY_MS, BASE_LISTEN_RETRY_MS * 2 ** attempt);\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>;\n // Presence heartbeat interval; defaults to one minute.\n heartbeatMs?: number;\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\ninterface Claim {\n method: string;\n params: JsonObject;\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// 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 data = (await txn.get(ref)).data() as Command | undefined;\n if (!data || data.status !== \"queued\") {\n return null;\n }\n txn.update(ref, { status: \"processing\", updatedAt: serverTimestamp() });\n return { method: data.method, params: data.params ?? {} };\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 const returned = await handler(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.\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 const now = Date.now();\n snapshot\n .docChanges()\n .filter((change) => change.type === \"added\")\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 = Date.now();\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 is the age of\n// the last acknowledgement rather than a count of failures.\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 for ${Math.round(silentMs / 1_000)}s — the remote cannot see this host`);\n onStale();\n },\n staleAfterMs: presenceStaleAfterMs(options),\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.\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, and PROBE_INTERVAL_MS must sit above `presenceStaleAfterMs()`. While the\n// two hosts each kept a copy, raising LISTEN_RETRY_WINDOW_MS from ~31s to 5 minutes\n// silently disabled `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\";\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\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 now?: () => number;\n}\n\ninterface RunnerContext {\n deps: ResilientHostRunnerDeps;\n schedule: (task: () => void, delayMs: number) => CancelTimer;\n now: () => 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.now() });\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// 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 = ctx.schedule(() => void runProbe(ctx), PROBE_INTERVAL_MS);\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 = ctx.schedule(() => launch(ctx), delayMs);\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 = ctx.schedule(() => void settle(ctx), SETTLE_MS);\n}\n\nexport function startResilientHostRunner(deps: ResilientHostRunnerDeps): () => void {\n const ctx: RunnerContext = {\n deps,\n schedule: deps.schedule ?? scheduleWithTimeout,\n now: deps.now ?? 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>;\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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"}
@@ -0,0 +1,23 @@
1
+ import { Firestore } from 'firebase/firestore';
2
+ import { Channel } from '../index.js';
3
+ export declare const PRESENCE_STALE_MS: number;
4
+ /** `null` = cannot be judged, which is NOT a failure: the document may simply not
5
+ * exist yet (a runner that has never announced), and treating "no answer" as
6
+ * "dead" would spin a reconnect loop against a host that is merely new. */
7
+ export type Liveness = boolean | null;
8
+ /** Judge a presence document that was read successfully. Exported for its own test:
9
+ * the freshness rule is the part worth pinning, and it needs no Firestore. */
10
+ export declare const presenceIsFresh: (data: Record<string, unknown> | undefined, now: number, staleAfterMs?: number) => Liveness;
11
+ /** Reject once `timeoutMs` has passed, so a stalled read answers instead of hanging. */
12
+ export declare const withTimeout: <T>(work: Promise<T>, timeoutMs: number) => Promise<T>;
13
+ export interface PresenceProbeDeps {
14
+ firestore: () => Firestore;
15
+ channel: Channel;
16
+ /** The runner's own staleness threshold. Pass `presenceStaleAfterMs(options)` when
17
+ * the runner uses a custom `heartbeatMs`, so the two judgments cannot diverge. */
18
+ staleAfterMs?: number;
19
+ timeoutMs?: number;
20
+ now?: () => number;
21
+ }
22
+ /** Reads the host's own presence document from the server and reports whether it is fresh. */
23
+ export declare const createPresenceProbe: (deps: PresenceProbeDeps) => (() => Promise<Liveness>);
@@ -0,0 +1,24 @@
1
+ import { RunnerHealth } from '../health.js';
2
+ import { HostRunnerOptions } from './hostRunner.js';
3
+ import { RemoteHostLogger } from './lifecycle.js';
4
+ import { Liveness } from './presenceProbe.js';
5
+ export declare const reconnectDelayMs: (attempt: number) => number;
6
+ /** Cancels a scheduled task. Handing back a closure rather than a timer handle keeps
7
+ * the seam free of platform timer types, so a test clock is an ordinary function. */
8
+ export type CancelTimer = () => void;
9
+ export interface ResilientHostRunnerDeps {
10
+ /** Starts the host runner, returning its stop function. Throws if the session is gone. */
11
+ start: (options: HostRunnerOptions) => () => void;
12
+ /** The options the lifecycle handed us; `onClosed` is passed on only once we give up. */
13
+ options: HostRunnerOptions;
14
+ /** Positive liveness check (`createPresenceProbe`). Without one, recovery falls back
15
+ * to trusting silence — which is what let a dead channel report itself green. */
16
+ checkAlive?: () => Promise<Liveness>;
17
+ /** State changes, for a host that renders channel health. Optional: a host with no
18
+ * such UI should not have to pass an empty function. */
19
+ onHealth?: (health: RunnerHealth) => void;
20
+ log: Pick<RemoteHostLogger, "info" | "warn">;
21
+ schedule?: (task: () => void, delayMs: number) => CancelTimer;
22
+ now?: () => number;
23
+ }
24
+ export declare function startResilientHostRunner(deps: ResilientHostRunnerDeps): () => void;
@@ -0,0 +1,36 @@
1
+ import { collection, doc } from "firebase/firestore";
2
+ //#region src/remote-host/health.ts
3
+ var RUNNER_HEALTH_STATES = [
4
+ "online",
5
+ "reconnecting",
6
+ "offline"
7
+ ];
8
+ var isRecord = (value) => typeof value === "object" && value !== null;
9
+ var isRunnerHealthState = (value) => RUNNER_HEALTH_STATES.some((state) => state === value);
10
+ /** Narrows a parsed HTTP payload. The client renders whatever this accepts, so a
11
+ * half-shaped health has to read as "no health reported" rather than as a state. */
12
+ var isRunnerHealth = (value) => isRecord(value) && isRunnerHealthState(value.state) && (value.lastError === null || typeof value.lastError === "string") && typeof value.changedAt === "number";
13
+ //#endregion
14
+ //#region src/remote-host/index.ts
15
+ /** Widen a JSON-shaped handler payload to the channel's `JsonObject`.
16
+ *
17
+ * Exists so the `Jsonify` reasoning above lives in ONE place. Before this,
18
+ * eight remote-host handlers each carried their own `as unknown as JsonObject`
19
+ * with the justification re-argued in eight slightly different comments —
20
+ * which is how a rule stops being reviewable. */
21
+ var toJsonObject = (payload) => payload;
22
+ var isExpired = (command, now) => typeof command.expiresAt === "number" && now >= command.expiresAt;
23
+ var byCreatedAt = (left, right) => (left.createdAt ?? 0) - (right.createdAt ?? 0);
24
+ var REMOTE_HOST_PROTOCOL_VERSION = 2;
25
+ var buildHostPresence = (channel, handlers, online) => ({
26
+ online,
27
+ hostId: channel.hostId,
28
+ protocolVersion: 2,
29
+ capabilities: Object.keys(handlers)
30
+ });
31
+ var commandsCollection = (firestore, channel) => collection(firestore, "users", channel.uid, "hosts", channel.hostId, "commands");
32
+ var hostDoc = (firestore, channel) => doc(firestore, "users", channel.uid, "hosts", channel.hostId);
33
+ //#endregion
34
+ export { hostDoc as a, RUNNER_HEALTH_STATES as c, commandsCollection as i, isRunnerHealth as l, buildHostPresence as n, isExpired as o, byCreatedAt as r, toJsonObject as s, REMOTE_HOST_PROTOCOL_VERSION as t, isRunnerHealthState as u };
35
+
36
+ //# sourceMappingURL=remote-host-D_BRFHcI.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote-host-D_BRFHcI.js","names":[],"sources":["../src/remote-host/health.ts","../src/remote-host/index.ts"],"sourcesContent":["// Health of the remote-host command channel, as reported by the resilient runner\n// and rendered by a host's toolbar control. Browser-safe on purpose: the client\n// narrows the parsed HTTP payload with the same guard the server writes it from,\n// so the two sides cannot drift on the state names.\n//\n// online — the Firestore subscription is up; the phone can reach this host\n// reconnecting — it died and is being re-subscribed with backoff (self-healing)\n// offline — re-subscribing stopped helping, or nothing is connected at all;\n// recovering needs a re-auth from the browser's parked session\n//\n// Deliberately no UI wording here — how a state reads to a user is each host's\n// i18n, and core owning it would make the shared package a translation authority.\nexport const RUNNER_HEALTH_STATES = [\"online\", \"reconnecting\", \"offline\"] as const;\nexport type RunnerHealthState = (typeof RUNNER_HEALTH_STATES)[number];\n\nexport interface RunnerHealth {\n state: RunnerHealthState;\n /** Last channel error seen, for the popover and the log. Null before the first one. */\n lastError: string | null;\n /** ms epoch of the last state change, so the UI can say how long it has been down. */\n changedAt: number;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null;\n\nexport const isRunnerHealthState = (value: unknown): value is RunnerHealthState => RUNNER_HEALTH_STATES.some((state) => state === value);\n\n/** Narrows a parsed HTTP payload. The client renders whatever this accepts, so a\n * half-shaped health has to read as \"no health reported\" rather than as a state. */\nexport const isRunnerHealth = (value: unknown): value is RunnerHealth =>\n isRecord(value) &&\n isRunnerHealthState(value.state) &&\n (value.lastError === null || typeof value.lastError === \"string\") &&\n typeof value.changedAt === \"number\";\n","// Remote-host command-channel protocol — the browser-safe contract shared by a\n// host (MulmoClaude, MulmoTerminal) and the remote/mobile client (mulmoserver).\n//\n// A host signs in to Firebase as the user, listens to that user's per-host\n// command queue in Firestore, runs a handler, and writes the result back; the\n// remote writes commands and reads results via a real-time listener. This module\n// owns the wire types + the Firestore path helpers. It is the single source of\n// truth so the host runner and the client never drift on the protocol.\n//\n// Ported from ../mulmoserver/src/firestore/commandChannel.ts and the per-host\n// copy that lived in MulmoClaude's server/remoteHost/. The one change vs. those\n// copies: the path helpers take the `firestore` instance as a parameter (rather\n// than importing a module-level singleton) so a single extracted module serves\n// every host's own Firebase init. The hostId is host-specific (\"mulmoclaude\",\n// \"mulmoterminal\") and is supplied by each host — there is no discovery.\nimport { CollectionReference, DocumentData, DocumentReference, Firestore, collection, doc } from \"firebase/firestore\";\n\n// JSON payloads carried by the command channel. Explicit JSON types keep the\n// channel typed without resorting to any/unknown.\nexport type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };\nexport type JsonObject = Record<string, JsonValue>;\n\n/** Structural JSON view of `T`, recursively.\n *\n * TypeScript gives an implicit index signature to type aliases and mapped\n * types but NOT to interfaces, so a payload assembled from domain interfaces\n * (`Shortcut`, `FeedSummary`, …) cannot satisfy `Record<string, JsonValue>`\n * structurally — even though it is plain JSON at runtime. Mapping over `T`\n * reconstructs it as an anonymous type, which does get that index signature.\n *\n * Recursive on purpose: a top-level-only map would still leave nested\n * interfaces (`{ shortcuts: Shortcut[] }`) unassignable, which is the case\n * every handler here actually has. */\n// The function branch must come BEFORE the object branch: a function IS an\n// object to TypeScript, so without it a function maps to `{}` and sails\n// through — the helper would accept a payload that serialises to nothing.\n// Verified: `toJsonObject({ callback: () => undefined })` compiled clean until\n// this branch existed (CodeRabbit, #2596).\nexport type Jsonify<T> = T extends JsonValue\n ? T\n : T extends (...args: never[]) => unknown\n ? never\n : T extends (infer U)[]\n ? Jsonify<U>[]\n : T extends object\n ? { [K in keyof T]: Jsonify<T[K]> }\n : never;\n\n/** Widen a JSON-shaped handler payload to the channel's `JsonObject`.\n *\n * Exists so the `Jsonify` reasoning above lives in ONE place. Before this,\n * eight remote-host handlers each carried their own `as unknown as JsonObject`\n * with the justification re-argued in eight slightly different comments —\n * which is how a rule stops being reviewable. */\nexport const toJsonObject = <T extends object>(payload: Jsonify<T>): JsonObject => payload as JsonObject;\n\n// A channel routes commands to one specific host. Both sides agree on a\n// hardcoded hostId per use case (e.g. \"mulmoclaude\", \"mulmoterminal\"); there is\n// no discovery — the remote and host just share the id.\nexport interface Channel {\n uid: string;\n hostId: string;\n}\n\nexport type CommandStatus = \"queued\" | \"processing\" | \"done\" | \"error\";\n\nexport interface CommandError {\n code: string;\n message: string;\n}\n\n// One document in a channel's commands subcollection is one API-call-like\n// request. The remote (mobile) writes method/params; the host writes\n// result/error/status.\nexport interface Command {\n method: string;\n params: JsonObject;\n status: CommandStatus;\n result: JsonValue;\n error: CommandError | null;\n createdBy: \"remote\" | \"host\";\n // Offline-queue fields (all optional; absent ⇒ pre-offline-queue behaviour, so\n // this is backward-compatible with every deployed client). Epoch-millisecond\n // NUMBERS set by the remote at enqueue time — deliberately plain numbers, not\n // Firestore Timestamps, so `isExpired` / `byCreatedAt` stay pure + browser-safe\n // and unit-testable without a Firestore fake. Clock skew over a multi-day expiry\n // window is immaterial. See plans/done/feat-remote-offline-queue.md.\n createdAt?: number; // enqueue time — age/display + best-effort dispatch bias (NOT a strict order guarantee; chat is async)\n expiresAt?: number; // deadline; past it the host deletes the command + its staged attachments\n queuedOffline?: boolean; // emitted while the host was offline (gates the remote's attachment rollback)\n}\n\n// A command is expired once `now` reaches its remote-set deadline. Absent\n// `expiresAt` ⇒ it never expires (pre-offline-queue commands). Pure with an\n// injected `now` for deterministic tests; the runner passes `Date.now()`.\nexport const isExpired = (command: Pick<Command, \"expiresAt\">, now: number): boolean => typeof command.expiresAt === \"number\" && now >= command.expiresAt;\n\n// Best-effort dispatch bias for a drained batch: oldest enqueue first. This is\n// NOT an ordering guarantee — commands run concurrently and may complete out of\n// order (chat is asynchronous, by design); it only nudges which one starts first.\n// A command with no `createdAt` sorts as oldest (0) so it is never starved.\nexport const byCreatedAt = (left: Pick<Command, \"createdAt\">, right: Pick<Command, \"createdAt\">): number => (left.createdAt ?? 0) - (right.createdAt ?? 0);\n\nexport type CommandHandler = (params: JsonObject) => JsonValue | Promise<JsonValue>;\nexport type CommandHandlers = Record<string, CommandHandler>;\n\n// Bumped when the command-channel wire protocol changes in a way the remote must\n// gate on. Advertised in the presence doc so the remote can check compatibility\n// before issuing commands.\n//\n// v2: offline queueing. The host honours `expiresAt` (deletes an expired command\n// + its staged attachments instead of spawning a stale chat). A remote MUST see\n// protocolVersion >= 2 before queueing a startChat while the host is offline —\n// a v1 host silently ignores `expiresAt`, so a queued chat would spawn stale on\n// reconnect with its uploads never cleaned up.\nexport const REMOTE_HOST_PROTOCOL_VERSION = 2;\n\n// The presence doc's payload: online flag + a capability advertisement. Written\n// by the host on every heartbeat; the remote reads it from the presence listener\n// it already runs (no extra round trip, known the instant the host is online).\n// Browser-safe so the mobile client compiles against the same shape.\n// `updatedAt` (a Firestore serverTimestamp) is added by the runner at write time\n// and is intentionally not part of this capability contract.\nexport interface HostPresence {\n online: boolean;\n hostId: string;\n protocolVersion: number;\n // Method names the host serves — the keys of the live handler table.\n capabilities: string[];\n}\n\n// Build the presence payload from the live handler table. Capabilities are\n// `Object.keys(handlers)` so registering a handler is the ONLY step needed to\n// advertise it — there is no second list to keep in sync.\nexport const buildHostPresence = (channel: Channel, handlers: CommandHandlers, online: boolean): HostPresence => ({\n online,\n hostId: channel.hostId,\n protocolVersion: REMOTE_HOST_PROTOCOL_VERSION,\n capabilities: Object.keys(handlers),\n});\n\n// Per-host command queue: users/{uid}/hosts/{hostId}/commands.\nexport const commandsCollection = (firestore: Firestore, channel: Channel): CollectionReference<DocumentData> =>\n collection(firestore, \"users\", channel.uid, \"hosts\", channel.hostId, \"commands\");\n\n// Presence doc for a host: users/{uid}/hosts/{hostId}. The host heartbeats\n// { online, updatedAt } here; the remote reads it to know if the host is up.\nexport const hostDoc = (firestore: Firestore, channel: Channel): DocumentReference<DocumentData> =>\n doc(firestore, \"users\", channel.uid, \"hosts\", channel.hostId);\n\n// Channel health as the resilient runner reports it. Browser-safe alongside the\n// wire types because the control that renders it runs in the client.\nexport { RUNNER_HEALTH_STATES, isRunnerHealth, isRunnerHealthState } from \"./health.js\";\nexport type { RunnerHealth, RunnerHealthState } from \"./health.js\";\n"],"mappings":";;AAYA,IAAa,uBAAuB;CAAC;CAAU;CAAgB;AAAS;AAWxE,IAAM,YAAY,UAAqD,OAAO,UAAU,YAAY,UAAU;AAE9G,IAAa,uBAAuB,UAA+C,qBAAqB,MAAM,UAAU,UAAU,KAAK;;;AAIvI,IAAa,kBAAkB,UAC7B,SAAS,KAAK,KACd,oBAAoB,MAAM,KAAK,MAC9B,MAAM,cAAc,QAAQ,OAAO,MAAM,cAAc,aACxD,OAAO,MAAM,cAAc;;;;;;;;;ACqB7B,IAAa,gBAAkC,YAAoC;AAyCnF,IAAa,aAAa,SAAqC,QAAyB,OAAO,QAAQ,cAAc,YAAY,OAAO,QAAQ;AAMhJ,IAAa,eAAe,MAAkC,WAA+C,KAAK,aAAa,MAAM,MAAM,aAAa;AAcxJ,IAAa,+BAA+B;AAmB5C,IAAa,qBAAqB,SAAkB,UAA2B,YAAmC;CAChH;CACA,QAAQ,QAAQ;CAChB,iBAAA;CACA,cAAc,OAAO,KAAK,QAAQ;AACpC;AAGA,IAAa,sBAAsB,WAAsB,YACvD,WAAW,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,QAAQ,UAAU;AAIjF,IAAa,WAAW,WAAsB,YAC5C,IAAI,WAAW,SAAS,QAAQ,KAAK,SAAS,QAAQ,MAAM"}
@@ -0,0 +1,95 @@
1
+ let firebase_firestore = require("firebase/firestore");
2
+ //#region src/remote-host/health.ts
3
+ var RUNNER_HEALTH_STATES = [
4
+ "online",
5
+ "reconnecting",
6
+ "offline"
7
+ ];
8
+ var isRecord = (value) => typeof value === "object" && value !== null;
9
+ var isRunnerHealthState = (value) => RUNNER_HEALTH_STATES.some((state) => state === value);
10
+ /** Narrows a parsed HTTP payload. The client renders whatever this accepts, so a
11
+ * half-shaped health has to read as "no health reported" rather than as a state. */
12
+ var isRunnerHealth = (value) => isRecord(value) && isRunnerHealthState(value.state) && (value.lastError === null || typeof value.lastError === "string") && typeof value.changedAt === "number";
13
+ //#endregion
14
+ //#region src/remote-host/index.ts
15
+ /** Widen a JSON-shaped handler payload to the channel's `JsonObject`.
16
+ *
17
+ * Exists so the `Jsonify` reasoning above lives in ONE place. Before this,
18
+ * eight remote-host handlers each carried their own `as unknown as JsonObject`
19
+ * with the justification re-argued in eight slightly different comments —
20
+ * which is how a rule stops being reviewable. */
21
+ var toJsonObject = (payload) => payload;
22
+ var isExpired = (command, now) => typeof command.expiresAt === "number" && now >= command.expiresAt;
23
+ var byCreatedAt = (left, right) => (left.createdAt ?? 0) - (right.createdAt ?? 0);
24
+ var REMOTE_HOST_PROTOCOL_VERSION = 2;
25
+ var buildHostPresence = (channel, handlers, online) => ({
26
+ online,
27
+ hostId: channel.hostId,
28
+ protocolVersion: 2,
29
+ capabilities: Object.keys(handlers)
30
+ });
31
+ var commandsCollection = (firestore, channel) => (0, firebase_firestore.collection)(firestore, "users", channel.uid, "hosts", channel.hostId, "commands");
32
+ var hostDoc = (firestore, channel) => (0, firebase_firestore.doc)(firestore, "users", channel.uid, "hosts", channel.hostId);
33
+ //#endregion
34
+ Object.defineProperty(exports, "REMOTE_HOST_PROTOCOL_VERSION", {
35
+ enumerable: true,
36
+ get: function() {
37
+ return REMOTE_HOST_PROTOCOL_VERSION;
38
+ }
39
+ });
40
+ Object.defineProperty(exports, "RUNNER_HEALTH_STATES", {
41
+ enumerable: true,
42
+ get: function() {
43
+ return RUNNER_HEALTH_STATES;
44
+ }
45
+ });
46
+ Object.defineProperty(exports, "buildHostPresence", {
47
+ enumerable: true,
48
+ get: function() {
49
+ return buildHostPresence;
50
+ }
51
+ });
52
+ Object.defineProperty(exports, "byCreatedAt", {
53
+ enumerable: true,
54
+ get: function() {
55
+ return byCreatedAt;
56
+ }
57
+ });
58
+ Object.defineProperty(exports, "commandsCollection", {
59
+ enumerable: true,
60
+ get: function() {
61
+ return commandsCollection;
62
+ }
63
+ });
64
+ Object.defineProperty(exports, "hostDoc", {
65
+ enumerable: true,
66
+ get: function() {
67
+ return hostDoc;
68
+ }
69
+ });
70
+ Object.defineProperty(exports, "isExpired", {
71
+ enumerable: true,
72
+ get: function() {
73
+ return isExpired;
74
+ }
75
+ });
76
+ Object.defineProperty(exports, "isRunnerHealth", {
77
+ enumerable: true,
78
+ get: function() {
79
+ return isRunnerHealth;
80
+ }
81
+ });
82
+ Object.defineProperty(exports, "isRunnerHealthState", {
83
+ enumerable: true,
84
+ get: function() {
85
+ return isRunnerHealthState;
86
+ }
87
+ });
88
+ Object.defineProperty(exports, "toJsonObject", {
89
+ enumerable: true,
90
+ get: function() {
91
+ return toJsonObject;
92
+ }
93
+ });
94
+
95
+ //# sourceMappingURL=remote-host-DkDVxNim.cjs.map