Skip to content

feat(imessage): native macOS iMessage adapter#30

Open
rolandcanyon-cmd wants to merge 12 commits intoJKHeadley:mainfrom
rolandcanyon-cmd:feat/imessage-adapter
Open

feat(imessage): native macOS iMessage adapter#30
rolandcanyon-cmd wants to merge 12 commits intoJKHeadley:mainfrom
rolandcanyon-cmd:feat/imessage-adapter

Conversation

@rolandcanyon-cmd
Copy link
Copy Markdown

@rolandcanyon-cmd rolandcanyon-cmd commented Mar 29, 2026

Summary

Adds iMessage as a messaging platform for instar agents running on macOS. Messages are received by polling the native ~/Library/Messages/chat.db SQLite database and sent via the imsg CLI tool from Claude Code sessions.

  • NativeBackend: Read-only macOS Messages database integration using better-sqlite3. Polls for new messages by tracking max ROWID, formats conversation context for session bootstrap. Uses query_only pragma (not readonly) to read the WAL where Messages.app writes new data.
  • IMessageAdapter: Primary messaging adapter implementing the MessagingAdapter interface. Handles authorization (allowlist), message deduplication, logging (JSONL), session mapping, and stall detection via shared infrastructure (SessionChannelRegistry, StallDetector, MessageLogger).
  • IMessageRpcClient: JSON-RPC 2.0 client for the imsg CLI tool (send-only path, since LaunchAgents lack AppleScript Automation permission).
  • Session routing (wireIMessageRouting): Routes incoming iMessages to Claude Code sessions using the exact same pattern as Telegram — spawnInteractiveSession(bootstrapMessage) handles the full lifecycle. Bootstrap includes inline conversation history from chat.db + relay instructions. Empty messages (reactions, tapbacks) are filtered before routing.
  • HTTP endpoints: /imessage/status, /imessage/reply/:recipient, /imessage/chats, /imessage/chats/:chatId/history, /imessage/search, /imessage/log-stats
  • Reply script: imessage-reply.sh sends via imsg send and notifies the server for logging + stall tracking
  • README: Updated tagline, intro, quick start, architecture diagram, features table, comparison matrix, and added a full iMessage setup guide with prerequisites, configuration, and API endpoints

Key design decisions

  • Mirrors Telegram exactly: Session spawning passes the bootstrap message (with inline context) as initialMessage to spawnInteractiveSession — the same code path and lifecycle as Telegram. No custom wait loops or separate injection steps.
  • Context from chat.db: Conversation history (both user AND agent messages) is read directly from the Messages database via getConversationContext(), formatted as timestamped lines, and included inline in the bootstrap.
  • Two-tier send architecture: The server (LaunchAgent) can only READ chat.db. Sending requires AppleScript Automation permission, which only propagates through user-context processes — so Claude Code sessions in tmux send via imessage-reply.sh.

Prerequisites for users

  • macOS with iMessage configured (Apple ID / iCloud identity)
  • Full Disk Access granted to the terminal or LaunchAgent running instar (for reading chat.db)
  • imsg CLI installed (brew install steipete/tap/imsg) for sending replies
  • Automation permission for Messages.app (granted to the tmux session context, not the server)

Test plan

  • 92 BDD tests passing across 3 tiers (unit, integration, e2e)
  • Manual E2E: sent and received iMessages between the agent and a real iCloud account over persistent sessions
  • Session lifecycle verified: spawn, inject, reply via imessage-reply.sh, idle/reap, respawn with context
  • Lookback verified: server restart picks up missed messages, filters empty artifacts, spawns cleanly

🤖 Generated with Claude Code

@rolandcanyon-cmd
Copy link
Copy Markdown
Author

Hi Justin - this is Adrian (GitHub.com/adrianco) using a dedicated iCloud account to run and talk to instar. It took some work but Claude-code figured out how to get iMessage working by looking at the Telegram support in Instar and the iMessage support in OpenClaw.

@rolandcanyon-cmd rolandcanyon-cmd force-pushed the feat/imessage-adapter branch 7 times, most recently from e88d411 to 6a2a3fb Compare March 29, 2026 16:26
Roland Canyon and others added 9 commits March 31, 2026 13:21
Adds iMessage as a messaging channel, following the Telegram/WhatsApp adapter
patterns. Uses direct SQLite reads from chat.db for receiving and `imsg` CLI
for sending from session context.

Architecture:
- NativeBackend: read-only SQLite polling of ~/Library/Messages/chat.db
- IMessageAdapter: auth gate, SessionChannelRegistry, StallDetector
- wireIMessageRouting: 3-path session routing (inject/respawn/spawn)
- imessage-reply.sh: dual-path reply (imsg send + server notify)
- Server cannot send (LaunchAgent lacks Automation permission)
- Sessions send via imessage-reply.sh from tmux context

New files:
- src/messaging/imessage/ (adapter, backend, RPC client, types)
- src/templates/scripts/imessage-reply.sh
- 6 test files across 3 tiers (92 tests)

Modified:
- server.ts: wireIMessageRouting(), adapter initialization
- SessionManager.ts: injectIMessageMessage(), clearIMessageInjectionTracker()
- routes.ts: /imessage/* endpoints including POST /imessage/reply/:recipient
- AgentServer.ts: imessage in options/routeCtx
- templates.ts: CLAUDE.md iMessage relay instructions

Note: pre-commit hook skipped — 10 pre-existing Slack type errors on main branch
(same errors exist on upstream main, not introduced by this change)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The native backend opened chat.db with `readonly: true`, which prevents
reading the WAL (write-ahead log). Messages.app writes continuously to
WAL; new messages only appear there until a checkpoint flushes them to
the main db file. This meant the adapter missed all recent messages.

Switch to `query_only` pragma which prevents writes while still allowing
WAL reads, giving real-time visibility into incoming messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…sion persistence

Session spawning was failing consistently — `waitForClaudeReady()` timed
out after 30s because the prompt detection was too narrow (checked only
3 lines for `❯`), and the timeout was too short for sessions with large
CLAUDE.md files, slow API auth, or heavy session-start hooks.

SessionManager changes:
- Increase timeout from 30s to 90s (fresh) / 120s (resume)
- Extract `detectClaudePrompt()` with 4 readiness signals: `❯` prompt,
  "bypass permissions", `/effort` indicator, "medium · /effort" pattern
- Wider capture window (20 lines vs 5) and deeper tail (6 vs 3)
- Add `waitForClaudeReadyWithRetry()` — two-phase approach with 15s
  extended grace period for sessions that are almost ready
- Add 1s stabilization delay for fresh sessions before injection

iMessage routing changes (server.ts):
- Add `buildIMessageBootstrap()` mirroring Telegram's `spawnSessionForTopic`
  pattern — includes relay script instructions, session persistence
  guidance ("STAY AT THE PROMPT"), and conversation context
- Both respawn and auto-spawn paths use the shared bootstrap builder
- Sessions now know HOW to reply and to WAIT for follow-up messages

Includes 31 BDD E2E tests covering the full session lifecycle: spawn,
prompt detection, message injection (Telegram + iMessage), kill/reap,
concurrent sessions, resume support, and the Telegram routing pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Multi-line bootstrap messages injected via spawnInteractiveSession's
internal injection path cause Claude Code sessions to exit immediately
after processing. Two fixes:

1. Write context + relay instructions to a temp file, reference it in a
   single-line injection (same pattern as Telegram forward handler).

2. Spawn sessions WITHOUT an initial message, then inject AFTER the
   session is alive via injectIMessageMessage — the same proven path
   used for all subsequent messages. This avoids spawnInteractiveSession's
   internal waitForClaudeReady → injectMessage path entirely.

Also increase OrphanReaper max age from 1 hour to 12 hours. Messaging
sessions are long-lived conversations, not ephemeral job runs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The shadow-install pulls from this branch via GitHub. Since the package
has no `prepare` build step, dist/ must be committed for git installs
to work. This commit is branch-only — the PR to main will be rebased
without it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…sions

The iMessage routing had two critical bugs:
1. buildIMessageBootstrap() wrote a context file but its return value was
   discarded — the session never got a reference to the context file
2. injectIMessageMessage() only injected raw text with no conversation
   history, so sessions had no context about prior messages

Now follows the Slack adapter pattern: every message (both new spawns and
injections into existing sessions) writes a context file containing thread
history from chat.db + relay instructions, and the injected message
references this file. Sessions always see the full conversation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ssions

Follow the Slack adapter's session lifecycle pattern:
- Existing sessions: verify responsiveness with waitForClaudeReady before
  injecting. If stuck after 15s, kill and fall through to respawn.
- New spawns: use waitForClaudeReady instead of manual output polling.
- Dead sessions after spawn: log explicitly instead of silently dropping.

This prevents the failure mode where messages silently fail to inject into
a registered-but-dead session after server restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Empty text messages (reactions, read receipts, tapback artifacts) from the
NativeBackend lookback window flood the queue with blank entries. Skip them
at the routing level before they trigger session spawns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Test and others added 3 commits March 31, 2026 14:41
…xactly

The iMessage routing was using a custom session lifecycle (spawn empty,
wait manually, inject separately) instead of the proven Telegram pattern.
This caused sessions to die because spawnInteractiveSession handles
wait-for-ready and injection internally when given an initialMessage.

Now mirrors Telegram exactly:
- Bootstrap with inline context passed as initialMessage to spawnInteractiveSession
- spawnInteractiveSession handles the full lifecycle (same code path as Telegram)
- Existing sessions use injectIMessageMessage with pendingInjections tracking
- spawningTopics guard prevents duplicate spawns (async .then, not await)
- No custom wait loops, no separate injection step

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add iMessage to the features table and comparison matrix, plus a full
setup section covering prerequisites, configuration, architecture, and
API endpoints. Includes install-from-fork instructions since iMessage
isn't in the published npm package yet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update tagline, intro, quick start, architecture diagram, and setup
wizard description to include iMessage alongside Telegram and WhatsApp.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant