imessage: surface every inbound image, not just the first

An iMessage with several photos arrived with only the first one in the
channel tag: handleInbound took the first image attachment and stopped.
Collect every image attachment instead (same filters as before) and pass
each path in the notification meta as image_path, image_path_2,
image_path_3, ... so the model can Read all of them. A single image still
produces exactly image_path, so existing behaviour is unchanged.

Numbered keys rather than an array or a joined list: Claude Code accepts
only string values in channel meta and renders each key as an attribute
on the <channel> tag, so keys must be plain identifiers; one key per path
also avoids inventing a delimiter for sender-controlled filenames. The
attachments query gains ORDER BY a.ROWID so the numbering follows
attachment ROWID (creation) order. Instructions and README updated to
match; version 0.1.0 -> 0.2.0 in plugin.json and package.json.

Known limits, unchanged by this patch: attachments still downloading
when the message row is first polled (filename NULL) are skipped and,
since the poll watermark has already passed the message, never picked up
later; HEIC is passed through unconverted; attachments with a NULL
mime_type still pass the image filter; no cap on images per message.
This commit is contained in:
dltn
2026-09-30 15:34:53 -04:00
parent 2a8ad9f746
commit b6041c7721
4 changed files with 17 additions and 11 deletions
@@ -1,7 +1,7 @@
{
"name": "imessage",
"description": "iMessage channel for Claude Code \u2014 reads chat.db directly, sends via AppleScript. Built-in access control; manage pairing, allowlists, and policy via /imessage:access.",
"version": "0.1.0",
"version": "0.2.0",
"keywords": [
"imessage",
"messaging",
+1 -1
View File
@@ -55,7 +55,7 @@ Handles are phone numbers (`+15551234567`) or Apple ID emails (`them@icloud.com`
| **Inbound** | Polls `chat.db` once a second for `ROWID > watermark`. Watermark initializes to `MAX(ROWID)` at boot — old messages aren't replayed on restart. |
| **Outbound** | `osascript` with `tell application "Messages" to send …`. Text and chat GUID pass through argv so there's no escaping footgun. |
| **History & search** | Direct SQLite queries against `chat.db`. Full history — not just messages since the server started. |
| **Attachments** | `chat.db` stores absolute filesystem paths. The first inbound image per message is surfaced to the assistant as a local path it can `Read`. Outbound attachments send as separate messages after the text. |
| **Attachments** | `chat.db` stores absolute filesystem paths. Every inbound image in a message is surfaced to the assistant as a local path it can `Read` (`image_path`, `image_path_2`, …). Outbound attachments send as separate messages after the text. |
## Environment variables
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-channel-imessage",
"version": "0.1.0",
"version": "0.2.0",
"license": "Apache-2.0",
"type": "module",
"bin": "./server.ts",
+14 -8
View File
@@ -168,6 +168,7 @@ const qAttachments = db.query<AttRow, [number]>(`
FROM attachment a
JOIN message_attachment_join maj ON maj.attachment_id = a.ROWID
WHERE maj.message_id = ?
ORDER BY a.ROWID
`)
// Your own addresses, from message.account ("E:you@icloud.com" / "p:+1555...")
@@ -557,7 +558,7 @@ const mcp = new Server(
instructions: [
'The sender reads iMessage, not this session. Anything you want them to see must go through the reply tool — your transcript output never reaches their chat.',
'',
'Messages from iMessage arrive as <channel source="imessage" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file — it is an image the sender attached. Reply with the reply tool — pass chat_id back.',
'Messages from iMessage arrive as <channel source="imessage" chat_id="..." message_id="..." user="..." ts="...">. If the tag has image_path attributes (image_path, image_path_2, ...), Read each file in order — they are images the sender attached. Reply with the reply tool — pass chat_id back.',
'',
'reply accepts file paths (files: ["/abs/path.png"]) for attachments.',
'',
@@ -844,20 +845,25 @@ function handleInbound(r: Row): void {
}
// attachment.filename is an absolute path (sometimes tilde-prefixed) —
// already on disk, no download. Include the first image inline.
let imagePath: string | undefined
// already on disk, no download. Collect every image, in attachment ROWID order.
const imagePaths: string[] = []
if (hasAttachments) {
for (const att of qAttachments.all(r.rowid)) {
if (!att.filename) continue
if (att.mime_type && !att.mime_type.startsWith('image/')) continue
imagePath = expandTilde(att.filename)
break
imagePaths.push(expandTilde(att.filename))
}
}
// image_path goes in meta only — an in-content "[image attached — read: PATH]"
// Image paths go in meta only — an in-content "[image attached — read: PATH]"
// annotation is forgeable by any allowlisted sender typing that string.
const content = text || (imagePath ? '(image)' : '')
// One key per image: image_path, image_path_2, image_path_3, ... (Claude
// Code accepts only string meta values, so no array).
const imageMeta: Record<string, string> = {}
imagePaths.forEach((p, i) => {
imageMeta[i === 0 ? 'image_path' : `image_path_${i + 1}`] = p
})
const content = text || (imagePaths.length ? '(image)' : '')
void mcp.notification({
method: 'notifications/claude/channel',
@@ -868,7 +874,7 @@ function handleInbound(r: Row): void {
message_id: r.guid,
user: sender,
ts: appleDate(r.date).toISOString(),
...(imagePath ? { image_path: imagePath } : {}),
...imageMeta,
},
},
})