Skip to content

feat(bus): mail-model inbox — unread/read, keep-unread wait, reply-to, 500-char cap - #96

Merged
rr0hit merged 6 commits into
mainfrom
flow-mail-model
Sep 7, 2026
Merged

rr0hit merged 6 commits into
mainfrom
flow-mail-model

Conversation

@rr0hit

@rr0hit rr0hit commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

What

Mail-model enhancements to the flow message bus. All changes are additive and non-breaking — flow inbox pop and every existing verb/flag keep their exact current behavior. Bundles the pop-only API groundwork with the new inbox surface.

Message bus (code)

  • flow inbox --all — list everything still retained (read + unread), each with id + status, not just unread.
  • flow inbox read <id> — show/ack a specific message out of pop's oldest-first order (works for already-consumed messages too — displays without re-mutating).
  • flow inbox pop --wait --keep-unread — reader/relay wake primitive: returns the message but does not ack it (claims the row delivered so a loop won't re-return it, but leaves a human-directed message unanswered/immortal so a forwarder can pass it along). Plain pop --wait is unchanged.
  • flow message <addr> "..." --reply-to <id> — stamp a parent id so a routed reply carries lineage; the receiver sees ↳ in reply to [id]: <snippet>. Validated at send time.
  • Mail nomenclature (unread / read) in human output and --json (mail field). New reply_to column migrated idempotently on open; sweep retention widened so an unacked human message is immortal like pending.
  • Body cap raised 200 → 500 (non-breaking — a larger limit; short messages unaffected).

Skill

  • references/messaging.md + SKILL.md §4.18 rewritten: mail nomenclature, two Monitor recipes (consumer pop --wait vs reader/relay pop --wait --keep-unread), two-purpose framing (reach-out-to-user / peer-collab), niche/escalate content trimmed.
  • Auto-mode guidance: headless flow do --auto agents should post regular progress via flow message user/flow broadcast, not only when blocked.

Vanishing-message bug — investigated, RESOLVED (not a durability bug)

Both reported ids survived in the DB as acked. Root cause: the sending session's own UserPromptSubmit hook auto-acks its outbound human-directed messages and raced the out-of-band Telegram relay. The --keep-unread reader primitive is the fix — the relay claims the row delivered (which the pending-only prompt-hook ack can no longer touch) and acks it by id only after the human answers.

Verification

make test green. All six enhancements exercised end-to-end in an isolated sandbox (22/22 assertions pass): inbox --all, read <id> (out-of-order ack + already-read), pop --keep-unread (wakes without acking, loop-safe, contrasted against acking pop), --reply-to (lineage shown + invalid-id rejected), 500-char cap (500 accepted / 501 rejected / 300 accepted), and mail nomenclature.

🤖 Generated with Claude Code

anshulsao
anshulsao previously approved these changes Sep 7, 2026

@anshulsao anshulsao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Build/vet/tests pass locally; the retention-predicate refactor (shared busRollable) and the widened atomic ack guard are clean.

Three low-severity non-blockers noted (fix at leisure):

  1. busSnippet slices the body by byte offset — can split a multi-byte rune now that the cap is 500 chars; use runes.
  2. PreToolUse matcher is an allowlist (Bash|Edit|Write|...), but the docstrings say "every tool" — MCP/TodoWrite calls won't surface a mid-turn stop; align docs or widen matcher.
  3. Dropping migrateBusKindBroadcast leaves legacy kind='post' rows stranded (immortal under the new retention predicate) on very old DBs.

rr0hit and others added 6 commits September 7, 2026 11:28
- flow inbox pop is the ONLY consumption API: ack and due subcommands
  removed, along with the whole escalation apparatus (next_notify_at /
  attempts columns for fresh installs, DueBusMessages, BumpNotifyAttempt,
  AckMessageByID). Popping answers, delivers, and clears; agents and
  notifier scripts just loop it. Ack-on-reply (the UserPromptSubmit
  hook) and wait metrics remain — that is behavior, not API surface.
- No sender can message its own address: a bound session is rejected
  addressing its own inbox, and the human their own queue.
- Reserved assignee renamed self -> user (agents misread 'message self'
  as talking to themselves). Existing rows migrated idempotently on
  open; the one-shot post->broadcast kind migration is deleted (no
  released DB ever needed it).
- Usage, skill §4.18, and references/messaging.md rewritten to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFGNJQo5xNBBrT7rZMuUTu
…ssage --reply-to

Additive, non-breaking additions on top of the pop-only bus:

- `flow inbox --all` lists the whole retained queue (read + unread);
  default `flow inbox` still lists only unread.
- `flow inbox read <id>` shows any message by id (incl. consumed) and
  marks it read — acks a specific message out of pop's oldest-first order.
- `flow inbox pop --wait --keep-unread` is the reader/relay wake: returns
  the oldest unread, claims it `delivered` (loop-safe) but never acks, so
  a forwarder can pass mail along without consuming the human's answer.
- `flow message --reply-to <id>` stamps a parent id (new nullable
  `reply_to` column, migrated on open); the receiver sees the lineage.
- Mail nomenclature (unread/read) in human output and `--json` (`mail`).
- Retention: a delivered-but-unacked human message is immortal like
  pending — an unanswered question never vanishes, whoever forwarded it.

Skill §4.18 + references/messaging.md rewritten: mail nomenclature,
two-purpose framing (reach-out-to-user / peer-collab), two Monitor
recipes (consumer pop-wait, reader pop-wait --keep-unread).

Vanishing-message bug ruled out as durability: the ids were auto-acked
by the sending session's UserPromptSubmit hook (acked_by='prompt') before
the relay forwarded them, not lost from storage. `--keep-unread` closes
the delivery-starvation gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Bump busBodyMax 200 -> 500 (non-breaking; short messages unaffected).
  The 200 cap forced constant splitting for the Telegram relay.
- Skill (SKILL.md §4.18 + references/messaging.md): document that
  headless `flow do --auto` agents should post regular progress via
  `flow message user`/`flow broadcast`, not only when blocked — a silent
  run looks stuck. Update the two "≤200 chars" citations to "≤500".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface a just-arrived DIRECTED inbox message right before a tool runs, so
a mid-turn "stop, don't do X" reaches the agent before the action it would
cancel — the case SessionStart/UserPromptSubmit/Stop nudges surface too
late.

The existing pending-count notice is NOT delta-gated (raw COUNT(*) of
pending rows), so wiring it as-is into PreToolUse — which fires on every
tool call — would re-nudge the same set every call. Instead add a
bus_surfaced high-water mark: PendingDirectedUnsurfacedForTask returns only
messages not yet announced, MarkSurfaced records them, so each message is
surfaced at most once and quiet tool calls stay silent. Broadcasts are
excluded (FYIs, not action-changing); urgent messages lead. Inform-only —
hooks never consume.

Wires `flow hook pre-tool-use` into settings.json via a new
Install/UninstallPreToolUseHook on the harness interface (claude installs;
codex no-ops), and on skill install/uninstall/auto-upgrade. bus_surfaced
marks are cleaned on task close-out and pruned in SweepBus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PreToolUse delta-gated nudge previously surfaced only directed
messages, excluding broadcasts as FYIs. Per refinement, it now fires on
ANY unread item (message OR broadcast) addressed to the session: rename
PendingDirectedUnsurfacedForTask -> PendingUnsurfacedForTask and drop the
kind='message' filter. Delta-gating (bus_surfaced high-water mark), urgent
lead, and inform-only (never consumes) are unchanged, so each new unread
still surfaces exactly once with no per-call spam. Test flipped from
"ignores broadcasts" to "surfaces broadcasts" (once, then delta-gated);
wording + docs (messaging.md) updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port Anshul's #95 flag-parsing correction into this branch's docs: the
mail-model rewrite of §4.18 had dropped it. references/messaging.md now
states the tolerant command form (body positional or --body; --urgent/
--body/--reply-to may come in any order; unknown flags rejected, never
stored as the body), and the app.go --help note lists --reply-to as a
known flag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rr0hit
rr0hit merged commit 1f10e6a into main Sep 7, 2026
3 checks passed
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.

2 participants