@mulmoclaude/core 0.26.0 → 0.27.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 (88) hide show
  1. package/assets/helps/error-recovery.md +55 -0
  2. package/dist/collection/core/errorMessage.d.ts +1 -4
  3. package/dist/collection/core/ontologyGraph.d.ts +58 -0
  4. package/dist/collection/core/uiTypes.d.ts +7 -0
  5. package/dist/collection/index.cjs +81 -10
  6. package/dist/collection/index.cjs.map +1 -1
  7. package/dist/collection/index.d.ts +1 -0
  8. package/dist/collection/index.js +74 -4
  9. package/dist/collection/index.js.map +1 -1
  10. package/dist/collection/registry/server/index.cjs +8 -7
  11. package/dist/collection/registry/server/index.cjs.map +1 -1
  12. package/dist/collection/registry/server/index.js +3 -2
  13. package/dist/collection/registry/server/index.js.map +1 -1
  14. package/dist/collection/server/index.cjs +3 -3
  15. package/dist/collection/server/index.js +3 -3
  16. package/dist/collection/server/ontology.d.ts +2 -24
  17. package/dist/collection/server/util.d.ts +1 -2
  18. package/dist/collection-watchers/index.cjs +5 -4
  19. package/dist/collection-watchers/index.cjs.map +1 -1
  20. package/dist/collection-watchers/index.js +4 -3
  21. package/dist/collection-watchers/index.js.map +1 -1
  22. package/dist/{discovery-QUi_7aNG.js → discovery-B9Vfojyy.js} +2 -2
  23. package/dist/{discovery-QUi_7aNG.js.map → discovery-B9Vfojyy.js.map} +1 -1
  24. package/dist/{discovery-cwtfyDxl.cjs → discovery-BxbJy2VN.cjs} +2 -2
  25. package/dist/{discovery-cwtfyDxl.cjs.map → discovery-BxbJy2VN.cjs.map} +1 -1
  26. package/dist/errors-7P5eMOSX.cjs +30 -0
  27. package/dist/errors-7P5eMOSX.cjs.map +1 -0
  28. package/dist/errors-eid6Mes3.js +19 -0
  29. package/dist/errors-eid6Mes3.js.map +1 -0
  30. package/dist/feeds/index.cjs +2 -2
  31. package/dist/feeds/index.js +2 -2
  32. package/dist/feeds/server/index.cjs +4 -4
  33. package/dist/feeds/server/index.js +4 -4
  34. package/dist/google/index.cjs +8 -18
  35. package/dist/google/index.cjs.map +1 -1
  36. package/dist/google/index.js +3 -13
  37. package/dist/google/index.js.map +1 -1
  38. package/dist/google/util.d.ts +2 -4
  39. package/dist/{ids-P2T89Gou.js → ids-D1M1T6KJ.js} +26 -3
  40. package/dist/ids-D1M1T6KJ.js.map +1 -0
  41. package/dist/{ids-Dl9Ij63K.cjs → ids-DWmHjm17.cjs} +37 -2
  42. package/dist/ids-DWmHjm17.cjs.map +1 -0
  43. package/dist/{ingestTypes-C-IQB4nw.js → ingestTypes-Ci4eOgv-.js} +2 -2
  44. package/dist/{ingestTypes-C-IQB4nw.js.map → ingestTypes-Ci4eOgv-.js.map} +1 -1
  45. package/dist/{ingestTypes-DZpAC3N_.cjs → ingestTypes-DbuQNuK6.cjs} +2 -2
  46. package/dist/{ingestTypes-DZpAC3N_.cjs.map → ingestTypes-DbuQNuK6.cjs.map} +1 -1
  47. package/dist/{promptSafety-BOc1B3im.cjs → promptSafety-0ZKHX-6J.cjs} +5 -39
  48. package/dist/promptSafety-0ZKHX-6J.cjs.map +1 -0
  49. package/dist/{promptSafety-B5KPkaNY.js → promptSafety-RE1SPxBN.js} +3 -25
  50. package/dist/promptSafety-RE1SPxBN.js.map +1 -0
  51. package/dist/remote-host/server/index.cjs +4 -4
  52. package/dist/remote-host/server/index.cjs.map +1 -1
  53. package/dist/remote-host/server/index.js +1 -1
  54. package/dist/scheduler/index.cjs +2 -4
  55. package/dist/scheduler/index.cjs.map +1 -1
  56. package/dist/scheduler/index.js +1 -3
  57. package/dist/scheduler/index.js.map +1 -1
  58. package/dist/{server-B7mtx4Cd.cjs → server-DOGKfUuD.cjs} +9 -17
  59. package/dist/server-DOGKfUuD.cjs.map +1 -0
  60. package/dist/{server-CNLZZONa.js → server-sGNe-msA.js} +9 -11
  61. package/dist/server-sGNe-msA.js.map +1 -0
  62. package/dist/text-CaAh9ZSO.cjs +27 -0
  63. package/dist/text-CaAh9ZSO.cjs.map +1 -0
  64. package/dist/text-CjDbP2O1.js +22 -0
  65. package/dist/text-CjDbP2O1.js.map +1 -0
  66. package/dist/utils/errors.d.ts +2 -0
  67. package/dist/utils/index.cjs +6 -0
  68. package/dist/utils/index.d.ts +2 -0
  69. package/dist/utils/index.js +3 -0
  70. package/dist/utils/text.d.ts +12 -0
  71. package/dist/whisper/index.cjs +7 -9
  72. package/dist/whisper/index.cjs.map +1 -1
  73. package/dist/whisper/index.js +1 -3
  74. package/dist/whisper/index.js.map +1 -1
  75. package/dist/whisper/internal.d.ts +1 -1
  76. package/dist/workspace-setup/index.js +12 -3
  77. package/dist/workspace-setup/index.js.map +1 -1
  78. package/package.json +7 -1
  79. package/dist/errorMessage--LvXe_Jx.cjs +0 -16
  80. package/dist/errorMessage--LvXe_Jx.cjs.map +0 -1
  81. package/dist/errorMessage-DIdhheHk.js +0 -11
  82. package/dist/errorMessage-DIdhheHk.js.map +0 -1
  83. package/dist/ids-Dl9Ij63K.cjs.map +0 -1
  84. package/dist/ids-P2T89Gou.js.map +0 -1
  85. package/dist/promptSafety-B5KPkaNY.js.map +0 -1
  86. package/dist/promptSafety-BOc1B3im.cjs.map +0 -1
  87. package/dist/server-B7mtx4Cd.cjs.map +0 -1
  88. package/dist/server-CNLZZONa.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"promptSafety-RE1SPxBN.js","names":[],"sources":["../src/collection/core/actionVisible.ts","../src/collection/core/backlinks.ts","../src/collection/core/where.ts","../src/collection/core/completion.ts","../src/collection/core/dynamicIcon.ts","../src/collection/core/derivedFormula.ts","../src/collection/core/deriveAll.ts","../src/collection/core/calendarGrid.ts","../src/collection/core/promptSafety.ts"],"sourcesContent":["// Pure `when`-predicate visibility helpers for schema-driven\n// collections — used both for action buttons and for conditionally\n// shown fields. Kept as their own module (no Vue) so CollectionView\n// can stay thin and the match semantics are pinned by unit tests.\n// Domain-free: the host compares the stringified record value against\n// the allowed set with no knowledge of what the field means.\n\nimport { fieldTextOrNull } from \"./fieldText\";\n\n/** A `when` predicate: render only when the open record's `field`\n * (stringified) is one of `in`. Shared shape for action buttons and\n * conditionally visible fields. */\nexport interface WhenPredicate {\n field: string;\n in: string[];\n}\n\n/** Core matcher:\n * - no `when` ⇒ always true (visible);\n * - otherwise true only when `record[when.field]` is present and its\n * stringified value is one of `when.in`.\n * A missing/undefined/null field is treated as \"not a match\"\n * (hidden), so a status-gated target never shows on a record that\n * lacks the status. */\nexport function whenMatches(when: WhenPredicate | undefined, record: Record<string, unknown>): boolean {\n if (!when) return true;\n // `fieldTextOrNull` rather than `String(...)`: an array/object field would\n // stringify to \"[object Object]\" and could match a `when.in` entry by\n // accident. No text ⇒ no match, same as an absent field.\n const text = fieldTextOrNull(record[when.field]);\n if (text === null) return false;\n return when.in.includes(text);\n}\n\n/** Minimal shape this helper needs from an action — the optional state\n * gate, whichever name its kind uses: `when` on the seeded kinds\n * (chat/agent), `require` on mutate. Accepts the full CollectionAction\n * union (each variant declares at most one of the two). */\nexport interface ActionWithWhen {\n when?: WhenPredicate;\n require?: WhenPredicate;\n}\n\n/** True when the action's button should render against `record` — and,\n * server-side, whether it may RUN (visibility is the authorization\n * rule, for every kind). */\nexport function actionVisible(action: ActionWithWhen, record: Record<string, unknown>): boolean {\n return whenMatches(action.when ?? action.require, record);\n}\n\n/** The run key naming one in-flight `kind: \"agent\"` action button —\n * written by the server's dispatch guard, read back by the client from\n * the detail response's `runningActions` to drive the spinner. ONE\n * builder (isomorphic) so the two sides can't drift. Collection-level\n * and per-record actions live in distinct namespaces so an id collision\n * between `actions` and `collectionActions` can't alias. */\nexport function agentActionRunKey(actionId: string, itemId?: string): string {\n return itemId === undefined ? `collection/${actionId}` : `item/${itemId}/${actionId}`;\n}\n\n/** Minimal shape this helper needs from a field spec — just its\n * optional `when` predicate. Accepts the full FieldSpec too. */\nexport interface FieldWithWhen {\n when?: WhenPredicate;\n}\n\n/** True when the field should render against `record`. A field with\n * no `when` is always shown; otherwise it's shown only when the\n * record matches (e.g. hide a rating field until `visited` is true).\n * Purely presentational — a hidden field's stored value is never\n * altered, so toggling the gate back on restores it. */\nexport function fieldVisible(field: FieldWithWhen, record: Record<string, unknown>): boolean {\n return whenMatches(field.when, record);\n}\n","// Pure resolution for `backlinks` fields (plan step ② of\n// plans/done/collection-ontology.md): the display-only reverse side of `ref`.\n// Both the server enrichment (`server/derive.ts`) and the client detail\n// view derive the row set through THESE helpers, so the LLM (getItems)\n// and the user (record panel) always see the same rows — the same\n// single-implementation rule `deriveAll` follows for formulas. No zod,\n// no I/O; safe for the browser barrel.\n\nimport { whenMatches } from \"./actionVisible\";\nimport { fieldTextOrNull } from \"./fieldText\";\nimport type { CollectionFieldSpec, CollectionItem } from \"./schema\";\n\n/** The `backlinks` member of the field-spec union. */\nexport type BacklinksFieldSpec = Extract<CollectionFieldSpec, { type: \"backlinks\" }>;\n\n/** The SOURCE records whose `via` field stores `recordId` (compared as\n * strings, like every ref deref), with the optional `filter` applied —\n * in the source items' given order. Fail-soft by construction: a `via`\n * key that doesn't exist on the source records simply matches nothing.\n * Callers pass DERIVED source records, so a `filter`/`display` on a\n * derived column works when its formula is SELF-CONTAINED (an invoice\n * `total` = sum over its own line items); a source column that derefs\n * yet another collection stays absent — the same each-record-derives-\n * against-itself rule ref targets follow. */\nexport function backlinkRows(spec: Pick<BacklinksFieldSpec, \"via\" | \"filter\">, recordId: string, sourceItems: CollectionItem[]): CollectionItem[] {\n if (!recordId) return [];\n // A `via` field holding an array/object has no id to compare — skip it\n // rather than testing \"[object Object]\" against the record id.\n return sourceItems.filter((item) => fieldTextOrNull(item[spec.via]) === recordId && whenMatches(spec.filter, item));\n}\n\n/** Project one backlink row to the keys consumers surface: the source\n * collection's primaryKey (rows must stay addressable — it's the link\n * target) plus the declared `display` columns. Keys the row doesn't\n * carry are simply absent, mirroring `projectFields` in getItems. */\nexport function projectBacklinkRow(row: CollectionItem, display: readonly string[], primaryKey: string): CollectionItem {\n const keys = display.includes(primaryKey) ? display : [primaryKey, ...display];\n return Object.fromEntries(keys.filter((key) => key in row).map((key) => [key, row[key]]));\n}\n\n/** The `rollup` member of the field-spec union. */\nexport type RollupFieldSpec = Extract<CollectionFieldSpec, { type: \"rollup\" }>;\n\n/** Numeric coercion shared by the strict record lint (`./recordZ`) and\n * rollup sums: a plain number, or a non-blank numeric string (renderers\n * coerce those via `Number(...)`, so they display fine). Anything else —\n * arrays (`[]` stringifies to `\"\"` = 0, `[42]` to `\"42\"`), booleans,\n * objects — is NaN. Lives here (zod-free) so both consumers share one\n * definition of \"numeric\". */\nexport function coerceNumeric(value: unknown): number {\n if (typeof value === \"number\") return value;\n if (typeof value === \"string\" && value.trim() !== \"\") return Number(value);\n return NaN;\n}\n\n/** The rollup aggregate over the matching source rows (plan step ⑤):\n * `count` = how many rows match; `sum` = the total of `column` over\n * them, skipping non-numeric / absent values (a partially-filled source\n * still sums what's there). An EMPTY match set is a real 0 — the\n * fail-soft null lives at the caller, for a source collection that\n * couldn't be resolved at all. Same derived-source-records contract as\n * `backlinkRows`: pass records derived against themselves, so summing a\n * self-contained derived column (an invoice `total`) works. */\nexport function rollupValue(spec: Pick<RollupFieldSpec, \"via\" | \"filter\" | \"op\" | \"column\">, recordId: string, sourceItems: CollectionItem[]): number {\n const rows = backlinkRows(spec, recordId, sourceItems);\n if (spec.op === \"count\") return rows.length;\n let total = 0;\n for (const row of rows) {\n const value = coerceNumeric(spec.column === undefined ? undefined : row[spec.column]);\n if (Number.isFinite(value)) total += value;\n }\n return total;\n}\n","// Pure SQL-like `where` predicate for `dynamicIcon` (see\n// `DynamicIconSource.where` / `DynamicIconRule.where` in `./schema`).\n// An AND of typed conditions — richer than the single-field `CollectionWhen`\n// used elsewhere (fields/actions via `./actionVisible`), which stays as-is\n// for its existing callers. No fs, no host state. The condition SHAPES are\n// derived from the zod source of truth in `./schemaZ` (type-only imports —\n// this evaluator stays zod-free at runtime).\n\nimport type { z } from \"zod\";\nimport type { ValueRefZ, WhereCondZ, WhereZ } from \"./schemaZ\";\n\n/** Reads the comparison value from a field instead of a schema literal:\n * - `record` set → another record: `recordsById[record][field]` (e.g. a\n * `_config` singleton's `defaultCity`, following a per-user setting);\n * - `record` omitted → the SAME record being matched (field-to-field, e.g.\n * `spent > budget`). */\nexport type ValueRef = z.infer<typeof ValueRefZ>;\n\n/** One typed condition: `record[field] <op> value`. The comparison value is\n * either a literal `value` (a plain string for every op except `in`, which\n * takes the allowed set) or a `valueFrom` reference resolved against the\n * `recordsById` map passed to `matchesWhere`. Exactly one of the two is\n * expected — enforced by zod at the schema boundary (`./schemaZ`), not\n * here. */\nexport type WhereCond = z.infer<typeof WhereCondZ>;\n\n/** Comparison operators one `WhereCond` may apply to `record[field]`. */\nexport type WhereOp = WhereCond[\"op\"];\n\n/** A `where` clause is the AND of its conditions — every one must match. */\nexport type Where = z.infer<typeof WhereZ>;\n\n/** True when `record[field]` is absent (`undefined`/`null`) — the only case\n * where `ne` and every other op disagree on the result. */\nfunction isMissing(raw: unknown): boolean {\n return raw === undefined || raw === null;\n}\n\n/** The effective comparison value for `cond`: its literal `value`, or — for\n * a `valueFrom` reference — the target field read out of `recordsById`.\n * `undefined` means UNRESOLVED (no such record, or the field on it is\n * missing); the caller must treat that as \"never matches\", not as a\n * literal `undefined` value to compare against. */\nfunction resolveValue(cond: WhereCond, record: Record<string, unknown>, recordsById: Record<string, Record<string, unknown>>): string | string[] | undefined {\n if (!cond.valueFrom) return cond.value;\n const { record: refRecord, field } = cond.valueFrom;\n const target = refRecord === undefined ? record : recordsById[refRecord];\n const raw = target?.[field];\n return isMissing(raw) ? undefined : String(raw);\n}\n\nfunction matchesNumericOp(operator: \"gt\" | \"gte\" | \"lt\" | \"lte\", left: number, right: number): boolean {\n if (operator === \"gt\") return left > right;\n if (operator === \"gte\") return left >= right;\n if (operator === \"lt\") return left < right;\n return left <= right;\n}\n\n/** `Number(\"\")` / `Number(\" \")` are `0`, not `NaN`, so treat a blank string\n * as non-numeric explicitly — an empty field must fail a numeric compare,\n * not read as zero. */\nfunction toNumber(raw: string): number {\n return raw.trim() === \"\" ? NaN : Number(raw);\n}\n\nfunction matchesNumeric(operator: \"gt\" | \"gte\" | \"lt\" | \"lte\", raw: string, value: string | string[]): boolean {\n if (Array.isArray(value)) return false;\n const left = toNumber(raw);\n const right = toNumber(value);\n if (Number.isNaN(left) || Number.isNaN(right)) return false;\n return matchesNumericOp(operator, left, right);\n}\n\n/** True when the present string `raw` satisfies `operator` against the\n * resolved `value` (field known to exist — MISSING is handled by the\n * caller before this runs, and an UNRESOLVED `valueFrom` never reaches\n * here either). */\nfunction matchesPresent(operator: WhereOp, raw: string, value: string | string[]): boolean {\n switch (operator) {\n case \"eq\":\n return raw === String(value);\n case \"ne\":\n return raw !== String(value);\n case \"in\":\n return Array.isArray(value) && value.includes(raw);\n case \"contains\":\n return raw.includes(String(value));\n case \"gt\":\n case \"gte\":\n case \"lt\":\n case \"lte\":\n return matchesNumeric(operator, raw, value);\n default:\n return false;\n }\n}\n\n/** True when `record` satisfies one condition, given `recordsById` to\n * resolve a `valueFrom` reference. Two independent MISSING cases, checked\n * in order:\n * - `record[cond.field]` absent (`undefined`/`null`) → matches only `ne`\n * (vacuously true — \"not equal to X\" holds when there's no value at\n * all); every other op is false. Unchanged from the literal-`value`\n * behaviour, regardless of whether `valueFrom` would also resolve.\n * - the resolved comparison value is UNRESOLVED (a `valueFrom` whose\n * target record/field doesn't exist) → false for EVERY op, including\n * `ne` — a broken reference must never spuriously match. */\nfunction matchesCond(cond: WhereCond, record: Record<string, unknown>, recordsById: Record<string, Record<string, unknown>>): boolean {\n const raw = record[cond.field];\n if (isMissing(raw)) return cond.op === \"ne\";\n const value = resolveValue(cond, record, recordsById);\n if (value === undefined) return false;\n return matchesPresent(cond.op, String(raw), value);\n}\n\n/** True when `record` satisfies every condition in `where` (AND). An empty\n * `where` matches everything. `recordsById` — the source collection's\n * records keyed by primaryKey — resolves any `valueFrom` reference;\n * omitted (default `{}`) for callers with no cross-record lookups, in\n * which case every `valueFrom` condition is UNRESOLVED and so never\n * matches. */\nexport function matchesWhere(where: Where, record: Record<string, unknown>, recordsById: Record<string, Record<string, unknown>> = {}): boolean {\n return where.every((cond) => matchesCond(cond, record, recordsById));\n}\n","// The \"is this record done?\" predicate — THE single implementation,\n// shared by the notification reconciler (bell clearing, collection-watchers),\n// spawn (successor-predicate fallback, ../server/spawn.ts), and view-side\n// completion filters. Zod-free and I/O-free like the rest of `core/`, so\n// it is browser-safe through the collection barrel.\n//\n// Two completion forms (see `CollectionSchemaZ`'s completion refine):\n// - legacy pair: `completionField` names a stored field and\n// `completionDoneValues` lists the values that mean done —\n// done ⇔ `String(item[completionField])` ∈ `completionDoneValues`.\n// - flag form: `completionField` names a `flag` field (and\n// `completionDoneValues` is absent) — done ⇔ the flag's `where`\n// matches. Evaluated directly against the raw record here (NOT read\n// from a materialized value) because callers like the reconciler and\n// spawn work on records straight off disk, before any `deriveAll`\n// enrichment. That raw evaluation is CORRECT BY CONSTRUCTION: a\n// schema-level refine rejects a completion flag whose `where`\n// references computed fields, so every condition reads stored data.\n\nimport { fieldTextOrNull } from \"./fieldText\";\nimport { matchesWhere, type Where } from \"./where\";\n\n/** The slice of a parsed schema the done predicate reads — minimal\n * structural shape (like `DerivableSchema`) so the client and server\n * `CollectionSchema` types both satisfy it as-is. */\nexport interface CompletionSchemaView {\n /** Optional so legacy-pair callers (and their test fixtures) that\n * never consult field specs keep working; only the flag form needs\n * to look the completion field up. */\n fields?: Record<string, { type: string; where?: Where }>;\n completionField?: string;\n completionDoneValues?: readonly string[];\n}\n\n/** True iff the schema declares completion tracking AND `item` is done\n * under whichever completion form the schema uses (see module doc). */\nexport function itemIsDone(schema: CompletionSchemaView, item: Record<string, unknown>): boolean {\n const { completionField, completionDoneValues } = schema;\n if (!completionField) return false;\n const spec = schema.fields?.[completionField];\n if (spec?.type === \"flag\" && spec.where) return matchesWhere(spec.where, item);\n if (!completionDoneValues) return false;\n // An array/object field has no text form; treat it as \"not done\" rather than\n // letting \"[object Object]\" match a configured done-value.\n const text = fieldTextOrNull(item[completionField]);\n if (text === null) return false;\n return completionDoneValues.includes(text);\n}\n","// Pure resolver for a collection's dynamic launcher-shortcut icon (see\n// `CollectionSchema.dynamicIcon`). Selects one \"source\" record from a\n// (possibly cross-collection, optionally `where`-filtered) records pool,\n// then maps it through a first-match-wins rules list to an icon name.\n// No fs, no host state — the server-side compute\n// (`packages/core/src/collection/server/dynamicIcon.ts`) loads the source\n// collection's records and calls these.\n\nimport { matchesWhere } from \"./where\";\nimport type { CollectionFieldSpec, CollectionItem, CollectionSchema, DynamicIconSource, DynamicIconSpec } from \"./schema\";\n\n/** The record with the greatest `String(record[field])` (localeCompare) —\n * ties keep the first-seen record (stable left-to-right `reduce`). */\nfunction latestByField(pool: CollectionItem[], field: string): CollectionItem {\n return pool.reduce((latest, candidate) => (String(candidate[field] ?? \"\").localeCompare(String(latest[field] ?? \"\")) > 0 ? candidate : latest));\n}\n\n/** Reduce `records` to the one record that decides the effective icon, per\n * `source`'s `where` filter + `from` strategy:\n * - pool = `source.where`-filtered records, or every record when unset;\n * - an empty pool resolves to `null` (no source record → fallback);\n * - `from: \"first\"` / `\"when\"` → the first pool record (storage order);\n * - `from: \"latest\"` (default), with `orderBy` given → the pool record\n * whose `String(record[orderBy])` sorts highest;\n * - `from: \"latest\"`, with no `orderBy` → the last pool record.\n * `recordsById` (the source collection's records keyed by primaryKey)\n * resolves any `valueFrom` reference inside `source.where`; omitted for\n * callers with no cross-record lookups. */\nexport function selectDynamicRecord(\n records: CollectionItem[],\n source: DynamicIconSource,\n orderBy: string | undefined,\n recordsById: Record<string, CollectionItem> = {},\n): CollectionItem | null {\n const { where } = source;\n const pool = where ? records.filter((record) => matchesWhere(where, record, recordsById)) : records;\n if (pool.length === 0) return null;\n if (source.from === \"first\" || source.from === \"when\") return pool[0];\n return orderBy ? latestByField(pool, orderBy) : pool[pool.length - 1];\n}\n\n/** Map a resolved source record to the effective icon: `spec.fallback`\n * (or the collection's own static `icon`) when there's no record or no\n * rule matches; otherwise the `icon` of the first rule whose `where`\n * matches the record. `recordsById` resolves any `valueFrom` reference\n * inside a rule's `where`, same as `selectDynamicRecord`. */\nexport function resolveIcon(\n record: CollectionItem | null,\n spec: DynamicIconSpec,\n staticIcon: string,\n recordsById: Record<string, CollectionItem> = {},\n): string {\n const fallback = spec.fallback ?? staticIcon;\n if (!record) return fallback;\n const matched = spec.rules.find((rule) => matchesWhere(rule.where, record, recordsById));\n return matched ? matched.icon : fallback;\n}\n\nconst isDateLikeField = (field: CollectionFieldSpec): boolean => field.type === \"date\" || field.type === \"datetime\";\n\n/** The first field key (declaration order) whose type is `date` or\n * `datetime` — the default `orderBy` for `from: \"latest\"` when a\n * `DynamicIconSource` doesn't name one. `undefined` when the schema has\n * no date-like field. */\nexport function firstDateField(schema: CollectionSchema): string | undefined {\n return Object.entries(schema.fields).find(([, field]) => isDateLikeField(field))?.[0];\n}\n","// Tiny expression evaluator for the `derived` field type on\n// schema-driven collections (see plans/done/feat-mc-invoice.md).\n//\n// Grammar (recursive-descent, no precedence climbing — six\n// non-terminals total):\n//\n// expr := term (('+' | '-') term)*\n// term := factor (('*' | '/') factor)*\n// factor := number | sumCall | refAccess | identifier | '(' expr ')'\n// sumCall:= 'sum' '(' sumArg ')'\n// sumArg := tableCol (('*' | '/') tableCol)* // e.g. lineItems[].quantity * lineItems[].rate\n// tableCol := identifier '[]' '.' identifier\n// refAccess := identifier '.' identifier // e.g. ticker.price — deref a ref field into its target record\n//\n// `identifier` accepts top-level field names (single segment).\n// Inside `sumArg`, identifiers are the `<table>[].col` form.\n// A two-segment `<field>.<col>` at factor level is a *ref deref*:\n// `<field>` must be a `ref`-typed field on this record (its stored\n// value is the target item's slug), and `<col>` is a numeric column\n// read from that target record. The caller resolves the target into\n// `ctx.refs` (it owns the schema + the loaded target collection);\n// the evaluator stays pure and never does I/O.\n//\n// What's deliberately NOT supported (and would parse-error rather\n// than silently misbehave):\n// - String literals, boolean operators, comparisons, conditionals\n// - Nested function calls beyond `sum(...)`\n// - Anything in the record that isn't a number / table-of-objects\n//\n// All evaluation is pure — no eval(), no Function constructor.\n// Returns `null` on any failure (parse error, unbound identifier,\n// non-finite arithmetic). The caller renders `null` as em-dash in\n// the table cell + form display.\n\nexport interface FormulaContext {\n /** The record being evaluated. For derived fields in the form,\n * this is the live draft (text + table both converted via the\n * same `draftToRecord` pipeline). For the main table cell,\n * this is the persisted item. */\n record: Record<string, unknown>;\n /** Resolved ref-target records for THIS row, keyed by the local\n * `ref` field name. The caller (which has the schema + the linked\n * collection's items loaded) maps each ref field's stored slug to\n * the full target record and passes it here, so a `<field>.<col>`\n * formula can read a numeric column off the referenced record\n * (e.g. `shares * ticker.price`). A missing key or `null` value\n * (unknown field / dangling slug) makes that deref evaluate to\n * NaN → the whole formula returns `null` → em-dash, consistent\n * with every other failure mode. Absent ⇒ no refs available. */\n refs?: Record<string, Record<string, unknown> | null>;\n}\n\nexport function evaluateDerived(formula: string, ctx: FormulaContext): number | null {\n let tokens: Token[];\n try {\n tokens = tokenize(formula);\n } catch {\n return null;\n }\n // eslint-disable-next-line @typescript-eslint/no-use-before-define -- Parser class is defined later in the file (grouped with its AST + evaluator); evaluateDerived runs after module init so the TDZ concern doesn't apply.\n const parser = new Parser(tokens);\n let ast: Node;\n try {\n ast = parser.parseExpr();\n if (!parser.atEnd()) return null; // trailing junk\n } catch {\n return null;\n }\n const value = evaluate(ast, ctx);\n return Number.isFinite(value) ? value : null;\n}\n\n// ─── Tokens ────────────────────────────────────────────────\n\ntype TokenKind = \"number\" | \"ident\" | \"(\" | \")\" | \"+\" | \"-\" | \"*\" | \"/\" | \"[]\" | \".\";\n\ninterface Token {\n kind: TokenKind;\n value?: string | number;\n}\n\nconst SINGLE_CHAR_PUNCT = new Set<TokenKind>([\"(\", \")\", \"+\", \"-\", \"*\", \"/\", \".\"]);\n\ninterface Cursor {\n input: string;\n index: number;\n}\n\nfunction consumeWhitespace(cur: Cursor): boolean {\n const char = cur.input[cur.index];\n if (char === \" \" || char === \"\\t\" || char === \"\\n\") {\n cur.index++;\n return true;\n }\n return false;\n}\n\nfunction consumeNumber(cur: Cursor): Token | null {\n const char = cur.input[cur.index] ?? \"\";\n const next = cur.input[cur.index + 1] ?? \"\";\n if (!isDigit(char) && !(char === \".\" && isDigit(next))) return null;\n let raw = \"\";\n while (cur.index < cur.input.length) {\n const here = cur.input[cur.index] ?? \"\";\n if (!isDigit(here) && here !== \".\") break;\n raw += here;\n cur.index++;\n }\n const num = Number(raw);\n if (!Number.isFinite(num)) throw new Error(\"bad number\");\n return { kind: \"number\", value: num };\n}\n\nfunction consumeIdent(cur: Cursor): Token | null {\n const char = cur.input[cur.index] ?? \"\";\n if (!isIdentStart(char)) return null;\n let raw = \"\";\n while (cur.index < cur.input.length && isIdentChar(cur.input[cur.index] ?? \"\")) {\n raw += cur.input[cur.index];\n cur.index++;\n }\n return { kind: \"ident\", value: raw };\n}\n\nfunction consumePunct(cur: Cursor): Token | null {\n const char = cur.input[cur.index] ?? \"\";\n if (char === \"[\" && cur.input[cur.index + 1] === \"]\") {\n cur.index += 2;\n return { kind: \"[]\" };\n }\n if (SINGLE_CHAR_PUNCT.has(char as TokenKind)) {\n cur.index++;\n return { kind: char as TokenKind };\n }\n return null;\n}\n\nfunction tokenize(input: string): Token[] {\n const tokens: Token[] = [];\n const cur: Cursor = { input, index: 0 };\n while (cur.index < input.length) {\n if (consumeWhitespace(cur)) continue;\n // Number FIRST so a leading-dot literal (`.25`) isn't split by\n // the `.` punctuation branch.\n const numTok = consumeNumber(cur);\n if (numTok) {\n tokens.push(numTok);\n continue;\n }\n const punctTok = consumePunct(cur);\n if (punctTok) {\n tokens.push(punctTok);\n continue;\n }\n const identTok = consumeIdent(cur);\n if (identTok) {\n tokens.push(identTok);\n continue;\n }\n throw new Error(`unexpected char ${input[cur.index]}`);\n }\n return tokens;\n}\n\nfunction isDigit(char: string): boolean {\n return char >= \"0\" && char <= \"9\";\n}\nfunction isIdentStart(char: string): boolean {\n return (char >= \"a\" && char <= \"z\") || (char >= \"A\" && char <= \"Z\") || char === \"_\";\n}\nfunction isIdentChar(char: string): boolean {\n return isIdentStart(char) || isDigit(char);\n}\n\n// ─── AST + Parser ───────────────────────────────────────────\n\ntype Node =\n | { kind: \"num\"; value: number }\n | { kind: \"ident\"; name: string }\n | { kind: \"ref\"; field: string; col: string }\n | { kind: \"binop\"; operator: \"+\" | \"-\" | \"*\" | \"/\"; left: Node; right: Node }\n | { kind: \"sum\"; arg: SumArg };\n\ninterface SumArg {\n // factors multiplied/divided together; each is a (tableName, colName) ref into a row.\n factors: { table: string; col: string }[];\n /** Operators between factors: length = factors.length - 1; each\n * is \"*\" or \"/\". For a single-factor sum (`sum(lineItems[].amount)`)\n * this is empty. */\n operators: (\"*\" | \"/\")[];\n}\n\nclass Parser {\n private cursor = 0;\n constructor(private readonly tokens: Token[]) {}\n\n atEnd(): boolean {\n return this.cursor >= this.tokens.length;\n }\n private peek(): Token | undefined {\n return this.tokens[this.cursor];\n }\n private consume(): Token {\n const tok = this.tokens[this.cursor++];\n if (!tok) throw new Error(\"unexpected end of input\");\n return tok;\n }\n private expect(kind: TokenKind): Token {\n const tok = this.consume();\n if (tok.kind !== kind) throw new Error(`expected ${kind}, got ${tok.kind}`);\n return tok;\n }\n\n parseExpr(): Node {\n let left = this.parseTerm();\n while (this.peek()?.kind === \"+\" || this.peek()?.kind === \"-\") {\n const operator = this.consume().kind as \"+\" | \"-\";\n const right = this.parseTerm();\n left = { kind: \"binop\", operator, left, right };\n }\n return left;\n }\n\n private parseTerm(): Node {\n let left = this.parseFactor();\n while (this.peek()?.kind === \"*\" || this.peek()?.kind === \"/\") {\n const operator = this.consume().kind as \"*\" | \"/\";\n const right = this.parseFactor();\n left = { kind: \"binop\", operator, left, right };\n }\n return left;\n }\n\n private parseFactor(): Node {\n const tok = this.peek();\n if (!tok) throw new Error(\"unexpected end in factor\");\n if (tok.kind === \"number\") {\n this.consume();\n return { kind: \"num\", value: tok.value as number };\n }\n if (tok.kind === \"(\") {\n this.consume();\n const inner = this.parseExpr();\n this.expect(\")\");\n return inner;\n }\n if (tok.kind === \"ident\") {\n const name = (tok.value as string) ?? \"\";\n // sum(...) — only function call we support\n if (name === \"sum\" && this.tokens[this.cursor + 1]?.kind === \"(\") {\n this.consume(); // ident\n this.expect(\"(\");\n const arg = this.parseSumArg();\n this.expect(\")\");\n return { kind: \"sum\", arg };\n }\n this.consume(); // ident\n // ref deref: `<field>.<col>` (e.g. ticker.price). The table-row\n // form `<table>[].col` only appears inside sum(), so a `.`\n // immediately after a top-level ident is unambiguously a ref\n // dereference here.\n if (this.peek()?.kind === \".\") {\n this.consume(); // '.'\n const col = this.expect(\"ident\");\n return { kind: \"ref\", field: name, col: col.value as string };\n }\n return { kind: \"ident\", name };\n }\n throw new Error(`unexpected token ${tok.kind} in factor`);\n }\n\n private parseSumArg(): SumArg {\n const factors: { table: string; col: string }[] = [];\n const operators: (\"*\" | \"/\")[] = [];\n factors.push(this.parseTableCol());\n while (this.peek()?.kind === \"*\" || this.peek()?.kind === \"/\") {\n const operator = this.consume().kind as \"*\" | \"/\";\n operators.push(operator);\n factors.push(this.parseTableCol());\n }\n return { factors, operators };\n }\n\n private parseTableCol(): { table: string; col: string } {\n const tableTok = this.expect(\"ident\");\n this.expect(\"[]\");\n this.expect(\".\");\n const colTok = this.expect(\"ident\");\n return { table: tableTok.value as string, col: colTok.value as string };\n }\n}\n\n// ─── Evaluator ──────────────────────────────────────────────\n\nfunction evaluate(node: Node, ctx: FormulaContext): number {\n if (node.kind === \"num\") return node.value;\n if (node.kind === \"ident\") {\n const raw = ctx.record[node.name];\n return toFiniteNumber(raw);\n }\n if (node.kind === \"ref\") {\n // `<field>.<col>`: read `col` off the resolved target record the\n // caller put in ctx.refs. Unknown field / dangling slug → null →\n // NaN, so the whole formula fails soft to an em-dash.\n const target = ctx.refs?.[node.field] ?? null;\n if (!target) return Number.NaN;\n return toFiniteNumber(target[node.col]);\n }\n if (node.kind === \"binop\") {\n const left = evaluate(node.left, ctx);\n const right = evaluate(node.right, ctx);\n return applyBinop(node.operator, left, right);\n }\n if (node.kind === \"sum\") {\n return evaluateSum(node.arg, ctx);\n }\n // Exhaustive — TS narrows above branches but throw keeps runtime honest.\n throw new Error(`unknown node`);\n}\n\nfunction applyBinop(operator: \"+\" | \"-\" | \"*\" | \"/\", left: number, right: number): number {\n if (!Number.isFinite(left) || !Number.isFinite(right)) return Number.NaN;\n if (operator === \"+\") return left + right;\n if (operator === \"-\") return left - right;\n if (operator === \"*\") return left * right;\n // operator === \"/\"\n if (right === 0) return Number.NaN;\n return left / right;\n}\n\nfunction evaluateSum(arg: SumArg, ctx: FormulaContext): number {\n if (arg.factors.length === 0) return 0;\n const tableName = arg.factors[0].table;\n // All factors must reference the SAME table (you can't multiply\n // a row from lineItems against a row from another table — the\n // semantics would be ambiguous). Reject mismatch.\n for (const factor of arg.factors) {\n if (factor.table !== tableName) return Number.NaN;\n }\n const rows = ctx.record[tableName];\n if (!Array.isArray(rows)) return 0;\n let total = 0;\n for (const row of rows) {\n if (!row || typeof row !== \"object\") continue;\n let product = toFiniteNumber((row as Record<string, unknown>)[arg.factors[0].col]);\n if (!Number.isFinite(product)) return Number.NaN;\n for (let i = 1; i < arg.factors.length; i++) {\n const value = toFiniteNumber((row as Record<string, unknown>)[arg.factors[i].col]);\n if (!Number.isFinite(value)) return Number.NaN;\n product = applyBinop(arg.operators[i - 1], product, value);\n }\n total += product;\n }\n return total;\n}\n\nfunction toFiniteNumber(value: unknown): number {\n if (typeof value === \"number\") return Number.isFinite(value) ? value : Number.NaN;\n if (typeof value === \"string\" && value.length > 0) {\n const num = Number(value);\n return Number.isFinite(num) ? num : Number.NaN;\n }\n return Number.NaN;\n}\n","// The derived-field saturation loop for schema-driven collections,\n// extracted from `composables/collections/useCollectionRendering.ts` so\n// the server (manageCollection getItems enrichment) and the client\n// (table cells, form display) evaluate formulas through ONE\n// implementation — if the two ever diverged, the UI and the LLM would\n// disagree on a number. Pure module: no Vue, no I/O.\n//\n// Like `actionVisible.ts`, the input types are minimal structural\n// shapes so both the client `FieldSpec`/`CollectionSchema`\n// (src/components/collectionTypes.ts) and the server\n// `CollectionFieldSpec`/`CollectionSchema`\n// (server/workspace/collections/types.ts) satisfy them as-is.\n\nimport { evaluateDerived, type FormulaContext } from \"./derivedFormula\";\nimport { matchesWhere, type Where } from \"./where\";\n\n/** Minimal field shape the derive loop needs — accepts both the client\n * FieldSpec and the server CollectionFieldSpec. */\nexport interface DerivableFieldSpec {\n type: string;\n /** When type === \"ref\": slug of the target collection. */\n to?: string;\n /** When type === \"derived\": formula evaluated against the record. */\n formula?: string;\n /** When type === \"flag\": predicate matched against the record. */\n where?: Where;\n}\n\n/** Minimal schema shape: just the ordered field map. */\nexport interface DerivableSchema {\n fields: Record<string, DerivableFieldSpec>;\n}\n\nexport type DerivableRecord = Record<string, unknown>;\n\n/** Per-target-collection cache of loaded referenced records:\n * target collection slug → item slug → full record. Mirrors the\n * client's `RefRecordCache` / the server's enrichment loader. */\nexport type DeriveRefRecords = Record<string, Record<string, DerivableRecord>>;\n\n/** Map each `ref` field's stored slug to its loaded target record (or\n * null when dangling / not loaded), keyed by the LOCAL field name —\n * the shape `evaluateDerived` reads for `<field>.<col>` derefs. */\nexport function resolveRowRefs(schema: DerivableSchema, record: DerivableRecord, refRecords: DeriveRefRecords): NonNullable<FormulaContext[\"refs\"]> {\n const refs: NonNullable<FormulaContext[\"refs\"]> = {};\n for (const [key, field] of Object.entries(schema.fields)) {\n if (field.type !== \"ref\" || !field.to) continue;\n const slug = record[key];\n refs[key] = typeof slug === \"string\" ? (refRecords[field.to]?.[slug] ?? null) : null;\n }\n return refs;\n}\n\n/** True for the field types the saturation loop below (re)computes:\n * `derived` formulas and `flag` predicates. */\nfunction isLoopComputed(field: DerivableFieldSpec): boolean {\n return field.type === \"derived\" || field.type === \"flag\";\n}\n\n/** The value one loop-computed field takes against the current\n * `enriched` record: a `derived` formula result (`null` = failed) or a\n * `flag` predicate match (total — always a boolean). `undefined` for\n * every other field type (nothing to compute). */\nfunction computeFieldValue(\n field: DerivableFieldSpec,\n enriched: DerivableRecord,\n refs: NonNullable<FormulaContext[\"refs\"]>,\n): number | boolean | null | undefined {\n if (field.type === \"derived\" && field.formula) return evaluateDerived(field.formula, { record: enriched, refs });\n if (field.type === \"flag\" && field.where) return matchesWhere(field.where, enriched);\n return undefined;\n}\n\n/** Evaluate every `derived` and `flag` field against `base`, saturating\n * so a computed field can read another one computed in an earlier pass\n * (`subtotal → tax → total` converges in ≤ field-count passes; a flag\n * may read a derived value, or another flag via its stringified\n * boolean). Cycles can't loop forever — passes are bounded by the\n * number of computed fields and the loop breaks as soon as a pass\n * changes nothing. Failed formulas stay ABSENT (the UI renders them as\n * em-dash); flags are total (always true/false). Returns a copy;\n * `base` is never mutated.\n *\n * Computed keys already present in `base` are stripped before\n * evaluation: computed output is host-truth, never persisted-input\n * fallback. A record JSON can carry a stale (or forged) computed value\n * — raw Write/Edit, legacy data — and without the strip, a failing\n * formula would silently surface that value as if the host computed\n * it. */\nexport function deriveAll(schema: DerivableSchema, base: DerivableRecord, refRecords: DeriveRefRecords): DerivableRecord {\n const computedKeys = new Set(\n Object.entries(schema.fields)\n .filter(([, field]) => isLoopComputed(field))\n .map(([key]) => key),\n );\n const enriched: DerivableRecord = Object.fromEntries(Object.entries(base).filter(([key]) => !computedKeys.has(key)));\n const refs = resolveRowRefs(schema, base, refRecords);\n for (let pass = 0; pass < computedKeys.size; pass++) {\n let mutated = false;\n for (const [key, field] of Object.entries(schema.fields)) {\n const next = computeFieldValue(field, enriched, refs);\n if (next !== undefined && next !== null && enriched[key] !== next) {\n enriched[key] = next;\n mutated = true;\n }\n }\n if (!mutated) break;\n }\n return enriched;\n}\n","// Pure, deterministic helpers for the collection calendar view: parse\n// `date`-field values, build a month grid, and bucket records onto the\n// days they cover. No `Date.now()` / `new Date()` (argless) here — every\n// function takes its inputs explicitly so the logic is unit-testable\n// without faking the clock. All internal arithmetic runs in UTC (which\n// has no DST), so fixed 86_400_000 ms steps never skip or double a day.\n\nconst MS_PER_DAY = 86_400_000;\nconst ISO_DATE_RE = /^(\\d{4})-(\\d{2})-(\\d{2})$/;\n// A two-digit field (hours / minutes / seconds) of a clock value.\nconst TWO_DIGIT_RE = /^\\d{2}$/;\n// A single clock token inside a free-form `time` string field (e.g. the\n// \"14:00-17:00\" / \"17:00-\" / \"16:30\" / \"終日\" shapes seen in user data).\nconst CLOCK_RE = /(\\d{1,2}):(\\d{2})/g;\n// Range separators we tolerate between two clock tokens: ASCII hyphen, en/em\n// dash, tilde, and the Japanese wave dashes.\nconst RANGE_SEP_RE = /[-–—~〜~]/;\n\n/** Minutes in a full day — the timeline's vertical extent. */\nexport const MINUTES_PER_DAY = 1440;\n\n/** A civil date triple. `month` is 1-12 (NOT the 0-based `Date` month). */\nexport interface Ymd {\n year: number;\n month: number;\n day: number;\n}\n\n/** One cell of the 6×7 month grid. */\nexport interface DayCell {\n ymd: Ymd;\n /** False for the leading/trailing days that belong to the adjacent\n * month (rendered greyed). */\n inMonth: boolean;\n /** Canonical `YYYY-MM-DD` key for this cell. */\n key: string;\n}\n\n/** A record placed on the calendar: the inclusive `[start, end]` span of\n * days it covers. `end === start` for a single-day record. `startMin` /\n * `endMin` are minutes-of-day for the time-allocation (day) view, resolved\n * from either a `datetime` field's clock or a separate time-string field.\n * `null` means \"no clock\" — `startMin === null && endMin === null` is an\n * all-day record; a non-null `startMin` with a null `endMin` is a\n * point-in-time record (rendered as a single line). */\nexport interface RecordSpan<T> {\n item: T;\n start: Ymd;\n end: Ymd;\n startMin: number | null;\n endMin: number | null;\n}\n\nfunction pad2(value: number): string {\n return String(value).padStart(2, \"0\");\n}\n\n/** Canonical `YYYY-MM-DD` string for a civil date. */\nexport function ymdKey(ymd: Ymd): string {\n return `${String(ymd.year).padStart(4, \"0\")}-${pad2(ymd.month)}-${pad2(ymd.day)}`;\n}\n\n/** Strictly parse a `YYYY-MM-DD` string into a civil date, rejecting\n * anything that isn't a real calendar day (e.g. `2026-02-30`, `2026-13-01`).\n * Returns null for non-strings and malformed values so callers can route\n * records with no usable date into the \"no date\" tray rather than crash. */\nexport function parseIsoDate(value: unknown): Ymd | null {\n if (typeof value !== \"string\") return null;\n const match = ISO_DATE_RE.exec(value.trim());\n if (!match) return null;\n const year = Number(match[1]);\n const month = Number(match[2]);\n const day = Number(match[3]);\n // Round-trip through a UTC Date to reject impossible days: a value the\n // Date constructor rolls over (Feb 30 → Mar 2) won't match back.\n const probe = new Date(Date.UTC(year, month - 1, day));\n if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return null;\n return { year, month, day };\n}\n\n/** Minutes-of-day for an `HH:MM` pair, or null when out of range. */\nfunction clockToMinutes(hours: number, minutes: number): number | null {\n if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;\n return hours * 60 + minutes;\n}\n\n/** Strictly parse a `YYYY-MM-DDTHH:MM` (optional `:SS`) datetime into its\n * civil date and minutes-of-day. Returns null for anything that isn't a real\n * calendar day or a valid 24h clock. */\nexport function parseIsoDateTime(value: unknown): { ymd: Ymd; minutes: number } | null {\n if (typeof value !== \"string\") return null;\n const trimmed = value.trim();\n const tIndex = trimmed.indexOf(\"T\");\n if (tIndex === -1) return null;\n const ymd = parseIsoDate(trimmed.slice(0, tIndex));\n if (!ymd) return null;\n // `HH:MM` with an optional `:SS` the browser appends for non-zero seconds.\n const parts = trimmed.slice(tIndex + 1).split(\":\");\n if (parts.length < 2 || parts.length > 3 || !parts.every((part) => TWO_DIGIT_RE.test(part))) return null;\n const minutes = clockToMinutes(Number(parts[0]), Number(parts[1]));\n if (minutes === null) return null;\n return { ymd, minutes };\n}\n\n/** Civil date from either a `YYYY-MM-DD` or a `YYYY-MM-DDTHH:MM` value, so the\n * month grid buckets date-only and datetime anchors alike. */\nexport function dateOf(value: unknown): Ymd | null {\n return parseIsoDate(value) ?? parseIsoDateTime(value)?.ymd ?? null;\n}\n\n/** Minutes-of-day from a datetime value, or null for date-only / invalid. */\nfunction timeOf(value: unknown): number | null {\n return parseIsoDateTime(value)?.minutes ?? null;\n}\n\n/** Parse a free-form time-string field into start/end minutes-of-day.\n * Handles the common shapes in user data:\n * \"14:00-17:00\" → { start: 840, end: 1020 } (range → block)\n * \"17:00-\" → { start: 1020, end: null } (open end → single line)\n * \"16:30\" → { start: 990, end: null } (point in time → single line)\n * \"終日\" / \"\" → null (no clock → all-day)\n * Returns null when no clock token is parseable. */\nexport function parseTimeRange(value: unknown): { startMin: number | null; endMin: number | null } | null {\n if (typeof value !== \"string\") return null;\n const text = value.trim();\n if (!text) return null;\n const tokens = [...text.matchAll(CLOCK_RE)];\n if (tokens.length === 0) return null;\n const minutesOf = (match: RegExpMatchArray): number | null => clockToMinutes(Number(match[1]), Number(match[2]));\n // No separator → a single point in time (start only).\n if (!RANGE_SEP_RE.test(text)) {\n const startMin = minutesOf(tokens[0]);\n return startMin === null ? null : { startMin, endMin: null };\n }\n // Separator present → assign each token to the side of the first separator.\n const sepIndex = text.search(RANGE_SEP_RE);\n let startMin: number | null = null;\n let endMin: number | null = null;\n for (const token of tokens) {\n if ((token.index ?? 0) < sepIndex) startMin = minutesOf(token);\n else endMin = minutesOf(token);\n }\n // A start-less range (\"-09:00\") has no anchor on the timeline → treat as\n // unparseable so the record falls back to the all-day strip.\n if (startMin === null) return null;\n return { startMin, endMin };\n}\n\nfunction ymdToUtcMs(ymd: Ymd): number {\n return Date.UTC(ymd.year, ymd.month - 1, ymd.day);\n}\n\nfunction utcMsToYmd(epochMs: number): Ymd {\n const date = new Date(epochMs);\n return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate() };\n}\n\n/** Chronological comparison: negative if `left` precedes `right`, 0 if the\n * same day, positive if after. */\nexport function compareYmd(left: Ymd, right: Ymd): number {\n return ymdToUtcMs(left) - ymdToUtcMs(right);\n}\n\n/** True iff `day` falls within the inclusive span `[span.start, span.end]`. */\nexport function spanCoversDay<T>(span: RecordSpan<T>, day: Ymd): boolean {\n return compareYmd(span.start, day) <= 0 && compareYmd(day, span.end) <= 0;\n}\n\n/** Build the 6×7 (42-cell) grid for the given month, including the\n * leading/trailing days of the adjacent months so every week is full.\n * `month` is 1-12. `weekStartsOn` is 0 (Sunday) … 6 (Saturday). */\nexport function buildMonthGrid(year: number, month: number, weekStartsOn = 0): DayCell[] {\n const firstWeekday = new Date(Date.UTC(year, month - 1, 1)).getUTCDay();\n const lead = (firstWeekday - weekStartsOn + 7) % 7;\n const startMs = Date.UTC(year, month - 1, 1) - lead * MS_PER_DAY;\n const cells: DayCell[] = [];\n for (let i = 0; i < 42; i++) {\n const ymd = utcMsToYmd(startMs + i * MS_PER_DAY);\n cells.push({ ymd, inMonth: ymd.year === year && ymd.month === month, key: ymdKey(ymd) });\n }\n return cells;\n}\n\n/** Resolve a record's calendar span from its date/datetime fields. Returns\n * null when the anchor date is missing/invalid (→ the caller's \"no date\"\n * tray). An end date that is missing, invalid, or earlier than the start\n * collapses to a single-day span — never an inverted range.\n *\n * Times for the day (time-allocation) view come from, in priority order:\n * 1. the clock on a `datetime` anchor/end value, else\n * 2. `timeField` — a separate free-form time-string column (e.g. \"14:00-17:00\").\n * A record with no resolvable clock has `startMin === endMin === null`. */\nexport function recordSpan<T extends Record<string, unknown>>(item: T, anchorField: string, endField?: string, timeField?: string): RecordSpan<T> | null {\n const startRaw = item[anchorField];\n const start = dateOf(startRaw);\n if (!start) return null;\n let end = start;\n let startMin = timeOf(startRaw);\n let endMin: number | null = null;\n if (endField) {\n const endRaw = item[endField];\n const parsedEnd = dateOf(endRaw);\n if (parsedEnd && compareYmd(parsedEnd, start) >= 0) {\n end = parsedEnd;\n endMin = timeOf(endRaw);\n }\n }\n // Fall back to a separate time-string field only when the date fields\n // carried no clock (the date-only anchor + `time` column shape).\n if (timeField && startMin === null && endMin === null) {\n const range = parseTimeRange(item[timeField]);\n if (range) {\n ({ startMin, endMin } = range);\n }\n }\n return { item, start, end, startMin, endMin };\n}\n\n/** Split records into those that land on the calendar (with their spans)\n * and those with no usable anchor date (the \"no date\" tray). Spans are\n * sorted by start day so same-day stacking is stable across renders. */\nexport function bucketRecords<T extends Record<string, unknown>>(\n items: readonly T[],\n anchorField: string,\n endField?: string,\n timeField?: string,\n): { spans: RecordSpan<T>[]; noDate: T[] } {\n const spans: RecordSpan<T>[] = [];\n const noDate: T[] = [];\n for (const item of items) {\n const span = recordSpan(item, anchorField, endField, timeField);\n if (span) spans.push(span);\n else noDate.push(item);\n }\n spans.sort((left, right) => compareYmd(left.start, right.start));\n return { spans, noDate };\n}\n\n/** Geometry for one record on one day of the time-allocation view.\n * `kind`:\n * \"allDay\" — no clock anywhere → render in the bottom all-day strip.\n * \"line\" — a single point in time → a 1px marker at `startMin`.\n * \"block\" — a [startMin, endMin) span, clamped to this day's [0, 1440).\n * `bleedsBefore` / `bleedsAfter` flag a multi-day span that began on an\n * earlier day or continues onto a later one (so the view can show arrows). */\nexport interface DaySlice {\n kind: \"allDay\" | \"line\" | \"block\";\n startMin: number;\n endMin: number;\n bleedsBefore: boolean;\n bleedsAfter: boolean;\n}\n\n/** Project a record's span onto a single day for the time-allocation view, or\n * null when the span doesn't cover that day. */\nexport function daySlice<T>(span: RecordSpan<T>, day: Ymd): DaySlice | null {\n if (!spanCoversDay(span, day)) return null;\n const hasStart = span.startMin !== null;\n const hasEnd = span.endMin !== null;\n if (!hasStart && !hasEnd) {\n return { kind: \"allDay\", startMin: 0, endMin: MINUTES_PER_DAY, bleedsBefore: false, bleedsAfter: false };\n }\n const singleDay = compareYmd(span.start, span.end) === 0;\n const isStartDay = compareYmd(day, span.start) === 0;\n const isEndDay = compareYmd(day, span.end) === 0;\n // A point in time: a start clock with no end, all on one day.\n if (singleDay && hasStart && !hasEnd) {\n return { kind: \"line\", startMin: span.startMin as number, endMin: span.startMin as number, bleedsBefore: false, bleedsAfter: false };\n }\n const startMin = isStartDay && hasStart ? (span.startMin as number) : 0;\n const endMin = isEndDay && hasEnd ? (span.endMin as number) : MINUTES_PER_DAY;\n // Zero-length or inverted same-day range → degrade to a line.\n if (singleDay && endMin <= startMin) {\n return { kind: \"line\", startMin, endMin: startMin, bleedsBefore: false, bleedsAfter: false };\n }\n return { kind: \"block\", startMin, endMin, bleedsBefore: !isStartDay, bleedsAfter: !isEndDay };\n}\n\n/** Side-by-side lane assignment for overlapping timeline blocks. Each input\n * is an `[startMin, endMin)` interval; the result (parallel to the input)\n * gives each item its `lane` (column index) and the `lanes` total of its\n * overlap cluster, so a renderer can size every block to `1 / lanes` width\n * and offset it by `lane / lanes`. Non-overlapping items get `lanes === 1`. */\nexport interface LaneSpan {\n startMin: number;\n endMin: number;\n}\nexport interface LaneAssignment {\n lane: number;\n lanes: number;\n}\n\nexport function assignLanes(blocks: readonly LaneSpan[]): LaneAssignment[] {\n const order = [...blocks.keys()].sort((left, right) => blocks[left].startMin - blocks[right].startMin || blocks[left].endMin - blocks[right].endMin);\n const result: LaneAssignment[] = blocks.map(() => ({ lane: 0, lanes: 1 }));\n let cluster: number[] = [];\n let clusterEnd = Number.NEGATIVE_INFINITY;\n const laneEnds: number[] = [];\n const flush = (): void => {\n for (const index of cluster) result[index].lanes = laneEnds.length;\n cluster = [];\n laneEnds.length = 0;\n clusterEnd = Number.NEGATIVE_INFINITY;\n };\n for (const index of order) {\n const block = blocks[index];\n if (cluster.length > 0 && block.startMin >= clusterEnd) flush();\n let lane = laneEnds.findIndex((end) => end <= block.startMin);\n if (lane === -1) {\n lane = laneEnds.length;\n laneEnds.push(block.endMin);\n } else {\n laneEnds[lane] = block.endMin;\n }\n result[index].lane = lane;\n cluster.push(index);\n clusterEnd = Math.max(clusterEnd, block.endMin);\n }\n flush();\n return result;\n}\n\n/** Month label key inputs — returns the 1st of the month as a `Date` so the\n * component can feed it to `Intl.DateTimeFormat(locale, …)` for a localized\n * \"April 2026\" header without this module taking a locale dependency. */\nexport function monthAnchorDate(year: number, month: number): Date {\n return new Date(Date.UTC(year, month - 1, 1));\n}\n","// Neutralize structural prompt-injection vectors in a short, record-derived\n// string before it rides into an LLM-facing prompt: strip angle brackets,\n// defang backticks / `${` template openings, collapse whitespace (so an\n// embedded newline can't fabricate a pseudo-instruction on its own line), and\n// clip to a small budget. Mirrors the host server's own defang so the two\n// can't drift (#1677).\n\n/** Max chars kept — the first batch of a validation issue is enough to act on. */\nconst DEFANG_MAX_LEN = 200;\n\nexport function defangForPrompt(value: string): string {\n return value.replace(/[<>]/g, \"\").replace(/`/g, \"'\").replace(/\\$\\{/g, \"$ {\").replace(/\\s+/g, \" \").slice(0, DEFANG_MAX_LEN);\n}\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_errorMessage = require("../../errorMessage--LvXe_Jx.cjs");
2
+ const require_errors = require("../../errors-7P5eMOSX.cjs");
3
3
  const require_remote_host_index = require("../index.cjs");
4
4
  let firebase_firestore = require("firebase/firestore");
5
5
  let firebase_auth = require("firebase/auth");
@@ -40,7 +40,7 @@ var runHandler = async (ref, claim, handler) => {
40
40
  method: claim.method
41
41
  };
42
42
  } catch (error) {
43
- const message = require_errorMessage.errorMessage(error);
43
+ const message = require_errors.errorMessage(error);
44
44
  await writeError(ref, "handler_error", message);
45
45
  return {
46
46
  phase: "error",
@@ -56,14 +56,14 @@ var expireCommand = async (ref, command, options, uid) => {
56
56
  options.onEvent?.({
57
57
  phase: "error",
58
58
  method: command.method,
59
- message: `onExpire failed: ${require_errorMessage.errorMessage(error)}`
59
+ message: `onExpire failed: ${require_errors.errorMessage(error)}`
60
60
  });
61
61
  }
62
62
  await (0, firebase_firestore.deleteDoc)(ref).catch((error) => {
63
63
  options.onEvent?.({
64
64
  phase: "error",
65
65
  method: command.method,
66
- message: `expire delete failed: ${require_errorMessage.errorMessage(error)}`
66
+ message: `expire delete failed: ${require_errors.errorMessage(error)}`
67
67
  });
68
68
  });
69
69
  options.onEvent?.({
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":[],"sources":["../../../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":["// 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 { DocumentReference, Firestore, deleteDoc, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } 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\";\n\nconst DEFAULT_HEARTBEAT_MS = 60_000;\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}\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\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler): Promise<HostEvent> => {\n try {\n const result = await handler(claim.params);\n await updateDoc(ref, { status: \"done\", result: result ?? null, 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: CommandHandler | undefined = 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));\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 presence = hostDoc(firestore, channel);\n // Advertise online/offline + the capability set (method names + protocol\n // version) on the same doc the remote already listens to for presence.\n // Returns void, not the promise: presence is advertised best-effort from\n // three call sites (announce, the snapshot-error path, the teardown), none of\n // which can await. Terminating the chain here with `.catch(noop)` keeps every\n // caller from floating a promise it has no way to handle.\n const writePresence = (online: boolean): void => {\n setDoc(presence, { ...buildHostPresence(channel, handlers, online), updatedAt: serverTimestamp() }).catch(noop);\n };\n const announce = () => {\n writePresence(true);\n };\n announce();\n const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const ctx: RunnerContext = { firestore, handlers, options, uid: channel.uid };\n const unsubscribe = onSnapshot(\n queuedCommands,\n (snapshot) => {\n const now = Date.now();\n // Best-effort oldest-first DISPATCH only. Commands are processed\n // concurrently (not awaited in turn) and out-of-order completion is fine by\n // design — chat is asynchronous — so this sort just biases which command\n // starts first; it is not an ordering guarantee. We still sort in memory\n // rather than orderBy(\"createdAt\") on the query because a Firestore orderBy\n // silently EXCLUDES docs missing the field — which would drop every\n // pre-offline-queue command (no createdAt) from the queue entirely.\n const added = 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 added.forEach(({ ref, command }) => {\n processCommand(ctx, ref, command, now).catch(noop);\n });\n },\n (error) => {\n options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n // A Firestore onSnapshot error terminates the listener and it does not\n // recover on its own. Stop advertising presence (clear the heartbeat +\n // write online:false) so remotes see the host as offline instead of a\n // live host that silently consumes no commands.\n clearInterval(beat);\n writePresence(false);\n options.onClosed?.();\n },\n );\n\n return () => {\n clearInterval(beat);\n writePresence(false);\n unsubscribe();\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 { 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\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: ${event.phase} ${event.method}`),\n onExpire: deps.onExpire,\n // Fatal listener death: drop the handle so status() stops reporting\n // connected — but only if it still points at THIS runner (a later\n // reconnect may have replaced it).\n onClosed: () => {\n if (stopRunner === runner) {\n stopRunner = null;\n log.warn(\"host runner listener died; marked disconnected\");\n }\n },\n });\n stopRunner = runner;\n log.info(`${verb} as ${uid}, host runner started (hostId=${deps.hostId})`);\n return status();\n };\n\n const connect = (idToken: string): Promise<RemoteHostStatus> => serialize(async () => attach(await deps.signIn(idToken), \"connected\"));\n\n const reconnect = (sessionBlob: string): Promise<RemoteHostStatus> =>\n serialize(async () => {\n const { restore } = deps;\n if (!restore) throw new Error(\"remote-host reconnect requires a `restore` dependency\");\n return attach(await restore(sessionBlob), \"reconnected\");\n });\n\n const disconnect = (): Promise<RemoteHostStatus> =>\n serialize(async () => {\n stopIfRunning();\n await deps.signOut();\n log.info(\"disconnected, host runner stopped\");\n return status();\n });\n\n return { connect, reconnect, disconnect, status };\n};\n","// Firebase credential exchange for the remote-host runner.\n//\n// The host authenticates to Firestore *as the user* (Option B) using the\n// Firebase JS SDK's signInWithCredential with a browser-minted Google OAuth ID\n// token — no Admin SDK, no project service account. Security rules keep the host\n// scoped to that user's own users/{uid}/… subtree.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/auth.ts. The `auth`\n// instance is a parameter so each host binds its own Firebase init; the factory\n// returns the low-level credential primitives (the connect/disconnect lifecycle\n// that starts/stops the host runner lives in createRemoteHost).\nimport { Auth, GoogleAuthProvider, signInWithCredential, signOut } from \"firebase/auth\";\n\nexport interface RemoteHostAuth {\n // Establish the Firebase session from a browser-minted Google OAuth ID token.\n // The token is used once here; the JS SDK then holds its own refresh token for\n // the process lifetime. Resolves to the authenticated uid.\n signInHost: (idToken: string) => Promise<string>;\n // Tear down the Firebase session (in-memory persistence → re-login on restart).\n signOutHost: () => Promise<void>;\n // The currently signed-in uid, or null when disconnected.\n currentUid: () => string | null;\n}\n\nexport const createRemoteHostAuth = (auth: Auth): RemoteHostAuth => ({\n signInHost: async (idToken: string): Promise<string> => {\n const credential = GoogleAuthProvider.credential(idToken);\n const userCredential = await signInWithCredential(auth, credential);\n return userCredential.user.uid;\n },\n signOutHost: (): Promise<void> => signOut(auth),\n currentUid: (): string | null => auth.currentUser?.uid ?? null,\n});\n","// A custom Firebase Auth persistence that keeps the signed-in session in memory\n// but can be seeded from — and exported back to — an opaque blob, so a host's\n// Firebase session survives a process restart by being parked in the browser's\n// localStorage (mulmoserver#50, \"case A'\"). The blob is whatever the SDK wrote\n// into persistence, round-tripped through JSON; we NEVER interpret its fields,\n// so we don't couple to the SDK's serialized-user format across versions.\n//\n// **Persistence is a CLASS, not an instance.** `initializeAuth(app, { persistence })`\n// hands the value to the SDK's `_getInstance(cls)`, which asserts `cls instanceof\n// Function` (\"Expected a class definition\") and then `new cls()`. Passing a plain\n// object throws — the SDK's own `inMemoryPersistence` is likewise a class. The\n// class is defined per factory call so its instances share this call's store;\n// `type: \"LOCAL\"` makes the SDK treat it as durable and persist the user.\n//\n// Seed BEFORE `initializeAuth` — the SDK reads persistence once at init. `onChange`\n// fires when the SDK writes/removes a key (a token update that rotates the stored\n// blob, or a sign-out) — the signal to re-sync the browser copy.\nimport type { Persistence } from \"firebase/auth\";\n\n// The SDK stores values as JSON objects (a serialized user) or strings. We treat\n// them opaquely — a value is either.\ntype PersistenceValue = Record<string, unknown> | string;\n\ntype StorageListener = (value: PersistenceValue | null) => void;\n\n// The instance the SDK builds via `new` and then drives with these `_`-methods.\nexport interface HostAuthPersistenceInstance extends Persistence {\n type: \"LOCAL\";\n _isAvailable: () => Promise<boolean>;\n _set: (key: string, value: PersistenceValue) => Promise<void>;\n _get: (key: string) => Promise<PersistenceValue | null>;\n _remove: (key: string) => Promise<void>;\n _addListener: (key: string, listener: StorageListener) => void;\n _removeListener: (key: string, listener: StorageListener) => void;\n}\n\n// The class value passed to `initializeAuth`. The static `type` makes it\n// assignable to the SDK's `Persistence` (a class-valued-typed-as-instance, the\n// same trick the SDK uses for `inMemoryPersistence`); `new ()` lets the SDK — and\n// the tests — instantiate it.\nexport interface HostAuthPersistenceClass extends Persistence {\n type: \"LOCAL\";\n new (): HostAuthPersistenceInstance;\n}\n\nexport interface HostSessionPersistence {\n /** Pass to `initializeAuth(app, { persistence })`. It's a class the SDK `new`s. */\n persistence: HostAuthPersistenceClass;\n /** Load a previously exported blob. Call BEFORE `initializeAuth`. */\n seed: (blob: string) => void;\n /** Serialize the current contents, or `null` when empty (no session). */\n exportBlob: () => string | null;\n /** Notified with the fresh blob (or `null`) whenever the SDK writes/removes. */\n onChange: (listener: (blob: string | null) => void) => () => void;\n /** Drop all contents (used when tearing down the Firebase app). */\n clear: () => void;\n}\n\nconst isRecord = (value: unknown): value is Record<string, unknown> => typeof value === \"object\" && value !== null && !Array.isArray(value);\n\nconst isPersistenceValue = (value: unknown): value is PersistenceValue => typeof value === \"string\" || isRecord(value);\n\n// True when `blob` is a JSON object `seed` can load. A blob that fails this can\n// never restore a session (corrupt localStorage, wrong shape), so callers treat\n// it as an expired/invalid session rather than a transient error — the client\n// then drops it instead of retrying the same doomed blob forever.\nexport const isSeedableBlob = (blob: string): boolean => {\n try {\n return isRecord(JSON.parse(blob));\n } catch {\n return false;\n }\n};\n\n// A class (constructor) so the SDK's `_getInstance` accepts it (it asserts\n// `cls instanceof Function`, then `new cls()`); instances hold the shared\n// `store`/`notify`, so a fresh `new` still sees the seeded/live data. `static\n// type` is for the `Persistence` type bridge; the instance `type` is what the\n// SDK reads after `new`.\nconst makeHostPersistenceClass = (store: Map<string, PersistenceValue>, notify: () => void): HostAuthPersistenceClass => {\n class HostAuthPersistence {\n static readonly type = \"LOCAL\" as const;\n readonly type = \"LOCAL\" as const;\n private readonly store = store;\n private readonly notify = notify;\n private readonly external = new Set<StorageListener>();\n _isAvailable(): Promise<boolean> {\n return Promise.resolve(this.store instanceof Map);\n }\n async _set(key: string, value: PersistenceValue): Promise<void> {\n this.store.set(key, value);\n this.notify();\n }\n _get(key: string): Promise<PersistenceValue | null> {\n return Promise.resolve(this.store.get(key) ?? null);\n }\n async _remove(key: string): Promise<void> {\n this.store.delete(key);\n this.notify();\n }\n // Node has no cross-tab storage events to deliver, so registered listeners\n // never fire; we still track them so add/remove stay symmetric.\n _addListener(_key: string, listener: StorageListener): void {\n this.external.add(listener);\n }\n _removeListener(_key: string, listener: StorageListener): void {\n this.external.delete(listener);\n }\n }\n return HostAuthPersistence;\n};\n\nexport const createHostSessionPersistence = (): HostSessionPersistence => {\n const store = new Map<string, PersistenceValue>();\n const listeners = new Set<(blob: string | null) => void>();\n\n const exportBlob = (): string | null => (store.size === 0 ? null : JSON.stringify(Object.fromEntries(store)));\n\n const notify = (): void => {\n const blob = exportBlob();\n listeners.forEach((listener) => listener(blob));\n };\n\n const persistence = makeHostPersistenceClass(store, notify);\n\n const seed = (blob: string): void => {\n const parsed: unknown = JSON.parse(blob);\n if (!isRecord(parsed)) throw new Error(\"host session blob must be a JSON object\");\n store.clear();\n for (const [key, value] of Object.entries(parsed)) {\n if (isPersistenceValue(value)) store.set(key, value);\n }\n };\n\n const onChange = (listener: (blob: string | null) => void): (() => void) => {\n listeners.add(listener);\n return () => listeners.delete(listener);\n };\n\n const clear = (): void => {\n store.clear();\n };\n\n return { persistence, seed, exportBlob, onChange, clear };\n};\n","// Firebase init for a remote-host runner.\n//\n// A host acts as a *host*: it signs in to Firebase as the user (via\n// signInWithCredential, see auth.ts) and listens to that user's command queue in\n// Firestore. The modular firebase/firestore + firebase/auth SDKs run in Node, so\n// this mirrors a browser init but also exposes Firestore (default database,\n// which must be in Native mode) and Storage.\n//\n// Extracted into core from MulmoClaude's server/remoteHost/firebase.ts. The\n// public web config is a parameter so each host supplies its own (both hosts\n// reuse the shared mulmoserver project).\nimport { deleteApp, FirebaseApp, FirebaseOptions, initializeApp } from \"firebase/app\";\nimport { Auth, getAuth, initializeAuth } from \"firebase/auth\";\nimport { Firestore, getFirestore } from \"firebase/firestore\";\nimport { FirebaseStorage, getStorage } from \"firebase/storage\";\n\nimport { createHostSessionPersistence, type HostSessionPersistence } from \"./sessionPersistence.js\";\n\nexport interface RemoteHostFirebase {\n app: FirebaseApp;\n auth: Auth;\n firestore: Firestore;\n // Storage carries the full-res attachment bytes the command channel can't (a\n // Firestore command doc caps at ~1 MiB). The host, signed in as the user,\n // pulls each staged upload from `users/{uid}/uploads/{id}` and deletes it\n // after ingest.\n storage: FirebaseStorage;\n}\n\nexport const createRemoteHostFirebase = (config: FirebaseOptions): RemoteHostFirebase => {\n const app = initializeApp(config);\n return { app, auth: getAuth(app), firestore: getFirestore(app), storage: getStorage(app) };\n};\n\nexport interface RemoteHostSessionHandles {\n auth: Auth;\n firestore: Firestore;\n storage: FirebaseStorage;\n // The signed-in uid restored from a seed blob, or null when opened fresh\n // (before `signInHost`) or when the blob held no valid session.\n uid: string | null;\n}\n\n// A restartable Firebase session for a host, backed by the export/seed-able\n// persistence (mulmoserver#50, \"case A'\"). Because `initializeAuth` reads\n// persistence once and can run only once per app, each `open` spins up a FRESH\n// app (unique name) with the persistence seeded first, then tears down the\n// previous app — so a reconnect can restore a browser-parked session, and a\n// fresh connect starts clean. `exportSession`/`onSessionChange` expose the blob\n// the browser stores and the signal to re-sync it.\n// Runs on the freshly opened handles BEFORE the previous session is torn down.\n// Reject to keep the open non-destructive: the fresh app is rolled back and the\n// previous session is preserved (so a failed sign-in or an expired blob doesn't\n// replace a healthy live session).\nexport type RemoteHostSessionValidate = (handles: RemoteHostSessionHandles) => Promise<void>;\n\nexport interface RemoteHostSession {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => Promise<RemoteHostSessionHandles>;\n close: () => Promise<void>;\n exportSession: () => string | null;\n onSessionChange: (cb: (blob: string | null) => void) => () => void;\n}\n\nconst noop = (): void => undefined;\n\n// Run operations one-at-a-time in submission order. `open`/`close` mutate shared\n// store/app state, so overlapping calls must not interleave (they would leak an\n// app or leave `app` pointing at a torn-down instance). Mirrors the lifecycle's\n// serialization; a failed op doesn't block the next.\nconst makeSerializer = () => {\n let transition: Promise<unknown> = Promise.resolve();\n return <T>(operation: () => Promise<T>): Promise<T> => {\n const next = transition.then(operation, operation);\n transition = next.then(noop, noop);\n return next;\n };\n};\n\n// Build a fresh app from the (already seeded) persistence and wait for the\n// restored auth state to settle. Persistence restore is async, so `uid`\n// reflects a seeded session only after `authStateReady()` (null when the blob\n// was empty/invalid, or on a fresh connect before `signInHost`).\nconst openFreshApp = async (\n config: FirebaseOptions,\n store: HostSessionPersistence,\n name: string,\n): Promise<{ app: FirebaseApp; handles: RemoteHostSessionHandles }> => {\n // `initializeApp` registers the app in the SDK's global registry, so if init\n // then throws we must delete it — otherwise repeated reconnect failures leak\n // a registered app each time.\n const app = initializeApp(config, name);\n try {\n const auth = initializeAuth(app, { persistence: store.persistence });\n await auth.authStateReady();\n return { app, handles: { auth, firestore: getFirestore(app), storage: getStorage(app), uid: auth.currentUser?.uid ?? null } };\n } catch (error) {\n await deleteApp(app).catch(() => undefined);\n throw error;\n }\n};\n\nexport const createRemoteHostSession = (config: FirebaseOptions): RemoteHostSession => {\n const store = createHostSessionPersistence();\n let app: FirebaseApp | null = null;\n let appSeq = 0;\n const serialize = makeSerializer();\n\n const closeInner = async (): Promise<void> => {\n const previous = app;\n app = null;\n store.clear();\n if (previous) await deleteApp(previous);\n };\n\n // Run the caller's check on the fresh handles; on rejection, delete the fresh\n // app so only the caller-visible error escapes and the previous session (never\n // touched here) stays live.\n const validateOrRollback = async (nextApp: FirebaseApp, handles: RemoteHostSessionHandles, validate?: RemoteHostSessionValidate): Promise<void> => {\n if (!validate) return;\n try {\n await validate(handles);\n } catch (error) {\n await deleteApp(nextApp).catch(() => undefined);\n throw error;\n }\n };\n\n const openInner = async (seedBlob?: string, validate?: RemoteHostSessionValidate): Promise<RemoteHostSessionHandles> => {\n // Non-destructive: keep the current session intact until the fresh app is\n // proven to come up AND pass `validate` (sign-in / uid). A bad seed blob, a\n // failed init, or a rejected validation must not tear down a healthy session\n // — the reconnect contract (mulmoserver#50). So we tear the previous app\n // down only AFTER success, and roll the store back on failure.\n const previousApp = app;\n const previousBlob = store.exportBlob();\n appSeq += 1;\n try {\n store.clear();\n if (seedBlob) store.seed(seedBlob);\n const { app: nextApp, handles } = await openFreshApp(config, store, `remote-host-${appSeq}`);\n await validateOrRollback(nextApp, handles, validate);\n app = nextApp;\n if (previousApp) await deleteApp(previousApp).catch(() => undefined);\n return handles;\n } catch (error) {\n store.clear();\n if (previousBlob) store.seed(previousBlob);\n throw error;\n }\n };\n\n return {\n open: (seedBlob?: string, validate?: RemoteHostSessionValidate) => serialize(() => openInner(seedBlob, validate)),\n close: () => serialize(closeInner),\n exportSession: store.exportBlob,\n onSessionChange: store.onChange,\n };\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":[],"sources":["../../../src/remote-host/server/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":["// 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 { DocumentReference, Firestore, deleteDoc, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } 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\";\n\nconst DEFAULT_HEARTBEAT_MS = 60_000;\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}\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\nconst runHandler = async (ref: DocumentReference, claim: Claim, handler: CommandHandler): Promise<HostEvent> => {\n try {\n const result = await handler(claim.params);\n await updateDoc(ref, { status: \"done\", result: result ?? null, 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: CommandHandler | undefined = 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));\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 presence = hostDoc(firestore, channel);\n // Advertise online/offline + the capability set (method names + protocol\n // version) on the same doc the remote already listens to for presence.\n // Returns void, not the promise: presence is advertised best-effort from\n // three call sites (announce, the snapshot-error path, the teardown), none of\n // which can await. Terminating the chain here with `.catch(noop)` keeps every\n // caller from floating a promise it has no way to handle.\n const writePresence = (online: boolean): void => {\n setDoc(presence, { ...buildHostPresence(channel, handlers, online), updatedAt: serverTimestamp() }).catch(noop);\n };\n const announce = () => {\n writePresence(true);\n };\n announce();\n const beat = setInterval(announce, options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS);\n\n const queuedCommands = query(commandsCollection(firestore, channel), where(\"status\", \"==\", \"queued\"));\n const ctx: RunnerContext = { firestore, handlers, options, uid: channel.uid };\n const unsubscribe = onSnapshot(\n queuedCommands,\n (snapshot) => {\n const now = Date.now();\n // Best-effort oldest-first DISPATCH only. Commands are processed\n // concurrently (not awaited in turn) and out-of-order completion is fine by\n // design — chat is asynchronous — so this sort just biases which command\n // starts first; it is not an ordering guarantee. We still sort in memory\n // rather than orderBy(\"createdAt\") on the query because a Firestore orderBy\n // silently EXCLUDES docs missing the field — which would drop every\n // pre-offline-queue command (no createdAt) from the queue entirely.\n const added = 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 added.forEach(({ ref, command }) => {\n processCommand(ctx, ref, command, now).catch(noop);\n });\n },\n (error) => {\n options.onEvent?.({ phase: \"error\", method: \"listen\", message: error.message });\n // A Firestore onSnapshot error terminates the listener and it does not\n // recover on its own. Stop advertising presence (clear the heartbeat +\n // write online:false) so remotes see the host as offline instead of a\n // live host that silently consumes no commands.\n clearInterval(beat);\n writePresence(false);\n options.onClosed?.();\n },\n );\n\n return () => {\n clearInterval(beat);\n writePresence(false);\n unsubscribe();\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 { 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\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: ${event.phase} ${event.method}`),\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":";;;;;;;;AAuBA,IAAM,uBAAuB;AAgC7B,IAAM,eAAa,KAAA;AAGnB,IAAM,cAAc,KAAwB,MAAc,aAAA,GAAA,mBAAA,UAAA,CAC9C,KAAK;CAAE,QAAQ;CAAS,OAAO;EAAE;EAAM;CAAQ;CAAG,YAAA,GAAA,mBAAA,gBAAA,CAA2B;AAAE,CAAC,CAAC,CAAC,MAAM,MAAI;AAIxG,IAAM,gBAAgB,WAAsB,SAAA,GAAA,mBAAA,eAAA,CAC3B,WAAW,OAAO,QAAQ;CACvC,MAAM,QAAQ,MAAM,IAAI,IAAI,GAAG,EAAA,CAAG,KAAK;CACvC,IAAI,CAAC,QAAQ,KAAK,WAAW,UAC3B,OAAO;CAET,IAAI,OAAO,KAAK;EAAE,QAAQ;EAAc,YAAA,GAAA,mBAAA,gBAAA,CAA2B;CAAE,CAAC;CACtE,OAAO;EAAE,QAAQ,KAAK;EAAQ,QAAQ,KAAK,UAAU,CAAC;CAAE;AAC1D,CAAC;AAEH,IAAM,aAAa,OAAO,KAAwB,OAAc,YAAgD;CAC9G,IAAI;EAEF,OAAA,GAAA,mBAAA,UAAA,CAAgB,KAAK;GAAE,QAAQ;GAAQ,QAAQ,MAD1B,QAAQ,MAAM,MAAM,KACgB;GAAM,YAAA,GAAA,mBAAA,gBAAA,CAA2B;EAAE,CAAC;EAC7F,OAAO;GAAE,OAAO;GAAQ,QAAQ,MAAM;EAAO;CAC/C,SAAS,OAAO;EACd,MAAM,UAAU,eAAA,aAAa,KAAK;EAClC,MAAM,WAAW,KAAK,iBAAiB,OAAO;EAC9C,OAAO;GAAE,OAAO;GAAS,QAAQ,MAAM;GAAQ;EAAQ;CACzD;AACF;AAOA,IAAM,gBAAgB,OAAO,KAAwB,SAAkB,SAA4B,QAAgB;CACjH,IAAI;EACF,MAAM,QAAQ,WAAW,SAAS,GAAG;CACvC,SAAS,OAAO;EACd,QAAQ,UAAU;GAAE,OAAO;GAAS,QAAQ,QAAQ;GAAQ,SAAS,oBAAoB,eAAA,aAAa,KAAK;EAAI,CAAC;CAClH;CAIA,OAAA,GAAA,mBAAA,UAAA,CAAgB,GAAG,CAAC,CAAC,OAAO,UAAU;EACpC,QAAQ,UAAU;GAAE,OAAO;GAAS,QAAQ,QAAQ;GAAQ,SAAS,yBAAyB,eAAA,aAAa,KAAK;EAAI,CAAC;CACvH,CAAC;CACD,QAAQ,UAAU;EAAE,OAAO;EAAQ,QAAQ,QAAQ;EAAQ,SAAS;CAAU,CAAC;AACjF;AAYA,IAAM,iBAAiB,OAAO,KAAoB,KAAwB,SAAkB,QAAgB;CAC1G,MAAM,EAAE,UAAU,YAAY;CAE9B,IAAI,0BAAA,UAAU,SAAS,GAAG,GAAG;EAC3B,MAAM,cAAc,KAAK,SAAS,SAAS,IAAI,GAAG;EAClD;CACF;CACA,MAAM,QAAQ,MAAM,aAAa,IAAI,WAAW,GAAG;CACnD,IAAI,CAAC,OACH;CAEF,QAAQ,UAAU;EAAE,OAAO;EAAY,QAAQ,MAAM;CAAO,CAAC;CAC7D,MAAM,UAAsC,SAAS,MAAM;CAC3D,IAAI,CAAC,SAAS;EACZ,MAAM,WAAW,KAAK,kBAAkB,0BAA0B,MAAM,QAAQ;EAChF,QAAQ,UAAU;GAAE,OAAO;GAAS,QAAQ,MAAM;GAAQ,SAAS;EAAiB,CAAC;EACrF;CACF;CACA,QAAQ,UAAU,MAAM,WAAW,KAAK,OAAO,OAAO,CAAC;AACzD;AAMA,IAAa,mBAAmB,WAAsB,SAAkB,UAA2B,UAA6B,CAAC,MAAoB;CACnJ,MAAM,WAAW,0BAAA,QAAQ,WAAW,OAAO;CAO3C,MAAM,iBAAiB,WAA0B;EAC/C,CAAA,GAAA,mBAAA,OAAA,CAAO,UAAU;GAAE,GAAG,0BAAA,kBAAkB,SAAS,UAAU,MAAM;GAAG,YAAA,GAAA,mBAAA,gBAAA,CAA2B;EAAE,CAAC,CAAC,CAAC,MAAM,MAAI;CAChH;CACA,MAAM,iBAAiB;EACrB,cAAc,IAAI;CACpB;CACA,SAAS;CACT,MAAM,OAAO,YAAY,UAAU,QAAQ,eAAe,oBAAoB;CAE9E,MAAM,kBAAA,GAAA,mBAAA,MAAA,CAAuB,0BAAA,mBAAmB,WAAW,OAAO,IAAA,GAAA,mBAAA,MAAA,CAAS,UAAU,MAAM,QAAQ,CAAC;CACpG,MAAM,MAAqB;EAAE;EAAW;EAAU;EAAS,KAAK,QAAQ;CAAI;CAC5E,MAAM,eAAA,GAAA,mBAAA,WAAA,CACJ,iBACC,aAAa;EACZ,MAAM,MAAM,KAAK,IAAI;EAarB,SAJG,WAAW,CAAC,CACZ,QAAQ,WAAW,OAAO,SAAS,OAAO,CAAC,CAC3C,KAAK,YAAY;GAAE,KAAK,OAAO,IAAI;GAAK,SAAS,OAAO,IAAI,KAAK;EAAa,EAAE,CAAC,CACjF,MAAM,MAAM,UAAU,0BAAA,YAAY,KAAK,SAAS,MAAM,OAAO,CAChE,CAAA,CAAM,SAAS,EAAE,KAAK,cAAc;GAClC,eAAe,KAAK,KAAK,SAAS,GAAG,CAAC,CAAC,MAAM,MAAI;EACnD,CAAC;CACH,IACC,UAAU;EACT,QAAQ,UAAU;GAAE,OAAO;GAAS,QAAQ;GAAU,SAAS,MAAM;EAAQ,CAAC;EAK9E,cAAc,IAAI;EAClB,cAAc,KAAK;EACnB,QAAQ,WAAW;CACrB,CACF;CAEA,aAAa;EACX,cAAc,IAAI;EAClB,cAAc,KAAK;EACnB,YAAY;CACd;AACF;;;ACrIA,IAAM,eAAa,KAAA;AACnB,IAAM,eAAiC;CAAE,MAAM;CAAM,MAAM;CAAM,OAAO;AAAK;AAE7E,IAAa,oBAAoB,SAA8C;CAC7E,MAAM,MAAM,KAAK,OAAO;CAIxB,IAAI,aAAkC;CAMtC,IAAI,aAA+B,QAAQ,QAAQ;CAEnD,MAAM,aAAgB,cAA4C;EAChE,MAAM,OAAO,WAAW,KAAK,WAAW,SAAS;EACjD,aAAa,KAAK,KAAK,QAAM,MAAI;EACjC,OAAO;CACT;CAEA,MAAM,sBAAsB;EAC1B,IAAI,YAAY;GACd,WAAW;GACX,aAAa;EACf;CACF;CAEA,MAAM,gBAAkC;EAAE,WAAW,eAAe;EAAM,KAAK,KAAK,WAAW;CAAE;CAIjG,MAAM,UAAU,KAAa,SAAmC;EAC9D,cAAc;EACd,MAAM,SAAS,KAAK,YAAY;GAAE;GAAK,QAAQ,KAAK;EAAO,GAAG,KAAK,UAAU;GAC3E,UAAU,UAAU,IAAI,MAAM,eAAe,MAAM,MAAM,GAAG,MAAM,QAAQ;GAC1E,UAAU,KAAK;GAIf,gBAAgB;IACd,IAAI,eAAe,QAAQ;KACzB,aAAa;KACb,IAAI,KAAK,gDAAgD;IAC3D;GACF;EACF,CAAC;EACD,aAAa;EACb,IAAI,KAAK,GAAG,KAAK,MAAM,IAAI,gCAAgC,KAAK,OAAO,EAAE;EACzE,OAAO,OAAO;CAChB;CAEA,MAAM,WAAW,YAA+C,UAAU,YAAY,OAAO,MAAM,KAAK,OAAO,OAAO,GAAG,WAAW,CAAC;CAErI,MAAM,aAAa,gBACjB,UAAU,YAAY;EACpB,MAAM,EAAE,YAAY;EACpB,IAAI,CAAC,SAAS,MAAM,IAAI,MAAM,uDAAuD;EACrF,OAAO,OAAO,MAAM,QAAQ,WAAW,GAAG,aAAa;CACzD,CAAC;CAEH,MAAM,mBACJ,UAAU,YAAY;EACpB,cAAc;EACd,MAAM,KAAK,QAAQ;EACnB,IAAI,KAAK,mCAAmC;EAC5C,OAAO,OAAO;CAChB,CAAC;CAEH,OAAO;EAAE;EAAS;EAAW;EAAY;CAAO;AAClD;;;AC9GA,IAAa,wBAAwB,UAAgC;CACnE,YAAY,OAAO,YAAqC;EAGtD,QAAO,OAAA,GAAA,cAAA,qBAAA,CAD2C,MAD/B,cAAA,mBAAmB,WAAW,OACO,CAAU,EAAA,CAC5C,KAAK;CAC7B;CACA,oBAAA,GAAA,cAAA,QAAA,CAA0C,IAAI;CAC9C,kBAAiC,KAAK,aAAa,OAAO;AAC5D;;;AC0BA,IAAM,YAAY,UAAqD,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAE1I,IAAM,sBAAsB,UAA8C,OAAO,UAAU,YAAY,SAAS,KAAK;AAMrH,IAAa,kBAAkB,SAA0B;CACvD,IAAI;EACF,OAAO,SAAS,KAAK,MAAM,IAAI,CAAC;CAClC,QAAQ;EACN,OAAO;CACT;AACF;AAOA,IAAM,4BAA4B,OAAsC,WAAiD;CACvH,MAAM,oBAAoB;EACxB,OAAgB,OAAO;EACvB,OAAgB;EAChB,QAAyB;EACzB,SAA0B;EAC1B,2BAA4B,IAAI,IAAqB;EACrD,eAAiC;GAC/B,OAAO,QAAQ,QAAQ,KAAK,iBAAiB,GAAG;EAClD;EACA,MAAM,KAAK,KAAa,OAAwC;GAC9D,KAAK,MAAM,IAAI,KAAK,KAAK;GACzB,KAAK,OAAO;EACd;EACA,KAAK,KAA+C;GAClD,OAAO,QAAQ,QAAQ,KAAK,MAAM,IAAI,GAAG,KAAK,IAAI;EACpD;EACA,MAAM,QAAQ,KAA4B;GACxC,KAAK,MAAM,OAAO,GAAG;GACrB,KAAK,OAAO;EACd;EAGA,aAAa,MAAc,UAAiC;GAC1D,KAAK,SAAS,IAAI,QAAQ;EAC5B;EACA,gBAAgB,MAAc,UAAiC;GAC7D,KAAK,SAAS,OAAO,QAAQ;EAC/B;CACF;CACA,OAAO;AACT;AAEA,IAAa,qCAA6D;CACxE,MAAM,wBAAQ,IAAI,IAA8B;CAChD,MAAM,4BAAY,IAAI,IAAmC;CAEzD,MAAM,mBAAmC,MAAM,SAAS,IAAI,OAAO,KAAK,UAAU,OAAO,YAAY,KAAK,CAAC;CAE3G,MAAM,eAAqB;EACzB,MAAM,OAAO,WAAW;EACxB,UAAU,SAAS,aAAa,SAAS,IAAI,CAAC;CAChD;CAEA,MAAM,cAAc,yBAAyB,OAAO,MAAM;CAE1D,MAAM,QAAQ,SAAuB;EACnC,MAAM,SAAkB,KAAK,MAAM,IAAI;EACvC,IAAI,CAAC,SAAS,MAAM,GAAG,MAAM,IAAI,MAAM,yCAAyC;EAChF,MAAM,MAAM;EACZ,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,GAC9C,IAAI,mBAAmB,KAAK,GAAG,MAAM,IAAI,KAAK,KAAK;CAEvD;CAEA,MAAM,YAAY,aAA0D;EAC1E,UAAU,IAAI,QAAQ;EACtB,aAAa,UAAU,OAAO,QAAQ;CACxC;CAEA,MAAM,cAAoB;EACxB,MAAM,MAAM;CACd;CAEA,OAAO;EAAE;EAAa;EAAM;EAAY;EAAU;CAAM;AAC1D;;;ACnHA,IAAa,4BAA4B,WAAgD;CACvF,MAAM,OAAA,GAAA,aAAA,cAAA,CAAoB,MAAM;CAChC,OAAO;EAAE;EAAK,OAAA,GAAA,cAAA,QAAA,CAAc,GAAG;EAAG,YAAA,GAAA,mBAAA,aAAA,CAAwB,GAAG;EAAG,UAAA,GAAA,iBAAA,WAAA,CAAoB,GAAG;CAAE;AAC3F;AA+BA,IAAM,aAAmB,KAAA;AAMzB,IAAM,uBAAuB;CAC3B,IAAI,aAA+B,QAAQ,QAAQ;CACnD,QAAW,cAA4C;EACrD,MAAM,OAAO,WAAW,KAAK,WAAW,SAAS;EACjD,aAAa,KAAK,KAAK,MAAM,IAAI;EACjC,OAAO;CACT;AACF;AAMA,IAAM,eAAe,OACnB,QACA,OACA,SACqE;CAIrE,MAAM,OAAA,GAAA,aAAA,cAAA,CAAoB,QAAQ,IAAI;CACtC,IAAI;EACF,MAAM,QAAA,GAAA,cAAA,eAAA,CAAsB,KAAK,EAAE,aAAa,MAAM,YAAY,CAAC;EACnE,MAAM,KAAK,eAAe;EAC1B,OAAO;GAAE;GAAK,SAAS;IAAE;IAAM,YAAA,GAAA,mBAAA,aAAA,CAAwB,GAAG;IAAG,UAAA,GAAA,iBAAA,WAAA,CAAoB,GAAG;IAAG,KAAK,KAAK,aAAa,OAAO;GAAK;EAAE;CAC9H,SAAS,OAAO;EACd,OAAA,GAAA,aAAA,UAAA,CAAgB,GAAG,CAAC,CAAC,YAAY,KAAA,CAAS;EAC1C,MAAM;CACR;AACF;AAEA,IAAa,2BAA2B,WAA+C;CACrF,MAAM,QAAQ,6BAA6B;CAC3C,IAAI,MAA0B;CAC9B,IAAI,SAAS;CACb,MAAM,YAAY,eAAe;CAEjC,MAAM,aAAa,YAA2B;EAC5C,MAAM,WAAW;EACjB,MAAM;EACN,MAAM,MAAM;EACZ,IAAI,UAAU,OAAA,GAAA,aAAA,UAAA,CAAgB,QAAQ;CACxC;CAKA,MAAM,qBAAqB,OAAO,SAAsB,SAAmC,aAAwD;EACjJ,IAAI,CAAC,UAAU;EACf,IAAI;GACF,MAAM,SAAS,OAAO;EACxB,SAAS,OAAO;GACd,OAAA,GAAA,aAAA,UAAA,CAAgB,OAAO,CAAC,CAAC,YAAY,KAAA,CAAS;GAC9C,MAAM;EACR;CACF;CAEA,MAAM,YAAY,OAAO,UAAmB,aAA4E;EAMtH,MAAM,cAAc;EACpB,MAAM,eAAe,MAAM,WAAW;EACtC,UAAU;EACV,IAAI;GACF,MAAM,MAAM;GACZ,IAAI,UAAU,MAAM,KAAK,QAAQ;GACjC,MAAM,EAAE,KAAK,SAAS,YAAY,MAAM,aAAa,QAAQ,OAAO,eAAe,QAAQ;GAC3F,MAAM,mBAAmB,SAAS,SAAS,QAAQ;GACnD,MAAM;GACN,IAAI,aAAa,OAAA,GAAA,aAAA,UAAA,CAAgB,WAAW,CAAC,CAAC,YAAY,KAAA,CAAS;GACnE,OAAO;EACT,SAAS,OAAO;GACd,MAAM,MAAM;GACZ,IAAI,cAAc,MAAM,KAAK,YAAY;GACzC,MAAM;EACR;CACF;CAEA,OAAO;EACL,OAAO,UAAmB,aAAyC,gBAAgB,UAAU,UAAU,QAAQ,CAAC;EAChH,aAAa,UAAU,UAAU;EACjC,eAAe,MAAM;EACrB,iBAAiB,MAAM;CACzB;AACF"}
@@ -1,4 +1,4 @@
1
- import { t as errorMessage } from "../../errorMessage-DIdhheHk.js";
1
+ import { t as errorMessage } from "../../errors-eid6Mes3.js";
2
2
  import { buildHostPresence, byCreatedAt, commandsCollection, hostDoc, isExpired } from "../index.js";
3
3
  import { deleteDoc, getFirestore, onSnapshot, query, runTransaction, serverTimestamp, setDoc, updateDoc, where } from "firebase/firestore";
4
4
  import { GoogleAuthProvider, getAuth, initializeAuth, signInWithCredential, signOut } from "firebase/auth";
@@ -1,5 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_rolldown_runtime = require("../rolldown-runtime-D6vf50IK.cjs");
3
+ const require_errors = require("../errors-7P5eMOSX.cjs");
3
4
  let node_path = require("node:path");
4
5
  node_path = require_rolldown_runtime.__toESM(node_path, 1);
5
6
  let node_fs = require("node:fs");
@@ -193,9 +194,6 @@ function requireConfig() {
193
194
  function logger() {
194
195
  return config?.log ?? NOOP_LOG;
195
196
  }
196
- function errorMessage(err) {
197
- return err instanceof Error ? err.message : String(err);
198
- }
199
197
  function stateFilePath() {
200
198
  return node_path.default.join(requireConfig().workspaceRoot, SCHEDULER_CONFIG_DIR, "state.json");
201
199
  }
@@ -334,7 +332,7 @@ async function executeAndLog(task, scheduledFor, trigger) {
334
332
  try {
335
333
  await task.run();
336
334
  } catch (err) {
337
- errMsg = errorMessage(err);
335
+ errMsg = require_errors.errorMessage(err);
338
336
  logger().error("task failed", {
339
337
  taskId: task.id,
340
338
  error: errMsg