@mapier/imsg-sdk 0.1.5 → 0.2.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.
- package/README.md +27 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +15 -0
- package/dist/cli.js.map +1 -0
- package/dist/doctor.d.ts +25 -0
- package/dist/doctor.js +92 -0
- package/dist/doctor.js.map +1 -0
- package/dist/gateway/fake.d.ts +21 -17
- package/dist/gateway/fake.js +31 -2
- package/dist/gateway/fake.js.map +1 -1
- package/dist/imsg/rpc.d.ts +2 -1
- package/dist/imsg/rpc.js +11 -2
- package/dist/imsg/rpc.js.map +1 -1
- package/dist/imsg/watch.d.ts +1 -1
- package/dist/imsg/watch.js +9 -2
- package/dist/imsg/watch.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/docs/api.md +58 -0
- package/docs/capability-matrix.md +23 -0
- package/docs/compatibility.md +48 -0
- package/docs/gateway-contract.md +817 -0
- package/docs/host-mac-control.md +188 -0
- package/docs/imsg-polls.md +473 -0
- package/docs/interactions.md +176 -0
- package/docs/operations.md +47 -0
- package/package.json +9 -1
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
# Gateway contract — ImsgGateway ↔ FakeGateway
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
`Gateway` is the only boundary between the agent and iMessage. Two implementations:
|
|
6
|
+
|
|
7
|
+
- **ImsgGateway** — real. Wraps `openclaw/imsg` (JSON-RPC subprocess + CLI) running on the
|
|
8
|
+
host Mac. See `docs/host-mac-control.md` for how that host is reached and configured.
|
|
9
|
+
- **FakeGateway** — dev/simulator/eval. No Mac, no `imsg` binary, no Messages.app.
|
|
10
|
+
|
|
11
|
+
Most development happens against FakeGateway, with no way to check behavior against real
|
|
12
|
+
iMessage in the loop. That makes FakeGateway's fidelity the whole ballgame: if it lies about
|
|
13
|
+
what iMessage does, every feature built against it either breaks on the host Mac or, worse,
|
|
14
|
+
assumes a capability iMessage doesn't have.
|
|
15
|
+
|
|
16
|
+
Two rules follow:
|
|
17
|
+
|
|
18
|
+
1. **FakeGateway must reproduce every behavioral invariant in §2.** Not "close enough" —
|
|
19
|
+
exact, including the annoying ones (id monotonicity, reaction toggling, most-recent-only
|
|
20
|
+
targeting).
|
|
21
|
+
2. **FakeGateway may never offer more capability than ImsgGateway.** If the real `imsg` CLI
|
|
22
|
+
can't do it, FakeGateway must not let the agent do it either. A capability that only exists
|
|
23
|
+
in the fake is a trap: code gets built against it, then breaks the first time it touches a
|
|
24
|
+
real Mac.
|
|
25
|
+
3. **Fidelity is about SETUP, not just messaging.** The trap in rules 1–2 also springs from the
|
|
26
|
+
*state a user can be in*, not only the operations we can run. Any sim tool that fabricates a
|
|
27
|
+
user (dev-mint, god-mode create — `docs/sim-dev-tools.md`) must land them in a state a real
|
|
28
|
+
user could actually reach: a real user reaches onboarding *because they texted the prefill
|
|
29
|
+
first*, so their DM thread exists before any link completes. A sim shortcut that mints a link
|
|
30
|
+
for a never-texted user puts them in an impossible state and makes green sim runs lie about
|
|
31
|
+
prod. When you add a setup shortcut, ask "could a real user be here?" — if not, seed whatever
|
|
32
|
+
the real arrival would have created (e.g. the DM thread via `gateway.ensureDm`), or the test
|
|
33
|
+
is a fiction.
|
|
34
|
+
|
|
35
|
+
Conformance is enforced by one shared contract test suite (§4) parameterized over both
|
|
36
|
+
implementations. Against FakeGateway it runs in normal CI. Against ImsgGateway it runs on the
|
|
37
|
+
host Mac — manual today, CI-optional once that runner exists.
|
|
38
|
+
|
|
39
|
+
Ground truth: the invariants below were verified live against **imsg 0.12.0** on the host Mac
|
|
40
|
+
(2026-07-02 conformance smoke, `scripts/live-smoke.ts`) and re-verified against **imsg 0.12.3**
|
|
41
|
+
(2026-07-07 upgrade smoke, `scripts/live-smoke.ts 3996`). Earlier drafts cited imsg's docs;
|
|
42
|
+
where the live binary disagreed with a docs reading (reaction state in history,
|
|
43
|
+
`chats.list` field names), the live behavior below is authoritative.
|
|
44
|
+
|
|
45
|
+
Read-only gateway changes may use `scripts/live-smoke.ts <chatId> --read-only` when
|
|
46
|
+
Messages.app UI automation is unavailable. That mode verifies history, portable chat
|
|
47
|
+
directory, bounded attachment-aware history, and end-bound filtering only; it makes no
|
|
48
|
+
claim about send, reaction, or group mutation behavior.
|
|
49
|
+
|
|
50
|
+
## 1. Interface
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
interface Gateway {
|
|
54
|
+
readonly capabilities: {
|
|
55
|
+
emojiTapback: boolean;
|
|
56
|
+
stickerSend: boolean;
|
|
57
|
+
groupPhoto: boolean;
|
|
58
|
+
groupParticipants: boolean;
|
|
59
|
+
namePhotoSharing: boolean;
|
|
60
|
+
};
|
|
61
|
+
subscribe(sinceId?: number): AsyncIterable<GatewayEvent>;
|
|
62
|
+
history(chatId: number, limit?: number): Promise<ImsgMessage[]>;
|
|
63
|
+
listChats(limit?: number): Promise<GatewayChat[]>;
|
|
64
|
+
historyRange(chatId: number, range: GatewayHistoryRange): Promise<ImsgMessage[]>;
|
|
65
|
+
send(target: { chatId: number } | { to: string }, text: string): Promise<SendResult>;
|
|
66
|
+
react(chatId: number, reaction: Reaction, expectedGuid?: string): Promise<ReactResult>;
|
|
67
|
+
createGroup(handles: string[], firstMessage: string): Promise<{ chatId: number }>;
|
|
68
|
+
recentReactions(chatId: number, limit?: number): Promise<ReactionNote[]>;
|
|
69
|
+
resolveDmChat(handle: string): Promise<number | null>;
|
|
70
|
+
resolveGroupChat(request: GroupChatResolutionRequest): Promise<GroupChatResolution>;
|
|
71
|
+
sendStatus(guid: string): Promise<SendStatus>;
|
|
72
|
+
checkHandle(address: string, opts?: { aliasType?: 'phone' | 'email' }): Promise<HandleCheck>;
|
|
73
|
+
|
|
74
|
+
// Tier 2 (bridge-backed — imsg launch required). See "Tier 2" below.
|
|
75
|
+
tapback(chatId: number, targetGuid: string, reaction: Reaction, remove?: boolean): Promise<ReactResult>;
|
|
76
|
+
emojiTapback(chatId: number, targetGuid: string, emoji: string, remove?: boolean): Promise<ReactResult>;
|
|
77
|
+
sendRich(
|
|
78
|
+
chatId: number,
|
|
79
|
+
text: string,
|
|
80
|
+
opts?: { effect?: string; replyToGuid?: string; subject?: string },
|
|
81
|
+
): Promise<SendResult>;
|
|
82
|
+
sendAttachment(
|
|
83
|
+
chatId: number,
|
|
84
|
+
filePath: string,
|
|
85
|
+
opts?: { audio?: boolean; replyToGuid?: string },
|
|
86
|
+
): Promise<SendResult>;
|
|
87
|
+
sendSticker(
|
|
88
|
+
chatId: number,
|
|
89
|
+
filePath: string,
|
|
90
|
+
opts?: { attachToGuid?: string; partIndex?: number },
|
|
91
|
+
): Promise<SendResult>;
|
|
92
|
+
sendPoll(chatId: number, question: string, options: string[]): Promise<SendResult>;
|
|
93
|
+
sendRichLink(chatId: number, url: string): Promise<SendResult>;
|
|
94
|
+
editMessage(chatId: number, targetGuid: string, text: string): Promise<{ ok: boolean }>;
|
|
95
|
+
unsendMessage(chatId: number, targetGuid: string): Promise<{ ok: boolean }>;
|
|
96
|
+
deleteMessage(chatId: number, targetGuid: string): Promise<{ ok: boolean }>;
|
|
97
|
+
setTyping(chatId: number, on: boolean): Promise<{ ok: boolean }>;
|
|
98
|
+
markRead(chatId: number): Promise<{ ok: boolean }>;
|
|
99
|
+
renameGroup(chatId: number, name: string): Promise<{ ok: boolean }>;
|
|
100
|
+
setGroupPhoto(chatId: number, filePath?: string): Promise<{ ok: boolean }>;
|
|
101
|
+
addParticipant(chatId: number, handle: string): Promise<{ ok: boolean }>;
|
|
102
|
+
removeParticipant(chatId: number, handle: string): Promise<{ ok: boolean }>;
|
|
103
|
+
leaveGroup(chatId: number): Promise<{ ok: boolean }>;
|
|
104
|
+
shareNamePhoto(chatId: number): Promise<{
|
|
105
|
+
ok: boolean;
|
|
106
|
+
skipped?: 'not-offered';
|
|
107
|
+
effectStarted?: boolean;
|
|
108
|
+
}>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface ReactionNote {
|
|
112
|
+
id: number; // the reaction's global rowid
|
|
113
|
+
// 'custom' covers an arbitrary-emoji tapback (iOS 18+, imsg surfaces it as
|
|
114
|
+
// type "custom" with the real emoji in `emoji`). Sending requires the
|
|
115
|
+
// patched-fork emojiTapback capability.
|
|
116
|
+
reaction: Reaction | 'custom';
|
|
117
|
+
emoji?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface ReactResult {
|
|
121
|
+
ok: boolean;
|
|
122
|
+
skipped?: 'already-reacted' | 'stale-target' | 'not-reacted';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type GatewayEvent = ImsgMessage; // message or reaction; see §2 shape
|
|
126
|
+
|
|
127
|
+
type Reaction = 'love' | 'like' | 'dislike' | 'laugh' | 'emphasis' | 'question';
|
|
128
|
+
|
|
129
|
+
interface SendResult {
|
|
130
|
+
ok: boolean;
|
|
131
|
+
id?: number;
|
|
132
|
+
guid?: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface GroupChatResolutionRequest {
|
|
136
|
+
conversationExternalId: string;
|
|
137
|
+
expectedParticipantExternalIds: readonly string[];
|
|
138
|
+
excludedParticipantExternalIds?: readonly string[];
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Tier 2 (bridge-backed)
|
|
143
|
+
|
|
144
|
+
Everything below requires the IMCore bridge (`imsg launch`, SIP disabled — see
|
|
145
|
+
`docs/imsg-polls.md` for the host setup and `docs/host-mac-control.md`).
|
|
146
|
+
Unlike `react()`/`createGroup()`, none of these drive Messages.app UI
|
|
147
|
+
automation: they call the injected dylib's private IMCore API directly
|
|
148
|
+
through openclaw's per-request v2 IPC queue (built for concurrent access), so
|
|
149
|
+
`ImsgGateway` does not serialize them through the UI-automation Mutex. All of
|
|
150
|
+
them are wired over the long-lived `imsg rpc` child (`src/imsg/rpc.ts`), the
|
|
151
|
+
same transport as `send`/`history`/`chats.list` — openclaw exposes every verb
|
|
152
|
+
below as a JSON-RPC method (confirmed against openclaw's `RPCServer.swift`
|
|
153
|
+
`kSupportedRPCMethods` dispatch table and `docs/rpc.md` "Bridge Message
|
|
154
|
+
Actions"), so none of it needed a new `execFile` CLI subprocess.
|
|
155
|
+
|
|
156
|
+
**Patched-fork capability discovery.** Stock imsg can advertise the same RPC
|
|
157
|
+
method names while still lacking Mapier's macOS-26 fixes and custom `emoji`
|
|
158
|
+
parameter. `ImsgGateway` therefore uses `IMSG_BIN` (falling back to `imsg`) for
|
|
159
|
+
both the long-lived RPC process and its one-time `status --json` probe.
|
|
160
|
+
Patched verbs require the injected bridge's explicit selector markers
|
|
161
|
+
`emojiTapbackSend`, `stickerSend`, `groupPhotoUpdate`, `groupAddParticipant`, and
|
|
162
|
+
`groupRemoveParticipant`; Name & Photo requires both `namePhotoShouldOffer`
|
|
163
|
+
and `namePhotoShare`; emoji additionally requires the selected RPC binary
|
|
164
|
+
to report `rpc_features:["tapback.emoji"]`. That second half prevents a
|
|
165
|
+
patched dylib behind a stock CLI from false-advertising custom emoji support.
|
|
166
|
+
Missing status, malformed JSON, or absent markers fail closed.
|
|
167
|
+
`FakeGateway.capabilities` mirrors this gate and can disable the same surface
|
|
168
|
+
for parity tests. A remote adapter (e.g. imsg-agent's connector) must gate on
|
|
169
|
+
the capability descriptor it actually negotiated; the presence of a gateway
|
|
170
|
+
method on a wire never enables a feature by itself.
|
|
171
|
+
|
|
172
|
+
**Guid-targeting window.** `tapback`, `editMessage`, `unsendMessage`, and
|
|
173
|
+
`deleteMessage` can target ANY message guid, but `ImsgGateway` locates that
|
|
174
|
+
guid by scanning `history(chatId, 30)` — the same 30-message ceiling
|
|
175
|
+
`recentReactions` already documents above — so a guid older than that is as
|
|
176
|
+
invisible to these methods as it is to `recentReactions`. `tapback` and
|
|
177
|
+
`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
|
|
182
|
+
must enforce the identical window regardless: finding the target guid by
|
|
183
|
+
scanning ALL of `this.messages` unconditionally would let the fake succeed
|
|
184
|
+
at targeting a message the real path can't reach (parity rule 2) — a
|
|
185
|
+
guid-targeted verb "working" in the fake against a message 40 rows back but
|
|
186
|
+
refusing on the real path is exactly the trap this contract exists to
|
|
187
|
+
prevent.
|
|
188
|
+
|
|
189
|
+
- **`tapback(chatId, targetGuid, reaction, remove?)`** — a second, bridge-based
|
|
190
|
+
tapback path alongside `react()`. What it adds: targets **any** message guid
|
|
191
|
+
in the chat, not just the newest incoming bubble; works in **group** chats
|
|
192
|
+
(the bridge has no focused-window check to defeat, unlike the AppleScript
|
|
193
|
+
automation `react()` depends on); explicit add/remove instead of toggle.
|
|
194
|
+
What it does **not** add by itself: an arbitrary/custom emoji. openclaw's own bridge
|
|
195
|
+
handler (`RPCServer+BridgeMessageHandlers.swift` `handleTapback` →
|
|
196
|
+
`normalizeBridgeReactionType`, backed by the closed `BridgeReactionKind` enum
|
|
197
|
+
in `IMsgBridgeProtocol.swift` and the `kindMap` in the injected
|
|
198
|
+
`IMsgInjected.m` `handleSendReaction`) accepts only the same 6 canonical
|
|
199
|
+
kinds `react()` does, and openclaw's own CHANGELOG documents this as
|
|
200
|
+
deliberate: "fix: reject unsupported custom emoji reaction sends instead of
|
|
201
|
+
taking a no-op AppleScript path" (#55). So `reaction` stays the closed
|
|
202
|
+
`Reaction` type here too.
|
|
203
|
+
- **`emojiTapback(chatId, targetGuid, emoji, remove?)`** — Mapier-fork extension
|
|
204
|
+
backed by `IMEmojiTapback` + `IMTapbackSender`. It targets any guid in the
|
|
205
|
+
same 30-message scan window as `tapback`, uses explicit add/remove guards,
|
|
206
|
+
and verifies history for `type:"custom"` plus the exact emoji before
|
|
207
|
+
returning success. The RPC sends `emoji` instead of `kind`; sending both
|
|
208
|
+
would fall back into stock imsg's classic-6 normalizer. Live-verified on the
|
|
209
|
+
host Mac with 👻 and 💀; chat.db records `associated_message_type=2006`.
|
|
210
|
+
- **`sendRich(chatId, text, opts?)`** — real and confirmed present on the
|
|
211
|
+
host's selected patched imsg 0.13.x (`send.rich`, `RPCServer+BridgeMessageHandlers.swift`
|
|
212
|
+
`handleSendRich`; shipped well before 0.12.3 per CHANGELOG). Targets an
|
|
213
|
+
**existing** chat only — unlike `send()` there is no `{ to: handle }`
|
|
214
|
+
find-or-create form. `opts.effect` is a Messages expressive-send effect
|
|
215
|
+
short name; valid values are bubble `impact`, `loud`, `gentle`, `invisibleink` and
|
|
216
|
+
screen `confetti`, `lasers`, `fireworks`, `balloons`, `sparkles`, `spotlight`,
|
|
217
|
+
`echo`, `love`, `celebration`. `opts.replyToGuid` sets an inline reply target;
|
|
218
|
+
`opts.subject` sets the Messages subject line. Subject is write-only through
|
|
219
|
+
this Gateway because `ImsgMessage` has no subject field. The `url` submode is
|
|
220
|
+
exposed separately as `sendRichLink` because openclaw treats URL and
|
|
221
|
+
text/subject/effect/reply as mutually exclusive modes.
|
|
222
|
+
- **`sendAttachment(chatId, filePath, opts?)`** — `send.attachment`
|
|
223
|
+
(`handleSendAttachment`). Targets an **existing** chat only. `opts.audio:true`
|
|
224
|
+
sends the file as a native voice-note bubble; `opts.replyToGuid` sets an
|
|
225
|
+
inline reply target. A successful send echoes an outbound row, but file
|
|
226
|
+
content and audio mode are not asserted from the synchronous send response.
|
|
227
|
+
Attachment metadata may later appear on history/watch rows through the
|
|
228
|
+
read-side `attachments[]` shape described below.
|
|
229
|
+
- **`sendSticker(chatId, filePath, opts?)`** — patched-fork `send.sticker`,
|
|
230
|
+
selector-gated by `stickerSend`. Targets an **existing iMessage chat only**;
|
|
231
|
+
there is no find-or-create form. It accepts PNG/APNG/GIF/JPEG files up to
|
|
232
|
+
500 KiB, 618×618, and 100 frames, and can optionally attach to a message guid/part.
|
|
233
|
+
Success maps the always-present `transfer_guid` to `SendResult.guid`.
|
|
234
|
+
FakeGateway conservatively echoes an outbound row but does not claim file
|
|
235
|
+
validity or rendered sticker metadata, which the send response cannot verify.
|
|
236
|
+
**Host-verified 2026-07-14/15 — delivers.** Initially failed
|
|
237
|
+
`{"error":"Dylib error: Could not securely open sticker directory"}`: the
|
|
238
|
+
injected dylib's sticker secure-open fd-walk started at the home directory,
|
|
239
|
+
which the Messages sandbox EPERMs (`open("/Users/…")` denied). Fixed in the
|
|
240
|
+
fork (`mapier/deploy`, "sticker sandbox-safe secure-open") by anchoring
|
|
241
|
+
the per-component `O_NOFOLLOW`+uid walk at the shallowest sandbox-openable
|
|
242
|
+
ancestor instead of home. After rebuild+inject, `send.sticker` delivers — a
|
|
243
|
+
real sticker row lands with `is_sticker=1, is_sent=1, error=0`.
|
|
244
|
+
- **`checkHandle(address, opts?)`** — bridge-backed Apple IDS reachability via
|
|
245
|
+
`handles.check`; iMessage only, never SMS. FakeGateway returns available only
|
|
246
|
+
from evidence it actually owns: participants of known chats or an explicit
|
|
247
|
+
test/sim reachability override. A well-formed address with no evidence returns
|
|
248
|
+
`available:false`/`idStatus:0`. This deliberately under-claims users Apple IDS
|
|
249
|
+
might find reachable rather than inventing reachability the fake cannot know.
|
|
250
|
+
**Host-verified 2026-07-14 (both branches):** a real iMessage test phone →
|
|
251
|
+
`available:true, id_status:1`; an unregistered number → `available:false,
|
|
252
|
+
id_status:0`. The live false-branch confirms the fake's evidence-based model
|
|
253
|
+
is faithful, not an over-claim.
|
|
254
|
+
- **`sendPoll(chatId, question, options)`** — `poll.send`
|
|
255
|
+
(`handlePollSend`), targeting an **existing** chat only and requiring at
|
|
256
|
+
least two options. The RPC's `guid` becomes `SendResult.guid` (the fork
|
|
257
|
+
docs' earlier `messageGuid` name was wrong — see the ledger below), which
|
|
258
|
+
identifies the balloon row. Poll creation has a real readback postcondition:
|
|
259
|
+
the balloon and caption rows described under "Native poll readback" below.
|
|
260
|
+
- **`sendRichLink(chatId, url)`** — the `send.rich` URL submode
|
|
261
|
+
(`handleSendRichLink`), targeting an **existing** chat only. The caller
|
|
262
|
+
supplies only the bare URL; the bridge builds the preview title/image from
|
|
263
|
+
the page. **The submode is queued/async: it returns `{ ok, queued }` with NO
|
|
264
|
+
guid** (unlike text/poll/attachment, which return a guid) — the URL-preview
|
|
265
|
+
balloon lands as its own row later. `SendResult.guid` is therefore always
|
|
266
|
+
undefined here; FakeGateway must not return one either.
|
|
267
|
+
- **`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
|
|
270
|
+
high-water mark (`WHERE ROWID > cursor`), so an UPDATE to an existing row is
|
|
271
|
+
structurally invisible to the watch stream — the only way to observe an
|
|
272
|
+
edit is re-reading `history()` and comparing text for a guid you already
|
|
273
|
+
have. This is a new invariant: elsewhere in this doc "same guid" has meant
|
|
274
|
+
"same immutable content" (e.g. poll rows); edited messages break that.
|
|
275
|
+
- **`unsendMessage`/`deleteMessage(chatId, targetGuid)`** — `message.unsend`
|
|
276
|
+
(`retractMessagePart:`) / `message.delete` (`deleteChatItems:`), two
|
|
277
|
+
distinct native APIs. **Live-verified on the host Mac:** unsend retracts in
|
|
278
|
+
place (row persists and text clears); delete dispatches successfully but
|
|
279
|
+
has no stable `history()` postcondition (the row persisted in one live run
|
|
280
|
+
and was absent in another). imsg exposes no `is_unsent`/`is_deleted` marker,
|
|
281
|
+
so after a guid pre-check, delete `{ok:true}` means only that the RPC
|
|
282
|
+
dispatched. The fake conservatively leaves its history row unchanged.
|
|
283
|
+
- **`setTyping(chatId, on)`/`markRead(chatId)`** — `typing`/`read`
|
|
284
|
+
(`handleTyping`/`handleRead`). **Fire-and-forget: no observable effect
|
|
285
|
+
through anything this Gateway exposes.** `is_read`/`date_read` are real
|
|
286
|
+
imsg message fields per `docs/json.md`, but the Gateway does not model them
|
|
287
|
+
as a verification surface. `ok: true` means
|
|
288
|
+
"the RPC call was dispatched without error," never "confirmed."
|
|
289
|
+
- **`renameGroup`/`addParticipant`/`removeParticipant`** — `group.rename`
|
|
290
|
+
(`setDisplayName`) / `group.addParticipant` / `group.removeParticipant`
|
|
291
|
+
(`RPCServer+ChatHandlers.swift`). Verified against `chats.list`'s
|
|
292
|
+
`display_name`/`participants` fields — the same source `createGroup`'s
|
|
293
|
+
content-verify and `resolveDmChat` already read via `ImsgRpc.chats()`.
|
|
294
|
+
The long-lived RPC process keeps a stale chat-metadata view after its own
|
|
295
|
+
mutation on macOS 26, while a fresh `imsg chats` process sees the update.
|
|
296
|
+
Verification therefore polls fresh, read-only CLI processes (using the
|
|
297
|
+
same `IMSG_BIN`) for up to 20 seconds before returning `ok:false`.
|
|
298
|
+
- **`setGroupPhoto`/`leaveGroup`** — `group.setIcon` / `group.leave`. No field
|
|
299
|
+
in imsg's JSON output reflects a group photo, and `participants[]` always
|
|
300
|
+
excludes the local user (see "Participants exclude the local user" above),
|
|
301
|
+
so leaving never changes anything queryable either — both are
|
|
302
|
+
fire-and-forget through the Gateway, same caveat as `setTyping`/`markRead`.
|
|
303
|
+
The patched host path is **receiver-verified** (2026-07-15, iMessage group →
|
|
304
|
+
the icon rendered on the recipient device). The fork **stages** the image
|
|
305
|
+
into the Messages Attachments tree (`stageAttachment`) before transferring it
|
|
306
|
+
via `createNewOutgoingGroupPhotoTransferWithLocalFileURL:`, then persists an
|
|
307
|
+
`at_0_...` group-photo GUID, posted as a silent group-action event with no
|
|
308
|
+
attachment bubble. Staging is load-bearing: an unstaged path is unreadable by
|
|
309
|
+
sandboxed imagent, so the GUID binds locally but the file never uploads (no
|
|
310
|
+
attachment row, no delivery — the original failure). A custom emoji /
|
|
311
|
+
solid / gradient icon is just a rendered PNG sent through this same path — no
|
|
312
|
+
special API.
|
|
313
|
+
**`leaveGroup` is currently dead on the live host** (observed 2026-07-17,
|
|
314
|
+
macOS 26.5.1, fork 0.13.0): `group.leave` returns `-32603 Internal error`
|
|
315
|
+
regardless of group size, and `imsg status` lists no leave-related selector
|
|
316
|
+
(its neighbors `deleteChat`/`editMessage` are also ✗ on this build). The
|
|
317
|
+
gateway degrades to `{ ok: false }`. Treat leave as unavailable until the
|
|
318
|
+
fork restores the selector; imsg-sdk issue #5 tracks it.
|
|
319
|
+
|
|
320
|
+
All Tier-2 group-management methods reject a DM `chatId` (`ok: false`) on
|
|
321
|
+
both implementations — none of Apple's group primitives apply to a 1:1 chat.
|
|
322
|
+
|
|
323
|
+
- **`shareNamePhoto(chatId)`** — Apple Messages' native **Share Name & Photo**
|
|
324
|
+
flow, not a vCard or Contacts attachment. It first calls
|
|
325
|
+
`contacts.shouldShareContact` and requires `available:true` plus
|
|
326
|
+
`should_offer:true`; otherwise it fails or returns the idempotent
|
|
327
|
+
`skipped:"not-offered"`. Only then does it call
|
|
328
|
+
`contacts.shareContactCard`, and success requires `requested:true`. The
|
|
329
|
+
result carries `effectStarted:false` for a preflight failure and
|
|
330
|
+
`effectStarted:true` when the RPC share invocation began but did not return a
|
|
331
|
+
verified success. A consumer maps the latter to reconciliation instead of
|
|
332
|
+
claiming the effect never started. The
|
|
333
|
+
runtime invokes this once per process on the first allowlisted inbound text
|
|
334
|
+
in a DM; reaction-only and blocked senders never trigger it. That process
|
|
335
|
+
guard is required: live verification saw Apple's advisory briefly return
|
|
336
|
+
`true` once after a successful share before settling to `false`, so
|
|
337
|
+
`should_offer` alone is not a durable idempotency key across restarts.
|
|
338
|
+
FakeGateway models the settled state and skips its second call. On
|
|
339
|
+
2026-07-14 the host returned `requested:true` for the disposable test group
|
|
340
|
+
and the receiving iPhone displayed Mapi's new contact info, closing the
|
|
341
|
+
receiver-side verification gap.
|
|
342
|
+
|
|
343
|
+
**Host-verification ledger.** Exercised through `ImsgGateway` on the host Mac
|
|
344
|
+
2026-07-14 (`scripts/tier2-smoke.ts`, chat 3996):
|
|
345
|
+
|
|
346
|
+
- **`sendRich` subject** — live-verified: `chat.db message.subject` populated,
|
|
347
|
+
and the bold subject line rendered receiver-visible on the test phone.
|
|
348
|
+
- **`sendPoll`** — live-verified: renders as a native interactive poll on the
|
|
349
|
+
receiving device; `poll.send` returns the balloon guid as `guid` (the earlier
|
|
350
|
+
`messageGuid` read was wrong and is fixed), confirmed via poll readback.
|
|
351
|
+
- **`sendRichLink`** — live-verified delivered after the Mapier fork fixed the
|
|
352
|
+
bridge's deferred `dispatch_after` path (`mapier/richlink-sync-fix`). The host
|
|
353
|
+
recorded an `is_sent=1` URLBalloonProvider row and `send_status` reported
|
|
354
|
+
delivery. The RPC still returns only `{ok, queued}` with no guid, so that
|
|
355
|
+
immediate response alone is not delivery proof.
|
|
356
|
+
- **`sendAttachment`** — `send.attachment` returns a guid synchronously;
|
|
357
|
+
attachment-join row (and `is_audio_message=1` for the `audio:true` leg)
|
|
358
|
+
verified via a direct `chat.db` read. Opt-in via `--attachment`/`--audio-file`.
|
|
359
|
+
|
|
360
|
+
Each leg prints an EYEBALL step because a local write is not proof of
|
|
361
|
+
receiver-visible delivery (the group-photo lesson). The `sendRich`
|
|
362
|
+
text/effect/reply path was already live-verified.
|
|
363
|
+
|
|
364
|
+
- **subscribe(sinceId?)** — long-lived event stream of new messages/reactions across all
|
|
365
|
+
chats the host Mac's Messages account can see. `sinceId` is an **exclusive** cursor: only
|
|
366
|
+
events with `id > sinceId` are delivered (catch-up semantics, not "starting at"). Today
|
|
367
|
+
`ImsgWatch` (`src/imsg/watch.ts`) owns a dedicated `ImsgRpc.subscribe` process wrapping
|
|
368
|
+
`watch.subscribe` with `include_reactions:true` and `attachments:true`. When the caller
|
|
369
|
+
supplies a durable cursor, the SDK retires that read-side process every 10 seconds and
|
|
370
|
+
resumes a fresh process from the last observed global rowid. The overlap-free handoff is
|
|
371
|
+
lossless because the cursor is exclusive, and duplicate replay at the boundary is discarded.
|
|
372
|
+
The rotation timer starts when the process is created; it does not wait for the native
|
|
373
|
+
`watch.subscribe` acknowledgement, which can itself remain pending. This bounds a native
|
|
374
|
+
subscription that remains process-alive but silently stops reading `chat.db`; transport
|
|
375
|
+
heartbeat alone is not delivery health. The paired native watcher gives an unjoined row a
|
|
376
|
+
bounded two-second window to acquire its chat association, then skips it so that malformed
|
|
377
|
+
row cannot hold the global cursor behind the SDK's ten-second supervision deadline. The RPC
|
|
378
|
+
emits a content-free `watch.warning`, which the SDK reports as
|
|
379
|
+
`imsg_watch_unresolved_chat_row_skipped`, whenever that recovery path is used, and advances the
|
|
380
|
+
rotating cursor through that row so a tail orphan is not retried forever. A cursorless
|
|
381
|
+
subscription keeps its first native session until its first event establishes a resumable rowid.
|
|
382
|
+
**Single consumer.** The event queue is instance-shared; a second concurrent subscriber
|
|
383
|
+
would silently steal events, so both implementations throw on one. Documented divergence:
|
|
384
|
+
ImsgGateway allows one `subscribe()` per gateway *lifetime* (the SDK rotates its internal
|
|
385
|
+
read process; callers still do not re-subscribe), while FakeGateway allows
|
|
386
|
+
*sequential* re-subscription (one active at a time) so the simulator can model
|
|
387
|
+
reconnect-after-downtime against a single in-memory store.
|
|
388
|
+
|
|
389
|
+
- **listChats(limit?)** — read-only portable chat directory for backfill. Rows
|
|
390
|
+
carry the local execution-only `chatId`, portable conversation GUID, group bit,
|
|
391
|
+
canonical participants, and last-message timestamp. A full page means the caller has
|
|
392
|
+
not proved directory completeness. Rows missing portable GUID or participants are
|
|
393
|
+
omitted and therefore also prevent a completeness claim.
|
|
394
|
+
- **historyRange(chatId, range)** — oldest-first, reaction-row-free messages within an
|
|
395
|
+
inclusive `startAt` and exclusive `endBefore` time window. It optionally includes
|
|
396
|
+
attachment metadata. A backfill consumer recursively splits a full window rather than
|
|
397
|
+
treating a bounded response as complete; a still-full minimum window becomes a
|
|
398
|
+
disclosed backfill gap.
|
|
399
|
+
|
|
400
|
+
**Lifecycle.** Every JSON-RPC request has a 30-second monotonic owner timeout. A timeout
|
|
401
|
+
rejects the request, retires the whole RPC child, and invokes the unexpected-exit callback;
|
|
402
|
+
the owner must fail closed because any in-flight mutation may already have started. This also
|
|
403
|
+
bounds a native RPC process that remains alive before accepting its first request (including a
|
|
404
|
+
headless macOS permission wait). Spawn failures follow the same callback path instead of
|
|
405
|
+
escaping as an unhandled child-process error.
|
|
406
|
+
|
|
407
|
+
The Gateway contract has no abort or close signal: quiescing a consumer does
|
|
408
|
+
not promise that an outstanding `AsyncIterator.next()` inside `ImsgGateway.subscribe()` was
|
|
409
|
+
canceled. The process owner stops the Gateway during final shutdown. An intentional
|
|
410
|
+
`ImsgRpc.stop()` closes the child without invoking its unexpected-exit callback; an exit
|
|
411
|
+
that occurs before an intentional stop still invokes that callback so the process owner can
|
|
412
|
+
fail closed (covered by `tests/imsg-rpc.test.ts`).
|
|
413
|
+
|
|
414
|
+
- **history(chatId, limit)** — most recent `limit` messages in a chat, both sides, reactions
|
|
415
|
+
and attachment metadata included. `ImsgRpc.history` (`src/imsg/rpc.ts`)
|
|
416
|
+
calls `messages.history` with `attachments:true` and re-sorts
|
|
417
|
+
oldest-first by `id` because RPC order is undocumented — any Gateway impl must return the
|
|
418
|
+
same normalized order.
|
|
419
|
+
- **send(target, text)** — one outbound text. `target` is `{ chatId }` for an existing thread
|
|
420
|
+
(DM or group) or `{ to: handle }` for a DM by handle, find-or-creating the thread. Returns
|
|
421
|
+
`{ ok, id?, guid? }`; `ok: false` on failure — see §2 failure modes.
|
|
422
|
+
- **react(chatId, reaction, expectedGuid?)** — send a tapback to the chat's most-recent
|
|
423
|
+
**incoming** bubble (the real CLI's own semantics — our outbound messages are never the
|
|
424
|
+
target); there is no message-id targeting because the real path doesn't have one (§2).
|
|
425
|
+
`expectedGuid` is a **precondition, not targeting**: if provided and the newest incoming
|
|
426
|
+
bubble is no longer that guid at fire time, the react is skipped
|
|
427
|
+
(`{ ok: true, skipped: 'stale-target' }`) — a queued react must not tag a message that
|
|
428
|
+
arrived after the decision was made. Implementations also skip
|
|
429
|
+
(`skipped: 'already-reacted'`) when the same reaction from us is already active on the
|
|
430
|
+
target — our own `react()` never un-reacts (toggle protection; see §2). `ok: false` means
|
|
431
|
+
the reaction did not verifiably land (§2 "exit 0 lies").
|
|
432
|
+
- **createGroup(handles, firstMessage)** — create a group thread and send its first message
|
|
433
|
+
in one call (sending the message is what creates the thread on the real path — see §2).
|
|
434
|
+
Returns the new `chatId`, located by **content**: the thread whose recent history contains
|
|
435
|
+
our `firstMessage`, not merely one matching the participant set (§2). Throws if the message
|
|
436
|
+
never appears — the compose can fail silently. No standalone "create empty group" or
|
|
437
|
+
"add participant" exists.
|
|
438
|
+
- **recentReactions(chatId, limit?)** — user tapbacks on OUR messages, newest-first; `limit`
|
|
439
|
+
bounds the returned notes (~10 default). Read from history's per-message `reactions[]`
|
|
440
|
+
aggregates (§2), so it survives process restarts. **Scan ceiling:** only the chat's most
|
|
441
|
+
recent 30 messages are scanned (the real path reads `history(chatId, 30)`) — a tapback on
|
|
442
|
+
an older message is invisible, and FakeGateway enforces the same window so features can't
|
|
443
|
+
be built against reach the real gateway doesn't have. Feeds "[reacted X to your message]"
|
|
444
|
+
acknowledgment notes into agent context.
|
|
445
|
+
- **resolveDmChat(handle)** — the numeric `chat_id` of the existing DM thread with `handle`, or
|
|
446
|
+
`null` if none exists. **Read-only** — it must never create a thread (the real path reads
|
|
447
|
+
`chat.db` via `chats.list` and cannot mint one; FakeGateway may not exceed that). The runtime
|
|
448
|
+
keeps an in-memory `handle → chat_id` cache warmed by inbound traffic; this recovers it on a
|
|
449
|
+
cold miss — a link completion replaying after a pod restart, before the user's next message,
|
|
450
|
+
would otherwise defer forever (imsg-agent's `handleLinkCompletion`). The real path derives
|
|
451
|
+
the DM row as the non-group chat whose sole `participants[]` entry is the handle (live-verified
|
|
452
|
+
2026-07-08 against imsg 0.12.3, `scripts/chats-probe.ts`: 37/37 DM rows carried exactly one
|
|
453
|
+
participant = the peer handle; DM-vs-group is `!is_group`, never participant count). The method
|
|
454
|
+
itself passed its host-Mac smoke the same day — `scripts/resolve-dm-smoke.ts` (the manual rule-1
|
|
455
|
+
gate for this method; re-run it on imsg upgrades): known test-phone handle → its exact chat_id,
|
|
456
|
+
unknown handle → null with no thread composed, repeat resolve stable.
|
|
457
|
+
- **resolveGroupChat(request)** — read-only portable group resolution. It makes one high-ceiling
|
|
458
|
+
local `chats.list` read and uses each group row's portable `guid` and `participants` directly;
|
|
459
|
+
it does not open every group's message history. It accepts a result only when the
|
|
460
|
+
scan is complete, exactly one row has the requested GUID, and its normalized participant set
|
|
461
|
+
exactly equals `expectedParticipantExternalIds` after removing the explicitly declared pod/self
|
|
462
|
+
identities. Invalid input, an incomplete directory, no match, duplicate matches, an unreported
|
|
463
|
+
membership, and membership drift are distinct fail-closed outcomes. `guid` and `participants`
|
|
464
|
+
are optional on the imsg wire, so a group row without `guid` makes the directory incomplete;
|
|
465
|
+
an otherwise readable matching group without `participants` yields
|
|
466
|
+
`participant_directory_unavailable`, never `participant_mismatch`: reporting drift
|
|
467
|
+
about members that were never read would silently disable matching against a correct ops chat,
|
|
468
|
+
which is the exact "`imsg` lies" failure class this contract exists to catch. `FakeGateway` can
|
|
469
|
+
reach both degraded states through `setGroupDirectoryComplete` and
|
|
470
|
+
`setGroupParticipantsUnreported`, so neither branch is fake-unreachable. Live verification on
|
|
471
|
+
2026-07-14 against imsg 0.12.3 read all 3,922 visible chats in about one second; all 96 group rows
|
|
472
|
+
reported a GUID and participants, and `scripts/resolve-group-smoke.ts` passed exact resolution,
|
|
473
|
+
stable repeat resolution, membership mismatch, and unknown-GUID rejection. That live run is
|
|
474
|
+
the verification of record; no separate evidence row exists. The returned numeric `chat_id` is an ephemeral
|
|
475
|
+
process-local execution target: it is never configuration, durable state, or connector payload.
|
|
476
|
+
`scripts/resolve-group-smoke.ts` is the read-only host-Mac rule-1 gate.
|
|
477
|
+
- **sendStatus(guid)** — read-only `message.send_status` lookup in chat.db; it
|
|
478
|
+
does not require the bridge. Missing rows normalize to `pending` with null
|
|
479
|
+
status fields. Recipient read-receipt settings legitimately leave
|
|
480
|
+
`date_read`/`date_delivered` null for many users; normalized delivery state is
|
|
481
|
+
reliable regardless. FakeGateway reports `sent` only for guids it emitted and
|
|
482
|
+
never invents delivered/read evidence; unknown guids are `pending` with null fields.
|
|
483
|
+
**Host-verified 2026-07-14:** a freshly-sent guid returned
|
|
484
|
+
`send_state:"delivered"` with `is_sent/is_delivered:true`, `date_delivered`
|
|
485
|
+
populated, and `date_read:null` (recipient read-receipts off) — the exact
|
|
486
|
+
`status_fields` shape the wrapper types model.
|
|
487
|
+
|
|
488
|
+
## 2. Behavioral invariants FakeGateway must reproduce
|
|
489
|
+
|
|
490
|
+
This is the heart of the doc. Every item here is either load-bearing for agent logic today or
|
|
491
|
+
a hard ceiling on what iMessage can do — FakeGateway gets both right or it isn't a fake, it's
|
|
492
|
+
a different product.
|
|
493
|
+
|
|
494
|
+
- **Monotonic ids, global, never reused.** `id` mirrors `chat.db` rowid semantics: it strictly
|
|
495
|
+
increases across **all** chats, not per-chat. The cursor protocol (`id > cursor`, exclusive)
|
|
496
|
+
in imsg-agent (`handleInbound`) depends on this — `if (msg.id <= (await store.getCursor())) return;`. A
|
|
497
|
+
FakeGateway that resets ids per chat, or reuses one, breaks cursor advance silently.
|
|
498
|
+
|
|
499
|
+
- **Echo of own sends.** A successful `send()` later arrives back through the event stream
|
|
500
|
+
as a message with `is_from_me: true`, and shows up in `history()`. Cursor advance, history
|
|
501
|
+
hydration (imsg-agent's `toModelMessages`), and same-role message merging all
|
|
502
|
+
assume this echo happens. FakeGateway must emit it, not just record the send silently.
|
|
503
|
+
|
|
504
|
+
- **history() hides reaction ROWS but carries reaction STATE.** Verified live on imsg 0.12.0
|
|
505
|
+
(2026-07-02) and imsg 0.12.3 (2026-07-07): `messages.history` returns no `is_reaction` rows
|
|
506
|
+
(they'd duplicate the reacted
|
|
507
|
+
message), but every message carries a `reactions[]` aggregate — the current tapback state on
|
|
508
|
+
that bubble: `{ id, type, emoji?, is_from_me, sender?, created_at? }` per reaction.
|
|
509
|
+
Consequence: reaction-state logic (toggle guard, `recentReactions`) reads history — it is
|
|
510
|
+
authoritative and restart-safe. The subscribe stream (`include_reactions: true`) still
|
|
511
|
+
delivers reaction EVENTS for realtime wake-ups, but is never the source of state: a process
|
|
512
|
+
that just booted has seen no stream events yet.
|
|
513
|
+
Caveat: in `reactions[]`, trust `is_from_me`, not `sender` — the live binary fills `sender`
|
|
514
|
+
with the chat peer's handle even for our own reactions.
|
|
515
|
+
FakeGateway must reproduce both halves: filter reaction rows out of `history()` AND maintain
|
|
516
|
+
`reactions[]` on each message.
|
|
517
|
+
Text can be present in FakeGateway even where the real store's raw `text` column would be
|
|
518
|
+
`NULL` (real iMessage decodes `attributedBody` — see `docs/host-mac-control.md` §4).
|
|
519
|
+
FakeGateway just stores text plainly; only the outward guarantee that `text` is populated
|
|
520
|
+
matters.
|
|
521
|
+
|
|
522
|
+
- **Message event shape** (`ImsgMessage`, `src/types.ts`):
|
|
523
|
+
`id, chat_id, is_group, guid, sender, sender_name, is_from_me, text, created_at,
|
|
524
|
+
reactions[]`.
|
|
525
|
+
Reaction events additionally carry:
|
|
526
|
+
`is_reaction, reaction_type, reaction_emoji, is_reaction_add, reacted_to_guid`.
|
|
527
|
+
Fake↔live reaction-event shape verified against the 2026-07-03 live capture.
|
|
528
|
+
FakeGateway events must be structurally identical — same fields, same optionality.
|
|
529
|
+
When attachment metadata was requested, a message may additionally carry
|
|
530
|
+
`attachments[]`: `filename` plus optional `transfer_name`, `uti`, `mime_type`,
|
|
531
|
+
`total_bytes`, `is_sticker`, `missing`, and local absolute `original_path`.
|
|
532
|
+
Field names are the LIVE RPC wire shape (host-verified 2026-07-14 via
|
|
533
|
+
`messages.history attachments:true` against the injected 0.13.0 build) — the
|
|
534
|
+
fork's `docs/attachments.md` table lists `byte_size`/`path`, but the binary
|
|
535
|
+
emits `total_bytes`/`original_path`; the wire wins. The file is already on
|
|
536
|
+
the host Mac; no download verb exists. `missing:true` means Messages aged the
|
|
537
|
+
local file out. The fork omits `attachments` when none exist, and FakeGateway
|
|
538
|
+
does the same. **Host-verified 2026-07-14:** a `send-attachment` PNG and a
|
|
539
|
+
prior voice-note `.aiff` both read back through history with the full field
|
|
540
|
+
set populated.
|
|
541
|
+
Host paths are execution details and never cross a remoting wire.
|
|
542
|
+
|
|
543
|
+
- **`chat_guid` is populated on every message and history row** (live-verified 2026-07-02
|
|
544
|
+
against imsg 0.12.0 and re-verified 2026-07-07 against imsg 0.12.3): DMs are
|
|
545
|
+
`any;-;<handle>` (e.g. `any;-;+17738867338`), groups are
|
|
546
|
+
`any;+;<32-hex>`. It is the stable, portable thread identifier — `chat_id` is a local
|
|
547
|
+
chat.db ROWID and must never be persisted off-Mac (the Supabase store keys
|
|
548
|
+
`agent_message_log`/`agent_turns` on `chat_guid`, v0/design.md §4). FakeGateway synthesizes
|
|
549
|
+
the same formats per chat.
|
|
550
|
+
|
|
551
|
+
- **Portable group resolution never trusts a saved numeric rowid.** `chat_id` can change across
|
|
552
|
+
Mac state and never identifies a portable thread. Both gateways therefore resolve a group GUID
|
|
553
|
+
afresh, require a complete unique directory result, and compare exact normalized membership
|
|
554
|
+
independently from the set of handles authorized to act. A partial scan or participant mismatch
|
|
555
|
+
disables the dependent workflow; it never falls back to a saved numeric chat id.
|
|
556
|
+
|
|
557
|
+
- **A DM's chat_id is recoverable from the handle at any time, but only if the thread exists.**
|
|
558
|
+
`chats.list` (`src/imsg/rpc.ts`) is authoritative and restart-safe: the real gateway can map a
|
|
559
|
+
handle back to its `chat_id` whenever a DM thread is present, because chat.db persists the
|
|
560
|
+
peer handle as the row's sole `participants[]` entry (live-verified — see §1 `resolveDmChat`).
|
|
561
|
+
It cannot conjure a chat_id for a handle that has never messaged (no row exists yet). This is
|
|
562
|
+
why the runtime's cold-cache recovery is a *resolve-or-defer*, never a create: `resolveDmChat`
|
|
563
|
+
returns the id if the thread exists and `null` otherwise, and the completion stays unprocessed
|
|
564
|
+
(store-redelivered) until inbound traffic actually creates the DM. FakeGateway mirrors both
|
|
565
|
+
halves — its `resolveDmChat` finds an existing DM chat and returns `null` for an unknown handle,
|
|
566
|
+
never minting one (that would let the sim deliver where the real gateway defers).
|
|
567
|
+
|
|
568
|
+
- **Reactions toggle — and our own react() must therefore guard.** On the platform, reacting
|
|
569
|
+
with a type that already exists from the same sender on the target bubble **removes** it
|
|
570
|
+
(event with `is_reaction_add: false`) — it does not no-op or stack. Tapbacks are per-sender:
|
|
571
|
+
we can only ever toggle OUR OWN reactions; another party's tapback is untouchable. FakeGateway
|
|
572
|
+
must model toggle for *other parties* (fixture driver `injectReaction` simulates a user's
|
|
573
|
+
tapback, toggling on repeat). But the Gateway's own `react()` never un-reacts: it checks the
|
|
574
|
+
target's `reactions[]` in history and skips with `{ skipped: 'already-reacted' }` instead of
|
|
575
|
+
firing a removal (live incident 2026-07-01: an unguarded react removed an agent-account heart
|
|
576
|
+
placed manually on the host Mac). Toggle is platform truth; the guard is our contract on top
|
|
577
|
+
of it.
|
|
578
|
+
|
|
579
|
+
- **react() targets the most-recent INCOMING bubble in the chat, full stop.** The real path
|
|
580
|
+
(`src/imsg/react.ts`, `imsg react --chat-id <id> --reaction <r>`) takes no message id, and
|
|
581
|
+
per its own help text reacts to "the most recent incoming message" — our own outbound
|
|
582
|
+
bubbles are never the target. FakeGateway's `react()` signature must not accept a message id
|
|
583
|
+
either, and must apply the reaction to the chat's newest incoming message only. Building a
|
|
584
|
+
"react to message N" feature against a fake that supports arbitrary targeting produces a
|
|
585
|
+
feature the real gateway cannot deliver.
|
|
586
|
+
Reaction set is closed: exactly the 6 tapback types in `REACTIONS`
|
|
587
|
+
(`src/imsg/react.ts`) — `love, like, dislike, laugh, emphasis, question`. No arbitrary-emoji
|
|
588
|
+
sending.
|
|
589
|
+
**DM-only.** The real automation (`scripts/react.applescript`, ours — `imsg react` was
|
|
590
|
+
dropped for firing into the focused chat when its search-select race loses) verifies the
|
|
591
|
+
focused thread by comparing handle digits against the window title, which group titles
|
|
592
|
+
don't carry. `react()` on a group chat returns `{ ok: false }` on both implementations.
|
|
593
|
+
|
|
594
|
+
- **react() exit 0 lies — success must be verified.** Observed live (2026-07-02): one
|
|
595
|
+
`imsg react` run exited 0 having applied nothing anywhere; another applied the tapback in
|
|
596
|
+
the WRONG chat (the UI automation acts on whatever chat Messages.app has focused when chat
|
|
597
|
+
selection fails). ImsgGateway therefore polls the target chat's history after firing and
|
|
598
|
+
reports `ok: true` only once the reaction appears on the intended message's `reactions[]`;
|
|
599
|
+
otherwise `ok: false`. Callers must treat `ok: false` as "did not happen" — never assume a
|
|
600
|
+
fired react landed. This is also why the 20× react precision gate (issue #13) exists.
|
|
601
|
+
**Cmd+T can silently fail — and the fallout is a SENT TEXT, not a no-op.** Observed live
|
|
602
|
+
(2026-07-17, display asleep on the host): the tapback picker never opened, the reaction key
|
|
603
|
+
landed in the compose field, and the script's trailing Return sent a literal `"1"` to the
|
|
604
|
+
chat. `react.applescript` therefore guards after typing the reaction key: if the focused UI
|
|
605
|
+
element is a text input containing the key, it clears the field and aborts with an error
|
|
606
|
+
(surfacing as `{ ok: false }`) instead of pressing Return. A failed react must never mutate
|
|
607
|
+
the conversation.
|
|
608
|
+
|
|
609
|
+
- **Group semantics.**
|
|
610
|
+
- `is_group: true`, chat identified by a hex-string identifier for groups (per
|
|
611
|
+
`docs/host-mac-control.md` §4: "numeric chat_id per thread; hex identifier = group chat").
|
|
612
|
+
- Messages carry `participants[]` in group chats.
|
|
613
|
+
- Groups are created **only** via `createGroup` — sending the first message is what creates
|
|
614
|
+
the thread (real path: `scripts/create-group.applescript`, driving `Cmd+N` compose +
|
|
615
|
+
keystroke recipients + Return to send, ~7s). There is still no "create empty group" on
|
|
616
|
+
the AppleScript path. Adding/removing a member on an *existing* group is now possible —
|
|
617
|
+
see "Tier 2" above (`addParticipant`/`removeParticipant`, bridge-backed) — but only through
|
|
618
|
+
those methods, not through `createGroup`.
|
|
619
|
+
- **Compose guard**: if Cmd+N fails to enter compose (sibling of the react Cmd+T failure,
|
|
620
|
+
observed live 2026-07-17), every scripted keystroke would land in the focused chat's
|
|
621
|
+
compose field and each Return would send it — recipient handles texted into an open
|
|
622
|
+
thread. `create-group.applescript` verifies the window title changed after Cmd+N (chat
|
|
623
|
+
title → "Messages") and aborts before typing anything if it didn't.
|
|
624
|
+
- **Group create can fail silently** (observed live 2026-07-02: recipient flapped from
|
|
625
|
+
iMessage to SMS registration; compose exited 0, no thread created, no message sent —
|
|
626
|
+
SMS handles cannot join an iMessage group). And Messages may **reuse an existing thread**
|
|
627
|
+
with the same participant set instead of creating a new one. Both are why the new thread
|
|
628
|
+
is located by content (recent history contains `firstMessage`) with a timeout that throws,
|
|
629
|
+
never by participant-set match alone.
|
|
630
|
+
- DMs are keyed by handle: `send({ to: handle }, text)` finds-or-creates the DM thread.
|
|
631
|
+
|
|
632
|
+
- **Latency & ordering are not instant.** Real sends take ~1–3s (AppleScript round trip);
|
|
633
|
+
real events arrive with debounce (~500ms) and can batch after a downtime gap (catch-up from
|
|
634
|
+
cursor on reconnect). FakeGateway should support a configurable artificial latency and a
|
|
635
|
+
catch-up/backlog mode so mailbox debounce and per-chat serialization
|
|
636
|
+
(imsg-agent's `Mailbox`) get exercised the way they will in production, not just against
|
|
637
|
+
an instant no-op fake.
|
|
638
|
+
|
|
639
|
+
- **Failure modes to simulate**, all of which the agent must survive without corrupting state:
|
|
640
|
+
- send failure (network/imsg error) — `send()` resolves `{ ok: false }` or rejects.
|
|
641
|
+
- react failure (UI-automation flake, nonzero exit from `imsg react`).
|
|
642
|
+
- gateway process death mid-stream — on reconnect, the agent must resume from its stored
|
|
643
|
+
cursor without re-processing or double-replying to anything at or before that cursor
|
|
644
|
+
(imsg-agent's `handleInbound` + stored cursor).
|
|
645
|
+
|
|
646
|
+
- **Native poll readback (imsg 0.12.3, live-verified 2026-07-07, full capture
|
|
647
|
+
in `docs/imsg-polls.md`).** Poll activity arrives as regular message rows with a `poll`
|
|
648
|
+
payload (`MessagePoll`, `src/types.ts`) on BOTH subscribe and history — unlike reaction
|
|
649
|
+
rows, history does NOT hide them, so poll state is restart-safe. Three row shapes:
|
|
650
|
+
- **Creation is TWO rows**: a balloon row (`poll.kind: "created"`,
|
|
651
|
+
`metadata.associated_message_type` 3 — observed 0 transiently right after send, Messages
|
|
652
|
+
restamps it, so classification must never key on the balloon's value — question +
|
|
653
|
+
options, imsg-synthesized display text
|
|
654
|
+
`"Sent a poll"`) followed by a plain caption row carrying the question with
|
|
655
|
+
`reply_to_guid` = the balloon guid — Messages never renders the poll title on the
|
|
656
|
+
balloon, so imsg always sends the caption.
|
|
657
|
+
- **A vote is a NEW row** (`metadata.associated_message_type` 4000, `poll.kind: "vote"`):
|
|
658
|
+
`poll.votes[]` is that participant's CURRENT selections (state, not an event log — a
|
|
659
|
+
re-vote is a new row with the new full selection), `option_text` pre-resolved by imsg,
|
|
660
|
+
`original_guid` = the balloon guid. Display text is a junk single space.
|
|
661
|
+
- **An added option ("Add Choice") is a NEW row** (`metadata.associated_message_type` 2)
|
|
662
|
+
re-carrying the FULL updated option list — but imsg mislabels it `poll.kind: "created"`
|
|
663
|
+
and its `poll.question` is unreliable. Display text is a junk U+FFFD. Consumers must
|
|
664
|
+
classify rows via `pollEventKind` and resolve the question through `original_guid` → the
|
|
665
|
+
balloon row (`src/polls.ts`) — never trust `kind`/`question` on non-balloon rows.
|
|
666
|
+
The Gateway creates polls through `sendPoll`; the manual host-Mac CLI equivalent is
|
|
667
|
+
`imsg poll send` (bridge-injected — `docs/imsg-polls.md`). FakeGateway reuses
|
|
668
|
+
`injectPollCreate` for the same balloon+caption postcondition. Vote and option-update
|
|
669
|
+
fixture drivers (`injectPollVote` / `injectPollAddOption`) reproduce the remaining shapes
|
|
670
|
+
above including the junk display texts and the kind mislabel; there is still no Gateway
|
|
671
|
+
poll vote/unvote surface. Open/unverified: the wire shape of a bare vote retraction (the
|
|
672
|
+
fake refuses empty selections until it's observed live) and multi-option selections.
|
|
673
|
+
|
|
674
|
+
- **Forbidden surface — none of this exists on the real path, so FakeGateway must not offer
|
|
675
|
+
it:**
|
|
676
|
+
- voting or unvoting on native polls through the Gateway (`sendPoll` creates polls only)
|
|
677
|
+
- sending an arbitrary/custom-emoji tapback through stock `tapback` — that
|
|
678
|
+
verb deliberately stays on the closed 6-type `Reaction` set. The separate
|
|
679
|
+
`emojiTapback` verb exists only when the patched bridge advertises its
|
|
680
|
+
capability marker
|
|
681
|
+
- outbound threaded replies through plain `send()` — inline replies exist only via
|
|
682
|
+
`sendRich`'s `replyToGuid` (Tier 2); `send()` has no reply parameter
|
|
683
|
+
|
|
684
|
+
Moved out of this list once Tier 2 landed (see "Tier 2" above for what each one actually
|
|
685
|
+
does and does not add): typing indicators, read receipts, editing/unsending/deleting
|
|
686
|
+
messages, reacting to an arbitrary (non-most-recent) message, and participant management
|
|
687
|
+
on an existing group (add/remove/rename/photo/leave).
|
|
688
|
+
|
|
689
|
+
- **Inbound media.** Real messages can carry `attachments[]` with empty `text`. FakeGateway must
|
|
690
|
+
be able to emit the exact attachment metadata with `text: undefined`/empty so
|
|
691
|
+
this path gets exercised — the agent currently ignores attachments gracefully
|
|
692
|
+
(imsg-agent's `toModelMessages`, `if (!m.text) continue;`), and that "gracefully" needs a fixture to
|
|
693
|
+
keep being true.
|
|
694
|
+
|
|
695
|
+
## 3. FakeGateway initial design
|
|
696
|
+
|
|
697
|
+
- **Backing store**: `sim_messages` table (Supabase local/dev) or in-memory for unit tests.
|
|
698
|
+
Auto-increment `id` is the rowid analog and must satisfy the global-monotonic invariant in
|
|
699
|
+
§2. `sim_chats` holds `is_group` / `participants`. Reactions are stored as message-shaped
|
|
700
|
+
rows with an association back to the target (mirroring `chat.db`'s
|
|
701
|
+
`associated_message_type` / `associated_message_guid` shape described in
|
|
702
|
+
`docs/host-mac-control.md` §4), not as a separate table — but `history()` must **filter
|
|
703
|
+
them out** while maintaining each message's `reactions[]` aggregate, mirroring the real
|
|
704
|
+
path (§2); reaction ROWS reach the agent only through the subscribe stream, reaction STATE
|
|
705
|
+
through history.
|
|
706
|
+
- **Drivers**:
|
|
707
|
+
1. Programmatic API for eval fixtures — inject an inbound message, reaction, or poll
|
|
708
|
+
event (`injectInbound` / `injectReaction` / `injectPollCreate` / `injectPollVote` /
|
|
709
|
+
`injectPollAddOption`), assert on outbound sends/reacts.
|
|
710
|
+
2. The simulator web UI (multi-fake-user iMessage-style chat) reads/writes the same store,
|
|
711
|
+
so a human can drive the same fixtures the eval harness does.
|
|
712
|
+
- **Determinism knobs**: `latency: 0` + no debounce batching for unit tests (fast, deterministic);
|
|
713
|
+
realistic timings for integration runs that need to exercise the mailbox debounce/serialize
|
|
714
|
+
path for real.
|
|
715
|
+
- **Hosted-Dev persistence**: `snapshot()` returns a versioned
|
|
716
|
+
`FakeGatewaySnapshot`; `devRestoreSnapshot()` accepts that exact contract.
|
|
717
|
+
The snapshot includes chats and current group names, messages, sent GUIDs,
|
|
718
|
+
reachable-address evidence, Name & Photo sharing state, and the degraded
|
|
719
|
+
group-directory fixture state. Restoring it must preserve `sendStatus()`,
|
|
720
|
+
`checkHandle()`, Name & Photo idempotency, current group metadata, and both
|
|
721
|
+
global monotonic counters. This driver-only API is not a `Gateway`
|
|
722
|
+
capability and has no real-gateway counterpart.
|
|
723
|
+
|
|
724
|
+
## 4. Conformance test suite
|
|
725
|
+
|
|
726
|
+
One spec file, parameterized by `Gateway` implementation, using the existing `node:test`
|
|
727
|
+
harness (`tsx --test`, see `package.json`). Required cases:
|
|
728
|
+
|
|
729
|
+
- cursor monotonicity (global, exclusive, never reused)
|
|
730
|
+
- send echo (own send reappears via subscribe + history with `is_from_me: true`)
|
|
731
|
+
- history returns both sides, **hides reaction rows**, and carries `reactions[]` aggregates
|
|
732
|
+
- reaction toggle round-trip via the fixture driver (`injectReaction` → event
|
|
733
|
+
`is_reaction_add: true` → inject same again → `is_reaction_add: false`)
|
|
734
|
+
- own `react()` toggle-guard: second `react()` of the same type returns
|
|
735
|
+
`{ skipped: 'already-reacted' }`, no removal event
|
|
736
|
+
- stale-target skip: `react(chat, r, expectedGuid)` after a newer incoming message arrived
|
|
737
|
+
returns `{ skipped: 'stale-target' }`, no reaction applied
|
|
738
|
+
- react targets the most-recent INCOMING message only — our own newer outbound does not
|
|
739
|
+
change the target
|
|
740
|
+
- recentReactions surfaces user tapbacks on our messages only, newest-first
|
|
741
|
+
- group create → send-by-chat-id works immediately after
|
|
742
|
+
- catch-up after simulated downtime (events since cursor arrive, nothing before it re-fires)
|
|
743
|
+
- poll create lands as a balloon row with `poll` payload plus a plain caption row, both
|
|
744
|
+
visible in history (poll rows are never hidden)
|
|
745
|
+
- a vote arrives as a NEW row carrying the voter's current selections with resolved
|
|
746
|
+
`option_text`; a re-vote is another new row with the new full selection
|
|
747
|
+
- a user-added option re-carries the full option list, mislabeled `kind:"created"` with
|
|
748
|
+
`metadata.associated_message_type` 2 and junk display text
|
|
749
|
+
- poll fixtures refuse unverified real-path behavior (unknown option ids, empty selections)
|
|
750
|
+
- send failure surfaces as `{ ok: false }` (or rejection) without corrupting cursor state
|
|
751
|
+
- `resolveDmChat(handle)` returns an existing DM's `chat_id`, `null` for a handle with no
|
|
752
|
+
thread, and creates nothing on the miss (read-only — mirrors the real path reading chat.db)
|
|
753
|
+
- portable group resolution accepts only a complete unique GUID hit with exact normalized
|
|
754
|
+
membership; missing GUID and participant drift fail closed without creating a thread
|
|
755
|
+
- attachment metadata round-trips intact through history and subscribe; plain
|
|
756
|
+
messages omit `attachments`
|
|
757
|
+
- `sendStatus` returns pending/null fields for an unknown guid and sent-or-better
|
|
758
|
+
for guids emitted by `send`/`sendRich`, without inventing delivery/read evidence
|
|
759
|
+
- `checkHandle` reports known phone/email chat participants reachable, conservatively
|
|
760
|
+
reports a well-formed unknown unavailable, and rejects malformed input
|
|
761
|
+
- `tapback` targets an explicit message guid (older than the newest incoming), not just the
|
|
762
|
+
newest incoming; add/remove are explicit (repeat add skips `already-reacted`, remove of an
|
|
763
|
+
inactive reaction skips `not-reacted`, never a toggle); works in a group chat; `ok: false`
|
|
764
|
+
for an unknown message guid
|
|
765
|
+
- `emojiTapback` sends a custom emoji to an explicit guid, carries
|
|
766
|
+
`type:"custom"` + the exact emoji in history, and uses the same explicit
|
|
767
|
+
add/remove skip semantics; disabling its capability makes it return
|
|
768
|
+
`ok:false` without mutation
|
|
769
|
+
- `tapback`/`editMessage`/`unsendMessage`/`deleteMessage` all return `ok: false` — without
|
|
770
|
+
mutating anything — for a guid outside the 30-message scan window (the guid-targeting
|
|
771
|
+
window above), even though the message still exists in the fake's full store
|
|
772
|
+
- `sendRich` sends to an existing chat with an inline reply target, and returns `ok: false`
|
|
773
|
+
for a chat that doesn't exist (no find-or-create, unlike `send`)
|
|
774
|
+
- `sendRich` accepts a subject while preserving the observable text echo (subject is
|
|
775
|
+
write-only through `ImsgMessage`)
|
|
776
|
+
- `sendAttachment` and its `audio:true` mode echo an outbound row, preserve an optional
|
|
777
|
+
inline-reply guid, and return `ok:false` for an unknown chat; the synchronous
|
|
778
|
+
send response does not prove attachment/audio readback metadata
|
|
779
|
+
- `sendSticker` echoes an outbound row and guid for a known chat, returns
|
|
780
|
+
`ok:false` for an unknown chat, and fails closed when `stickerSend` is absent
|
|
781
|
+
- `sendPoll` emits the native balloon+caption two-row shape, returns the balloon guid, and
|
|
782
|
+
returns `ok:false` for fewer than two options or an unknown chat
|
|
783
|
+
- `sendRichLink` echoes an outbound row and returns `ok:false` for an unknown chat; preview
|
|
784
|
+
metadata is not queryable through `ImsgMessage`
|
|
785
|
+
- `editMessage` changes the target row's text in place — same row count before/after, no
|
|
786
|
+
new row
|
|
787
|
+
- `unsendMessage` retracts the target row (text clears, row persists);
|
|
788
|
+
`deleteMessage` is dispatch-only with no stable history postcondition (the
|
|
789
|
+
fake conservatively preserves the row); both return `ok: false` for an
|
|
790
|
+
unknown guid
|
|
791
|
+
- `setTyping`/`markRead` dispatch `{ ok: true }` against an existing chat and `{ ok: false }`
|
|
792
|
+
against an unknown one, with no queryable state either way
|
|
793
|
+
- `renameGroup`/`addParticipant`/`removeParticipant` are reflected in a message sent
|
|
794
|
+
afterward (`chat_name`/`participants`) and return `ok: false` against a DM
|
|
795
|
+
- `setGroupPhoto`/`leaveGroup` return `ok: true` against a group and `ok: false` against a DM
|
|
796
|
+
- patched sticker, group-photo, and participant verbs return `ok:false` without mutation
|
|
797
|
+
when their `imsg status` capability markers are absent
|
|
798
|
+
- `recentReactions` surfaces an inbound custom-emoji reaction (`reaction: 'custom'`) with the
|
|
799
|
+
real `emoji` intact
|
|
800
|
+
- Fake-only restart coverage snapshots and restores a world, then proves
|
|
801
|
+
`sendStatus`, `checkHandle`, Name & Photo idempotency, current group metadata,
|
|
802
|
+
fixture state, and monotonic IDs survive exactly
|
|
803
|
+
|
|
804
|
+
FakeGateway runs this suite in CI on every PR. ImsgGateway runs it as a manual runbook step on
|
|
805
|
+
the host Mac (see `docs/host-mac-control.md`) until a host-Mac CI runner exists — at that point
|
|
806
|
+
it becomes CI-optional, not CI-required, since it depends on physical-Mac state (unlocked
|
|
807
|
+
session, TCC grants, an idle test phone).
|
|
808
|
+
|
|
809
|
+
**Rule for contributors**: any new capability added to FakeGateway requires, in the same PR:
|
|
810
|
+
|
|
811
|
+
1. Proof the real path supports it — a manual test run against ImsgGateway on the host Mac,
|
|
812
|
+
pasted into the PR description.
|
|
813
|
+
2. A conformance test added to this suite, passing against both implementations.
|
|
814
|
+
3. An update to this doc (§1 interface and/or §2 invariants).
|
|
815
|
+
|
|
816
|
+
Skip any of the three and the capability is forbidden surface, full stop — delete it rather
|
|
817
|
+
than merge it half-specified.
|