Skip to content

chore: merge upstream Marshal v0.58.0 into Muster (MUS-97, Step 0) [parked] - #180

Draft
Castor-o7 wants to merge 19 commits into
mainfrom
chore/upstream-v0.58-catchup
Draft

Castor-o7 wants to merge 19 commits into
mainfrom
chore/upstream-v0.58-catchup

Conversation

@Castor-o7

Copy link
Copy Markdown
Owner

Status: parked (draft). Conflicts are all resolved and the workspace builds with clippy -D warnings clean, but full verification isn't finished. Per frank-crocodile, the read and the merge freeze are the next rally's first job. Before merging: bring in current Muster main (#173 and anything since), then finish the checks below.

Merges upstream ignition-is-go/marshal 9b00c6f (v0.58.0; #131–#141, #147, #164, #165, #166) into Muster main. Nothing goes to ignition-is-go or Castor-o7/marshal. Conflicts were resolved with #84's (MUS-62) rules: Muster's names everywhere, upstream's additions taken, and one coherent design where both sides fixed the same thing.

Conflicts resolved

Build, CI, docs, TUI, UI

File(s) Resolution
Cargo.toml, crates/tui/Cargo.toml, crates/ui/Cargo.toml Kept Muster's crate names, workspace version, metadata and [patch.crates-io] (MUS-59 vendored myko-server). Upstream's only dependency changes are in the shim (socket2 dev-dep, the claude_hook.rs package exclude).
Cargo.lock Regenerated after the merge.
crates/tui/src/main.rs #[command(name = "muster-tui", version)]: Muster's name with upstream's --version (#135).
crates/ui/src/console.rs Muster's harness/identity fields on the console Session, plus upstream's agent_id: None.
.github/workflows/ci.yml Muster's jobs kept as they are (GitHub-hosted, unpinned-by-policy actions, gate/links/restart e2e, conformance, macOS, Windows cross-check with the cached debs, dependency audit). Added upstream's windows-native-wake job on GitHub-hosted windows-2025, renamed to muster-shim. Upstream's self-hosted-only steps (resource reports, actionlint, workflow validator) aren't taken.
scripts/ci/windows-isolation.ps1, scripts/ci/test_windows_isolation.ps1 Came in from upstream for that job; the marker file and test env-var prefix renamed to Muster's. Upstream's other scripts/ci/* helpers come in unused, like the sandbox in #84.
.github/workflows/release.yml Muster's MUS-14 release workflow (pinned SHAs, no cache, macOS arm64 + Linux x86_64 with SHA256SUMS). Upstream's flux/self-hosted publish pipeline isn't taken.
.github/workflows/public-ci-acceptance.yml, public-ci-red-team.yml Stay deleted (Muster dropped upstream's self-hosted pipeline in 7d2c5d7).
plugins/marshal-shim/* Stays deleted. Upstream's new hooks/hooks.json is identical to Muster's plugins/muster-shim/hooks/hooks.json apart from the name, and Muster's README already documents the hooks.
README.md Muster's Claude Code setup section (already documents the hooks, asyncRewake wait, statusline). Licence: Muster's wording with upstream's precise list of AGPL dependencies (myko, myko-macros, autosocket, myko-server, myko-leptos), in Muster's crate names. Inbox-block paragraph: upstream's sentence on the 10,000-character hook-block limit, remaining and escaping, since the merged hooks.rs takes #164's budget and escaping.
docs/install.md Muster's (the page points at the README; upstream's text describes the old socket/SQLite design).

crates/daemon, crates/entities

File(s) Resolution
crates/{daemon,entities}/Cargo.toml Muster: names, version.workspace, rust-version, metadata. Upstream changed only the version (0.54.1 → 0.58.0), so there are no dependency bumps to take.
entities/src/lib.rs Muster: keeps the succession and wake_pause modules.
entities/src/message.rs Upstream's to_session_id doc (#131 reload/resume wording). Muster's operator@example.com example. truncated_notice (new from upstream) points at muster://messages.
entities/src/message_read.rs Upstream's #131 docs (no session cascade; sweep_orphan_reads), with muster-pi. The behaviour is the same on both sides: Muster's MUS-46 had already dropped the session cascade.
entities/src/session.rs Muster's acks_pushes doc (MUS-81, plugin wording) plus harness/identity. Took upstream's agent_id field and tests (#166), and its RegisterSession command plus update_registered_session. That command is harmless; see Open questions.
entities/src/broadcast_message.rs Both imports (body_too_large, and MessageRead for #140's acks_pushes skip). Muster's room_label(&room.id.0) with upstream's truncated-id argument on mention_push_content. Took upstream's truncated-preview test, with Muster's email literal and muster://.
entities/src/send_message.rs Muster's comments and logic (MUS-71 pause-aware WakeStatus::Expected via controls::wakes_paused(), MUS-81). Took upstream's direct_push_content(…, truncated_id) tests, with operator@example.com and muster://.
entities/src/wake_watcher.rs (AA) Muster's file (gate-written doc, is_observed/explain and their test). Added agent_id: None to its Session literal.
daemon/src/main.rs Muster base: subcommand CLI, TLS/gate, build_server persister overrides (WakePause, RemoteSession), serve_until_stopped with the MUS-58 ready gate. Dropped the duplicate STOP_DRAIN/stop_signal that upstream's side reintroduced. Layered upstream #135 on top. -V/--version prints muster-daemon X. --foreground is accepted ahead of a bare or --tls run. Any other argument after it, or any non-UTF-8 argument (via args_os), is refused with exit 2, muster-daemon: unknown argument: … and Try 'muster-daemon --help'. (this used to be an anyhow bail with exit 1). --help now prints usage(): name and version, Usage: muster-daemon …, Muster's command list (USAGE), options, and environment (MUSTER_BIND, MUSTER_HOOK_BIND, MUSTER_TLS, MYKO_POSTGRES_URL, RUST_LOG). USAGE still begins usage: for subcommand errors.
daemon/tests/cli_flags.rs (upstream A) Renamed to muster-daemon/MUSTER_*. It also sets MUSTER_STATE_DIR to a fresh dir under CARGO_TARGET_TMPDIR and removes MUSTER_TLS, so the --foreground run never reads the operator's real pairings, pause or tokens.
daemon/src/http_listener.rs Muster base: Access (local token + machine_may_act, MUS-7), run_bound, conn limiter, MUS-10 pause on /hook/wait, X-Muster-Restarting. Took upstream's ack_header() (#164 boundary fix: a JSON id list, DEL escaped) as X-Muster-Ack-V1, which matches the merged shim's link.rs. While every id is plain ([A-Za-z0-9._:-]), it also sends the old comma-separated X-Muster-Ack, for hook clients from before V1 and scripts/test-gate-e2e.sh. Every hook response, refusals included, goes through write_response_with, which always sends Content-Length. I added 401 Unauthorized and 503 Service Unavailable reason phrases (they used to say "OK"). The gate's own 426 refusals (gate.rs) already carry Content-Length.
daemon/src/hooks.rs Inbox rendering and leasing: upstream #164 is the base. That means the UTF-16 budget (AUTO_INBOX_OUTPUT_BUDGET_UTF16), peer_text_within/peer_handle/peer_attr (escape & < >, every line-break class, drop invisible and bidi characters), the per-line escape-aware cut, the 192 KiB ack-header cap, and leasing only the messages shown (so no release step is needed). Layered from Muster: MUS-66 link-aware sender (nickname@link via MessageLink/RemoteSession); MUS-86 reoffer log; the MUSTER_ACK_LEASE_SECS env var; session-start ensure_assigned (MUS-3) and succession::take_notice, now with upstream's peer_attr on the nickname; with_known_platform and attested (gate-attested operator and host). register_hook_session now goes through upstream's update_registered_session (a serialised read-modify-write, with agent_id from the body) carrying Muster's attestation, harness and identity logic. That is a direct emit_set, not the RegisterSession command. Muster's quote_body/quoted_preview and its char-count budget (INBOX_BLOCK_MAX_CHARS) are replaced by upstream's equivalents. The Muster tests for them were dropped, since upstream's renamed tests cover the same cases, and a_message_body_cannot_forge_inbox_framing was kept and moved onto peer_text_within. truncation_notice uses upstream's shared truncated_notice text.
daemon/src/cleanup.rs Muster base (MUS-58 sweep_memberships, kept-session reaping, boot tests). Took upstream's sweep_orphan_reads in sweep_messages and its test. Merged the MESSAGE_TTL/sweep_messages docs: MUS-46, plus the orphan-read sweep that replaces "read rows go at next boot".
daemon/tests/send_message_command.rs Kept Muster's pause tests (pause_state, the paused-waiter and paused-push tests, the pull-waiter test). Added upstream's a_recipient_with_a_waiter_expects_a_wake, holding pause_state(false). Muster's idempotency_key, harness and identity literals stay.
daemon/tests/send_message_e2e.rs, broadcast_message_command.rs, ws_reconnect_subscription.rs Muster names, with Muster's literal fields plus upstream's agent_id.
daemon/tests/hook_inbox_e2e.rs Upstream imports (MessageRead and friends for the #131 tests), renamed. The truncation assertion now uses upstream's [truncated; full message text with muster://.
daemon/tests/hook_client_ack.rs (AA) Muster's harness (run_until(.., None, ..), MUSTER_ACK_LEASE_SECS, idempotency_key). Took upstream's V1 ack_ids parser and its four tests: injection round-trip, undisplayed backlog, oversized id, concurrent readers. Added the_legacy_ack_header_is_sent_only_for_plain_ids.
daemon/tests/hook_wait_e2e.rs (AA) Muster's file (session-end-keeps-mail, hostile-body and full-inbox tests) plus upstream's agent_id. The hostile-body assertion now expects &lt;/muster_inbox&gt;, because upstream escapes > as well.
daemon/tests/hook_inbox_framing.rs, hook_inbox_safety.rs, session_del_inbox_e2e.rs (upstream A, not conflicted) Renamed to Muster names. Added harness/identity and idempotency_key: None so they build. hook_inbox_framing and hook_inbox_safety call muster_entities::rate::set_enabled(false) in setup: they send more than the 20-message burst, and plain-mode Muster has no send limits either (MUS-64).
Muster-only files that hold Session literals (cleanup.rs, succession.rs, auto_rooms.rs, entities/send_message.rs tests, and tests/{identity_settle,identity_succession,pause_and_allow,rate_limit,read_messages_recipient,redaction,remote_send}.rs) agent_id: None added so they build. cleanup.rs tests: dropped Muster's duplicate set_read/read_ids helpers, which are equivalent to the ones upstream's test brought in. cargo fmt applied to both crates.

crates/shim

File(s) Resolution
crates/shim/Cargo.toml Muster's package block (name, workspace version, metadata). Upstream's exclude = ["tests/claude_hook.rs"] and socket2 dev-dep came in cleanly.
src/link.rs (add/add) Base: Muster (pairing, pinned TLS, tunnel, redeem_invite, https_post_why, and PostFailure with Certificate/NotTls/426 and its MUS-83 wording). Layered from upstream #164: MAX_RESPONSE_BYTES, response_length, framed read_reply (stops at Content-Length, one deadline), and the strict parse_response: the body must be exactly its Content-Length, no Transfer-Encoding, one ack header, no control characters, no acks on an empty body. A body short of Content-Length is rejected and nothing is acked from it. Ack header is now X-Muster-Ack-V1: <JSON array> (upstream's X-Marshal-Ack-V1, renamed); restarting header X-Muster-Restarting. connect_any takes a deadline (upstream), but Muster's 5 s per-attempt cap stays (a paired gate is across a network). https_post_why now connects by a deadline and caps the TLS read at MAX_RESPONSE_BYTES + 1; its reply still goes through the strict parse_response. Tests: all of upstream's (renamed), Muster's not-a-gate TLS test, plus two new ones: 401/403/426 + Certificate are refusals, and the old comma-separated X-Muster-Ack is ignored.
src/codex_hook.rs Base: Muster (pairing/TLS routing, identity_q MUS-3, HookRoute MUS-18, MUS-22 operator line, MUS-38 nickname cache, MUS-7 local token, MUSTER_* env). Layered from upstream: one deadline per request (http_post_why(.., deadline), resolve_within, write loop, link::read_reply); TIMEOUT 5 s → 3 s (fits Codex SessionEnd's 3 s); registration_body with agent_id from MUSTER_AGENT_ID (trimmed, body only, never the query) on every hook, the wait and session-register; acquire_waiter_lock with the parent check; poll_timeout/default_lock_wait (70 s, was 35 s); deliver returns io::Result and write_watch_events so a closed Monitor/hook pipe leaves mail unacked; resolve_base now uses the shim's own order (file, then env) via crate::unpaired_daemon_address(); Muster's previous env-first order was a mismatch with the MCP path. New glue: hook_pairing and post_why (pairing over TLS, else plain, both by deadline). Hook ack: its own harness.timeout() budget (Muster had used 10 s; upstream reused the request's remaining deadline). Upstream's new unit tests added.
src/main.rs Base: Muster (dispatch for tell/all/roster/doctor/setup/pair/pairing/unpair, async daemon_address() with pairing/TLS and refusal::Credential, with_local_token, MUS-3 harness/identity, MUS-26, MUS-38, wake_pause/remote_sessions cells). From upstream: -h/--help, -V/--version (muster-shim <ver>), unknown argument … (try 'muster-shim --help'); usage(), rewritten to list every Muster subcommand, the pairing, muster/daemon-address, MUSTER_DAEMON_ADDRESS, MYKO_ADDRESS, 6255 and MUSTER_BASE_URL; first_daemon_address (pure, tested) behind a new sync unpaired_daemon_address(), which Muster's daemon_address() now uses in its unpaired branch; #140 forward_push (acks a new_message/mention push with AckMessages once written) and acks_pushes: (!is_codex).then_some(true); agent_id: codex_hook::agent_id_from_env() on the Session; the claude-hook wait comment. A duplicate wake_watchers_cell from upstream was dropped. Not taken: upstream's RegisterSession/DeleteSession commands in emit_session_set/emit_session_del, so Muster's raw Session SET/DEL events stay (see Open questions).
src/tools.rs Muster's (muster_entities, wake_pause/remote_sessions cells, MUS-3 declared_role/harness on the roster), with upstream's agent_id on whoami/roster and pub(crate) await_command, which merged cleanly. Upstream's new codex_identity_metadata_comes_only_from_the_authoritative_row test now builds a ToolHost with Muster's extra cells and uses muster:// URIs.
src/channels.rs Muster's. Upstream's hunk re-added macos_reads_the_parent_cmdline, which Muster already has (duplicate).
src/codex_bridge.rs Muster's test session() literal (harness/identity, plus upstream's agent_id: None, which merged cleanly). Upstream's message() test helper was dropped: its only callers are tests Muster replaced with the MUS-77/94 per-path watcher.
tests/claude_hook.rs (add/add) Upstream's file renamed to Muster's names. It's a superset of Muster's: it adds MUSTER_AGENT_ID checks, APPDATA isolation, and the boundary tests (incomplete reply, trickled reply, closed Monitor pipe, oversized reply, waiter takeover, agent_id only in the JSON body). Their raw frames use X-Muster-Ack-V1.
tests/codex_bridge_e2e.rs Muster's env (MUSTER_BASE_URL, MUSTER_HOST, muster_shim=debug) plus upstream's agent id as MUSTER_AGENT_ID; Muster's Session literal.
tests/claude_push_ack_e2e.rs, tests/cli_flags.rs (new from upstream) Renamed to Muster's names (muster-shim, muster_entities, MUSTER_*, plugin:muster-shim@muster). cli_flags also checks that --help names claude-hook, watch, tell, all, roster, doctor, setup, pair, pairing, unpair and MYKO_ADDRESS.

plugins

File(s) Resolution
muster-pi/src/inbox-pull.ts, test/inbox-pull.test.ts (AA) Muster's (MUS-81), whole file. It's upstream #136's InboxPuller plus holdPush/PushHold, per-hold leases and holdLeaseFromEnv. That makes it a superset: upstream's file has no push holds and no lease. Muster's tests are upstream's 8 plus 10 for holds and leases.
muster-pi/src/live-push.ts (AA) Muster's deliverLivePush (hold, inject, commit or release; pull fallback acked once shown). Upstream's version (#134: inject, then ack(message_id), with a drainInbox fallback that acks before showing) is replaced. livePushText now comes from upstream's inbound.ts and is re-exported, so Muster's tests still import it from here.
muster-pi/src/inbound.ts, test/inbound.test.ts (UA) Upstream's file (#132), with muster_messages. The doc comment now says the full body stays in the durable store, because with acksPushes the daemon no longer marks a push read when it delivers it. Test comment adjusted the same way.
muster-pi/test/live-push.test.ts (AA) Muster's tests, plus three of upstream's cases ported to the hold/pull fake: the reply requirement is kept, the truncation notice leaves the inbox alone, and a push with no body and an empty inbox injects nothing. Upstream's drainInbox/ack fake tests are dropped, because they test the replaced mechanism.
muster-pi/src/daemon.ts Muster's: InboxPuller with leaseMs: holdLeaseFromEnv(), plus holdPush. Upstream's drainInbox is dropped (nothing uses it now). Took upstream's agentId on Session (auto-merged). MUS-90's everConnected reconnect wake is unchanged.
muster-pi/src/index.ts Muster's imports and live-push target (hold/pull). Took upstream #147's before_agent_start prompt guard (const prompt, pushMusterStatus). Muster's muster-inbox customType. agent_id in whoami and roster came through the auto-merge.
muster-pi/src/entities.ts Muster's message_id doc comment plus upstream's CONTEXT_BODY_MAX_CHARS body comment.
muster-pi/test/{before-agent-start,embedding-agent}.test.ts (UA) Upstream's tests (#147, #166), renamed.
muster-opencode/src/index.ts Both. MUS-90's claimShown guard at the top of the push handler, then upstream #137: pushText() (flags truncated previews) and no drain after the prompt (it acked messages without showing them). The reconnect wake (everConnected, a body-less notify after the re-SETs) is in daemon.ts, which upstream didn't conflict with. A body-less notify still goes through drainInbox, and drainInboxInner still uses claimShown. The comment explains how the two fit: chat.message's drain for that prompt acks the pushed id but doesn't show it again.
muster-opencode/src/entities.ts Muster's message_id comment (MUS-90).
muster-opencode/README.md Upstream's sentence (adds "a live push through the plugin itself…"), with Muster's names.
muster-opencode/src/push.ts, test/push.test.ts, test/embedding-agent.test.ts (UA) Upstream's (#137, #166), renamed.
muster-prime-agent/src/inbox-pull.ts, test/inbox-pull.test.ts (UA) Upstream's (#136), plus one public method, InboxPuller.holds(sessionId, id), so a push can see that a pull holds a message.
muster-prime-agent/src/daemon.ts One design: upstream's pullInbox (ack only once shown) with MUS-90's shown-id guard built into it. drainInbox is gone. claimShown(sid,id) returns false if a push or a committed pull already showed the id, or if an outstanding pull holds it. The puller's read is readUnshown: it acks ids that were already shown without showing them again, and returns the rest. The puller's ack (called on commit) records the ids as shown before it acks. A released pull claims nothing, so its mail stays unread for the next pull. Took upstream's INBOX_COMMAND_TIMEOUT_MS and sendForInbox (bounded inbox commands). MUS-90's everConnected reconnect wake is unchanged. Its body-less notify goes to the pull fallback in index.ts.
muster-prime-agent/src/index.ts Upstream's: pullForTurn per turn, and the body-less (reconnect) fallback injects, then commits, or releases on failure. Muster's claimShown check on body pushes and its inline truncation notice are kept (auto-merged).
muster-prime-agent/test/daemon.test.ts (AA) Muster's three MUS-90 tests moved from drainInbox to pullInbox+commit. Upstream's "returns at once while disconnected" test moved from node:test to bun's expect. Two new tests: a push skips a message a pull holds, and a released pull neither acks nor claims.
muster-prime-agent/test/embedding-agent.test.ts (UA) Upstream's (#166), renamed.
plugins/marshal-shim/* Not touched (out of scope).

Decisions to confirm in the read

Verification so far (Murph, at 18d5195)

  • cargo build --workspace and cargo clippy --workspace --all-targets -- -D warnings: clean. cargo fmt --check: clean.
  • cargo test -p muster-entities -p muster-daemon: 260/260 (resolution agent, before integration).
  • Plugins under bun 1.3.14: pi 46 pass / 1 skip / 0 fail, opencode 23 / 1 / 0, prime-agent 17 / 1 / 0.
  • Workspace cargo test: see the follow-up comment (a first run was invalid because HOME was overridden).
  • Not yet done: gate e2e and restart e2e (the summaries weren't captured), cargo deny (not installed on Murph), Zoo trunk build, the conformance grid, and a merge of current main.
  • links e2e: 30 passed, 17 failed. Not yet checked against main to see whether these failures are new. This must be settled before merging.

Not done here

  • Murph isn't touched. Going live needs the Lamplighter's preflight and a daemon restart once this merges.
  • The port/* rebases and re-inventory (Step 0, part 3).

🤖 Generated with Claude Code

lobo235 and others added 19 commits November 5, 2026 13:20
* test(pi): add failing repro for truncated live pushes

The daemon clips a live push's body to CONTEXT_BODY_MAX_CHARS (2000) and
sets body_truncated, but marshal-pi injects the preview as if it were the
whole message.

Move the push text from onInboundMessage into src/inbound.ts unchanged so
it can be tested without a pi session or a daemon, and add cases for a
truncated push. The two truncated cases fail.

* fix(pi): say when a live push body was truncated

Declare message_id and body_truncated on NotifyChannelMeta, which the
daemon already sends with every push. When body_truncated is set, append
the same notice marshal-prime-agent uses, naming the message id, so the
agent knows to read the rest with marshal_messages.

The issue suggested filling in the full body from the inbox drain, but
send_message marks a live-delivered message read, so the unread drain
never returns it. Pulling the full body in would also undo the 2000-char
cap the daemon applies on purpose.
…136)

The per-turn pull raced `drainInbox` against a 2 s timeout, and
`drainInbox` acked what it read before returning. When the timeout won
after the ack went out, those messages were marked read and their text
was dropped.

`pullInbox` now reads without acking and returns the text with
`commit()` and `release()`. The turn hook commits a pull once its text
is in the turn; a pull that lands after the deadline is released, so
its messages stay unread for the next turn. Messages an outstanding
pull holds are not handed out by another one. prime-agent's no-body
live-push fallback injects first and commits after. marshal-pi keeps
`drainInbox` (pull and commit in one step) for its live-push path.

prime-agent's inbox read and ack now go through a bounded send, as
marshal-pi's already do: a pull returns at once while Marshal is
disconnected, and a read that never answers times out instead of
holding up every later pull for the session.
…runcated previews (#137)

After prompting a live push, the push handler called `drainInbox` and
dropped what it returned. The prompt's own `chat.message` hook has
already shown whatever was unread when the turn started, so the drain
could only find messages that turned unread while the turn ran, such as
a direct message whose push failed when the client dropped mid-turn,
and it acked them without showing them. It is the same pattern as #133
in marshal-pi. The drain is gone; the next turn's `chat.message` pull
delivers those messages.

The push body is a bounded preview. When the daemon cut it
(`body_truncated`), the prompt now says so and names the message to
read with `marshal_messages`, as prime-agent already does.
* test(pi): add failing repro for before_agent_start on custom-message runs

* fix(pi): don't assume a prompt in before_agent_start

pi 1.2.0 fires before_agent_start for runs started by
pi.sendMessage(..., { triggerTurn: true }) too. Those events have
messageType "custom" and no prompt, so the status handler threw
"Cannot read properties of undefined (reading 'length')" on every live
push. Skip the status update for a run without a prompt; it keeps the
status the last prompt set, as it did before pi 1.2.0.
…rshal-tui (#135)

* feat(daemon): answer --help and --version, refuse unknown arguments

The daemon never read its arguments, so `marshal-daemon --help` or a
mistyped flag started a real daemon. It now prints help or its version
and exits, and refuses any other argument, a non-UTF-8 one included,
with exit 2 before anything starts. `--foreground`, which
docs/install.md told people to pass, stays accepted on its own as a
no-op.

* feat(shim): answer --help and --version

`--help` lists the subcommands and how the shim finds the daemon, and
`--version` prints the version. Both answer before the runtime starts,
like `--check`. An unknown argument now points at `--help`.

* feat(tui): answer --version

clap already generates --help; declaring the version adds --version and -V.
* test(pi): add failing repro for truncated live pushes

The daemon clips a live push's body to CONTEXT_BODY_MAX_CHARS (2000) and
sets body_truncated, but marshal-pi injects the preview as if it were the
whole message.

Move the push text from onInboundMessage into src/inbound.ts unchanged so
it can be tested without a pi session or a daemon, and add cases for a
truncated push. The two truncated cases fail.

* fix(pi): say when a live push body was truncated

Declare message_id and body_truncated on NotifyChannelMeta, which the
daemon already sends with every push. When body_truncated is set, append
the same notice marshal-prime-agent uses, naming the message id, so the
agent knows to read the rest with marshal_messages.

The issue suggested filling in the full body from the inbox drain, but
send_message marks a live-delivered message read, so the unread drain
never returns it. Pulling the full body in would also undo the 2000-char
cap the daemon applies on purpose.

* test(pi): add failing repro for the drain after a live push

After injecting a live push, onInboundMessage calls drainInbox and throws
the result away. drainInbox acks every unread direct message it reads, and
the daemon has already marked the pushed message read, so the drain only
ever finds other messages, and those get acked without being shown.

Move the delivery out of onInboundMessage into src/live-push.ts unchanged
so it can be tested with a fake inbox. The case with another unread
message waiting fails.

* fix(pi): don't drain the inbox after a live push

The daemon marks a live-delivered message read, so the drain that followed
the push could only find other unread messages. It acked them and dropped
the rendered text, and the next prompt's inbox no longer had them.

Inject the pushed body and stop there, as marshal-prime-agent does. A push
without a body (older daemons) still drains the inbox and injects it.

* test(pi): a truncated live push keeps its notice without draining

* fix(pi): ack a live-pushed message once it is injected

The drain after a push used to ack the pushed message as a side effect.
A direct push is already read when the daemon delivers it, but a room
@mention is not, so without the drain it would show up again in the
next inbox. Ack the pushed message alone after injecting it.

* test(pi): preserve reply requirements in live pushes

* fix(pi): retain reply requirement in live push text

---------

Co-authored-by: max <max@lucid.rocks>
* fix(entities): make read-state sticky across a session reload

A pi session's id is stable across exit / /reload (sha1 of the session
file), so a session DEL is a temporary disconnect, not a permanent one.
MessageRead.session_id was a belongs_to(Session) cascade, so every reload
DEL'd the Session and cascade-DEL'd the acks, while the direct Messages
survived (their to_session_id cascade is silently unwired). On
re-registration the whole backlog was unread again and the pull-inbox
re-injected it (the 20-message dump of already-seen DMs).

Decouple the ack's lifetime from the session: it now follows the message
(belongs_to Message) only, so an already-read DM stays read across a
reload. Room messages are unaffected (hidden by the fresh joined_at on
re-join). Regression test: session_del_inbox_e2e.

* fix(daemon): sweep read marks whose message is gone

The TTL prune deletes messages with del_by_id, which skips relationship
cascades, so a pruned message's MessageRead rows stayed behind. Until
now a session DEL cleaned them up; with read state no longer tied to
the session, nothing did. The message sweep now also drops every read
mark whose message no longer exists.

* fix(entities): don't cascade a direct message with its recipient session

Myko 6.8.0 makes `#[belongs_to(Session)]` a real cascade, so a session
DEL now deletes every direct message addressed to it. A session row is
DEL'd and re-SET under the same id on a pi /reload, a resume, or a
reaped pull session's next hook, so its unread direct messages were
being deleted instead of waiting for it. Make `to_session_id` a plain
id; the TTL sweep still bounds the store. `to_room_id` keeps its
`belongs_to(Room)` cascade: rooms aren't re-created under the same id.

The session-DEL test now also asserts the direct message survives the
DEL, which the read check alone would not catch. On main it fails with
messages=0 after the DEL.

* test(inbox): update session resume command fixture

* test(inbox): preserve HTTP hook coverage across session resume

---------

Co-authored-by: max <max@lucid.rocks>
* docs(readme): say which dependencies are AGPL

Marshal's own source is MIT OR Apache-2.0, but it depends on myko,
myko-macros, autosocket and myko-server, which declare AGPL-3.0-or-later.
The licence section now says so, so anyone shipping binaries knows to
assess the combined work.

Ported from Castor-o7/muster #48 (3f8553e), only the disclosure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit b123c22)

* docs(readme): list myko-leptos among the AGPL dependencies

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
)

* feat(entities): let a session ack its own live pushes

`SendMessage` marks a direct message read as soon as its NotifyChannel
push is queued on the recipient's connection. If that connection stops
being read and then dies (a laptop going to sleep, a hung process), the
push is lost and the message is already read, so no inbox pull shows it
again.

A session can now set `acks_pushes`. For such a session the daemon
leaves a pushed message unread, and the client acks it once it has shown
it. A push lost on the way then comes back in the next pull. Sessions
that don't set it keep the current behavior.

* fix(shim): ack a live push only once it has been written to Claude

The Claude Code shim now registers with `acks_pushes`, so the daemon
leaves a direct push unread, and acks it itself after the MCP writer has
written and flushed the channel notification. A push the shim never
hands to Claude (stdout gone, connection that stopped being read) stays
unread for the next inbox pull instead of being marked read when the
daemon queued it.

`Notifier::channel_written` returns a receiver the writer task fulfils
after the flush; a failed write drops it, so nothing is acked. @mention
pings are acked the same way: they are direct messages too, so an
unacked one would come back in the per-turn inbox after the channel had
already shown it.

* test: specify reply requirements in push acknowledgment fixtures

* test: preserve session resume coverage with push acknowledgments

---------

Co-authored-by: max <max@lucid.rocks>
…141)

* feat(entities): let a session ack its own live pushes

`SendMessage` marks a direct message read as soon as its NotifyChannel
push is queued on the recipient's connection. If that connection stops
being read and then dies (a laptop going to sleep, a hung process), the
push is lost and the message is already read, so no inbox pull shows it
again.

A session can now set `acks_pushes`. For such a session the daemon
leaves a pushed message unread, and the client acks it once it has shown
it. A push lost on the way then comes back in the next pull. Sessions
that don't set it keep the current behavior.

* fix(shim): ack a live push only once it has been written to Claude

The Claude Code shim now registers with `acks_pushes`, so the daemon
leaves a direct push unread, and acks it itself after the MCP writer has
written and flushed the channel notification. A push the shim never
hands to Claude (stdout gone, connection that stopped being read) stays
unread for the next inbox pull instead of being marked read when the
daemon queued it.

`Notifier::channel_written` returns a receiver the writer task fulfils
after the flush; a failed write drops it, so nothing is acked. @mention
pings are acked the same way: they are direct messages too, so an
unacked one would come back in the per-turn inbox after the channel had
already shown it.

* feat(shim): pull the inbox into Claude Code turns with plugin hooks

The daemon's per-turn inbox (`/hook/session-start`, `/hook/prompt-submit`)
only reached Codex: the Claude plugin shipped no hooks and the docs never
mentioned them, so a message the live channel didn't show -- every
message in a session started without the channels flag, for one -- sat
unread until the agent happened to read `marshal://messages`.

`marshal-shim claude-hook <session-start|prompt-submit>` is the Codex hook
bridge for Claude Code, whose hooks take the same JSON in and out; it
sends `harness=claude` instead of `harness=codex`, so the session block
doesn't tell the agent to pass `asSession`. The plugin ships it as
`hooks/hooks.json`, and the READMEs give the same block for hand-wired
setups.

* fix(entities): say when a live push's preview was cut

A push carries a bounded preview in `meta.body`; when the daemon cut it,
the line the model reads (`content`) said nothing, so an agent could act
on half a message. Both direct and @mention pushes now append the notice
the hook inbox already used, naming the message to read in full. The
notice lives in one place, `truncated_notice`, shared with the hook inbox.

* fix(daemon): escape, cap and dedupe the per-turn hook inbox

The inbox the session hooks inject is now what Claude sessions see before
every prompt, so three gaps matter more than they did:

- Message bodies were copied in raw. A body could close `</marshal_inbox>`
  or start a line reading `- from <someone else>:` and pass for the
  daemon's own framing. Peer text (bodies, the operator address) is now
  escaped (`&amp;`, `&lt;`, `&gt;`). Every character a reader may take as
  a line end (`\n`, VT, FF, NEL, U+2028, U+2029) starts an indented
  continuation line, and other controls, bidi overrides, zero-width and
  tag characters are dropped. Handles (the sender's nickname, the
  operator address, the session's own nickname) are cut at 128 units, and
  the session's nickname is escaped as an attribute value: the stores
  accept any SET.
- The block had no overall size limit. Claude Code injects hook context
  whole only up to 10,000 characters and swaps a longer one for a file
  path and a short preview, yet every message in it was acked. Rendering
  now stops at 9,000 UTF-16 units, measured after escaping, which can grow
  a body fivefold. No single message may outgrow that budget, so the first
  one, which always goes in, still fits. Only what was rendered is acked,
  and the rest stays unread, counted in `remaining`, for the next turn.
- An @mention ping is a direct message, but a delivered live push of one
  wasn't marked read the way a direct send is, so the hook showed it a
  second time. It is now.

Codex sessions get the same escaping and size limit.

* fix(shim): bound a hook request at 3 s and find the daemon like the MCP path

A hook holds the agent's turn (Claude's UserPromptSubmit holds the
prompt). The request had a 5 s connect timeout plus a 5 s timeout per read,
so a daemon that was unreachable or stalled held every prompt for 5 s or
more. It now has one 3 s deadline from name lookup to last byte (the
lookup runs on a helper thread, since getaddrinfo has no timeout), a 1 s
connect timeout per resolved address (all are tried), and a 256 KiB
response cap.

Without an explicit base URL, the hook derived its address from
`MARSHAL_DAEMON_ADDRESS` before the address file, the reverse of the MCP
shim, so the two could reach different daemons and register the same
session on both. Both now use `daemon_address()`: file, then
`MARSHAL_DAEMON_ADDRESS`, then `MYKO_ADDRESS`, then localhost.

* fix(daemon): escape the message id in the hook inbox's truncation notice

Bodies and handles in <marshal_inbox> are escaped, but the id in the
truncation notice went in raw. SendMessage always mints a UUID, so only
a client SETting a Message directly could choose one, but such an id
could close the inbox block. Escape it like a handle.

* test: specify reply requirements in push acknowledgment fixtures

* test(inbox): retain mentions until the client acknowledges them

* fix(inbox): defer mention receipts for clients that acknowledge pushes

* test: preserve session resume coverage with push acknowledgments

---------

Co-authored-by: max <max@lucid.rocks>
…#139) (#164)

* feat(entities): let a session ack its own live pushes

`SendMessage` marks a direct message read as soon as its NotifyChannel
push is queued on the recipient's connection. If that connection stops
being read and then dies (a laptop going to sleep, a hung process), the
push is lost and the message is already read, so no inbox pull shows it
again.

A session can now set `acks_pushes`. For such a session the daemon
leaves a pushed message unread, and the client acks it once it has shown
it. A push lost on the way then comes back in the next pull. Sessions
that don't set it keep the current behavior.

* fix(shim): ack a live push only once it has been written to Claude

The Claude Code shim now registers with `acks_pushes`, so the daemon
leaves a direct push unread, and acks it itself after the MCP writer has
written and flushed the channel notification. A push the shim never
hands to Claude (stdout gone, connection that stopped being read) stays
unread for the next inbox pull instead of being marked read when the
daemon queued it.

`Notifier::channel_written` returns a receiver the writer task fulfils
after the flush; a failed write drops it, so nothing is acked. @mention
pings are acked the same way: they are direct messages too, so an
unacked one would come back in the per-turn inbox after the channel had
already shown it.

* feat(shim): pull the inbox into Claude Code turns with plugin hooks

The daemon's per-turn inbox (`/hook/session-start`, `/hook/prompt-submit`)
only reached Codex: the Claude plugin shipped no hooks and the docs never
mentioned them, so a message the live channel didn't show -- every
message in a session started without the channels flag, for one -- sat
unread until the agent happened to read `marshal://messages`.

`marshal-shim claude-hook <session-start|prompt-submit>` is the Codex hook
bridge for Claude Code, whose hooks take the same JSON in and out; it
sends `harness=claude` instead of `harness=codex`, so the session block
doesn't tell the agent to pass `asSession`. The plugin ships it as
`hooks/hooks.json`, and the READMEs give the same block for hand-wired
setups.

* fix(entities): say when a live push's preview was cut

A push carries a bounded preview in `meta.body`; when the daemon cut it,
the line the model reads (`content`) said nothing, so an agent could act
on half a message. Both direct and @mention pushes now append the notice
the hook inbox already used, naming the message to read in full. The
notice lives in one place, `truncated_notice`, shared with the hook inbox.

* fix(daemon): escape, cap and dedupe the per-turn hook inbox

The inbox the session hooks inject is now what Claude sessions see before
every prompt, so three gaps matter more than they did:

- Message bodies were copied in raw. A body could close `</marshal_inbox>`
  or start a line reading `- from <someone else>:` and pass for the
  daemon's own framing. Peer text (bodies, the operator address) is now
  escaped (`&amp;`, `&lt;`, `&gt;`). Every character a reader may take as
  a line end (`\n`, VT, FF, NEL, U+2028, U+2029) starts an indented
  continuation line, and other controls, bidi overrides, zero-width and
  tag characters are dropped. Handles (the sender's nickname, the
  operator address, the session's own nickname) are cut at 128 units, and
  the session's nickname is escaped as an attribute value: the stores
  accept any SET.
- The block had no overall size limit. Claude Code injects hook context
  whole only up to 10,000 characters and swaps a longer one for a file
  path and a short preview, yet every message in it was acked. Rendering
  now stops at 9,000 UTF-16 units, measured after escaping, which can grow
  a body fivefold. No single message may outgrow that budget, so the first
  one, which always goes in, still fits. Only what was rendered is acked,
  and the rest stays unread, counted in `remaining`, for the next turn.
- An @mention ping is a direct message, but a delivered live push of one
  wasn't marked read the way a direct send is, so the hook showed it a
  second time. It is now.

Codex sessions get the same escaping and size limit.

* fix(shim): bound a hook request at 3 s and find the daemon like the MCP path

A hook holds the agent's turn (Claude's UserPromptSubmit holds the
prompt). The request had a 5 s connect timeout plus a 5 s timeout per read,
so a daemon that was unreachable or stalled held every prompt for 5 s or
more. It now has one 3 s deadline from name lookup to last byte (the
lookup runs on a helper thread, since getaddrinfo has no timeout), a 1 s
connect timeout per resolved address (all are tried), and a 256 KiB
response cap.

Without an explicit base URL, the hook derived its address from
`MARSHAL_DAEMON_ADDRESS` before the address file, the reverse of the MCP
shim, so the two could reach different daemons and register the same
session on both. Both now use `daemon_address()`: file, then
`MARSHAL_DAEMON_ADDRESS`, then `MYKO_ADDRESS`, then localhost.

* fix(daemon): escape the message id in the hook inbox's truncation notice

Bodies and handles in <marshal_inbox> are escaped, but the id in the
truncation notice went in raw. SendMessage always mints a UUID, so only
a client SETting a Message directly could choose one, but such an id
could close the inbox block. Escape it like a handle.

* feat: deliver to and wake Claude Code without the channels flag

Claude Code launched without --dangerously-load-development-channels drops
`notifications/claude/channel`. On macOS the shim also couldn't read its
parent's command line, so it reported channels_enabled = unknown, and the
daemon pushed, got "delivered", and marked the mail read. Nothing pulled the
inbox either (#139). This adds a hook path that needs no flag:

- `marshal-shim claude-hook session-start|prompt-submit|post-tool-use`, wired
  by plugins/marshal-shim/hooks/hooks.json, pulls unread direct mail into the
  turn as a <marshal_inbox> block (2 s timeout, silent on failure).
- `marshal-shim claude-hook wait`, an asyncRewake hook on SessionStart and
  Stop, holds `/hook/wait` and exits 2 with the mail on stderr, which starts a
  turn in an idle session. A per-session lock keeps one waiter. It acks with
  `/hook/ack` only after printing (`ack=client`, `X-Marshal-Ack`), so a lost
  ack means a duplicate, not a loss. `marshal-shim watch [--once]` is the
  same loop for Claude's Monitor tool.
- The daemon leases surfaced mail for 60 s (MARSHAL_ACK_LEASE_SECS), so two
  readers don't both take it. It records a `WakeWatcher` while a wait is
  held (in memory only, swept with its session), reports `wake: expected`
  for such a session, and shows `wake` on the roster. On SIGTERM it ends held
  waits with `X-Marshal-Restarting`.
- On macOS the shim reads its parent's argv with `ps`, so a flagless session
  reports channels_enabled = false and is no longer pushed to. Without it the
  detection is always unknown on a Mac, the daemon pushes into a session that
  drops the notification, and marks the mail read.
- Smaller fixes: the shim tries every resolved address (localhost may
  resolve to ::1 first); long Monitor lines are kept whole; the truncation
  notice names a URI the resource serves; the shim re-registers when its
  roster row lost its client binding.

The hooks are on for every plugin user: the plugin loads hooks/hooks.json.
The plugin README says how to run without them (the MCP server wired
directly loads no hooks).

Unchanged from v0.54.1: if the hook listener can't bind or stops, the
daemon logs `[hook] listener exited: …` and keeps running. The hooks then
find no listener and give up quietly within their timeout.

Built on upstream main (94ecc24): the inbox line is upstream's, plus the new
truncation notice.

Security: none claimed. The hook listener stays unauthenticated, exactly as
in v0.54.1, and `/hook/ack` and `/hook/wait` trust the session_id they're
given, like the existing hook routes. Keep MARSHAL_HOOK_BIND on loopback or
a trusted network.

Ported from Castor-o7/muster as the end state of these changes, renamed
muster->marshal (scripts/rename-to-marshal.sh), with every gate, durability
and other change stripped. Source commits, by Muster PR:
  37731f2 (pre-fork) claude-hook + hooks.json + macOS cmdline
  #1  0ec6d21 /hook/wait + watch; 090b2a9 connect_any
  #4  b0e4643 long Monitor lines      #5  8214bb4 watch --once
  #6  f1ea3c5 asyncRewake waiter; 7d4fc53 its test
  #21 5f550ee why a watcher can't reach the daemon (its exit on a
    stopped hook listener isn't ported)
  #22 328b3b2 ack only after hand-off (leases, /hook/ack, ack=client)
  #23 f69e8a6 refusal notice on the watcher's path
  #37 1c9fedf re-register a row with no client binding
  #39 2faeaa8 + f0b0ca9 WakeWatcher, roster `wake`, wake: expected
  #42 64bcaf5 stop reporting a wait that handed out mail
  #43 4436966 WakeWatcher not persisted
  #45 d803287 + 21d80fb truncation notice
  #52 fa5cda5 connect_any tests (socket2 dev-dependency)
  #56 9069f6f + a939bf2 end held waits on SIGTERM

Dropped from those files (not Hooks):
  Gate: link.rs pairing/TLS/tunnel/invite and PostFailure::Certificate;
    the pairing branch in channels.rs daemon_address() and in every hook
    POST; the local-token header; http_listener Access, token and machine
    checks, Origin/upgrade refusal, ConnLimiter (#74); wakes_paused in
    /hook/wait and the roster (#76); hooks.rs attested() and the machine
    override (#16), and with_known_platform (#24 6a20210, a no-op without
    attestation); gate_filter
    entries (#39); pair/pairing/unpair subcommands; log redaction (#69);
    the 6157 default address.
  Durability: idempotency_key (#32), the clean-session-end mail test (#57).
  Other: terminal send (#19), roster command (#40), rustfmt-only churn,
    doc-address and README changes outside the hook section.

Tests: cargo test --workspace passes (claude_hook 10, hook_wait_e2e 7,
hook_inbox_e2e 1); clippy -D warnings clean; fmt clean;
MARSHAL_TEST_SHIM=<this shim> python3 -m unittest discover -s scripts/sandbox
passes (19). Not exercised by Zoo: every Claude-only path above.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(hooks): a prompt hook acks its mail only after printing it

The listener marked prompt-hook mail read once it had flushed the reply,
so a reply lost after that (a claude-hook killed at its timeout, a dropped
proxy hop) lost the mail.

/hook/prompt-submit and the other inbox hooks now take `ack=client`, as
/hook/wait does: the ids come back in `X-Marshal-Ack`, nothing is marked
read, and the lease keeps the mail from being offered twice until it
lapses. The shim's hook command (Claude and Codex) asks for it, prints the
context and flushes, then POSTs /hook/ack; if the print fails it doesn't
ack, so the mail comes again. A caller that doesn't ask keeps the daemon's
ack after the flush.

Ported from Castor-o7/muster #99 (71483ca), renamed muster->marshal.
Dropped (Gate): the pinned-TLS branch of the hook POST and the removal of
link::https_post, which this tree doesn't have. Dropped (Muster docs):
docs/hook-delivery.md.

Tests: hook_client_ack (new; fails without the listener change), plus the
workspace tests and the sandbox unittest with this shim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: report wake expected only when a waiter can wake the session

SendMessage told a sender `wake: expected` for any recipient with a live
client, while the roster's wake_state counts a client only with channels
on. So a Claude session whose MCP shim was connected without the channels
flag, and with no waiter, read `wake: none` on the roster and `wake:
expected` to the sender, though nothing could wake it.

Now a send says expected only when a waiter holds /hook/wait for the
recipient, matching the roster's `waiting`. The client_id clause dates from
when Codex's MCP shim registered a session; today it registers none, and
the TS plugins register with channels on and get the live push.

Ported from Castor-o7/muster #97 (14c2b25, MUS-71), renamed
muster->marshal. Dropped (Gate): the pause clause and its two tests, as this
tree has no pause. Dropped (Muster docs): hook-delivery.md, the roadmap and
the conformance suite's known-failure entry.

Tests: send_message_command gains a connected channels-off recipient with
no waiter (unobserved; fails on the old rule) and a recipient with a
waiter, connected or not (expected). send_message_e2e's channels-off case
now asserts unobserved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(hooks): log client acks and lease re-offers

When mail went unread after a hook, the daemon's log couldn't say whether a
reader had acked it or the lease had lapsed and it was offered again. Now:

- `/hook/ack` logs `[hook] <session>: client acked N message(s)`.
- An offer of mail whose lease lapsed unacked logs `[hook] <session>:
  offering N message(s) again: the last reader didn't ack within the N s
  lease`. A lapsed lease is remembered for an hour (FORGET_LAPSED) so the
  re-offer can be told from a first offer; mail that was acked and released
  isn't a re-offer.

Ported from Castor-o7/muster #127 (f008007, MUS-86), renamed
muster->marshal. Dropped (Gate): its gate e2e lines. Dropped (Muster docs):
the roadmap.

Tests: the lease tests now check the re-offer count, plus a lapsed lease
remembered and then forgotten, and an acked message that isn't a re-offer.
cargo test --workspace passes (193); clippy -D warnings and fmt clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* eval: Zoo campaign for this PR (gpt-6-luna, 3 repeats, 18 trials), baseline 94ecc24 vs 62552e6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(daemon): drop a stale "opt-in" from the hook listener comment

The hooks are on by default; the comment said opt-in from an earlier draft.
Comment only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(entities): any client can write a WakeWatcher without the gate

myko-server applies a client's SET with no per-entity check, so the hook
listener isn't the only writer. Say so, and that the row is a record, not
proof.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(entities): word the WakeWatcher note for upstream readers

Upstream has no gate, so drop "without the gate". The note also argued
WakeWatcher is separate because a Session row is only a claim, which a
client-writable WakeWatcher doesn't escape. Say plainly what it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(daemon): a held /hook/wait doesn't count as activity

Each wait re-registered the session and set last_activity_at, and a waiter
asks again each time one ends, so an idle session looked active. Operator
mail went to whichever session polled last, and the stale-session sweep never
reaped it. A wait now leaves last_activity_at alone, and a session a wait
registers starts with none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: specify reply requirements in push acknowledgment fixtures

* fix(daemon): a peer's body can't forge the inbox framing

Bodies went into the <marshal_inbox> block raw, so one holding
</marshal_inbox> or a newline and "- from X [response_required=...]:"
could close the block or pose as another entry. Escape & and <, drop control
characters, and indent continuation lines; sender and operator names too.

Cap the block at 9,000 characters, under Claude Code's 10,000-character hook
limit with room for the SessionStart identity line: twenty escaped previews
could pass it. Mail that doesn't fit is released, not acked, and comes in
the next hook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(daemon): the hook listener's wait loop doesn't spin under run()

run() dropped its watch sender at once, so every held wait's
stopping.changed() returned immediately and the loop busy-polled the inbox.
Keep the sender for the listener's lifetime. Drop a stale issue cite, and say
in the plugin README what an older shim without claude-hook prints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(inbox): retain mentions until the client acknowledges them

* fix(inbox): defer mention receipts for clients that acknowledge pushes

* Validate framed hook replies and acknowledge only successful bounded delivery

* Cover partial replies failed sinks and safe concurrent inbox acknowledgments

* Preserve canonical preview notice while linking to complete message history

* Align preview regression with shared canonical truncation text

* Keep resumed Claude waiters alive through orphaned poll takeover

* test: preserve session resume coverage with push acknowledgments

* Allow coarse socket timeout scheduling during waiter takeover

* test(shim): allow fallback connection attempts their full budget

* test(shim): isolate Windows waiter configuration

---------

Co-authored-by: lobo235 <7@netlobo.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: max <max@lucid.rocks>
* ci: preserve workflow validation and runner qualification

Add local workflow validation and best-effort resource reports from the #113 follow-up. Keep #91's Windows acceptance recipe manual and #125's compiler-cache qualification separate from production builds.

* feat(identity): propagate embedding-agent correlation metadata

Carry optional MARSHAL_AGENT_ID through authoritative hooks, shim registration,
native plugins, and roster/whoami output. Serialize hook/shim registration on
the server so an absent shim environment cannot erase hook metadata. Keep
session IDs, operator attribution, routing, acknowledgment, and leases separate.

Preserves the optional Session/roster capability proposed in closed #104,
original feature commit 86ee0aa. Tests cover hook/bridge/wait transport,
concurrent writers, old payloads, MCP output, exact legacy fallback, and all
three native plugins reconnecting to a restarted daemon.

Co-authored-by: Pulse Agent [session:01a05607-1623-7db0-8ca1-79abef182283] <agent+session-01a05607-1623-7db0-8ca1-79abef182283@agents.rship.io>
Co-authored-by: lucid-max <max@lucid.rocks>

* test(ci): run native embedding registration checks

---------

Co-authored-by: Pulse Agent [session:01a05607-1623-7db0-8ca1-79abef182283] <agent+session-01a05607-1623-7db0-8ca1-79abef182283@agents.rship.io>
Takes upstream ignition-is-go/marshal main at 9b00c6f (v0.55 to v0.58:
#131-#141, #147, #164, #165, #166) into Muster main. Conflicts were
resolved file by file; the per-file resolutions are in the PR body.

In short: Muster's names, crate versions, gate (MUS-59), links (MUS-66),
identity (MUS-3), wake watcher (MUS-77/94), acks and leases (MUS-75/81)
and MUS-90 plugin reconnect wake are kept. Upstream's --help/--version,
agent_id, sticky read state (#131), #164's hook inbox budget, escaping and
strict ack framing (X-Muster-Ack-V1 with a Content-Length), the native
Windows CI job, and the plugins' show-before-ack fixes are taken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Castor-o7

Copy link
Copy Markdown
Owner Author

Workspace tests at 18d5195 (Murph)

cargo test --workspace --no-fail-fast, with the config and state dirs isolated: 453 passed, 1 failed.

The failure is upstream's crates/shim/tests/claude_push_ack_e2e.rs::ordinary_shim_preserves_hook_metadata_and_exposes_it_in_resources: left: None, right: Some("agent/\"x\nopaque"). The shim's Session lost the agent_id that a hook wrote.

Cause: this is the trade-off already listed under Decisions to confirm. Muster's shim keeps raw Session SET/DEL, because the gate refuses upstream's RegisterSession, which is what merges hook-written metadata in upstream. A raw SET without agent_id overwrites it.

Options for the read:

  1. Allow RegisterSession/DeleteSession in gate_filter.rs and adopt upstream's registration.
  2. Have the shim carry over a hook-written agent_id (and harness metadata) when it SETs.
  3. Mark this test as a known difference.

This needs a decision before merging. Raw log: ~/src/muster-catchup-notes/ws-tests-18d5195.log on Murph.

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.

3 participants