@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.
@@ -0,0 +1,188 @@
1
+ # Host-Mac control — remote ops for the iMessage gateway Mac
2
+
3
+ How we remotely control the Mac that sends/receives iMessages for the Mapi agent.
4
+ Written generically as "the host Mac"; current concrete values below.
5
+
6
+ ## Current host
7
+
8
+ | Field | Value |
9
+ |---|---|
10
+ | Machine | Haoxiang's MacBook Air, macOS 26.5.1 |
11
+ | Tailnet IP | `100.64.126.62` |
12
+ | User | `mark` |
13
+ | Repo path | `~/mapier/imsg-agent` |
14
+ | tmux session | `mapi` (window 0 = shell, window `agent` = running agent) |
15
+ | Agent's iMessage number | `+16282640380` |
16
+ | Test phone 1 | `+17738867338` (chat 3996) |
17
+ | Test phone 2 | `+15105426028` (chat 22) |
18
+ | Test phone 3 | `+13129001215` — for group add/remove/leave legs (the platform blocks `removeParticipant`/`leaveGroup` below 3 other members, so smokes need a third handle to build a big-enough disposable group) |
19
+
20
+ ## 1. Access path
21
+
22
+ - Tailscale tailnet connects dev Mac ↔ host Mac. If status shows "stopped": `tailscale up`.
23
+ - SSH with key auth:
24
+
25
+ ```bash
26
+ ssh -o IdentitiesOnly=yes -i ~/.ssh/id_rsa mark@100.64.126.62
27
+ ```
28
+
29
+ `IdentitiesOnly=yes` avoids "Too many authentication failures" from ssh-agent key
30
+ spam — this error bit us during `ssh-copy-id`.
31
+ - Remote Login must be ON, all users, on the host: System Settings → General → Sharing.
32
+
33
+ ## 2. Why plain SSH is not enough (TCC model)
34
+
35
+ The core insight: sends and chat.db reads work over plain SSH once File access is
36
+ granted. But tapback/react and group-create use System Events UI automation
37
+ (keystrokes), and macOS TCC (Transparency, Consent, and Control) authorizes UI
38
+ automation **per responsible process**, not per user or per terminal.
39
+
40
+ - **Full Disk Access** is a plain file check — passes for any process once granted.
41
+ - **Accessibility** (send keystrokes) blames the daemonized **tmux server**
42
+ (ppid 1, launchd) — NOT Terminal.app, even when tmux was started from a Terminal
43
+ that already has Accessibility. Error looks like:
44
+
45
+ ```
46
+ System Events got an error: osascript is not allowed to send keystrokes. (1002)
47
+ ```
48
+
49
+ - **Fix**: grant Accessibility to the tmux *binary* itself, not to Terminal:
50
+ 1. System Settings → Privacy & Security → Accessibility → `+`
51
+ 2. `Cmd+Shift+G` → `/opt/homebrew/bin/tmux` → add → toggle ON
52
+ 3. Restart the tmux server so it picks up the grant:
53
+
54
+ ```bash
55
+ tmux kill-server && tmux new -s mapi
56
+ ```
57
+
58
+ - Also required once: Full Disk Access for the terminal app, and Automation →
59
+ Messages (this prompts on the first send/react — someone must click on the
60
+ host screen directly or via Screen Sharing to approve it).
61
+
62
+ ## 3. The tmux command bridge (how we drive it)
63
+
64
+ All GUI-privileged commands run **inside** the tmux session via `send-keys`,
65
+ result captured via `capture-pane`:
66
+
67
+ ```bash
68
+ ssh -o IdentitiesOnly=yes -i ~/.ssh/id_rsa mark@100.64.126.62 \
69
+ '/opt/homebrew/bin/tmux send-keys -t mapi "<command>" Enter; sleep 5; /opt/homebrew/bin/tmux capture-pane -t mapi -p -J'
70
+ ```
71
+
72
+ Gotchas:
73
+
74
+ - `capture-pane` only returns the visible viewport — long output gets clipped.
75
+ Redirect the command's output to a file and `cat` it over SSH instead of
76
+ reading it off the pane.
77
+ - `grep -v "^$"` exits 1 when everything is blank, which breaks `&&` chains.
78
+ - Quote `$` as `\$` — it has to survive shell → SSH → tmux send-keys quoting layers.
79
+ - Use full paths (`/opt/homebrew/bin/...`) — tmux panes may not have brew on `PATH`.
80
+ - `tmux new-window` with an inline command closes the window instantly if the
81
+ command fails. Create the window bare, then `send-keys` separately, so errors
82
+ stay visible instead of vanishing with the window.
83
+
84
+ ## 4. iMessage primitives
85
+
86
+ The host keeps `/opt/homebrew/bin/imsg` 0.12.3 as the basic-I/O baseline. The
87
+ pod uses `/Users/mark/imsg-emoji-build/bin/imsg` from Mapier's pinned
88
+ `mapier/deploy` branch via `IMSG_BIN`. That 0.13.x build contains the
89
+ live-verified macOS-26 participant/group-photo fixes and arbitrary-emoji RPC.
90
+ After rebuilding it, run `bin/imsg launch --json` to inject the matching dylib.
91
+
92
+ ```bash
93
+ # list chats — numeric chat_id per thread; hex identifier = group chat
94
+ imsg chats --limit 5
95
+
96
+ # read history — one JSON object per line
97
+ # text comes from attributedBody; the raw sqlite `text` column is often NULL
98
+ # on modern macOS, don't trust it
99
+ imsg history --chat-id <id> --limit N --json
100
+
101
+ # send — works for 1:1 (--to) or groups (--chat-id); AppleScript events,
102
+ # no UI focus needed, no autocorrect
103
+ imsg send --to "+1..." --text "..."
104
+ imsg send --chat-id <id> --text "..."
105
+
106
+ # react — UI automation, needs the Accessibility setup from §2, ~2s when working
107
+ imsg react --chat-id <id> --reaction love|like|dislike|laugh|emphasis|question
108
+
109
+ # native Apple Name & Photo flow (patched binary; privacy-sensitive share)
110
+ /Users/mark/imsg-emoji-build/bin/imsg name-photo status --chat '<chat-guid>' --json
111
+ /Users/mark/imsg-emoji-build/bin/imsg name-photo share --chat '<chat-guid>' --json
112
+ ```
113
+
114
+ Numeric chat IDs are local execution details, not configuration. A consumer
115
+ binding to a group configures it by portable GUID plus exact expected
116
+ participants and resolves it afresh at boot. After changing that binding or
117
+ upgrading `imsg`, run the read-only `scripts/resolve-group-smoke.ts`; it
118
+ performs no sends, reactions, or thread creation.
119
+
120
+ React caveats:
121
+
122
+ - Reactions **toggle**. Reacting where the same reaction already exists
123
+ **removes** it — check `reactions[]` in `imsg history` first.
124
+ - Targets the most-recent visible bubble; targeting precision is not yet
125
+ gate-tested (the 20× gate in `SETUP.md` §3d is still owed).
126
+
127
+ Name & Photo caveat: `share` sends the pod's configured identity/photo to
128
+ every participant. Inspect `status` and confirm the destination first. The
129
+ gateway requires `available:true` and `should_offer:true`; the 2026-07-14 live
130
+ gate returned `requested:true`, and the receiving test phone displayed Mapi's
131
+ new contact info.
132
+
133
+ Group creation:
134
+
135
+ ```bash
136
+ osascript scripts/create-group.applescript "+1A" "+1B" "first message"
137
+ ```
138
+
139
+ Drives `Cmd+N` compose keystrokes; sending the first message creates the
140
+ thread (~7s). Caveat: keystroke typing passes through Messages autocorrect
141
+ (turned "mapi" into "maxi") — planned fix is clipboard + `Cmd+V` paste. The
142
+ group appears in `imsg chats` immediately; send-by-chat-id then works normally.
143
+
144
+ Verification via sqlite when needed (only inside the FDA-granted tmux):
145
+
146
+ ```bash
147
+ sqlite3 ~/Library/Messages/chat.db
148
+ ```
149
+
150
+ - Tapbacks are rows with `associated_message_type` 2000–2005 (2000 = love),
151
+ `associated_message_guid` = `"p:0/<target-guid>"`.
152
+ - Date decode: `datetime(date/1000000000 + 978307200, 'unixepoch')`.
153
+
154
+ ## 5. Running the agent
155
+
156
+ ```bash
157
+ tmux new-window -t mapi -n agent
158
+ # then, via send-keys into that window:
159
+ cd ~/mapier/imsg-agent && /opt/homebrew/bin/pnpm dev 2>&1 | tee -a agent.log
160
+ ```
161
+
162
+ - pnpm on the host: `brew install pnpm` (or `corepack enable` — the repo pins
163
+ the version via `packageManager`). The old better-sqlite3 ABI-rebuild note
164
+ is obsolete: #20 removed the native dependency.
165
+ - Logs: `agent.log` in the repo. Prefixes: `[inbound]`, `[send]`, `[gate]`.
166
+ Multi-bubble bursts appear as consecutive `[send]` lines.
167
+ - `.env` on the host is **not in git**: `PERSONA_MODEL=deepseek:deepseek-chat`,
168
+ `DEEPSEEK_API_KEY`, `ALLOWED_HANDLES` (empty = agent replies to nobody).
169
+
170
+ ## 6. Host posture
171
+
172
+ Lid open, never lock, no auto-sleep — UI automation needs an unlocked GUI
173
+ session. FileVault may stay ON. Manual login required after any reboot.
174
+
175
+ ## Known-fragile list
176
+
177
+ - **React toggle behavior**: reacting twice with the same reaction removes it
178
+ instead of no-op'ing — always check existing `reactions[]` first.
179
+ - **React targeting precision unproven**: targets "most recent visible bubble";
180
+ the 20× accuracy gate (`SETUP.md` §3d) hasn't been run yet.
181
+ - **Autocorrect in group-create**: keystroke-typed group names/messages get
182
+ mangled by Messages autocorrect ("mapi" → "maxi"); clipboard-paste fix planned.
183
+ - **capture-pane clipping**: long command output silently truncates to the
184
+ visible tmux viewport — always redirect to a file for anything nontrivial.
185
+ - **TCC grants go stale after tmux/imsg brew upgrades**: a `brew upgrade` that
186
+ replaces the tmux or imsg binary can silently drop the Accessibility/Full
187
+ Disk Access grant — re-toggle it in System Settings if UI automation starts
188
+ failing after an upgrade.
@@ -0,0 +1,473 @@
1
+ # Native iMessage poll investigation
2
+
3
+ Date: 2026-07-07.
4
+
5
+ Purpose: capture the exact host setup, commands, and findings from testing
6
+ `openclaw/imsg` 0.12.3 native Messages polls for future investigation.
7
+
8
+ ## Host and test thread
9
+
10
+ Host:
11
+
12
+ - Machine: Haoxiang's MacBook Air, macOS 26.5.1
13
+ - Tailnet IP: `100.64.126.62`
14
+ - User: `mark`
15
+ - Repo path: `~/mapier/imsg-agent`
16
+ - Pod handle: `+16282640380`
17
+ - `imsg` version: `0.12.3`
18
+
19
+ Test thread:
20
+
21
+ - Chat rowid: `3996`
22
+ - Chat guid: `any;-;+17738867338`
23
+ - Peer handle: `+17738867338`
24
+ - `imsg chats --limit 5 --json` reported this thread as `service:"SMS"` even
25
+ though Messages rendered the native poll UI on the tester device.
26
+
27
+ ## Bridge setup
28
+
29
+ Native poll send/vote requires the `imsg` advanced bridge. Before enabling the
30
+ bridge, `imsg status --json` showed:
31
+
32
+ ```json
33
+ {
34
+ "advanced_features": false,
35
+ "v2_ready": false,
36
+ "bridge_version": 0,
37
+ "basic_features": true,
38
+ "sip": "disabled"
39
+ }
40
+ ```
41
+
42
+ The helper dylib shipped with the required macOS 26 architecture:
43
+
44
+ ```bash
45
+ lipo -archs /opt/homebrew/Cellar/imsg/0.12.3/libexec/imsg-bridge-helper.dylib
46
+ # x86_64 arm64 arm64e
47
+ ```
48
+
49
+ SIP was already disabled:
50
+
51
+ ```bash
52
+ csrutil status
53
+ # System Integrity Protection status: disabled.
54
+ ```
55
+
56
+ The missing macOS 26 setting was library validation. Check it with:
57
+
58
+ ```bash
59
+ defaults read /Library/Preferences/com.apple.security.libraryvalidation.plist DisableLibraryValidation
60
+ ```
61
+
62
+ It initially did not exist. Set it with admin privileges:
63
+
64
+ ```bash
65
+ sudo defaults write /Library/Preferences/com.apple.security.libraryvalidation.plist DisableLibraryValidation -bool true
66
+ ```
67
+
68
+ Then reboot the host. FileVault/manual login may be required before Tailscale
69
+ and SSH return.
70
+
71
+ After reboot:
72
+
73
+ ```bash
74
+ imsg launch --json
75
+ ```
76
+
77
+ Expected success:
78
+
79
+ ```json
80
+ {
81
+ "status": "launched",
82
+ "message": "Messages.app launched with dylib injection",
83
+ "dylib": "/opt/homebrew/Cellar/imsg/0.12.3/libexec/imsg-bridge-helper.dylib"
84
+ }
85
+ ```
86
+
87
+ Verify:
88
+
89
+ ```bash
90
+ imsg status --json
91
+ ```
92
+
93
+ Observed after the fix:
94
+
95
+ ```json
96
+ {
97
+ "advanced_features": true,
98
+ "v2_ready": true,
99
+ "bridge_version": 2,
100
+ "typing_indicators": true,
101
+ "read_receipts": true,
102
+ "message": "Connected to Messages.app. IMCore features available."
103
+ }
104
+ ```
105
+
106
+ Also verified the helper was loaded:
107
+
108
+ ```bash
109
+ pid=$(pgrep -x Messages | head -1)
110
+ lsof -p "$pid" | grep -i 'imsg-bridge\|bridge-helper'
111
+ ```
112
+
113
+ Observed:
114
+
115
+ ```text
116
+ /opt/homebrew/Cellar/imsg/0.12.3/libexec/imsg-bridge-helper.dylib
117
+ /Users/mark/Library/Containers/com.apple.MobileSMS/Data/.imsg-bridge-ready
118
+ ```
119
+
120
+ ## Poll send test
121
+
122
+ Command:
123
+
124
+ ```bash
125
+ imsg poll send \
126
+ --chat-id 3996 \
127
+ --question "poll smoke 1136?" \
128
+ --option "yes" \
129
+ --option "no" \
130
+ --json
131
+ ```
132
+
133
+ Observed success:
134
+
135
+ ```json
136
+ {
137
+ "messageGuid": "41F8E456-6EF3-4FC9-9736-93C29DD2E620",
138
+ "balloonBundleID": "com.apple.messages.MSMessageExtensionBalloonPlugin:0000000000:com.apple.messages.Polls",
139
+ "chatGuid": "any;-;+17738867338",
140
+ "poll": {
141
+ "kind": "created",
142
+ "event": "imessage.poll.created",
143
+ "question": "poll smoke 1136?",
144
+ "sessionIdentifier": "E76A5829-4530-4DC8-B106-88C2DA2795BD",
145
+ "options": [
146
+ {
147
+ "id": "D0EB4FE3-2D3E-4642-B178-C266E2B32052",
148
+ "text": "yes"
149
+ },
150
+ {
151
+ "id": "A316E9F5-3AFA-4275-80F2-DC96C1D99E30",
152
+ "text": "no"
153
+ }
154
+ ]
155
+ }
156
+ }
157
+ ```
158
+
159
+ ## Reading poll history
160
+
161
+ High-level read:
162
+
163
+ ```bash
164
+ imsg history --chat-id 3996 --limit 15 --json
165
+ ```
166
+
167
+ Observed rows:
168
+
169
+ - Row `65919`: poll balloon.
170
+ - `guid`: `41F8E456-6EF3-4FC9-9736-93C29DD2E620`
171
+ - `text`: replacement character
172
+ - `balloon_bundle_id`: `com.apple.messages.MSMessageExtensionBalloonPlugin:0000000000:com.apple.messages.Polls`
173
+ - `poll.kind`: `created`
174
+ - `poll.question`: `poll smoke 1136?`
175
+ - `poll.options`: `yes`, `no`
176
+ - Row `65920`: plain caption message.
177
+ - `guid`: `1F4396DE-18DF-461B-A6D5-E190B25EABBD`
178
+ - `text`: `poll smoke 1136?`
179
+ - `reply_to_guid`: poll message guid
180
+
181
+ This matches the `imsg poll --help` behavior: Messages does not render the poll
182
+ title on the balloon, so `imsg` sends the question as a visible plain caption
183
+ right after the poll.
184
+
185
+ Raw `chat.db` query used:
186
+
187
+ ```bash
188
+ sqlite3 ~/Library/Messages/chat.db "
189
+ select
190
+ m.ROWID,
191
+ datetime(m.date/1000000000 + 978307200, 'unixepoch') as ts,
192
+ m.guid,
193
+ m.is_from_me,
194
+ h.id as handle,
195
+ quote(m.text),
196
+ m.balloon_bundle_id,
197
+ m.associated_message_guid,
198
+ m.associated_message_type,
199
+ length(m.payload_data),
200
+ length(m.message_summary_info),
201
+ m.reply_to_guid
202
+ from message m
203
+ left join handle h on h.ROWID=m.handle_id
204
+ where m.ROWID >= 65918
205
+ order by m.ROWID;
206
+ "
207
+ ```
208
+
209
+ Observed poll rows:
210
+
211
+ ```text
212
+ 65919|2026-07-07 18:36:07|41F8E456-6EF3-4FC9-9736-93C29DD2E620|1|+17738867338|NULL|com.apple.messages.MSMessageExtensionBalloonPlugin:0000000000:com.apple.messages.Polls||0|7700|60|FBD9147D-F0D4-4B8C-8433-A16F10ACD1ED
213
+ 65920|2026-07-07 18:36:08|1F4396DE-18DF-461B-A6D5-E190B25EABBD|1|+17738867338|NULL|||0||51|41F8E456-6EF3-4FC9-9736-93C29DD2E620
214
+ ```
215
+
216
+ **Balloon `associated_message_type` is unstable early.** The capture above
217
+ (seconds after send) shows `0` on row 65919; re-querying the same row after
218
+ the vote/option activity synced returns `3`, and `imsg` reports
219
+ `poll.metadata.associated_message_type: 3` for it (verified twice on
220
+ 2026-07-07). Messages restamps the column. Consumers must never branch on the
221
+ balloon's value — classification keys on `kind`, type `2` (option update),
222
+ type `4000` (vote), and `original_guid` only (`pollEventKind`, `src/polls.ts`).
223
+
224
+ Dump payload blobs:
225
+
226
+ ```bash
227
+ sqlite3 ~/Library/Messages/chat.db "
228
+ select writefile('/tmp/poll_payload_65919.bin', payload_data)
229
+ from message
230
+ where ROWID=65919;
231
+
232
+ select writefile('/tmp/poll_summary_65919.bin', message_summary_info)
233
+ from message
234
+ where ROWID=65919;
235
+ "
236
+
237
+ file /tmp/poll_payload_65919.bin /tmp/poll_summary_65919.bin
238
+ plutil -p /tmp/poll_payload_65919.bin
239
+ plutil -p /tmp/poll_summary_65919.bin
240
+ strings -a /tmp/poll_payload_65919.bin | grep -Ei 'yes|no|poll|option|vote'
241
+ ```
242
+
243
+ The payload is an Apple binary property list. Its embedded `data:` URL decoded
244
+ to a JSON payload with:
245
+
246
+ ```json
247
+ {
248
+ "version": 1,
249
+ "item": {
250
+ "title": "poll smoke 1136?",
251
+ "creatorHandle": "e:mapi@mapier.ai",
252
+ "orderedPollOptions": [
253
+ {
254
+ "optionIdentifier": "D0EB4FE3-2D3E-4642-B178-C266E2B32052",
255
+ "canBeEdited": false,
256
+ "creatorHandle": "e:mapi@mapier.ai",
257
+ "attributedText": "yes",
258
+ "text": "yes"
259
+ },
260
+ {
261
+ "optionIdentifier": "A316E9F5-3AFA-4275-80F2-DC96C1D99E30",
262
+ "canBeEdited": false,
263
+ "creatorHandle": "e:mapi@mapier.ai",
264
+ "attributedText": "no",
265
+ "text": "no"
266
+ }
267
+ ]
268
+ }
269
+ }
270
+ ```
271
+
272
+ The URL had `c=2`, matching the two original options.
273
+
274
+ ## Vote and user-added option findings
275
+
276
+ Tester action 1: selected `yes`.
277
+
278
+ Tester action 2: added a third option from the user device.
279
+
280
+ Observed from the host afterward:
281
+
282
+ - `imsg history --chat-id 3996 --limit 15 --json` still showed only the two
283
+ original options.
284
+ - `sqlite3 ~/Library/Messages/chat.db` showed no new rows for chat `3996` after
285
+ rows `65919` and `65920`.
286
+ - `max(ROWID)` was `65922`, but rows `65921` and `65922` belonged to chat `190`,
287
+ not the poll chat.
288
+ - Re-dumping `payload_data` and `message_summary_info` for row `65919` produced
289
+ identical SHA-256 hashes before and after the vote/third-option test.
290
+ - The decoded payload still had `c=2` and only the original `yes`/`no` options.
291
+
292
+ Hash check used:
293
+
294
+ ```bash
295
+ shasum -a 256 \
296
+ /tmp/poll_payload_65919.bin \
297
+ /tmp/poll_payload_65919_after.bin \
298
+ /tmp/poll_summary_65919.bin \
299
+ /tmp/poll_summary_65919_after.bin
300
+ ```
301
+
302
+ Observed:
303
+
304
+ ```text
305
+ b80198e108faea14bca823748292528f7ed86d6c0e4407f92fd8717e8313a512 /tmp/poll_payload_65919.bin
306
+ b80198e108faea14bca823748292528f7ed86d6c0e4407f92fd8717e8313a512 /tmp/poll_payload_65919_after.bin
307
+ f29fc961af96b20a71de09b39b575c0f1358a263a704ffa8df03a760dbf91997 /tmp/poll_summary_65919.bin
308
+ f29fc961af96b20a71de09b39b575c0f1358a263a704ffa8df03a760dbf91997 /tmp/poll_summary_65919_after.bin
309
+ ```
310
+
311
+ Conclusion from this test: on this host and this thread, Mapi can see a poll it
312
+ creates, including the question and original options. It could not see the
313
+ tester vote or the tester-added third option.
314
+
315
+ **Correction (same day, ~18:52): the negative finding above was sync lag, not a
316
+ capability gap. See the follow-up section below — both the vote and the
317
+ user-added option landed in chat.db as new message rows minutes later, and
318
+ `imsg` 0.12.3 parses both.**
319
+
320
+ ## Follow-up: vote and option readback works
321
+
322
+ Re-checked the same chat later the same day. Two new rows had arrived in chat
323
+ `3996`, both from `+17738867338`, both pointing at the original poll guid:
324
+
325
+ ```text
326
+ 65925|2026-07-07 18:52:07|AF3C34AC-E272-46AF-931A-C65AA21CC1F9|is_from_me=0|+17738867338|associated_message_type=4000|assoc_guid=41F8E456-...|payload 706B|summary 239B
327
+ 65926|2026-07-07 18:52:20|C863C985-FCA7-45E3-BD9B-FEA749D04982|is_from_me=0|+17738867338|associated_message_type=2 |assoc_guid=41F8E456-...|payload 1876B|summary 61B
328
+ ```
329
+
330
+ ### Row 65925 — the vote (`associated_message_type` 4000)
331
+
332
+ `payload_data` is an NSKeyedArchiver plist wrapping a `data:` URL whose JSON is:
333
+
334
+ ```json
335
+ {"version":1,"item":{"votes":[{
336
+ "voteOptionIdentifier":"D0EB4FE3-2D3E-4642-B178-C266E2B32052",
337
+ "serverVoteTime":805143043.515404,
338
+ "participantHandle":"+17738867338"}]}}
339
+ ```
340
+
341
+ That option id is the original `yes`. `message_summary_info` is human-readable:
342
+ `ams = "You removed a vote from “no”"` — the tester apparently voted `no`
343
+ first, removed it, then voted `yes`; the `votes` array reflects the
344
+ participant's current vote state.
345
+
346
+ ### Row 65926 — the user-added option (`associated_message_type` 2)
347
+
348
+ Balloon session update. Decoded `data:` URL JSON carries the full updated
349
+ option list, with per-option `creatorHandle` identifying who added what:
350
+
351
+ ```json
352
+ {"item":{"creatorHandle":"+17738867338","title":"","orderedPollOptions":[
353
+ {"text":"yes","optionIdentifier":"D0EB4FE3-...","creatorHandle":"e:mapi@mapier.ai"},
354
+ {"text":"no","optionIdentifier":"A316E9F5-...","creatorHandle":"e:mapi@mapier.ai"},
355
+ {"text":"unsure","optionIdentifier":"2E247807-B986-4E57-ABB3-7E7128503A5A","creatorHandle":"+17738867338"}
356
+ ]},"version":1}
357
+ ```
358
+
359
+ The tester-added third option is `unsure`.
360
+
361
+ ### imsg 0.12.3 parses both — history and watch
362
+
363
+ No manual plist decoding needed. `imsg history --chat-id 3996 --json` returns:
364
+
365
+ - Vote row: `poll.kind: "vote"`, `poll.event: "imessage.poll.voted"`, and
366
+ `poll.vote` / `poll.votes[]` with `option_id`, **`option_text` already
367
+ resolved** (`"yes"`), `participant`, `server_time`, plus
368
+ `original_guid` = the poll message guid.
369
+ - Option-update row: full `poll.options[]` including `unsure`, `creator` =
370
+ `+17738867338`, `original_guid` = the poll guid.
371
+
372
+ `imsg watch --chat-id 3996 --since-rowid 65918 --json` replays the same rows
373
+ with the same `poll` objects, so the live watch/rpc stream the pod already
374
+ consumes carries poll events.
375
+
376
+ ### Parsing caveats
377
+
378
+ - The option-update row is mislabeled `poll.kind: "created"` /
379
+ `event: "imessage.poll.created"` (imsg keys "created" off the balloon
380
+ payload shape, and `associated_message_type` 2 is also used for fresh
381
+ balloons). Detect updates as: poll object whose `original_guid` differs
382
+ from the original poll send, or diff `options[]` against the options seen
383
+ at `poll send` time.
384
+ - The update row's `poll.question` is junk (the update payload has
385
+ `title: ""`, and imsg falls back to unrelated nearby text). Join via
386
+ `original_guid` to the original poll for the real question.
387
+ - `poll.votes[]` is the *sender's current votes*, not an event log — a vote
388
+ switch shows up as one row with the final state (the removal narrative only
389
+ survives in `message_summary_info.ams`).
390
+ - Timing: the vote/update rows appeared with `date` ≈ 18:52, ~16 min after the
391
+ poll send, and were absent during the first inspection. Whether that lag was
392
+ tester timing or Messages/CloudKit sync latency is unresolved — do not
393
+ assume poll updates are immediate.
394
+
395
+ ## Source-level notes (openclaw/imsg)
396
+
397
+ From reading the imsg source (github.com/openclaw/imsg) after the follow-up:
398
+
399
+ - Poll decode lives in `MessagePollDecoder` (`Sources/IMsgCore/MessagePolls.swift`)
400
+ with vote/option-text resolution in `MessageStore+Polls.swift`; the injected
401
+ bridge send path is `handleSendPoll` / `buildPollVoteIMMessage` in
402
+ `Sources/IMsgHelper/IMsgInjected.m`.
403
+ - **Reading polls needs no bridge/dylib** — only `poll send` / `poll vote`
404
+ (private IMCore API) require `imsg launch`. Readback is implicit in the
405
+ normal read paths (`history`, `watch`, RPC `messages.history` /
406
+ `watch.subscribe`); any row that decodes as a poll gets the `poll` object
407
+ attached, no flag needed.
408
+ - **No live IMCore push channel for polls.** The dylib event tailer
409
+ (`IMsgEventTailer`, used for typing indicators) has zero poll code. Poll
410
+ events are chat.db-derived via `MessageWatcher` (filesystem events + 5s
411
+ fallback poll), so latency floor is DB-write time, and votes only become
412
+ visible when Messages persists the rows.
413
+ - The decoder constants match what we observed: `voteAssociatedMessageType`
414
+ = 4000, `updateAssociatedMessageType` = 2; the reaction-exclusion SQL only
415
+ drops `associated_message_type` 2000–3006, so poll rows pass through by
416
+ default.
417
+ - Version timeline: readback shipped in 0.10.0, `poll vote` in 0.12.0, vote
418
+ option-text resolution from Add-Choice rows fixed in 0.12.2 (#153). Nothing
419
+ after 0.12.3 adds a new readback mechanism — 0.12.3 is current for polls.
420
+ - Watch caveat: `MessageWatcher` gives up on a row whose `chat_message_join`
421
+ hasn't resolved after 20 retries (`WatchState.unresolvedChatRetryLimit`) and
422
+ silently advances past it — a dropped row is indistinguishable from "never
423
+ written" if only watching. When debugging missing poll events, check the raw
424
+ `message` table by ROWID first, independent of the chat join.
425
+ - `poll vote` outbound had a macOS 26.4.x persistence bug fixed in 0.12.2
426
+ (#150) — evidence that Apple's Polls IMCore plumbing regresses across 26.x
427
+ point releases; treat inbound persistence as fragile too.
428
+ - The upstream repo's own docs (`docs/history.md`, "Manual native poll test
429
+ plan") document exactly our verification flow:
430
+ `imsg history --chat-id <id> --json | jq -c 'select(.poll != null)'`.
431
+
432
+ ## Current Mapi behavior
433
+
434
+ **Updated 2026-07-07 (same day, follow-up change): the pipeline now forwards
435
+ poll events to the LLM.** The original gap — `toModelMessages()` forwarded only
436
+ `m.text`, so the agent saw a vote as a blank bubble and an added option as
437
+ `�`, and replied "did something get lost in translation there? 😂" — is fixed:
438
+
439
+ - `src/types.ts` models the poll payload (`MessagePoll` on `ImsgMessage`);
440
+ both gateways pass it through (the rpc path already forwarded whole JSON
441
+ objects, so no `src/gateway/imsg.ts` change was needed).
442
+ - `src/polls.ts` classifies rows (`pollEventKind` — absorbing the
443
+ kind-mislabel and junk-question hazards above) and renders them as bracket
444
+ notes (`renderPollEvent`), resolving the question via `original_guid`.
445
+ - `toModelMessages()` (imsg-agent) pushes those notes — e.g.
446
+ `[voted "yes" on the poll "poll smoke 1136?"]`, `[added option "unsure" to
447
+ the poll "poll smoke 1136?"]` — instead of the junk display text.
448
+ - Contract + fake parity + sim UI: gateway-contract.md §2 "Native poll
449
+ readback", FakeGateway poll fixtures, sim poll bubble (sim-dev-tools.md §6).
450
+
451
+ ## Things to try next
452
+
453
+ Resolved by the follow-up above:
454
+
455
+ - ~~Try `imsg watch --json`~~ — watch emits the same `poll` objects as history.
456
+ - ~~Search whether poll updates land elsewhere~~ — they land in the same chat
457
+ as new `message` rows (`associated_message_type` 4000 = vote, 2 = option
458
+ update); the original balloon row is never rewritten.
459
+
460
+ Still open:
461
+
462
+ - Repeat in a known iMessage-only DM. The tested thread `3996` reports
463
+ `service:"SMS"` on the host.
464
+ - Repeat in an iMessage group chat (multi-participant votes; per-participant
465
+ attribution in `poll.votes[]`).
466
+ - Characterize vote-sync latency (the ~16 min gap above) and whether it
467
+ requires Messages.app frontmost / bridge injected.
468
+ - Vote removal without re-vote: does a bare retraction produce a 4000 row with
469
+ an empty `votes[]`?
470
+ - If building on this, update `src/types.ts`, gateway contract docs, fake
471
+ gateway parity, and `toModelMessages()` so the LLM receives poll events in a
472
+ stable text form — including the `original_guid` join and the
473
+ kind-mislabel caveats above.