Skip to content

flow message bus: message / inbox / post / watch - #93

Merged
rr0hit merged 20 commits into
mainfrom
feat/flow-page
Sep 2, 2026
Merged

flow message bus: message / inbox / post / watch#93
rr0hit merged 20 commits into
mainfrom
feat/flow-page

Conversation

@rr0hit

@rr0hit rr0hit commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

What

A CLI-only message bus built into flow: sessions call for the user's attention, message each other, and broadcast updates — queued in flow.db, escalated on a schedule, wait times measured. flow ships no notification UI (users script their own on flow inbox due) and hooks never consume mail (inform-only; the sole consumption paths are flow inbox pop and an agent's Monitor loop).

Commands

  • flow message <assignee>[/<task-slug>] "<body>" [--urgent] (alias msg) — directed. Bare assignee = the human: pending on an escalating notify schedule (due immediately, then 1m→30m cap) until answered. assignee/task-slug = the session bound to that task. Bodies ≤200 chars; done/archived task addresses rejected.
  • flow broadcast "<one-liner>" (alias post) — fan-out on write: one row per current watcher of the task/project/assignee; never interrupts, never escalates.
  • flow watch <task|project|assignee> [--as <assignee>] | --list | --rm <target> — subscriptions.
  • flow inbox [--as <assignee>] [--json] / flow inbox pop [--wait] [--timeout <s>] [--as] [--json] — consumption; pop atomically claims the oldest (exactly-once under concurrent consumers). --as self = the user's own queue from inside a bound session; --as <other> serves monitor/transport workers.
  • flow inbox ack [<id>] / flow inbox due [--json] / flow inbox stats — manual answer; the escalation feed for user notifier scripts; wait metrics.

Delivery & hooks (inform-only)

  • The canonical listener is one persistent Monitor: while true; do flow inbox pop --wait --timeout 300 --json || true; done — each message is an event that wakes the agent; no re-arming.
  • SessionStart: pending-count notices (user's queue + bound task's inbox) + listener discipline + watch encouragement. Unbound sessions get the user's pending count too.
  • UserPromptSubmit: the user's reply in the sending session acks that session's messages to their own queue (scoped by to_assignee) and injects "answered after "; plus pending counts.
  • Stop: broadcast nudge only (watchers exist, >30m since last broadcast, declined-nudge backoff 30m→4h, stop_hook_active loop guard). Never touches the inbox.
  • Requires Claude Code ≥ v2.1.163 for Stop-hook context (earlier versions: everything works except the Stop nudge). Codex harness no-ops the extra hooks.

Storage & retention

bus_messages / bus_watches / bus_nudges in flow.db (idempotent DDL; transactional kind migration with in-tx index recreation). Pop = atomic status claim, rows never deleted by consumption. Retention: newest 1000 rollable rows kept (consumed rows + broadcasts of any status); only pending directed messages never expire. Task close-out (done/archive) deletes the task's undeliverable rows, watches, and nudge stamp.

Skill

Lean §4.18 in SKILL.md + references/messaging.md (address grammar, Monitor-loop listener, etiquette: one message per wait, broadcasts are FYI, message whoever must act).

Testing

flowdb + command + hook tests, full suite green; live-verified end to end in a real session (message→due→pop→ack with wait metrics, broadcast fan-out, Monitor event delivery, Stop-nudge + backoff + loop guard). Two review rounds (internal multi-angle + Anshul's) addressed in-branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PFGNJQo5xNBBrT7rZMuUTu

rr0hit and others added 14 commits August 31, 2026 22:18
Directed pages (human: native iTerm notification with exponential
backoff until acked, wait metrics; session: context delivery via hooks
or listen), broadcast posts fanned out on write to watchers, watch
subscriptions, page listen for wake-on-mail agents, aggressive hook
handlers (session-start, user-prompt-submit ack-on-reply, post-tool-use
inbox drain + notify-scan, stop post-nudge), skill §4.18 +
references/paging.md, 90d retention sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFGNJQo5xNBBrT7rZMuUTu
flow message (directed, escalation schedule), flow inbox (list / pop
[--wait] / ack / due / stats), flow post, flow watch [--me]. All
terminal/notification UI removed from core — users script notifiers on
top of flow inbox due; agents park a Monitor or background Bash on flow
inbox pop --wait. Tables renamed bus_*, kind message|post. Skill §4.18 +
references/messaging.md rewritten.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFGNJQo5xNBBrT7rZMuUTu
… longer re-wakes the session every turn end

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFGNJQo5xNBBrT7rZMuUTu
flow done / flow archive now delete the task's undeliverable pending
inbox rows, its watches (as watcher and topic), nudge stamp, and
listener row. Messages the task sent to a human that are still pending
survive — closing a task doesn't un-ask a question the user hasn't
seen; those clear on pop/ack, consumed rows age out via the 90d sweep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFGNJQo5xNBBrT7rZMuUTu
…Stop-nudge backoff

Delivery now rides the parked `flow inbox pop --wait` listener:
SessionStart teaches park-and-re-arm (and watching tasks the session
depends on or creates), pop --wait's own output nudges the re-arm.
Prompt-submit drain remains the listener-less fallback. skill update
removes stale PostToolUse entries; the hook subcommand stays a silent
no-op for old installs. Stop-nudge declines now back off 30m→1h→2h..4h
(bus_nudges.attempts, migrated), reset by a post.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFGNJQo5xNBBrT7rZMuUTu
…c pop claims

Any assignee's queue is consumable: --me/--self forces the human self
queue from a bound session (e.g. a dedicated inbox-monitor task); --as
<assignee> targets any human queue (monitor/transport workers). --json
on inbox/pop/due emits machine-readable rows. Pops are now atomic
pending->acked/delivered claims, so concurrent consumers of one inbox
never double-pop. watch gains the same identity overrides.

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

The harness treats any Stop-hook output as blocking the turn and
force-ends after repeated blocks, telling hooks to check
stop_hook_active — the nudge now reads the stdin payload and stays
silent on hook-driven continuation stops (parse failures fail-safe to
silent). --me/--self collapsed into the one override that matters:
--as <assignee>, with --as self as the user's own queue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFGNJQo5xNBBrT7rZMuUTu
If the turn ends with pending mail and no live pop --wait listener,
the Stop hook drains it into context — the agent handles it before
idling instead of the mail waiting for the user's next prompt. Real
mail bypasses the post-nudge backoff and self-limits (the drain
consumes the rows); a live listener takes precedence (its message is
never stolen); stop_hook_active still silences everything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFGNJQo5xNBBrT7rZMuUTu
Session-start/stop hook guidance, pop --wait re-arm hint, and skill
docs now name Monitor first (waits without holding a shell, wakes on
exit) with background Bash as the fallback for harnesses without it.

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

Monitor streams stdout lines as events, so the loop is required and
also removes re-arming entirely: while true; do flow inbox pop --wait
--timeout 300 --json || true; done under persistent:true emits one
JSON event per message and zero timeout noise. Background Bash stays
the single-shot fallback with per-wake re-arm.

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

rr0hit commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Minimum Claude Code version note (verified against the Claude Code CHANGELOG):

  • UserPromptSubmit additionalContext (ack-on-reply + inbox drain): v1.0.59
  • SessionStart context output: long-standing
  • Stop hook additionalContext + continuation, and stop_hook_active in its stdin payload: v2.1.163 — this is the binding constraint for the post-nudge and turn-end stranded-mail delivery.

Recommendation: document Claude Code ≥ v2.1.163 for full bus behavior. On older versions everything else (message/inbox/pop/watch, the Monitor listener, SessionStart/UserPromptSubmit deliveries) works; only the Stop-hook features degrade.

rr0hit and others added 4 commits September 1, 2026 14:34
…as alias)

Stored kind renamed message|broadcast with a table-rebuild migration
(CHECK constraint pins the old value); JSON surface now emits
kind:broadcast. Stop nudge, skill, stats and usage updated.

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

Atomic pop claims already guarantee exactly-once with concurrent
consumers, so the bus_listeners table, heartbeats, pid probes, the
Stop-hook precedence check, and the age-based sweeps all go. Stop now
always drains stranded mail at turn end (a racing Monitor listener
loses harmlessly). Retention: keep the newest 1000 consumed rows;
pending never expires. bus_listeners dropped via DDL.

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

Review findings (Anshul) + design directive:
1. Hooks no longer consume mail: MarkDelivered removed from every hook
   path; hooks emit pending COUNTS only. Consumption happens solely via
   flow inbox pop / the armed Monitor loop, so dropped hook output can
   never lose mail. Stop no longer touches the inbox at all.
2. Escalation off-by-one: first re-notify is now 1m (60<<attempts),
   sequence 1,2,4,8,16m then 30m cap.
3. Pending broadcasts no longer immortal: the row-count roll covers
   broadcasts of any status; only pending directed messages are exempt.
4. Backoff overflow: exponent clamped before shifting, cap can't be
   bypassed by unbounded attempts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFGNJQo5xNBBrT7rZMuUTu
- Ack-on-reply scoped to the replier's own queue (AND to_assignee):
  replying no longer acks mail addressed to other assignees.
- flow message rejects a flag in the address slot (no more immortal
  '--urgent' queues) and rejects done/archived task addresses
  (undeliverable — their bus footprint is already cleaned).
- CleanupTaskBus matches watcher suffixes exactly in Go instead of
  LIKE (slugs with _ / %% no longer delete sibling subscriptions).
- flow init installs the full hook set (UserPromptSubmit + Stop,
  retired-hook cleanup) instead of stranding new installs without
  the ack contract until the next version bump.
- Unbound SessionStart also surfaces the user's pending-mail count.
- bus kind migration recreates the messages indexes inside the tx
  (RENAME kept the names on the old table; they self-healed only on
  the next open).
- Deterministic oldest-first: ORDER BY created_at, rowid everywhere
  (second-granularity timestamps tie-break by insertion order).
- Stale messaging.md paragraph promising Stop-hook mail delivery
  removed; dead MarkDelivered removed (again).

Rejected finding: 'Stop hooks do not consume additionalContext' —
empirically false on Claude Code >= v2.1.163 (observed live; also in
the changelog); documented as the minimum version on PR #93.

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

@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.

Review — message bus

Read the full bus layer, hooks, and command surface (through 4229c16). The core is solid: atomic single-row pop claims give exactly-once with no double-delivery, UserPromptSubmit acks are scoped to the sender's own session, stop_hook_active fail-safes to silent, the post→broadcast table rebuild is transactional, and idempotent DDL is clean. The bus_listeners drop + row-count retention is a nice simplification (it also closed two earlier concerns of mine — a local-vs-UTC skew in the old date sweep, and a listener-registration race).

Four things I'd want addressed. #1 is the merge gate; the rest are small.

🔴 1. Stop-hook drain marks mail delivered but the payload may never reach the model — internal/app/bushooks.go (cmdHookStopdrainTaskInbox)

drainTaskInbox calls MarkDelivered (flips pending→delivered) and returns the bodies via Stop hookSpecificOutput.additionalContext. Classic Claude Code Stop hooks influence a turn only via exit-code-2/stderr or {"decision":"block"} — an exit-0 additionalContext payload is, as far as I know, not injected for Stop. If that's the case, the rows are already consumed (no future pop/due returns them) but the model never sees the text → silent loss.

Since 4229c16 removed the !listenerAlive gate, this now drains unconditionally on every idle turn-end, so the exposure is larger than before. The passing test only checks the CLI's JSON output, not real harness injection, so it can't catch this.

Ask: confirm against the installed Claude Code version that Stop actually injects additionalContext. If it doesn't, don't MarkDelivered in the Stop path (leave rows pending for a real pop).

🟡 2. Escalation backoff is doubled — first reminder is 2m, not 1m — internal/flowdb/bus.go BumpNotifyAttempt

delay := time.Duration(60*(1<<uint(attempts+1))) * time.Second

attempts+1 is an off-by-one: at attempts=0 the first delay is 60*(1<<1)=120s. Sequence becomes 2m,4m,8m,16m,30m — every step double the documented 1m,2m,4m…. Fix: 1<<uint(attempts).

🟡 3. Broadcasts fanned out to the human accumulate forever — internal/app/post.go fan-out + SweepBus

If the human identity watches anything (flow watch <project> / flow watch self), every flow broadcast inserts a kind='broadcast', status='pending' row into the human queue. Those rows never escalate (only kind='message' is due-eligible), are never surfaced (flow inbox due is the only thing notifiers script), and are never swept (retention is WHERE status != 'pending', and pending rows don't count toward the 1000-kept window either). They clear only via a manual flow inbox pop.

Ask: either sweep kind='broadcast' even while pending (they're FYI, losing an old one is fine), or don't durably enqueue broadcasts to the human identity at all — a human has no pop --wait listener anyway.

🟢 4. BumpNotifyAttempt int64 overflow after ~57 escalations — same function

60*(1<<uint(attempts+1)) overflows int64 around attempts+1 ≈ 58; the > 30*time.Minute cap runs after the multiply, so a negative result slips through and sets next_notify_at in the past → the message re-fires on every poll. Clamp attempts (or cap before multiplying). Fixing #2's shift shifts the threshold but doesn't remove it.

Minor

migrateBusKindBroadcast's INSERT INTO bus_messages SELECT … relies on positional column order matching the new schema — correct today, just an easy-to-break coupling if the column list ever drifts. Worth a column-name list.

Nice feature overall — happy-path message→due→pop→ack works end to end. Just want #1 settled before this merges.

@rr0hit

rr0hit commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review — note it covers through 4229c16; all four findings were fixed in the two commits after that head (they mirror your DM findings):

  1. Stop drain / MarkDelivered (merge gate) → fixed at the root in 2a05a7b: hooks no longer consume anywhere — no MarkDelivered in any hook path, hooks emit pending counts only, and consumption happens solely via flow inbox pop / the Monitor loop. Independent of that, Stop additionalContext is injected on Claude Code ≥ v2.1.163 (changelog: "Stop and SubagentStop hooks can now return hookSpecificOutput.additionalContext…"), and we've observed it live; documented as the minimum version in an earlier comment here.
  2. Backoff off-by-one2a05a7b: 60<<attempts, sequence 1/2/4/8/16m→30m cap, pinned by test.
  3. Pending human broadcasts accumulate2a05a7b: your kind-based option taken — the newest-1000 roll covers kind='broadcast' of any status; only pending directed messages are immortal.
  4. Overflow2a05a7b: exponent clamped before the shift; test covers attempts=1000000.

Your migration column-list nit is fixed in the latest commit. dd6dfbf also landed a second review round's fixes (ack scoped to the replier's own queue, flag-in-address and done/archived-address rejection, flow init installing the full hook set, LIKE-wildcard cleanup bug, deterministic created_at, rowid ordering, in-tx index recreation).

Fresh eyes on current head welcome.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PFGNJQo5xNBBrT7rZMuUTu

anshulsao
anshulsao previously approved these changes Sep 1, 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 — all four findings addressed in dd6dfbf, verified against the code:

  • #1 Stop-hook consumedrainTaskInbox + MarkDelivered removed; hooks inform, never consume. Fair rejection on the premise too — good call documenting the min Claude Code version (≥ v2.1.163) where Stop injects additionalContext.
  • #2 backoff60<<uint(attempts) gives the intended 1m,2m,4m,8m,16m,30m.
  • #3 broadcast pile-up — sweep now rolls broadcasts by count while keeping pending questions exempt. 👍
  • #4 overflow — shift bounded by attempts < 5, can't wrap.

Nice extras beyond the review too — ack scoped by to_assignee, exact-match watcher cleanup, deterministic created_at, rowid ordering, and flow init installing the full hook set. Ship it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PFGNJQo5xNBBrT7rZMuUTu
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