feat(mailbox): channel-shaped envelope foundation (PR A) - #1444
Conversation
Adopt the semantics of Anthropic's Claude Code Channels (structured
`<channel source="X" meta_k=v>body</channel>` envelope) without adopting
the MCP transport. Genie owns the source layer — `genie send`, omni →
whatsapp, system nudges, future external adapters all funnel through the
same mailbox + native-inbox pipeline, with `source` attribution telling
the receiving Claude where the message came from.
Why: PRs B–F (codex hook handler, omni → native migration, system-nudge
migration, injectToTmuxPane removal, external adapters) all build on
this foundation.
Changes:
- migration 054: ALTER TABLE mailbox ADD source TEXT DEFAULT 'agent',
meta JSONB DEFAULT '{}'. Idempotent (IF NOT EXISTS).
- mailbox.send: opts arg `{ source?, meta? }`. Existing 4-arg callers
unchanged. MailboxMessage carries source + meta on every row.
- channel-envelope.ts: pure formatEnvelope/parseEnvelope helpers.
Plain-body passthrough for source='agent' (back-compat); structured
tag for everything else. Round-trips meta values with attribute
escaping.
- toNativeInboxMessage: when source != 'agent', wraps body in
`<channel …>` and carries source/meta onto NativeInboxMessage.
Default-source path is byte-identical to today's output.
- NativeInboxMessage: optional `source` and `meta` fields.
- genie agent inbox: `[<source>]` tag prepended to non-agent previews;
JSON output surfaces source + meta verbatim per conversation.
Tests:
- src/lib/channel-envelope.test.ts: 12 cases covering round-trip,
passthrough, escaping, malformed input.
- src/term-commands/agent/inbox.test.ts: 10 cases covering source tag
rendering and JSON enrichment.
- src/lib/__tests__/mailbox.test.ts: extended with 4 cases for default
source, explicit source=whatsapp round-trip, JSONB meta persistence,
and back-compat for legacy 4-arg callers.
- src/lib/claude-native-teams.test.ts: extended with 2 cases for
source/meta persistence + legacy-shape preservation.
- Existing fixtures in brief.test.ts, unified-log.test.ts,
scheduler-daemon.test.ts updated with source/meta defaults.
Validation:
- bun run typecheck: clean
- bunx biome check .: only pre-existing complexity warnings in unrelated
files
- bun test src/lib/__tests__/mailbox.test.ts src/lib/claude-native-teams.test.ts
src/lib/channel-envelope.test.ts src/term-commands/agent/inbox.test.ts:
92 pass, 0 fail
Out of scope (later PRs): codex UserPromptSubmit hook handler (PR B),
omni → native migration with source='whatsapp' (PR C), system-nudge
migration with source='system' (PR D), injectToTmuxPane removal (PR E),
external adapters (PR F+).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7f3c1d79fc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| const DEFAULT_SOURCE = 'agent'; | ||
|
|
||
| const TAG_OPEN_RE = /^<channel\s+([^>]*)>([\s\S]*)<\/channel>\s*$/; |
There was a problem hiding this comment.
Parse channel attrs without breaking on
> in values
parseEnvelope uses TAG_OPEN_RE with ([^>]*) for the attribute blob, so any attribute value containing > terminates the blob early and corrupts both parsed metadata and body. Since formatEnvelope does not escape >, a payload like meta: { reason: 'a > b' } produces an envelope that this parser misreads (e.g., body starts mid-attribute). This breaks the advertised format/parse round-trip for valid user text and can mis-route source/meta handling when such values appear.
Useful? React with 👍 / 👎.
| const meta: Record<string, string | number | boolean> = {}; | ||
| for (const [key, value] of Object.entries(msg.meta ?? {})) { | ||
| if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { | ||
| meta[key] = value; | ||
| } |
There was a problem hiding this comment.
Preserve non-primitive meta when converting to native inbox
toNativeInboxMessage drops every non-primitive msg.meta entry before writing native.meta, even though the mailbox now accepts arbitrary JSON metadata. In the non-default-source path this silently loses nested metadata (objects/arrays) in engineer.json, so downstream JSON readers cannot round-trip the original attribution data. This is a data-loss regression for any channel integration that stores structured metadata.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Code Review
This pull request introduces source attribution and metadata to the mailbox system, allowing messages to carry origin information and associated data end-to-end. Key changes include a database migration adding source and meta columns, a new channel-envelope utility for formatting and parsing structured message tags, and updates to the mailbox API and CLI to support these fields while maintaining backward compatibility. Review feedback focuses on preventing data loss by allowing nested objects in the meta field of NativeInboxMessage and using more idiomatic destructuring in the envelope parser.
| * attributes when the body is rendered into a `<channel …>` tag. Persisted | ||
| * verbatim so future readers can round-trip the data. | ||
| */ | ||
| meta?: Record<string, string | number | boolean>; |
There was a problem hiding this comment.
The meta property on NativeInboxMessage is typed to only allow primitive values (string, number, boolean), but the underlying MailboxMessage and database schema support nested objects. This causes data loss in toNativeInboxMessage where non-primitive values are filtered out. To ensure metadata can be round-tripped correctly, this type should be Record<string, unknown>.
| meta?: Record<string, string | number | boolean>; | |
| meta?: Record<string, unknown>; |
| const meta: Record<string, string | number | boolean> = {}; | ||
| for (const [key, value] of Object.entries(msg.meta ?? {})) { | ||
| if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { | ||
| meta[key] = value; | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| from: msg.from, | ||
| text: msg.body, | ||
| text: formatEnvelope({ source, from: msg.from, meta: msg.meta, body: msg.body }), | ||
| summary, | ||
| timestamp: msg.createdAt, | ||
| color, | ||
| read: false, | ||
| source, | ||
| meta, | ||
| }; |
There was a problem hiding this comment.
Following the recommended change to allow Record<string, unknown> for meta on NativeInboxMessage, this filtering logic is no longer necessary and currently causes data loss for nested objects. The meta from the original MailboxMessage should be passed through directly.
return {
from: msg.from,
text: formatEnvelope({ source, from: msg.from, meta: msg.meta, body: msg.body }),
summary,
timestamp: msg.createdAt,
color,
read: false,
source,
meta: msg.meta,
};| const source = attrs.source ?? DEFAULT_SOURCE; | ||
| const from = attrs.from; | ||
| const meta: Record<string, string> = {}; | ||
| for (const [key, value] of Object.entries(attrs)) { | ||
| if (key === 'source' || key === 'from') continue; | ||
| meta[key] = value; | ||
| } |
There was a problem hiding this comment.
This block of code to separate source, from, and meta from the attrs object can be made more concise and idiomatic using object destructuring with a rest parameter.
| const source = attrs.source ?? DEFAULT_SOURCE; | |
| const from = attrs.from; | |
| const meta: Record<string, string> = {}; | |
| for (const [key, value] of Object.entries(attrs)) { | |
| if (key === 'source' || key === 'from') continue; | |
| meta[key] = value; | |
| } | |
| const { source: parsedSource, from, ...meta } = attrs; | |
| const source = parsedSource ?? DEFAULT_SOURCE; |
Summary
Foundation PR for the CLI-native channels sprint. Extends the mailbox + native-inbox substrate to carry
sourceandmetaattribution alongside the message body, so future PRs (B–F) can route omni / system / external / peer-agent traffic through a single delivery substrate with structured provenance.Adopts the semantics of Anthropic's Claude Code Channels (the
<channel source="X" meta_k=v>body</channel>envelope) without adopting the MCP transport. Genie owns the source layer.Replaces #1432 (closed) — that PR had stale snapshots and accumulated unrelated commits during the test-infra-blocked wait. This is a fresh single-commit rebase off current
origin/devwith snapshot regen for4.260428.7.What lands
Schema (back-compat by default)
src/db/migrations/054_mailbox_source_meta.sql—source TEXT NOT NULL DEFAULT 'agent'+meta JSONB NOT NULL DEFAULT '{}'onmailbox. Idempotent (IF NOT EXISTS).Types + plumbing
MailboxMessageinterface gainssource: stringandmeta: Record<string, unknown>.mailbox.send(repoPath, from, to, body, opts?: { source?, meta? })— opts optional; existing callers untouched.NativeInboxMessagegains optionalsourceandmeta.Channel envelope renderer
src/lib/channel-envelope.ts— pureformatEnvelope+parseEnvelope. Defaultsource='agent'returns plain body for back-compat; non-default sources render<channel source="X" from="Y" k="v">body</channel>.Inbox-list rendering
genie inbox listprepends[<source>]for non-default sources in human render; JSON output exposessourceandmetaverbatim.Test plan
bun test src/lib/__tests__/mailbox.test.ts src/lib/claude-native-teams.test.ts src/lib/channel-envelope.test.ts src/term-commands/agent/inbox.test.ts test/visual/tui-snapshot.test.tsx→ 109/0/17 (all green incl. snapshots regen for 4.260428.7)bun run typecheckcleanbun run check) passed cleanly — no--no-verifyneeded.What this unblocks
UserPromptSubmithandler reads PG mailbox and returns pending messages asadditionalContext.claude-code.ts:deliver(omni → claude) fromtmux send-keystowriteNativeInboxwithsource='whatsapp'.claude-code.ts:injectNudgetowriteNativeInboxwithsource='system'.protocol-router.ts:injectToTmuxPaneonce metric confirms zero traffic.genie channel <kind>subcommand.Design doc:
.genie/brainstorms/codex-first-class-integration/DESIGN.md(landed via #1427).🤖 Generated with Claude Code