Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,8 +538,8 @@ successful durable inbox delivery.
Direct messages start or enter a recipient turn, so they consume transcript
context. Batch related details and reserve direct delivery for action, a
blocker, or a needed reply. Use an unmentioned room broadcast for FYI/progress.
Automatic body previews are bounded (2,000 characters per message and an
8,000-character hook-batch budget); the complete durable message remains
Automatic body previews are bounded (2,000 characters per live push; a hook
shows up to 6,000 of one message within an 8,000-character batch budget); the complete durable message remains
available through `muster://messages`. Codex wakes coalesce for 30 seconds so
a burst can join the active turn.

Expand Down
15 changes: 10 additions & 5 deletions crates/daemon/src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,18 @@ use myko::{
use serde_json::Value;

use muster_entities::{
AckMessages, CONTEXT_BODY_MAX_CHARS, GetAllSessions, GetAllWakeWatchers, HostInfo, MessageId,
MessageView, ReadMessages, Session, SessionId, WakeWatcher, WakeWatcherId, context_preview,
nickname_for,
AckMessages, GetAllSessions, GetAllWakeWatchers, HostInfo, MessageId, MessageView,
ReadMessages, Session, SessionId, WakeWatcher, WakeWatcherId, context_preview, nickname_for,
};

const AUTO_INBOX_BODY_BUDGET_CHARS: usize = 8_000;
const AUTO_INBOX_MIN_BODY_CHARS: usize = 256;
/// The most of one body a hook shows. Higher than the live push's
/// `CONTEXT_BODY_MAX_CHARS`, because a hook's block has room: a lone long DM
/// (a team summary, a review) arrives whole instead of cut at 2,000 and
/// needing a resource read (rally log §1.3). A batch still splits the
/// 8,000-char budget, and the block stays under Claude's 10,000-char limit.
const HOOK_BODY_MAX_CHARS: usize = 6_000;
/// The most an inbox block may be. Claude Code takes at most 10,000 characters
/// from a hook, and SessionStart puts the identity line in front of the block.
const INBOX_BLOCK_MAX_CHARS: usize = 9_000;
Expand Down Expand Up @@ -806,9 +811,9 @@ fn surface_unread(cmd_ctx: &CommandContext, sid: &str) -> (String, Vec<MessageId
// Automatic injection is deliberately bounded. The durable Message keeps
// the complete body; a hook carries only a UTF-8-safe preview so one log
// dump cannot monopolize the recipient's model context. Divide an 8k body
// budget across this batch, never exceeding the per-message live-push cap.
// budget across this batch, at most `HOOK_BODY_MAX_CHARS` each.
let per_message_chars = (AUTO_INBOX_BODY_BUDGET_CHARS / result.messages.len())
.clamp(AUTO_INBOX_MIN_BODY_CHARS, CONTEXT_BODY_MAX_CHARS);
.clamp(AUTO_INBOX_MIN_BODY_CHARS, HOOK_BODY_MAX_CHARS);
// Mail that came over a link names its sender as `nickname@link`, which is
// also how to answer it (MUS-66).
let message_links: Vec<Arc<muster_entities::MessageLink>> = cmd_ctx
Expand Down
23 changes: 21 additions & 2 deletions crates/daemon/tests/hook_inbox_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ fn stored_message_surfaces_via_prompt_submit_hook() {

// Automatic delivery carries a bounded, UTF-8-safe preview. The complete
// durable Message remains queryable even after the preview is acknowledged.
let long_body = format!("LONG-{}-TAIL", "é".repeat(3_000));
let long_body = format!("LONG-{}-TAIL", "é".repeat(7_000));
SendMessage {
response_required: false,
to_session_id: SessionId(Arc::from("recipient")),
Expand All @@ -270,7 +270,7 @@ fn stored_message_surfaces_via_prompt_submit_hook() {
"hook did not identify the bounded preview: {bounded:?}"
);
assert!(
bounded.chars().count() < 3_000,
bounded.chars().count() < 7_000,
"automatic inbox context exceeded its per-message bound"
);
let after_bounded = http_post(addr, "/hook/prompt-submit", r#"{"session_id":"recipient"}"#);
Expand All @@ -279,6 +279,25 @@ fn stored_message_surfaces_via_prompt_submit_hook() {
"bounded preview re-surfaced after successful hook write"
);

// A lone DM well past the live push's 2,000-char preview arrives whole in a
// hook (rally log §1.3): a team summary shouldn't need a resource read.
let summary = format!("SUMMARY-{}-END", "x".repeat(4_000));
SendMessage {
response_required: false,
to_session_id: SessionId(Arc::from("recipient")),
body: summary.clone(),
as_session: None,
idempotency_key: None,
}
.execute(cmd_ctx(&ctx, Some("c-sender")))
.expect("send persists summary");
let whole = http_post(addr, "/hook/prompt-submit", r#"{"session_id":"recipient"}"#);
assert!(
whole.contains(&summary),
"a lone 4,000-char DM was cut: {whole:?}"
);
assert!(!whole.contains("[truncated"), "{whole:?}");

// a wrong /hook path must 404 (the misroute that caused the outage).
let mut s = TcpStream::connect(addr).unwrap();
s.write_all(
Expand Down
2 changes: 1 addition & 1 deletion docs/hook-delivery.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Marshal's channel push. Every claim was checked against the code at
| Idle session wakes | yes, the notification starts a turn | yes, through the `wait` hook (below) |
| Busy session sees it | yes, as a channel event | at the next tool call (`PostToolUse`) or prompt (`UserPromptSubmit`) |
| Marked read when | the push is handed to the shim's WebSocket client (`send_message.rs`, the dedupe after `push_to_client`) | the hook output has been written back (prompt hooks), or the waiter has printed it and acked (`/hook/wait`) |
| Message body in context | a bounded preview in `meta.body` (2,000 chars) | a bounded preview in the `<muster_inbox>` block: at most 20 messages, an 8,000-char budget split between them (min 256, max 2,000 each) |
| Message body in context | a bounded preview in `meta.body` (2,000 chars) | a bounded preview in the `<muster_inbox>` block: at most 20 messages, an 8,000-char budget split between them (min 256, max 6,000 each, so a lone long message arrives whole) |
| Room broadcasts | not pushed; ambient | not surfaced; ambient. Only direct mail is injected |

Both paths share the daemon, the durable `Message` store and the read
Expand Down
6 changes: 6 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,12 @@ Merged and needing a live run: MUS-36 and MUS-37.
idle recipient is woken only when `wake` is `expected`. It is said once per shim, since #62
dropped the per-send note to keep tool output small. The statusline's `no live channel` warning
is unchanged: it still shows only when no wait hook is wired, the one case where it's a fault.
- [x] **MUS-102: A lone long DM arrives whole in a hook.** From the DiscoSim rally (2026-11-06,
issues log §1.3): team summaries arrived cut at 2,000 characters, each needing a resource read.
Done: a hook shows up to 6,000 characters of one message (`HOOK_BODY_MAX_CHARS`), still within
the 8,000-character batch budget and Claude's 10,000-character hook limit; the live push keeps
its 2,000. Not done: a `read_message(id)` tool, and §10.1 (mail marked read that the model
never saw), which needs its own look at when a hook's ack fires.
- [ ] **MUS-111: `setup.sh --from-source` builds a pinned release, not the default branch.** Filed by
robust-warbler: `--from-source` ran `cargo install --locked --git` on the unpinned default branch.
Done when: a build takes the latest release's tag by default, looked up once so it's the same
Expand Down
Loading