@mulmoclaude/core 5.0.1 → 5.3.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 (41) hide show
  1. package/assets/helps/error-recovery.md +78 -0
  2. package/assets/helps/google-calendar-collection.md +130 -15
  3. package/assets/helps/google.md +14 -4
  4. package/dist/collection/core/schemaZ.d.ts +15 -2
  5. package/dist/collection/core/viewChatPolicy.d.ts +6 -3
  6. package/dist/collection/index.cjs +6 -3
  7. package/dist/collection/index.cjs.map +1 -1
  8. package/dist/collection/index.js +6 -3
  9. package/dist/collection/index.js.map +1 -1
  10. package/dist/collection/registry/server/index.cjs +2 -2
  11. package/dist/collection/registry/server/index.js +2 -2
  12. package/dist/collection/server/index.cjs +2 -2
  13. package/dist/collection/server/index.js +2 -2
  14. package/dist/collection-watchers/index.cjs +2 -2
  15. package/dist/collection-watchers/index.js +2 -2
  16. package/dist/{discovery-CBjI1lx9.js → discovery-B_64PBZU.js} +14 -3
  17. package/dist/{discovery-CBjI1lx9.js.map → discovery-B_64PBZU.js.map} +1 -1
  18. package/dist/{discovery-DCMW05Bp.cjs → discovery-CDXYswtw.cjs} +14 -3
  19. package/dist/{discovery-DCMW05Bp.cjs.map → discovery-CDXYswtw.cjs.map} +1 -1
  20. package/dist/feeds/server/index.cjs +2 -2
  21. package/dist/feeds/server/index.js +2 -2
  22. package/dist/google/calendar.d.ts +24 -0
  23. package/dist/google/collectionPush.d.ts +45 -2
  24. package/dist/google/deletePlan.d.ts +32 -0
  25. package/dist/google/eventDerived.d.ts +19 -0
  26. package/dist/google/eventSpanInput.d.ts +58 -0
  27. package/dist/google/index.cjs +286 -18
  28. package/dist/google/index.cjs.map +1 -1
  29. package/dist/google/index.d.ts +5 -2
  30. package/dist/google/index.js +272 -19
  31. package/dist/google/index.js.map +1 -1
  32. package/dist/remote-view/index.cjs +7 -7
  33. package/dist/remote-view/index.cjs.map +1 -1
  34. package/dist/remote-view/index.d.ts +3 -3
  35. package/dist/remote-view/index.js +7 -7
  36. package/dist/remote-view/index.js.map +1 -1
  37. package/dist/{server-BMZ_BcPH.js → server-7POrhHKv.js} +2 -2
  38. package/dist/{server-BMZ_BcPH.js.map → server-7POrhHKv.js.map} +1 -1
  39. package/dist/{server-rA9FSkl2.cjs → server-DfYwSX8b.cjs} +2 -2
  40. package/dist/{server-rA9FSkl2.cjs.map → server-DfYwSX8b.cjs.map} +1 -1
  41. package/package.json +1 -1
@@ -657,6 +657,84 @@ workspace is already on a real filesystem and a conflict still will not
657
657
  clear, that is a new bug — report it with the calendar id and the
658
658
  record.
659
659
 
660
+ ## Push skips a record: "the record id cannot be used as a Google event id"
661
+
662
+ ### Symptoms
663
+
664
+ Push reports a record as skipped and names its id. It never reaches
665
+ Google, however many times the user presses the button. Editing the
666
+ record's other fields changes nothing.
667
+
668
+ ### Cause
669
+
670
+ A push CREATES the Google event with the record's own primary-key value
671
+ as the event id, so a record keeps its identity across the round trip.
672
+ Google constrains an event id to **lower-case base32hex**: 5-1024
673
+ characters from `0-9a-v` only. So no `w`, `x`, `y` or `z`, no upper
674
+ case, no hyphen, no underscore, no dot.
675
+
676
+ A semantic id anyone would reach for — `team-standup`, `Weekly_Sync`,
677
+ `2026-07-17` — breaks the rule on the hyphen, the case or the letters.
678
+ Records created through the collection UI get a generated id that
679
+ satisfies it; only a primary key someone typed or imported can fail.
680
+
681
+ ### Fix
682
+
683
+ Recreate the record without setting the primary field, and let the UI
684
+ generate the id. If the id has to be meaningful, it can be — as long as
685
+ every character is a digit or a letter `a` through `v` and there are at
686
+ least five of them (`teamstandup` passes, `team-standup` does not).
687
+
688
+ Renaming the primary key of an EXISTING record means deleting it and
689
+ adding it again: the primary key is the record's identity, so nothing
690
+ else reassigns it.
691
+
692
+ ## A record keeps showing as "edited" and the same push repeats forever
693
+
694
+ ### Symptoms
695
+
696
+ The same record is pushed on every cycle with content that never
697
+ changes, and the Google event's `updated` moves each time. No conflict
698
+ is reported and no data is lost — the calendar just receives the same
699
+ write over and over.
700
+
701
+ ### Cause
702
+
703
+ The push compares the record against the BASELINE in
704
+ `<workspace>/data/calendar/.push-state.json`, and the baseline advances
705
+ only when a PULL reports the event as changed. If a write reaches Google
706
+ but does NOT change the event's stored value — Google normalising the
707
+ value it was sent, or a write of the value the event already held — then
708
+ Google reports no change, the pull never sees the event, and the
709
+ baseline stays behind. The next push sees the same difference again.
710
+
711
+ This was checked on a live calendar and the Events API did **not**
712
+ normalise `description` HTML (empty `<div>`, `style` attributes and all
713
+ survived a round trip byte-for-byte), so it is not the common case. It
714
+ is written down because the SYMPTOM is indistinguishable from a real
715
+ repeated edit, and the two are told apart the same way.
716
+
717
+ ### Fix
718
+
719
+ Look at the baseline, not at the record. Read the event's entry in
720
+ `.push-state.json` and compare it with what `google` (`kind:
721
+ "calendarListEvents"`) reports for that event id:
722
+
723
+ - **Baseline matches Google, record differs** — an ordinary unpushed
724
+ edit. Nothing is wrong; the next push sends it.
725
+ - **Baseline differs from Google** — the baseline is stale. `autoPush`
726
+ does NOT fix this one: it converges only when the push actually
727
+ changed the stored value, because only then does the pull carry the
728
+ event. Press Sync to force a pull; if the event is still not reported,
729
+ the value Google holds is already what the push keeps sending, and the
730
+ baseline has to be rebuilt.
731
+
732
+ Rebuilding: delete the calendar's entry from `.push-state.json` (or the
733
+ file, if it covers only that calendar) and press Sync. The next pull
734
+ writes a fresh baseline from Google's current values. Tell the user
735
+ first — until that pull lands, a genuine unpushed local edit would be
736
+ indistinguishable from a mirrored one.
737
+
660
738
  ## A calendar collection only ever holds a handful of records
661
739
 
662
740
  ### Symptoms
@@ -17,6 +17,7 @@ author the schema when the user asks for one.
17
17
  | Google → collection | the `googleCalendar` block | hourly, on creation, and on the **Sync** button |
18
18
  | collection → Google | the **Push to Google** button | whenever the user clicks it |
19
19
  | collection → Google | `"autoPush": true` in the block | hourly, immediately before each pull |
20
+ | collection → Google, deletions | `"propagateDeletes": true` in the block | with every push. Off by default; see "Deleting" |
20
21
 
21
22
  When a user asks for "two-way sync", `autoPush` is the answer: set it and each
22
23
  scheduled run pushes local edits up and then pulls Google's changes down, as one
@@ -66,6 +67,9 @@ it isn't, sync silently does nothing until they link it in settings.
66
67
  would see rows with no content.
67
68
  - `autoPush` — push local edits on the sync schedule, just before each pull.
68
69
  Omit it (the default) and the push stays a button. See "Both directions".
70
+ - `propagateDeletes` — delete the Google event when its record is deleted here.
71
+ Omit it (the default) and a local deletion is reported and nothing else. See
72
+ "Deleting".
69
73
 
70
74
  Mappable event fields, two-way first: `summary`, `start`, `end`, `description`,
71
75
  `location`, `colorId`.
@@ -78,15 +82,56 @@ time),
78
82
  `transparency` (`"transparent"` when the event does not consume the attendee's
79
83
  time; `""` means opaque), `eventType` (all six Google returns: `default` / `birthday` / `focusTime` /
80
84
  `fromGmail` / `outOfOffice` / `workingLocation`), `hangoutLink` (the Meet URL),
81
- `recurringEventId` and `originalStartTime`.
85
+ `recurringEventId`, `originalStartTime`, `selfResponseStatus` and
86
+ `conferenceVideoUri`.
82
87
 
83
- The last two are how a recurring series stays legible. The sync asks Google to
84
- expand recurrences, so a weekly meeting arrives as one event per occurrence;
85
- `recurringEventId` names the series each occurrence came from (`""` for a
86
- one-off), and `originalStartTime` is the slot the occurrence held before anyone
87
- dragged it — so a moved occurrence reads as a move rather than as a deletion
88
- plus a new event. Map them when the user asks why one calendar edit produced a
89
- large batch of record changes.
88
+ `recurringEventId` and `originalStartTime` are how a recurring series stays
89
+ legible. The sync asks Google to expand recurrences, so a weekly meeting arrives
90
+ as one event per occurrence; `recurringEventId` names the series each occurrence
91
+ came from (`""` for a one-off), and `originalStartTime` is the slot the
92
+ occurrence held before anyone dragged it — so a moved occurrence reads as a move
93
+ rather than as a deletion plus a new event. Map them when the user asks why one
94
+ calendar edit produced a large batch of record changes.
95
+
96
+ ### `selfResponseStatus` and `conferenceVideoUri`
97
+
98
+ These two are not Google field names. Google answers `attendees` as an ARRAY and
99
+ `conferenceData` as an array inside an object, and a collection field holds one
100
+ value — so each is folded down to the single scalar the collection asks it for.
101
+ The rest of those structures is dropped; there is no way to map the attendee
102
+ list itself.
103
+
104
+ **`selfResponseStatus`** — the signed-in user's own `responseStatus`:
105
+ `needsAction`, `declined`, `tentative` or `accepted`.
106
+
107
+ `""` means Google reported none, and that is the COMMON case, not an edge one:
108
+ an event with no attendees has no entry to mark as the user, which is most of a
109
+ personal calendar. So it reads as "nothing said", never as "not going".
110
+
111
+ Filter with **`!= "declined"`**, never with `== "accepted"` — the second hides
112
+ every solo event too. A `flag` field is the usual way:
113
+
114
+ ```jsonc
115
+ "fields": {
116
+ "rsvp": { "type": "string", "label": "RSVP" },
117
+ "onMySchedule": {
118
+ "type": "flag",
119
+ "label": "Mine",
120
+ "where": [{ "field": "rsvp", "op": "ne", "value": "declined" }]
121
+ }
122
+ },
123
+ "googleCalendar": { "map": { "rsvp": "selfResponseStatus" } }
124
+ ```
125
+
126
+ **`conferenceVideoUri`** — the URL that joins the meeting, taken from the
127
+ `video` entry point. `""` when the event has no conference, and also when its
128
+ only entry points are a phone number or a dial-in page: a column named for
129
+ joining that sometimes held `tel:` would be worse than one the caller can see is
130
+ empty.
131
+
132
+ `hangoutLink` already carries this for Google Meet. `conferenceVideoUri` is what
133
+ reaches a calendar whose meetings are Zoom or Teams. A Meet event fills both, so
134
+ map whichever the user's calendar actually uses.
90
135
 
91
136
  `description` is the event body, and Google stores limited **HTML** in it. It is
92
137
  kept verbatim — mirroring it through a plain-text field and pushing it back would
@@ -106,6 +151,39 @@ Declaring it in `map` is a schema error.
106
151
  Use `datetime` (not `date`) for start/end when events have real clock times —
107
152
  the calendar day view then draws each record as a proportional time block.
108
153
 
154
+ ## All-day events
155
+
156
+ A `datetime` column stores an all-day event as `2026-07-17T00:00`, because that
157
+ is where the day view places it. That is fine for mirroring, but it is also
158
+ exactly how a real midnight appointment is stored — so a record CREATED locally
159
+ in a `datetime` column is pushed as a midnight event, never as an all-day one.
160
+
161
+ For a calendar whose events are all-day, give start/end a **`date`** column
162
+ instead. Google's bare date is then kept verbatim, and a record the user creates
163
+ by typing two dates is pushed as a real all-day event.
164
+
165
+ ```jsonc
166
+ "fields": {
167
+ "on": { "type": "date", "label": "From" },
168
+ "until": { "type": "date", "label": "To" }
169
+ },
170
+ "googleCalendar": { "map": { "on": "start", "until": "end" } }
171
+ ```
172
+
173
+ Google's all-day `end` is **exclusive** — it is the day AFTER the last day, so a
174
+ single day on the 17th is `on: 2026-07-17`, `until: 2026-07-18`. Records
175
+ mirrored from Google already carry it that way. Say so when the user asks why
176
+ the end date "looks a day late"; do not offset it, because the push sends the
177
+ stored value straight back.
178
+
179
+ An existing all-day event stays all-day when its dates are edited, whatever the
180
+ column type: the push reads what Google last reported for that event and keeps
181
+ its kind. Only a record with no such history — one created locally — depends on
182
+ the column type.
183
+
184
+ To create a one-off all-day event without a collection, use the `google` tool's
185
+ `calendarCreateEvent` with a bare date on both ends.
186
+
109
187
  ## When sync runs
110
188
 
111
189
  - **On creation** — the first sync starts as soon as the schema lands, so the
@@ -157,10 +235,9 @@ What the button does and deliberately does not do:
157
235
  - **Creates** an event for a record that never came from a sync.
158
236
  - **Updates** only the fields the user actually changed, so attendees,
159
237
  reminders and recurrence rules stay untouched.
160
- - **Never deletes.** A record deleted locally leaves its Google event alone —
161
- a Google delete removes the event for every attendee and cannot be undone.
162
- The count is reported so the user knows it was skipped; deleting for real is
163
- the `google` tool's `calendarDeleteEvent`, after confirming with them.
238
+ - **Never deletes, unless the collection asked it to.** See "Deleting" below;
239
+ without `propagateDeletes` a record deleted locally leaves its Google event
240
+ alone and the count is reported so the user knows.
164
241
  - **Skips a record edited on both sides** and reports it, rather than picking a
165
242
  winner. The user resolves it by editing one side to match. Under `autoPush`
166
243
  the pull that follows leaves that record alone too, so the local edit is not
@@ -174,9 +251,11 @@ What the button does and deliberately does not do:
174
251
  Reasons a record can be reported as skipped:
175
252
 
176
253
  - **Its record id cannot be a Google event id.** Google requires 5-1024
177
- characters from `0-9a-v`. Records created through the UI get a valid
178
- generated id; a semantic id you authored (`team-standup`) cannot be used. Fix
179
- by recreating the record without setting the primary field.
254
+ characters from `0-9a-v` (lower-case base32hex: digits plus `a`-`v`, so no
255
+ `w`-`z`, no upper case, no hyphen). Records created through the UI get a
256
+ valid generated id; a semantic id you authored (`team-standup`) cannot be
257
+ used. Fix by recreating the record without setting the primary field, or by
258
+ choosing an id that satisfies the rule.
180
259
  - **No `start` / `end` is mapped, on a record being CREATED.** An event cannot
181
260
  be created without a span. Editing an existing event is unaffected — a changed
182
261
  title or colour is patched on its own.
@@ -190,6 +269,42 @@ reach by id but has not added to their calendar list has no role to check, so
190
269
  the push goes ahead and reports Google's own refusal if the write turns out not
191
270
  to be allowed — being unlisted is not treated as being read-only.
192
271
 
272
+ ## Deleting
273
+
274
+ By default, a record deleted in the collection leaves its Google event alone.
275
+ The push reports the count and does nothing else, and the next sync brings the
276
+ record back — which is correct for a calendar Google owns, and wrong for one
277
+ where the collection is the primary copy.
278
+
279
+ `"propagateDeletes": true` in the `googleCalendar` block makes the push delete
280
+ those events too. **Ask the user before adding it**, the same as `autoPush` and
281
+ for a stronger reason: `autoPush` changes WHEN a write happens, this makes a
282
+ write irreversible.
283
+
284
+ Even on, the push **refuses an event that carries attendees** and reports it
285
+ instead. Deleting an invited event withdraws it from the guests' calendars,
286
+ which is a different act from tidying your own. **Any** attendee entry refuses,
287
+ including the one Google adds for the organiser — so an event only the user was
288
+ ever on is refused too, deliberately: telling the two apart means deciding which
289
+ entry is the user from a payload that may not say, and being wrong there
290
+ withdraws a real invitation. Either way, an event the user actually wants gone
291
+ is deleted with the `google` tool's `calendarDeleteEvent`, after confirming with
292
+ them.
293
+
294
+ The delete also carries the version the check was made against, so an attendee
295
+ added while the push was running makes Google refuse it rather than letting a
296
+ decision taken a moment earlier stand. That is reported like any other refusal;
297
+ pressing Push again re-checks.
298
+
299
+ There is no undo here and this app keeps no copy of what it deleted. Google
300
+ Calendar's own Trash holds a deleted event for a while, and that is where a
301
+ mistake is recovered from.
302
+
303
+ A deletion that carried stops being reported, because its baseline entry goes
304
+ with it. A deletion that was REFUSED keeps being reported on every push — the
305
+ event is still standing in Google, and the report is the only thing that says
306
+ so.
307
+
193
308
  ## Not for this
194
309
 
195
310
  A `dataSource` (CSV-backed) collection is read-only and cannot declare
@@ -32,13 +32,23 @@ from `calendarListCalendars` — to target another.
32
32
  | `calendarColors` | Palettes mapping a `colorId` to hex, for events and for calendars |
33
33
  | `calendarListEvents` | Upcoming events. Optional `calendarId`, `timeMin`, `maxResults` (1-50, default 10) |
34
34
  | `calendarSync` | Only what CHANGED since the last sync, via a stored token. Returns counts plus a capped sample |
35
- | `calendarCreateEvent` | Create. Requires `summary`, `start`, `end`; optional `description`, `calendarId`, `colorId` |
35
+ | `calendarCreateEvent` | Create, timed or all-day. Requires `summary`, `start`, `end`; optional `description`, `calendarId`, `colorId` |
36
36
  | `calendarUpdateEvent` | Edit in place. Requires `eventId` + at least one of `summary`, `start`, `end`, `description`, `colorId` |
37
37
  | `calendarDeleteEvent` | Delete. Requires `eventId` |
38
38
 
39
- **Date-times must carry a timezone offset** — `2026-07-17T09:00:00+09:00`, not
40
- `2026-07-17` and not `2026-07-17T09:00:00`. Calendar rejects the others with an
41
- opaque 400.
39
+ **A timed event's ends must carry a timezone offset** —
40
+ `2026-07-17T09:00:00+09:00`, not `2026-07-17T09:00:00`. Calendar rejects an
41
+ offset-less value with an opaque 400. The same goes for `timeMin`.
42
+
43
+ **An all-day event takes a bare date on BOTH ends** — `start: "2026-07-17"`,
44
+ `end: "2026-07-18"`. Its `end` is **exclusive**: it is the day AFTER the last
45
+ day, so a single day on the 17th ends on the 18th, and a three-day event
46
+ starting the 17th ends on the 20th. This is Google's own convention and the
47
+ values it reports back, so do not "correct" it.
48
+
49
+ One end of each kind is rejected. Editing an all-day event needs **both** ends —
50
+ a lone date is refused, because whether the stored event is all-day cannot be
51
+ told from the argument.
42
52
 
43
53
  **Editing is a patch.** Fields you omit keep their current value, so changing a
44
54
  title needs `eventId` + `summary` and nothing else. `description: ""` clears the
@@ -513,8 +513,12 @@ export declare const AgentIngestZ: z.ZodObject<{
513
513
  * limitation: `transparency` is writable and `eventType` is writable at
514
514
  * creation, yet neither is sent. That is also why
515
515
  * widening this enum needs no `.push-state.json` migration: the push baseline
516
- * (`ShadowEvent`) is keyed off the pushable list, not off this one. */
517
- export declare const GOOGLE_CALENDAR_SOURCE_FIELDS: readonly ["summary", "start", "end", "description", "location", "colorId", "htmlLink", "status", "recurringEventId", "originalStartTime", "updated", "transparency", "eventType", "hangoutLink"];
516
+ * (`ShadowEvent`) is keyed off the pushable list, not off this one.
517
+ *
518
+ * The last two are not Google field names but DERIVED scalars
519
+ * (`google/eventDerived.ts`): the structures they come from are arrays, and a
520
+ * collection field holds one value. */
521
+ export declare const GOOGLE_CALENDAR_SOURCE_FIELDS: readonly ["summary", "start", "end", "description", "location", "colorId", "htmlLink", "status", "recurringEventId", "originalStartTime", "updated", "transparency", "eventType", "hangoutLink", "selfResponseStatus", "conferenceVideoUri"];
518
522
  /** Marks a collection as the destination of the LLM-free Google Calendar
519
523
  * sync (#2095). `map` is collectionField → Google event field, so the user's
520
524
  * collection keeps whatever field names it already uses. */
@@ -535,8 +539,11 @@ export declare const GoogleCalendarSyncZ: z.ZodObject<{
535
539
  transparency: "transparency";
536
540
  eventType: "eventType";
537
541
  hangoutLink: "hangoutLink";
542
+ selfResponseStatus: "selfResponseStatus";
543
+ conferenceVideoUri: "conferenceVideoUri";
538
544
  }>>;
539
545
  autoPush: z.ZodOptional<z.ZodBoolean>;
546
+ propagateDeletes: z.ZodOptional<z.ZodBoolean>;
540
547
  }, z.core.$strip>;
541
548
  /** `ingest` is a discriminated union on `kind`: the three declarative
542
549
  * retrievers fetch-and-map; `agent` dispatches a hidden worker. Optional on
@@ -1148,8 +1155,11 @@ declare const CollectionObjectZ: z.ZodObject<{
1148
1155
  transparency: "transparency";
1149
1156
  eventType: "eventType";
1150
1157
  hangoutLink: "hangoutLink";
1158
+ selfResponseStatus: "selfResponseStatus";
1159
+ conferenceVideoUri: "conferenceVideoUri";
1151
1160
  }>>;
1152
1161
  autoPush: z.ZodOptional<z.ZodBoolean>;
1162
+ propagateDeletes: z.ZodOptional<z.ZodBoolean>;
1153
1163
  }, z.core.$strip>>;
1154
1164
  dynamicIcon: z.ZodOptional<z.ZodObject<{
1155
1165
  source: z.ZodObject<{
@@ -1646,8 +1656,11 @@ export declare const CollectionSchemaZ: z.ZodPreprocess<z.ZodObject<{
1646
1656
  transparency: "transparency";
1647
1657
  eventType: "eventType";
1648
1658
  hangoutLink: "hangoutLink";
1659
+ selfResponseStatus: "selfResponseStatus";
1660
+ conferenceVideoUri: "conferenceVideoUri";
1649
1661
  }>>;
1650
1662
  autoPush: z.ZodOptional<z.ZodBoolean>;
1663
+ propagateDeletes: z.ZodOptional<z.ZodBoolean>;
1651
1664
  }, z.core.$strip>>;
1652
1665
  dynamicIcon: z.ZodOptional<z.ZodObject<{
1653
1666
  source: z.ZodObject<{
@@ -7,7 +7,10 @@ import { CollectionCustomView } from './schema';
7
7
  * code composes the prompt text; letting it also decide whether that text runs
8
8
  * would put both halves of the decision inside the sandbox.
9
9
  *
10
- * The phone runtime does not consult this: it always sends, because a phone has
11
- * no Enter key for the user to press (receptron/mulmoterminal#1253). The flag
12
- * governs the desktop custom view and the desktop phone-frame preview. */
10
+ * EVERY surface consults this, the phone included. The phone once always sent,
11
+ * on the grounds that it has no Enter key to press — but that made the same
12
+ * view behave differently depending on where it was opened, and it silently
13
+ * overrode default-deny on a device the author never tested. The phone is
14
+ * served the resolved flag in its view payload rather than re-reading the
15
+ * schema, because it never sees the schema. */
13
16
  export declare function customViewSendsChat(view: Pick<CollectionCustomView, "allowSendChat">): boolean;
@@ -368,9 +368,12 @@ function firstMissingRequiredField(draft, schema) {
368
368
  * code composes the prompt text; letting it also decide whether that text runs
369
369
  * would put both halves of the decision inside the sandbox.
370
370
  *
371
- * The phone runtime does not consult this: it always sends, because a phone has
372
- * no Enter key for the user to press (receptron/mulmoterminal#1253). The flag
373
- * governs the desktop custom view and the desktop phone-frame preview. */
371
+ * EVERY surface consults this, the phone included. The phone once always sent,
372
+ * on the grounds that it has no Enter key to press — but that made the same
373
+ * view behave differently depending on where it was opened, and it silently
374
+ * overrode default-deny on a device the author never tested. The phone is
375
+ * served the resolved flag in its view payload rather than re-reading the
376
+ * schema, because it never sees the schema. */
374
377
  function customViewSendsChat(view) {
375
378
  return view.allowSendChat === true;
376
379
  }