@mapier/imsg-sdk 0.2.3 → 0.3.1

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.
@@ -57,6 +57,10 @@ interface Gateway {
57
57
  groupPhoto: boolean;
58
58
  groupParticipants: boolean;
59
59
  namePhotoSharing: boolean;
60
+ chatBackgroundSet: boolean;
61
+ chatBackgroundRemove: boolean;
62
+ editMessage: boolean;
63
+ mentionFormatting: boolean;
60
64
  };
61
65
  subscribe(sinceId?: number): AsyncIterable<GatewayEvent>;
62
66
  history(chatId: number, limit?: number): Promise<ImsgMessage[]>;
@@ -91,7 +95,11 @@ interface Gateway {
91
95
  ): Promise<SendResult>;
92
96
  sendPoll(chatId: number, question: string, options: string[]): Promise<SendResult>;
93
97
  sendRichLink(chatId: number, url: string): Promise<SendResult>;
94
- editMessage(chatId: number, targetGuid: string, text: string): Promise<{ ok: boolean }>;
98
+ editMessage(chatId: number, targetGuid: string, text: string): Promise<{
99
+ ok: boolean;
100
+ skipped?: 'unchanged';
101
+ effectStarted?: boolean;
102
+ }>;
95
103
  unsendMessage(chatId: number, targetGuid: string): Promise<{ ok: boolean }>;
96
104
  deleteMessage(chatId: number, targetGuid: string): Promise<{ ok: boolean }>;
97
105
  setTyping(chatId: number, on: boolean): Promise<{ ok: boolean }>;
@@ -106,6 +114,21 @@ interface Gateway {
106
114
  skipped?: 'not-offered';
107
115
  effectStarted?: boolean;
108
116
  }>;
117
+ chatBackgroundStatus(chatId: number): Promise<{
118
+ chatId: number;
119
+ backgroundSet: boolean;
120
+ backgroundGuid: string | null;
121
+ } | null>;
122
+ setChatBackground(chatId: number, preset?: 'gradient'): Promise<{
123
+ ok: boolean;
124
+ backgroundGuid?: string;
125
+ effectStarted?: boolean;
126
+ }>;
127
+ removeChatBackground(chatId: number, expectedGuid?: string): Promise<{
128
+ ok: boolean;
129
+ skipped?: 'no-background';
130
+ effectStarted?: boolean;
131
+ }>;
109
132
  }
110
133
 
111
134
  interface ReactionNote {
@@ -160,7 +183,16 @@ both the long-lived RPC process and its one-time `status --json` probe.
160
183
  Patched verbs require the injected bridge's explicit selector markers
161
184
  `emojiTapbackSend`, `stickerSend`, `groupPhotoUpdate`, `groupAddParticipant`, and
162
185
  `groupRemoveParticipant`; Name & Photo requires both `namePhotoShouldOffer`
163
- and `namePhotoShare`; emoji additionally requires the selected RPC binary
186
+ and `namePhotoShare`; chat backgrounds are gated per verb on
187
+ `chatBackgroundSetGradient` (setter + PosterBoard/ChatKit runtime) and
188
+ `chatBackgroundRemove` (setter + live-GUID getter), which the injected helper
189
+ already composes; edit message requires any one of `editMessageItemTranslation`
190
+ (macOS 27's five-argument `-[IMChat editMessageItem:…newPartTranslation:…]`,
191
+ imsg#3), `editMessageItem` (macOS 26) or `editMessage` (macOS 13) — the helper
192
+ uses the first present one; confirmed @mentions on `sendRich` require
193
+ `mentionFormatting`, which the helper advertises only when IMSharedUtilities'
194
+ confirmed-mention attribute key resolves at runtime (imsg#13); emoji
195
+ additionally requires the selected RPC binary
164
196
  to report `rpc_features:["tapback.emoji"]`. That second half prevents a
165
197
  patched dylib behind a stock CLI from false-advertising custom emoji support.
166
198
  Missing status, malformed JSON, or absent markers fail closed.
@@ -175,10 +207,11 @@ guid by scanning `history(chatId, 30)` — the same 30-message ceiling
175
207
  `recentReactions` already documents above — so a guid older than that is as
176
208
  invisible to these methods as it is to `recentReactions`. `tapback` and
177
209
  `deleteMessage` pre-check against that scan before firing, so they return
178
- `{ ok: false }` without touching anything; `editMessage`/`unsendMessage` fire
179
- the RPC first and only find the ceiling on the VERIFY pass, so a guid outside
180
- the window still reports `{ ok: false }` but may have mutated the real row on
181
- the way there "no confirmable effect," not a guaranteed no-op. FakeGateway
210
+ `{ ok: false }` without touching anything, and so does `editMessage` (it also
211
+ refuses a message that is not `is_from_me`); `unsendMessage` fires the RPC
212
+ first and only finds the ceiling on the VERIFY pass, so a guid outside the
213
+ window still reports `{ ok: false }` but may have mutated the real row on the
214
+ way there — "no confirmable effect," not a guaranteed no-op. FakeGateway
182
215
  must enforce the identical window regardless: finding the target guid by
183
216
  scanning ALL of `this.messages` unconditionally would let the fake succeed
184
217
  at targeting a message the real path can't reach (parity rule 2) — a
@@ -219,6 +252,46 @@ prevent.
219
252
  this Gateway because `ImsgMessage` has no subject field. The `url` submode is
220
253
  exposed separately as `sendRichLink` because openclaw treats URL and
221
254
  text/subject/effect/reply as mutually exclusive modes.
255
+ `opts.textFormatting` is a list of ranges over the text in **UTF-16 code
256
+ units** (a JS string index; an emoji is two units): `styles`
257
+ (bold/italic/underline/strikethrough, macOS 15+, silently dropped on older
258
+ hosts), `effect` (a named macOS 27 text effect, resolved to Apple's id in
259
+ `resolveTextFormatting`), and `mention` — a **confirmed @mention**
260
+ (imsg#13, merged into `mapier/deploy` as `7c31772` — the pinned submodule
261
+ — `docs/send.md` "Text formatting and mentions"; the rules below mirror
262
+ `IMsgMentionFormatting.m` at that revision). `mention` is the EXACT `IMHandle.ID` of a participant of the
263
+ target chat as IMCore reports it (E.164 phone or the email), matched
264
+ byte-for-byte by the helper against the live participant list; the visible
265
+ text in the range must be the person's display name, never the handle (a
266
+ handle as visible text renders as a phone link with no highlight and no
267
+ notification, verified 2026-09-03 on an iPhone). Do not prefix the name
268
+ with "@": Messages' composer swallows the typed "@" and a genuine mention
269
+ row carries only the highlighted name (an "@" in the range simply renders
270
+ as text, as the 2026-09-03 SDK run showed). Formatting is **never
271
+ degraded**: the shared preflight `sendRichPreflight` /
272
+ `textFormattingRefusal` runs before the bridge is touched and refuses, with
273
+ a typed `SendResult.refused`, a mention range on a bridge without the
274
+ `mentionFormatting` marker (`mention-capability`), bad offsets on ANY range
275
+ — non-integer, zero length, outside the text, or splitting a surrogate pair
276
+ (`text-formatting-range`; the helper would otherwise silently drop a style
277
+ range and send plain text, which a caller cannot detect since formatting
278
+ is write-only), and a mention identifier that is not a bounded id or whose
279
+ visible text is the handle itself (`mention-identifier`). The helper
280
+ refuses a non-participant handle (`-32603` "Mention identifier is not a
281
+ participant handle of this chat", no row written), which `ImsgGateway`
282
+ classifies as `mention-not-participant`; a chat whose live participant
283
+ list is unreadable refuses every mention the same way. FakeGateway applies
284
+ the same preflight, checks membership against its chat record, and refuses
285
+ all mentions on an unreported-directory group. **A bare `ok:false` without
286
+ `refused` is NOT a no-send guarantee** (bridge timeout, transport loss);
287
+ only a `refused` result proves nothing was sent. Formatting and mentions
288
+ are write-only through this Gateway: `ImsgMessage` exposes neither, so the
289
+ smoke reads the confirmed-mention attribute (`__kIMMentionConfirmedMention`
290
+ followed by the handle) from `chat.db message.attributedBody` directly. The
291
+ `--only-mention` leg is opt-in via `--mention-handle` even in the full
292
+ Tier-2 run, because a confirmed mention notifies a real person.
293
+ **Host-verified 2026-09-03** through `ImsgGateway` on the shared Mac,
294
+ including the recipient-side highlight on an iPhone (see the ledger).
222
295
  - **`sendAttachment(chatId, filePath, opts?)`** — `send.attachment`
223
296
  (`handleSendAttachment`). Targets an **existing** chat only. `opts.audio:true`
224
297
  sends the file as a native voice-note bubble; `opts.replyToGuid` sets an
@@ -265,8 +338,44 @@ prevent.
265
338
  balloon lands as its own row later. `SendResult.guid` is therefore always
266
339
  undefined here; FakeGateway must not return one either.
267
340
  - **`editMessage(chatId, targetGuid, text)`** — `message.edit`
268
- (`handleMessageEdit`). **Edits mutate the existing row's text IN PLACE: no
269
- new row, no `subscribe()` event.** `MessageWatcher`'s cursor is a ROWID
341
+ (`handleMessageEdit`; imsg `docs/edit.md`). Capability-gated on the
342
+ `editMessage` marker (see above): on macOS 27 the helper calls the
343
+ five-argument `editMessageItem:atPartIndex:withNewPartText:newPartTranslation:backwardCompatabilityText:`
344
+ with part index 0 and the new text as backwards-compatibility text, and the
345
+ RPC answers `{ok:true}` on invocation only. **Preflight before firing**, same
346
+ discipline as `tapback`/`deleteMessage`: the guid must be inside the scan
347
+ window — located by a short poll (3 × 1 s, sleeping only between reads),
348
+ because a just-sent guid can lag history by a beat and a one-shot read
349
+ would fail closed on a message about to appear —
350
+ and the message must be an **editable own plain-text message**
351
+ (`isEditableOwnTextMessage` in `gateway/types.ts`, shared by both
352
+ implementations): `is_from_me` (Apple only permits editing your own
353
+ messages and the helper does not check), non-empty `text`, and no
354
+ attachments/poll — the RPC always edits part 0, which on those rows is not
355
+ the text, and a retracted row has nothing left to edit. Anything else is
356
+ refused as `ok:false, effectStarted:false` without touching the bridge, as
357
+ is empty or whitespace-only text. Text identical to the current row is the
358
+ idempotent `skipped:'unchanged'` (`ok:true`, nothing fired). The whole
359
+ decision is one shared function (`decideEditMessagePreflight`) so the two
360
+ implementations cannot drift. Known gap: a rich-link balloon carries its
361
+ URL as `text` and nothing on `ImsgMessage` marks it, so the real path
362
+ fires on it and the read-back decides; FakeGateway's rich-link row has no
363
+ text and is refused. After the RPC fires, a structured `-32603` whose
364
+ `data` is one of the helper's pre-invoke refusals (chat/message not found,
365
+ no selector, bad signature) is `effectStarted:false`; a bridge timeout or
366
+ any other `-32603` (an exception after IMChat was invoked) and an
367
+ unconfirmed read-back are `ok:false, effectStarted:true` — a consumer
368
+ reconciles those rather than treating them as a no-op. **Host-verified
369
+ 2026-09-03** through `ImsgGateway` on the shared Mac (see the ledger
370
+ below); recipient-side rendering remains unobserved. `unsendMessage`
371
+ deliberately keeps its fire-first shape: retracting a just-sent row is a
372
+ legitimate use and its verify pass is the existing contract; a matching
373
+ preflight is a follow-up, not part of this change. Native CLI/RPC E2E on macOS 27 `26A5421a` (imsg#11, chat 8): both
374
+ paths edited a fresh own message and read the new text back on the same
375
+ guid/ROWID, with `message.date_edited` stamped — that column is not exposed
376
+ through `messages.history`, so the SDK smoke reads it from chat.db directly
377
+ while the gateway verifies by text read-back. **Edits mutate the existing
378
+ row's text IN PLACE: no new row, no `subscribe()` event.** `MessageWatcher`'s cursor is a ROWID
270
379
  high-water mark (`WHERE ROWID > cursor`), so an UPDATE to an existing row is
271
380
  structurally invisible to the watch stream — the only way to observe an
272
381
  edit is re-reading `history()` and comparing text for a guid you already
@@ -340,6 +449,84 @@ both implementations — none of Apple's group primitives apply to a 1:1 chat.
340
449
  and the receiving iPhone displayed Mapi's new contact info, closing the
341
450
  receiver-side verification gap.
342
451
 
452
+ - **Chat backgrounds** — `chatBackgroundStatus` / `setChatBackground` /
453
+ `removeChatBackground` (Mapier-Labs/imsg#12, `chat.background.status|set|remove`).
454
+ A background is chat-scoped state recorded in chat.db's background tables,
455
+ not a message row: there is no `subscribe()` event and no history row on
456
+ either implementation. DM and group chats both accept all three verbs.
457
+ `chatBackgroundStatus` is a plain chat.db read (no selector marker, but it
458
+ needs the imsg#12 binary: an older build answers `-32601` and the call
459
+ throws). `null` means the chat does not exist (`-32602`); any other failure
460
+ is thrown, so a transient rpc/bridge fault can never masquerade as "no such
461
+ chat". It returns the persisted background channel GUID, which **lags
462
+ imagent** — a durable snapshot, never the live IMChat state.
463
+ `setChatBackground` supports the single runtime-verified `gradient` preset.
464
+ On the native side the RPC returns on helper acceptance — its response
465
+ embeds the pre-mutation chat.db snapshot, so no separate preflight read —
466
+ and the pipeline (PosterBoard builder → hidden `OverFullScreen` editor →
467
+ ChatKit poster/watch payloads → `setTranscriptBackgroundAndSendToChat:transferID:`)
468
+ runs asynchronously, bounded by per-stage watchdogs and a 90 s overall cap.
469
+ `ImsgGateway` therefore polls `chat.background.status` under a 90 s
470
+ deadline on a **monotonic** clock (`performance.now()`; a wall-clock
471
+ rollback cannot stretch the wait — the native CLI's own window; read first,
472
+ sleep 1 s between), in the pure loop `src/gateway/chat-background.ts`. Each poll is a
473
+ local chat.db read. The helper's operation record is a bridge round-trip on
474
+ Messages' main thread — it competes with the pipeline itself and, because
475
+ the native JSON-RPC loop is strictly serial, holds every other gateway verb
476
+ for up to 10 s on a stall — so it is read only (a) once, pulled forward,
477
+ when a NEW candidate appears (a background GUID different from the
478
+ pre-mutation snapshot) and (b) on a 15 s cadence for early failure
479
+ detection; a helper read that times out doubles that gap, up to 60 s, and
480
+ chat.db polling carries on meanwhile. **Success requires operation
481
+ correlation**: a candidate is accepted only when it equals the record's
482
+ `transfer_id` (the identifier IMChat persists as the background GUID, imsg
483
+ `docs/chat-background.md`). A changed guid, a newer `set` event, or both
484
+ are NOT evidence — a delayed write from an earlier operation satisfies them
485
+ — and a helper that no longer tracks the operation (bounded record table,
486
+ Messages relaunch) leaves nothing to correlate with, so that case ends the
487
+ wait as ambiguous rather than guessing. A bridge-timeout on a verification
488
+ read keeps polling; any other read failure (dead rpc child, transport loss)
489
+ ends the wait as ambiguous. `ok:true` carries the persisted GUID; a re-set
490
+ replaces the background and the GUID changes. FakeGateway mints a fresh GUID
491
+ per set from a sequence it persists in its snapshot. Result semantics
492
+ follow `shareNamePhoto`: `effectStarted:false` is a proven preflight
493
+ rejection — capability absent, `-32602` (unknown chat, bad preset), or a
494
+ helper refusal the SDK recognises in the JSON-RPC `error.data` (busy with
495
+ another background operation — the helper holds one in-process lock —,
496
+ guard mismatch, missing selector/runtime, "did not queue"); `ok:false,
497
+ effectStarted:true` means the effect may have started and persistence was
498
+ not proven — helper record `failed`, the deadline passed, an
499
+ rpc-child/transport failure, a bridge timeout, or any other `-32603` such as
500
+ an exception raised after IMChat was invoked (the native server reports all
501
+ of these under one code with the reason only in `data`) — and must be
502
+ reconciled, never reported as a proven no-op.
503
+ `removeChatBackground` is driven by the helper's **live** IMChat GUID, never
504
+ the caller's chat.db snapshot. The optional `expectedGuid` is a
505
+ compare-and-clear guard evaluated against that live GUID **first**, before
506
+ the no-background check: a mismatch — including a guard on a chat with no
507
+ live background — is `ok:false, effectStarted:false` and leaves the
508
+ background in place. An empty string is no guard (`normalizeChatBackgroundGuard`
509
+ in `gateway/types.ts`, shared by both implementations; the wire only
510
+ carries a non-empty one). Only then is a chat with no live background the
511
+ explicit `skipped:'no-background'` no-op (`ok:true`, IMChat never invoked).
512
+ The gateway reads chat.db before firing (the remove response's own snapshot
513
+ is post-invoke) and after a real clear checks that response, then polls
514
+ under a 30 s deadline for a `clear` event newer than the baseline; an unchanged empty
515
+ snapshot counts only when the pre-mutation read recorded the background
516
+ that is now gone, because an already-lagging empty snapshot proves nothing.
517
+ Native Set/Remove semantics were smoke-verified on macOS 27 `26A5421a`
518
+ (matching live GUID, chat.db event, cache, watch cache, upload metadata),
519
+ and the production CLI/RPC path went through its matched-build E2E before
520
+ imsg#12 merged. The SDK verbs were **host-verified 2026-09-03** through
521
+ `ImsgGateway` on the shared Mac (`--only-chat-background` leg of
522
+ `scripts/tier2-smoke.ts`; see the ledger below); recipient-side rendering,
523
+ group chats and the replace path were **eyeballed 2026-09-04** (ledger
524
+ entry below), with one receiver-side condition: a brand-new thread from a
525
+ sender the iPhone has never seen may render no background until the
526
+ thread is "known" (see the entry). The leg
527
+ (`scripts/tier2-chat-background-leg.ts`) stops at the first failed check
528
+ and never clears a background whose guid it did not read back.
529
+
343
530
  **Host-verification ledger.** Exercised through `ImsgGateway` on the host Mac
344
531
  2026-07-14 (`scripts/tier2-smoke.ts`, chat 3996):
345
532
 
@@ -361,6 +548,118 @@ Each leg prints an EYEBALL step because a local write is not proof of
361
548
  receiver-visible delivery (the group-photo lesson). The `sendRich`
362
549
  text/effect/reply path was already live-verified.
363
550
 
551
+ Exercised 2026-09-03 on the shared Mac (macOS 27 `26A5421a`, user
552
+ `mapidev`, disposable DM chat 8, SDK `bf92147` against the deployed imsg#12
553
+ CLI/helper: `bridge_version 2`, `v2_ready true`, `chatBackgroundSetGradient
554
+ true`, `chatBackgroundRemove true`; `scripts/tier2-smoke.ts 8
555
+ --only-chat-background`, run dir `map175-sdk-bg-e2e.PCVLad`):
556
+
557
+ - **`setChatBackground` / `removeChatBackground` / `chatBackgroundStatus`**
558
+ — live-verified: both capability rows; baseline `backgroundSet:false`;
559
+ Set Gradient persisted a NEW background guid in ~7 s and a separate status
560
+ read returned the same guid; Remove with `NOT-THE-LIVE-GUID` →
561
+ `ok:false, effectStarted:false` with the background unchanged (the logged
562
+ `ImsgRpcError` is the deliberate mismatch); guarded Remove cleared it,
563
+ status read `backgroundSet:false`, and an unguarded Remove on the bare
564
+ chat → `skipped:'no-background'`. `ALL PASS`, exit 0; the audit recorded
565
+ two events, both in chat 8; native status identical before and after and
566
+ the fixture back at its no-background baseline. The `live-smoke.ts 8
567
+ --read-only` gate passed first. Not observed: the receiving device
568
+ rendering the Gradient; not covered: group chats, the replace (re-set)
569
+ path, relay/public-API integration.
570
+
571
+ Exercised 2026-09-03 on the same host (SDK `6cf6ac5` against the same
572
+ deployed helper: `editMessageItemTranslation true`, the two four-argument
573
+ markers false; `scripts/tier2-smoke.ts 8 --only-edit-message`, run dir
574
+ `map175-sdk-edit-e2e.fgUq4t`):
575
+
576
+ Exercised 2026-09-03 on the same host (SDK `6e28c82` against the deployed
577
+ imsg#13 helper, `mentionFormatting true`; disposable group chat 12 with
578
+ three phone-handle participants; `scripts/tier2-smoke.ts 12 --only-mention
579
+ --mention-handle <participant IMHandle.ID> --mention-name <display name>`, run dir
580
+ `map175-sdk-mention-e2e.PuauKG`):
581
+
582
+ - **`sendRich` confirmed @mention** — live-verified: capability row;
583
+ `@Neo tier2-smoke 21:13:08 mention` sent
584
+ (`86246294-B16D-4E5C-9E15-D5E80C90CB14`, row 3143) and read back on the
585
+ same guid; `chat.db attributedBody` carries `__kIMMentionConfirmedMention`
586
+ at byte 266 followed by the handle at byte 300; a non-participant handle
587
+ refused by the helper (`-32603` "Mention identifier is not a participant
588
+ handle of this chat") → `refused:'mention-not-participant'`, a range
589
+ outside the text → `'text-formatting-range'`, and the handle as visible
590
+ text → `'mention-identifier'`, none of the three texts in history
591
+ afterwards. 9/9 PASS, `ALL PASS`, exit 0; the audit recorded exactly one
592
+ event, in chat 12; native status identical before and after. The
593
+ `live-smoke.ts 12 --read-only` gate passed first (6/6). **Recipient-side
594
+ observed** this time: on the mentioned participant's iPhone the message
595
+ rendered with "@Neo" highlighted in the same style as a native mention.
596
+ Not covered: email-handle mentions (not covered natively either), the
597
+ `send-attachment` / multipart mention legs (no SDK surface).
598
+
599
+ - **`editMessage`** — live-verified: capability row from the three-marker
600
+ fold; marker message sent (`73263626-5A07-4B3C-8244-D3133B8A8EA6`), edited,
601
+ new text read back on the SAME guid, `chat.db message.date_edited`
602
+ stamped (`810141791007747072`); unchanged text → `skipped:'unchanged'`;
603
+ whitespace text → `ok:false, effectStarted:false` with the edited text
604
+ still in place; the chat's newest inbound row (the DM partner's echo of
605
+ the marker) → `ok:false, effectStarted:false` and untouched. 10/10 PASS,
606
+ `ALL PASS`, exit 0; native status identical before and after. The
607
+ `live-smoke.ts 8 --read-only` gate passed first (6/6). Not observed: the
608
+ receiving device rendering "Edited" (the inbound echo carried the original
609
+ text, which is the partner's copy, not evidence either way).
610
+
611
+ Eyeballed 2026-09-04 on the same host (SDK `5826316` = v0.3.0, deployed
612
+ `imsg` 0.13.0 helper, all four 0.3.0 capability flags true) with a human
613
+ watching the receiving iPhone (iOS 26.6.1) between steps, one gateway call
614
+ per invocation (`scripts/eyeball.ts`, checked into no branch — it is an
615
+ observation aid, not a check). Fixtures: DM chat 13 (the host's account →
616
+ the phone, created that day) and group chat 12 (three phone handles):
617
+
618
+ - **`setChatBackground` → receiver** — observed on the phone in the DM and
619
+ in the group: the conversation switched to the Gradient background and
620
+ Messages posted "<sender> changed the background." The phone's own
621
+ `imagent` log (Console.app, device streaming) showed the full inbound
622
+ path each time: `Received incoming transcript background` → asset fetch
623
+ → `Successfully blastdoor'd data` → `Writing poster` → `Update type:
624
+ setNewBackground`, ~6 s after the call. **Receiver-side condition (sticky,
625
+ trigger not isolated):** the first four Sets into the brand-new DM were
626
+ processed by the phone's `imagent` exactly the same way but Messages
627
+ rendered nothing and offered no Backgrounds entry in that DM; the host's
628
+ account was an email the phone had never seen. Right after that account
629
+ was saved as a contact the next Set rendered — and every later Set kept
630
+ rendering after the contact was deleted again, in the DM and in the
631
+ group, so whatever flips the thread from "does not render" to "renders"
632
+ stays flipped. Saving the contact is the time-correlated candidate, and
633
+ the phone's own background action in the group (before the group's first
634
+ successful Set) the other; separating them needs a fresh DM to a fresh
635
+ number. The Mac side gives no hint of any of this: chat.db, the upload
636
+ and the IDS send
637
+ (command 138, sealed for every endpoint of the number that advertises
638
+ `supports-transcript-backgrounds`) all succeed either way, so a
639
+ `setChatBackground` `ok:true` is proof the Mac set it, not that the
640
+ recipient can see it.
641
+ - **`removeChatBackground` → receiver** — observed in the DM and the group:
642
+ the background returned to default on the phone. The compare-and-clear
643
+ guard was exercised for real along the way: the phone had cleared and the
644
+ host re-set the background between two steps, so a Remove carrying the
645
+ earlier guid answered `ok:false, effectStarted:false` (the helper's
646
+ "does not match expected GUID" refusal) and left the live background in
647
+ place; the Remove with the live guid cleared it.
648
+ - **Reverse direction** — a background set and a background cleared from
649
+ the phone both arrived in the host's chat.db (`item_type 3`,
650
+ `group_action_type 4` / `6`, `handle_id` = the phone) and
651
+ `chatBackgroundStatus` tracked them. Host-initiated actions land as
652
+ `is_from_me 0, handle_id 0` rows; the status read keys off the chat's
653
+ live background guid, not that column.
654
+ - **`editMessage` → receiver** — observed in the DM: the phone showed the
655
+ SAME bubble with the new text and the "Edited" label, no new bubble
656
+ (marker `6026CE4C-1CF1-4B97-8C90-925834F59272`).
657
+ - Three IDS endpoints are registered for the receiving number; one of them
658
+ (a macOS 15 Mac, which has no transcript backgrounds) is dropped by the
659
+ sender's capability policy on every Set (`IF lacks(supports-transcript-
660
+ backgrounds) THEN failure`). That is per-endpoint pruning, not a failure
661
+ of the send, and the phone's endpoints are unaffected.
662
+
364
663
  - **subscribe(sinceId?)** — long-lived event stream of new messages/reactions across all
365
664
  chats the host Mac's Messages account can see. `sinceId` is an **exclusive** cursor: only
366
665
  events with `id > sinceId` are delivered (catch-up semantics, not "starting at"). Today
@@ -783,8 +1082,16 @@ harness (`tsx --test`, see `package.json`). Required cases:
783
1082
  returns `ok:false` for fewer than two options or an unknown chat
784
1083
  - `sendRichLink` echoes an outbound row and returns `ok:false` for an unknown chat; preview
785
1084
  metadata is not queryable through `ImsgMessage`
1085
+ - `sendRich` with a confirmed mention of a participant sends once (DM and group, alongside
1086
+ styles); a non-participant handle, a wrongly formatted participant, a range outside the
1087
+ text (mention OR style), an empty or padded handle, the handle as visible text, an
1088
+ unreported group directory, and a missing `mentionFormatting` marker each return
1089
+ `ok:false` with the matching typed `refused` reason and nothing sent
786
1090
  - `editMessage` changes the target row's text in place — same row count before/after, no
787
- new row
1091
+ new row; refuses an inbound (not `is_from_me`) message, empty/whitespace text, and our own
1092
+ message outside the scan window without mutation;
1093
+ unchanged text is the idempotent `skipped:'unchanged'`; returns `ok:false` without
1094
+ mutation when the `editMessage` capability marker is absent
788
1095
  - `unsendMessage` retracts the target row (text clears, row persists);
789
1096
  `deleteMessage` is dispatch-only with no stable history postcondition (the
790
1097
  fake conservatively preserves the row); both return `ok: false` for an
@@ -794,13 +1101,19 @@ harness (`tsx --test`, see `package.json`). Required cases:
794
1101
  - `renameGroup`/`addParticipant`/`removeParticipant` are reflected in a message sent
795
1102
  afterward (`chat_name`/`participants`) and return `ok: false` against a DM
796
1103
  - `setGroupPhoto`/`leaveGroup` return `ok: true` against a group and `ok: false` against a DM
797
- - patched sticker, group-photo, and participant verbs return `ok:false` without mutation
798
- when their `imsg status` capability markers are absent
1104
+ - patched sticker, group-photo, participant, and chat-background verbs return `ok:false`
1105
+ without mutation when their `imsg status` capability markers are absent
1106
+ - chat background: `setChatBackground` persists a NEW guid on a DM and a group (a re-set
1107
+ changes the guid), `chatBackgroundStatus` reflects it, `removeChatBackground` clears it;
1108
+ remove on a bare chat is the explicit `no-background` skip (also with an empty guard), a
1109
+ mismatched `expectedGuid` is a preflight rejection that leaves the background (and is
1110
+ checked before the no-background branch, so a stale guard on a bare chat rejects), and an
1111
+ unknown chat never starts an effect
799
1112
  - `recentReactions` surfaces an inbound custom-emoji reaction (`reaction: 'custom'`) with the
800
1113
  real `emoji` intact
801
1114
  - Fake-only restart coverage snapshots and restores a world, then proves
802
- `sendStatus`, `checkHandle`, Name & Photo idempotency, current group metadata,
803
- fixture state, and monotonic IDs survive exactly
1115
+ `sendStatus`, `checkHandle`, Name & Photo idempotency, chat backgrounds (and their
1116
+ guid sequence), current group metadata, fixture state, and monotonic IDs survive exactly
804
1117
 
805
1118
  FakeGateway runs this suite in CI on every PR. ImsgGateway runs it as a manual runbook step on
806
1119
  the host Mac (see `docs/host-mac-control.md`) until a host-Mac CI runner exists — at that point
@@ -66,8 +66,9 @@ for per-host fragility, and it's the reason for nearly every defensive choice in
66
66
  version, and why "wired ≠ host-verified."
67
67
 
68
68
  **The upside:** runtime resolution also buys adaptivity. One dylib can carry fallbacks
69
- and pick per host — which is exactly why `handleEditMessage` tries `editMessageItem:…`
70
- then falls back to `editMessage:…`, and why a fixed `leaveChat` would probe candidates
69
+ and pick per host — which is exactly why `handleEditMessage` tries the macOS 27
70
+ five-argument `editMessageItem:…newPartTranslation:…` first, then `editMessageItem:…`,
71
+ then `editMessage:…`, and why a fixed `leaveChat` would probe candidates
71
72
  and choose whichever the host has. A statically-linked binary couldn't branch like that;
72
73
  a runtime-resolved one straddles multiple macOS versions from a single build.
73
74
 
@@ -97,7 +98,7 @@ Ex.
97
98
  ### Mutate a message
98
99
  | Verb | Handler | Call | Host |
99
100
  |---|---|---|---|
100
- | `edit-message` | `handleEditMessage` | **IMChat** `editMessageItem:atPartIndex:withNewPartText:backwardCompatabilityText:` (fallback `editMessage:atPartIndex:…`) | **⚠ old selectors false; replacement identified, not wired/smoked see MAP-175 findings** |
101
+ | `edit-message` | `handleEditMessage` | **IMChat** `editMessageItem:atPartIndex:withNewPartText:newPartTranslation:backwardCompatabilityText:` (macOS 27; fallbacks `editMessageItem:…` / `editMessage:…`) | wired on macOS 27 (imsg#3), native CLI/RPC E2E'd (imsg#11); SDK verb capability-gated + preflighted, host `tier2-smoke --only-edit-message` pending |
101
102
  | `unsend-message` | `handleUnsendMessage` | **IMChat** `retractMessagePart:` | ✓ |
102
103
  | `delete-message` | `handleDeleteMessage` | **IMChat** `deleteChatItems:` (device-local only) | ✓ |
103
104
 
@@ -109,7 +110,7 @@ Ex.
109
110
  | `set-display-name` | `handleSetDisplayName` | **IMChat** `_setDisplayName:` (+ `sendGroupPhotoUpdate:`) | ✓ |
110
111
  | `update-group-photo` | `handleUpdateGroupPhoto` | **IMChat** `sendGroupPhotoUpdate:` + **IMFileTransferCenter** `createNewOutgoingGroupPhotoTransferWithLocalFileURL:` | ✓ |
111
112
  | `create-chat` | `handleCreateChat` | **IMChatRegistry** `chatForIMHandle:` / `chatForIMHandles:` (+ `_setDisplayName:`) | ✓ |
112
- | **`leave-chat`** | `handleLeaveChat` | **IMChat** `leaveChat` | **✗ selector gone ([#5]); replacement identified, not wired/smoked see MAP-175 findings** |
113
+ | **`leave-chat`** | `handleLeaveChat` | **IMChat** `leave` (macOS 27; `leaveChat` fallback for older hosts) | **⚠ call site updated to `leave`; not yet tested on relay e2e** |
113
114
  | `delete-chat` | `handleDeleteChat` | **IMChatRegistry** `sharedInstance` (delete path selector-gated) | ✗ not wired above |
114
115
 
115
116
  ### Read / presence
@@ -129,8 +130,10 @@ Ex.
129
130
 
130
131
  The selector **markers** reported by `imsg status --json` are probed in
131
132
  `IMsgInjected.m` near the top of the file: it checks `IMChat` for
133
+ `editMessageItem:…newPartTranslation:…` (marker `editMessageItemTranslation`),
132
134
  `editMessageItem:…`, `editMessage:…`, `retractMessagePart:`, `sendMessage:reason:`.
133
- Add a probe there when adding a verb so the SDK can capability-gate it.
135
+ Add a probe there when adding a verb so the SDK can capability-gate it; the SDK's
136
+ `editMessage` capability is any one of the three edit markers.
134
137
 
135
138
  ## macOS 27 operation callsites — MAP-175
136
139
 
@@ -139,12 +142,12 @@ operation callsites were found and verified by **Neo Shangguan** for MAP-175.
139
142
 
140
143
  | Operation | Current bridge selector | macOS 27 entrypoint found | Downstream operation dispatch | Integration status | Found by |
141
144
  |---|---|---|---|---|---|
142
- | `leave-chat` | `-[IMChat leaveChat]` — absent | `-[IMChat leave]` | `[provider _chat_leave:chat]` | Found; not wired/live-smoked | **Neo Shangguan** |
143
- | `edit-message` | Four-argument `-[IMChat editMessageItem:…]` / `-[IMChat editMessage:…]` — absent | Five-argument `-[IMChat editMessageItem:…newPartTranslation:…]` | `[provider _chat:chat sendEditedMessageItem:editedItem previousMessageItem:originalItem partIndex:index editType:1 backwardCompatabilityText:text]` | Found; not wired/live-smoked | **Neo Shangguan** |
144
- | `mentions` | No bridge operation | Confirmed-mention `NSAttributedString` attributes; no dedicated send selector | Existing `IMMessage` construction → `IMChat` send path | Payload construction found; not wired/live-smoked | **Neo Shangguan** |
145
- | `text-styling` / `text-effects` | No bridge operation | IM text-style/effect attributes on `NSAttributedString`; no dedicated send selector | Existing `IMMessage` construction → `IMChat` send path | Payload construction found; not wired/live-smoked | **Neo Shangguan** |
145
+ | `leave-chat` | `-[IMChat leaveChat]` — absent | `-[IMChat leave]` | `[provider _chat_leave:chat]` | Call site updated to `leave`; not yet tested on relay e2e | **Neo Shangguan** |
146
+ | `edit-message` | Four-argument `-[IMChat editMessageItem:…]` / `-[IMChat editMessage:…]` — absent | Five-argument `-[IMChat editMessageItem:…newPartTranslation:…]` | `[provider _chat:chat sendEditedMessageItem:editedItem previousMessageItem:originalItem partIndex:index editType:1 backwardCompatabilityText:text]` | Wired (imsg#3), native CLI/RPC E2E'd (imsg#11) and SDK verb host-verified 2026-09-03 on macOS 27 | **Neo Shangguan** |
147
+ | `mentions` | `send-rich` / `send.rich` `text_formatting[].mention` (imsg#13) | Confirmed-mention `NSAttributedString` attribute (`__kIMMentionConfirmedMention`) resolved at runtime; no dedicated send selector | Existing `IMMessage` construction → `IMChat` send path | Wired + native E2E'd 2026-09-03 (attribute keys match a genuine iPhone mention row; highlight + notification on the recipient); SDK verb host-verified 2026-09-03 incl. recipient highlight | **Neo Shangguan** |
148
+ | `text-styling` / `text-effects` | No bridge operation | IM text-style/effect attributes on `NSAttributedString`; no dedicated send selector | Existing `IMMessage` construction → `IMChat` send path | Wired + tested e2e relayside | **Neo Shangguan** |
146
149
  | `contact-card` | No contact-card-specific bridge operation | `.vcf` attachment identified by `kUTTypeVCard` / `public.vcard` | Existing `IMFileTransfer` attachment send path | Attachment representation found; not live-smoked | **Neo Shangguan** |
147
- | `chat-background` | No bridge operation | `-[IMChat setTranscriptBackgroundAndSendToChat:transferID:]` | `[provider _chat:chat setTranscriptBackgroundAndSendToChat:background transferID:transferID]` | Set/remove control flow found; asset/transfer contract not wired/live-smoked | **Neo Shangguan** |
150
+ | `chat-background` | `chat.background.set` / `chat.background.remove` / `chat.background.status` (Mapier-Labs/imsg#12, merged) | `-[IMChat setTranscriptBackgroundAndSendToChat:transferID:]` | `[provider _chat:chat setTranscriptBackgroundAndSendToChat:background transferID:transferID]` | Native bridge merged after matched-build CLI/RPC E2E; SDK verbs `setChatBackground` / `removeChatBackground` / `chatBackgroundStatus` in this repo; relay command + recipient-side validation pending | **Neo Shangguan** |
148
151
  | `location-request` | No bridge operation | `+[MSMessage findMyLocationRequestMessage]` | `+[CKComposition compositionWithMSMessage:appExtensionIdentifier:]` using `IMBalloonBundleIdentifierWaldo` → `-[CKChatController setComposition:]` | Factory/staging callsites found; payload/bridge route pending; not wired/live-smoked | **Neo Shangguan** |
149
152
 
150
153
  The summary records stable selector names for implementation. Numeric callsite
@@ -191,9 +194,9 @@ replacement entrypoint; bridge code should call it rather than reaching into
191
194
  group-leave operation, so it is not a `leaveChat` replacement.
192
195
 
193
196
  The existing `leaveChat` selector can remain as the older-system path, with
194
- `leave` as a runtime-gated fallback. This is a research result, not a release
195
- claim: the native fork has not been rebuilt/injected with the fallback and no
196
- live group-leave smoke has been performed.
197
+ `leave` as a runtime-gated fallback. **Update:** the bridge call site has since
198
+ been updated to probe `leave`; it has not yet been tested end-to-end through
199
+ relay (no relay e2e group-leave smoke performed).
197
200
 
198
201
  ### `edit-message` — found by Neo Shangguan
199
202
 
@@ -383,9 +386,11 @@ As with mentions, these mutation helpers live in the `/System/iOSSupport`
383
386
  ChatKit image and are evidence for payload shape rather than bridge call
384
387
  targets. A bridge implementation should resolve the native
385
388
  `IMSharedUtilities` symbols by name, construct the attributed payload itself,
386
- and reuse the existing `IMMessage`/`IMChat` send path. The marker values,
387
- serialization behavior, recipient rendering, and range handling still require
388
- a development-profile live send smoke.
389
+ and reuse the existing `IMMessage`/`IMChat` send path. **Update:** this is now
390
+ wired and tested end-to-end through relay the attributed payload is
391
+ constructed over the requested ranges and sent via the existing path, with
392
+ effects animating on device on the `mapidev` development profile. Inbound
393
+ styled-text parsing remains open if that direction is needed.
389
394
 
390
395
  ### `contact-card` — found by Neo Shangguan
391
396
 
@@ -494,21 +499,49 @@ else:
494
499
  ```
495
500
 
496
501
  `-[CKCoreChatController updateTranscriptBackground:transferID:]` begins at
497
- `0x1dbc7c538`. Its non-null asynchronous completion and null removal branch
498
- converge on the same `IMChat` selector:
502
+ `0x1dbc7c538`. Its non-null branch stages the prepared poster and watch payloads
503
+ on disk, obtains the poster URL's `absoluteString`, and then converges with the
504
+ null removal branch on the same `IMChat` selector:
499
505
 
500
506
  ```text
501
- set: [chat setTranscriptBackgroundAndSendToChat:preparedBackground
507
+ set: [chat setTranscriptBackgroundAndSendToChat:posterURL.absoluteString
502
508
  transferID:transferID] callsite 0x1dbc7cc38
503
509
  remove: [chat setTranscriptBackgroundAndSendToChat:nil
504
510
  transferID:transferID] callsite 0x1dbc7c710
505
511
  ```
506
512
 
513
+ The `absoluteString` selector stub immediately before the non-null callsite is
514
+ at `0x1e0016430` and resolves to selector address `0x1f5907443`. A live smoke
515
+ that passed the `CKTranscriptBackground` object itself produced
516
+ `-[ChatKit.CKTranscriptBackground length]: unrecognized selector`, further
517
+ confirming that the daemon-facing first argument is the staged URL string, not
518
+ the ChatKit wrapper object.
519
+
507
520
  This establishes that removal is represented by a null background sent through
508
- the same operation, not by a separate remove selector. A complete bridge recipe
509
- still needs the concrete background object/archive construction and channel
510
- transfer contract. Those details, recipient behavior, and both set/remove
511
- operations require a development-profile live smoke before release claims.
521
+ the same operation, not by a separate remove selector. The complete native
522
+ recipe archives the poster configuration, prepares its watch snapshot, stages
523
+ the resulting `posterData` and `watchData` at the poster URL and its
524
+ `im_associatedWatchBackgroundURL`, and passes the poster URL string to IMChat.
525
+ Recipient behavior outside the development profile still requires validation.
526
+
527
+ A read-only runtime probe on the macOS 27 development host (build `26A5421a`)
528
+ confirmed that `IMChat` responds to
529
+ `setTranscriptBackgroundAndSendToChat:transferID:`. Its live Objective-C method
530
+ signature returns `void`, has four total arguments (`self`, `_cmd`, and two
531
+ object arguments), and `+[NSString stringGUID]` is available for generating the
532
+ transfer identifier. A development-profile semantic smoke then generated and
533
+ staged a Gradient background, invoked IMChat, and matched the live background
534
+ GUID to the persisted `set` event and cache assets. Passing a null first
535
+ argument with a fresh transfer identifier subsequently cleared the same live
536
+ GUID and persisted a matching `clear` event. This verified the direct native
537
+ set/remove operations. The production bridge (Gradient builder, null-background
538
+ removal handler, capability probes, standalone smoke scripts, and
539
+ persistence-confirming CLI/RPC surfaces) then passed its matched CLI/dylib
540
+ E2E on the development profile and merged as Mapier-Labs/imsg#12 into
541
+ `mapier/deploy`. This repo's `ImsgGateway` exposes it as `setChatBackground`,
542
+ `removeChatBackground`, and `chatBackgroundStatus` (see
543
+ `docs/gateway-contract.md` §2 "Chat backgrounds"). Relay command wiring and
544
+ recipient-side end-to-end coverage remain pending.
512
545
 
513
546
  ### `location-request` — found by Neo Shangguan
514
547
 
@@ -558,13 +591,13 @@ for the MAP-175 findings above:
558
591
 
559
592
  | Operation | Readiness | What is resolved | Remaining implementation work |
560
593
  |---|---|---|---|
561
- | `leave-chat` | **Ready to wire** | macOS 27 `-[IMChat leave]` entrypoint and `_chat_leave:` dispatch | Add the runtime-gated `leave` fallback while retaining the older `leaveChat` path; rebuild/inject and live-smoke on `mapidev` |
562
- | `edit-message` | **Ready to wire** | Five-argument selector, argument order, edited-item builder, and daemon dispatch | Add an accurately cast `objc_msgSend`, runtime probe/fallbacks, and ordinary-edit `nil` translation handling; rebuild/inject and live-smoke |
563
- | `mentions` | **Partially blocked** | Native mention attribute symbols and confirmed-mention mutation path | Confirm the target identity/handle attribute value, serialization, and range behavior with a development-profile send/receive smoke |
564
- | `text-styling` / `text-effects` | **Prototype-ready** | Transport attributes, style bit mapping, effect type/name mapping, and existing send boundary | Resolve symbols dynamically, construct/serialize the attributed payload, validate mixed/attachment ranges and recipient rendering, then live-smoke |
594
+ | `leave-chat` | **Call site updated** | macOS 27 `-[IMChat leave]` entrypoint and `_chat_leave:` dispatch; call site re-pointed to `leave` with the `leaveChat` fallback retained | Test end-to-end through relay on `mapidev` (bridge call site updated, relay e2e not yet run) |
595
+ | `edit-message` | **Native merged (imsg#3/#11); SDK verb host-verified 2026-09-03** | Five-argument selector, argument order, edited-item builder, daemon dispatch, runtime probe/fallbacks, native CLI/RPC E2E | Relay `message.moderate edit` mapping of `effectStarted`; recipient-side rendering check |
596
+ | `mentions` | **Native merged (imsg#13, `7c31772`); SDK verb host-verified 2026-09-03** | Attribute key + handle value confirmed against a genuine row, recipient highlight + notification, membership fail-closed | Relay `message.send` payload for mention ranges |
597
+ | `text-styling` / `text-effects` | **Done — tested e2e relayside** | Transport attributes, style bit mapping, effect type/name mapping, existing send boundary; attributed payload constructed and sent through the relay path, animating on device | (inbound styled-text parsing still open if needed) |
565
598
  | `contact-card` | **Prototype-ready** | vCard UTI/content representation and reuse of the existing attachment-transfer path | Choose/validate MIME and transfer metadata, accept or serialize `.vcf` input, verify recipient rendering and inbound parsing |
566
- | `chat-background` remove | **Ready to prototype** | Null-background remove semantics, transfer-ID generation, `IMChat` entrypoint, and daemon dispatch | Wire the operation and validate removal plus recipient state on the development profile |
567
- | `chat-background` set | **Partially blocked** | Set control flow, Watch-data preparation selector, `IMChat` entrypoint, and daemon dispatch | Recover/reproduce the concrete background archive and channel-transfer contract; then validate upload, recipient rendering, and retry behavior |
599
+ | `chat-background` remove | **Native merged (imsg#12); SDK verb in this repo** | Null-background semantics, transfer-ID generation, selector ABI, live clear, persisted clear event, race-guard contract, matched-build CLI/RPC E2E | Host-Mac `tier2-smoke.ts --chat-background`; relay `chat.background.remove` command; validate recipient state |
600
+ | `chat-background` set | **Native merged (imsg#12); SDK verb in this repo** | Gradient configuration build, archive, poster/watch snapshot, staging contract, URL-string IMChat argument, live set, persisted event, cache/upload metadata, matched-build CLI/RPC E2E | Host-Mac `tier2-smoke.ts --chat-background`; relay `chat.background.set` command; validate recipient rendering and retry behavior |
568
601
  | `location-request` | **Callsite-only** | Waldo feature gate, `MSMessage` factory selector, `CKComposition` wrapper, and app-extension identifier | Recover the factory's serialized Waldo payload and a safe bridge route into the existing send path without depending on a `CKChatController` UI instance |
569
602
 
570
603
  Every new or replaced bridge path still needs defensive runtime capability
@@ -592,9 +625,10 @@ macOS version:
592
625
  the matched CLI+dylib, inject (SIP off), and smoke.
593
626
 
594
627
  For `leave-chat`: `handleLeaveChat` and everything above it (relay → RPC → file
595
- IPC → dispatch) is intact. The only dead link is `-[IMChat leaveChat]`. MAP-175
596
- identified `-[IMChat leave]` as its macOS 27 replacement; the remaining work is
597
- to re-point that call, add a status marker, rebuild/inject the matched native
598
- fork, and live-smoke it on the development profile.
628
+ IPC → dispatch) is intact. The only dead link was `-[IMChat leaveChat]`;
629
+ `-[IMChat leave]` is its macOS 27 replacement and the call site has since been
630
+ re-pointed to it (with `leaveChat` retained as the older-host path). The
631
+ remaining work is to test it end-to-end through relay on the development
632
+ profile.
599
633
 
600
634
  [#5]: https://github.com/Mapier-Labs/imsg-sdk/issues/5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mapier/imsg-sdk",
3
- "version": "0.2.3",
3
+ "version": "0.3.1",
4
4
  "description": "TypeScript SDK for iMessage automation on macOS: the Gateway contract, ImsgGateway (real), and a device-free FakeGateway.",
5
5
  "license": "MIT",
6
6
  "repository": {