Skip to content

feat: deliver owner messages to bots through the inbox (M3, part 1) - #3

Merged
httpsphl merged 5 commits into
mainfrom
feat/m3-courier
Sep 28, 2026
Merged

httpsphl merged 5 commits into
mainfrom
feat/m3-courier

Conversation

@httpsphl

Copy link
Copy Markdown
Owner

First slice of M3 (spec 17): durable owner messages delivered to bots through the Claude Code inbox. Bot-to-bot messages, MCP tools and tasks come in the next PR.

What changes

  • Inbox format verified with real Claude Code (2.1.283) before the courier depends on it (spec 9.2 and 19). The message line {"type":"user","message":{"role":"user","content":...}} is delivered and opens a turn. A missing or wrong auth line closes the connection. Claude Code never replies, and closing right after the write is safe. Three connections in a row were all delivered. The same run confirmed --continue resuming a real conversation.
  • core: Message, Delivery, Task and their enums, the messages.* / deliveries.* params, and the envelope the bot reads (English, with a relative deadline). All exported to protocol.gen.ts.
  • store: migration 0002_messages.sql (messages, deliveries, tasks). A message is saved with its delivery in one transaction. Each delivery transition is a single conditional UPDATE. due_deliveries returns only each bot's oldest pending delivery, and only while none of that bot's deliveries is being sent.
  • platform: named pipe client with a busy retry of up to 2 s. Only plain \\.\pipe\ names are accepted, since the address comes from a bot's hook.
  • courier:
    • It waits without counting attempts while the bot is not idle/busy/needs_approval or has no inbox.
    • It retries with backoff until max_attempts, then marks the delivery dead.
    • It recovers expired leases.
    • It drops the queue of archived bots.
    • A failed send does not count when the bot restarted during it.
  • clock: times come from an injectable clock. Tests drive retries with a manual clock.
  • RPC:
    • New methods: messages.send, messages.list, deliveries.list and deliveries.retry.
    • New notifications: message.created and delivery.changed.
    • system.status.deliveries now reports the backlog.
  • Also fixes the test daemon's botloftd path, which had backspace characters in it (left over from M2).

Tests

  • Store: order per bot, one delivery at a time, backoff, dead state, retry, deferral that does not count as an attempt, lease recovery, atomic insert.
  • Pipe writer against a local named pipe server, including the busy pipe case and rejected addresses.
  • tests/courier.rs, over RPC with FakeRuntime and FakeInbox:
    • waiting for the bot, then delivery;
    • order of three messages;
    • backoff, dead and deliveries.retry;
    • a restart during a failing send;
    • archiving a bot with a queued message;
    • validation errors.
  • Every commit builds and passes clippy on its own. Full suite: 126 tests, plus pnpm check.

Manual test with real Claude Code (done, 2.1.283)

  1. Run the dev daemon (BOTLOFT_HOME=.dev\home) with an existing bot whose folder is already trusted.
  2. Once the bot is idle, call messages.send {botId, body: "... reply only with: COURIER-OK"}.
  3. Expected result: deliveries.list shows sent with attempts: 0 a few ms later, and the bot's terminal shows [botloft] from the owner · crew ... and the reply COURIER-OK.

Result: as expected. The debug log shows the inbox token as <redacted>, and no message body appears in the log.

Still pending with real Claude Code (next PR)

  • ${BOTLOFT_BOT_TOKEN} expansion in the mcp.json headers (needs /mcp).
  • Two bots exchanging a task and its result without help (the M3 acceptance test).

The message line of the Claude Code inbox is not documented, so it was
tested with Claude Code 2.1.283 before the courier depends on it: the
line in 9.2 is delivered and opens a turn, a missing or wrong auth line
closes the connection, and Claude Code never replies. The same run
confirmed --continue resuming a real conversation.

Also settles what the courier does: in order and one at a time per bot,
a 10 s send timeout, deterministic retry backoff, archived bots' queues
dropped, and an English envelope with a relative deadline.
Protocol types for spec 9 and 11 (Message, Delivery, Task, their
enums and the messages.*/deliveries.* params), exported to TypeScript,
plus the envelope a bot reads when a message arrives and validation for
message bodies.
Migration 0002 creates the three tables of spec 12. A message is saved
with its delivery and the task it creates in one transaction. Delivery
transitions are single conditional UPDATEs; due_deliveries hands out
only each bot's oldest pending delivery, and only while none of that
bot's deliveries is being sent, so messages arrive in order.
Opens the bot's named pipe as a client, retries for up to 2 s while it
is busy, writes and closes. The address comes from a bot's hook, so
only plain names under \\.\pipe\ are accepted: a ".." segment could
make CreateFile open a real file.
The courier moves pending deliveries into the bots' inboxes: it waits
without counting attempts while a bot is not ready, retries failures
with backoff until max_attempts, recovers expired leases, drops the
queue of archived bots and does not blame a delivery when the bot
restarted during the send. Times come from an injectable clock, so the
tests drive retries with a manual clock and a fake inbox.

New RPC methods messages.send, messages.list, deliveries.list and
deliveries.retry, the message.created and delivery.changed
notifications, and the delivery backlog in system.status.

Also fixes the test daemon's botloftd path, which had backspace
characters in it.
@httpsphl
httpsphl merged commit 717c9d5 into main Sep 28, 2026
3 checks passed
@httpsphl
httpsphl deleted the feat/m3-courier branch September 28, 2026 19:24
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