Summary
Outgoing iMessages from Jarvis get reflected back as inbound messages, creating a reply loop. Root cause is a text normalization mismatch in the sentMessageCache echo cache.
Environment
- OpenClaw:
2026.3.31 (213a704)
- imsg:
0.5.0
- macOS: Darwin 25.3.0 (arm64)
- Chat type: iMessage DM
Root Cause
Step 1 — isSelfChat detection fires incorrectly
imsg RPC notifications report sent messages (is_from_me: true) with:
{
"sender": "+13179650799",
"chat_identifier": "+13179650799",
"is_from_me": true
}
Because sender === chat_identifier, the isSelfChat check in resolveIMessageInboundDecision evaluates to true. This bypasses the simple is_from_me → drop path and instead defers to the echo cache.
Step 2 — Echo cache lookup fails due to text corruption
imsg reads iMessage messages from the attributedBody SQLite column (NSAttributedString binary format). For sent messages, it prepends garbled bytes to the parsed text:
Stored in echo cache: "Good news — the portal is up..."
Reported by imsg RPC: "\xef\xbf\xbd@\x01Good news — the portal is up..."
The leading \xef\xbf\xbd (U+FFFD replacement char) followed by 1–2 control bytes is an NSAttributedString parsing artefact. The text keys do not match, so sentMessageCache.has() returns false.
Step 3 — Message dispatched as inbound → loop
With a cache miss on a isSelfChat message, the code falls through to accessDecision. The sender (+13179650799) is in allowFrom, so the message is dispatched as a real inbound message. Jarvis replies. Loop begins.
Reproduction
- Configure iMessage channel with
dmPolicy: allowlist, allowFrom: [<your_number>]
- Send multiple rapid replies from Jarvis to a DM conversation
- Each outgoing message fires an RPC notification with garbled
attributedBody text
- Echo cache misses → messages dispatched as inbound → loop
Note: Single-message replies often work fine — the loop is most likely when multiple replies are sent in rapid succession (race between sentMessageCache.remember() and the RPC notification arrival).
Observed Pattern
Messages that loop start with bytes ef bf bd (U+FFFD) in the attributedBody-parsed text. Messages from the text column (when populated) do not exhibit this issue.
Suggested Fix
In resolveIMessageInboundDecision, normalize the inbound message text before echo cache comparison — specifically, strip leading \uFFFD and control characters (\x00–\x1F) from the text before building the cache key:
function normalizeInboundBodyText(text: string): string {
// Strip leading NSAttributedString parse artefacts (U+FFFD + control bytes)
return text.replace(/^[\uFFFD\x00-\x1F]+/, "");
}
Alternatively, when is_from_me is true and the message is not a genuine self-chat (i.e. the sender is not the agent's own iCloud account), always drop without checking the echo cache. This could be gated on a new config key like channels.imessage.selfHandle to specify the agent's own iCloud address.
Workaround
Set channels.imessage.blockStreaming: true to coalesce rapid burst replies into a single message, reducing the race window. Does not fully eliminate the issue.
Additional Context
The destination_caller_id field in the RPC notification correctly identifies the agent's own iCloud address (e.g. [email protected]). This field could be used to reliably detect is_from_me messages that are NOT self-chats and hard-drop them without touching the echo cache.
Summary
Outgoing iMessages from Jarvis get reflected back as inbound messages, creating a reply loop. Root cause is a text normalization mismatch in the
sentMessageCacheecho cache.Environment
2026.3.31 (213a704)0.5.0Root Cause
Step 1 —
isSelfChatdetection fires incorrectlyimsgRPC notifications report sent messages (is_from_me: true) with:{ "sender": "+13179650799", "chat_identifier": "+13179650799", "is_from_me": true }Because
sender === chat_identifier, theisSelfChatcheck inresolveIMessageInboundDecisionevaluates totrue. This bypasses the simpleis_from_me → droppath and instead defers to the echo cache.Step 2 — Echo cache lookup fails due to text corruption
imsgreads iMessage messages from theattributedBodySQLite column (NSAttributedString binary format). For sent messages, it prepends garbled bytes to the parsed text:The leading
\xef\xbf\xbd(U+FFFD replacement char) followed by 1–2 control bytes is an NSAttributedString parsing artefact. The text keys do not match, sosentMessageCache.has()returnsfalse.Step 3 — Message dispatched as inbound → loop
With a cache miss on a
isSelfChatmessage, the code falls through toaccessDecision. The sender (+13179650799) is inallowFrom, so the message is dispatched as a real inbound message. Jarvis replies. Loop begins.Reproduction
dmPolicy: allowlist,allowFrom: [<your_number>]attributedBodytextNote: Single-message replies often work fine — the loop is most likely when multiple replies are sent in rapid succession (race between
sentMessageCache.remember()and the RPC notification arrival).Observed Pattern
Messages that loop start with bytes
ef bf bd(U+FFFD) in theattributedBody-parsed text. Messages from thetextcolumn (when populated) do not exhibit this issue.Suggested Fix
In
resolveIMessageInboundDecision, normalize the inbound message text before echo cache comparison — specifically, strip leading\uFFFDand control characters (\x00–\x1F) from the text before building the cache key:Alternatively, when
is_from_meistrueand the message is not a genuine self-chat (i.e. the sender is not the agent's own iCloud account), always drop without checking the echo cache. This could be gated on a new config key likechannels.imessage.selfHandleto specify the agent's own iCloud address.Workaround
Set
channels.imessage.blockStreaming: trueto coalesce rapid burst replies into a single message, reducing the race window. Does not fully eliminate the issue.Additional Context
The
destination_caller_idfield in the RPC notification correctly identifies the agent's own iCloud address (e.g.[email protected]). This field could be used to reliably detectis_from_memessages that are NOT self-chats and hard-drop them without touching the echo cache.