//! Wire types for the daemon ↔ stdio-MCP-bridge unix socket protocol. //! //! Same shape as `damocles-daemon`'s `DaemonRequest`/`DaemonResponse` //! (which this is forked from in spirit). Each request is a single //! JSON line; each response is a single JSON line back. The stdio MCP //! bridge holds a fresh connection per tool call — claude's tool //! lifecycle is shorter than a persistent matrix-sdk Client wants to //! live, so the daemon stays alive and the MCP reconnects per call. use serde::{Deserialize, Serialize}; /// Request envelope from the stdio MCP bridge to the daemon: which /// matrix `account` to act as, plus the operation itself. The daemon /// holds an account→Client registry (one client per declared matrix /// account) and routes `op` to the resolved client. /// /// `account` is nested rather than flattened onto [`DaemonOp`] so we /// dodge the serde "internally-tagged enum + `#[serde(flatten)]`" /// edge cases; the bridge and daemon ship together so the wire shape /// is private. Wire: /// `{"account":"ccc","op":{"method":"send_message","room":…,"body":…}}`. #[derive(Debug, Serialize, Deserialize)] pub struct DaemonRequest { /// Logical account name to act as (matches a `name` in /// `hyperhive.matrixAccounts`). `None` selects the primary account /// (the first declared one / the single legacy account), so /// single-account callers omit it entirely. #[serde(default, skip_serializing_if = "Option::is_none")] pub account: Option, /// The matrix operation to perform on the resolved account. pub op: DaemonOp, } /// The matrix operation a [`DaemonRequest`] carries. The MCP bridge /// owns the on-wire shape claude sees; this enum is the internal /// shape the daemon dispatches over. #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "method")] pub enum DaemonOp { /// Post a plain-text or markdown message to a room. `room` accepts /// either a matrix room id (`!abc:server`) or a canonical alias /// (`#name:server`); the daemon resolves aliases server-side. #[serde(rename = "send_message")] SendMessage { room: String, body: String }, /// Open (or reuse) a DM with `user_id` and post `body`. Creates /// the DM room if one doesn't already exist between this agent /// and the user. #[serde(rename = "send_dm")] SendDm { user_id: String, body: String }, /// Upload a local file and post it as an attachment to `room` /// (id or alias). `caption`, when set, is sent as a follow-up /// text message in the same room. #[serde(rename = "send_file")] SendFile { room: String, path: String, caption: Option, }, /// Resolve (find-or-create) the DM room with `user_id` and return /// its room id, without sending anything. Lets a caller obtain the /// DM room id and then use the room-based tools (`send_file`, /// `send_message`, …) against it — so there is no per-tool `_dm` /// variant. #[serde(rename = "open_dm")] OpenDm { user_id: String }, /// React to a specific event with an emoji `key`. Matrix-spec /// `m.reaction` annotation. #[serde(rename = "send_reaction")] SendReaction { room: String, event_id: String, key: String, }, /// Reply to `event_id` in `room` with `body` as a threaded reply. /// Sets the `m.in_reply_to` relation so matrix clients render the /// thread. #[serde(rename = "send_reply")] SendReply { room: String, event_id: String, body: String, }, /// Mark `event_id` (in `room`) as read for this agent. Sends a /// read receipt; bumps the room's "unread" indicator down on /// matrix clients (and for other agents). #[serde(rename = "mark_read")] MarkRead { room: String, event_id: String }, /// Redact `event_id` in `room` — ask the homeserver to strip the /// event's content (matrix-spec `m.room.redaction`), optionally with /// a human-readable `reason`. The agent must have a high enough power /// level (its own events, or moderator rights for others'); the /// server rejects otherwise. #[serde(rename = "send_redact")] SendRedact { room: String, event_id: String, reason: Option, }, /// List rooms the agent has joined. Returns each room's id + /// canonical alias (when present) + name + member count. #[serde(rename = "list_rooms")] ListRooms, /// List the members of a room. Each entry carries the matrix /// user id + the resolved display name (when set). #[serde(rename = "list_room_members")] ListRoomMembers { room: String }, /// Read the last `limit` events from a room's timeline. Caller /// gets each event's id, sender, `server_ts`, type, and body (best- /// effort plain-text extraction from `m.text` / `m.notice` etc.). #[serde(rename = "read_room")] ReadRoom { room: String, limit: Option }, /// Download the media attachment carried by `event_id` in `room` /// and write it to a local file (`dest_path`, or a temp file named /// after the attachment when omitted), returning the path. The /// read-side counterpart of `send_file`. #[serde(rename = "download_file")] DownloadFile { room: String, event_id: String, dest_path: Option, }, /// List rooms this agent has been invited to but not yet joined. /// Returns each room's id, canonical alias (when present), and /// display name. #[serde(rename = "list_invites")] ListInvites, /// Join a room by id (`!abc:server`) or alias (`#name:server`). /// Accepts a pending invite if one exists; also joins public rooms /// the agent hasn't been explicitly invited to. After joining the /// room will appear in `list_rooms`. #[serde(rename = "join_room")] JoinRoom { room: String }, /// Resolve a pending invite to `room` (id or alias) by either /// accepting it (join) or rejecting it (decline + leave). For rooms /// you were *invited* to; `join_room` is the path for joining a /// public room you weren't invited to. #[serde(rename = "resolve_invite")] ResolveInvite { room: String, action: InviteAction }, /// Invite `user_id` (`@user:server`) to `room` (id or alias). The /// calling agent must already be a member with a high enough power /// level to invite. Idempotent-ish: inviting an already-joined or /// already-invited user surfaces the matrix error from the server. #[serde(rename = "invite_user")] InviteUser { room: String, user_id: String }, /// Return the count of rooms with unread notifications. Used by /// the harness `get_loose_ends` to surface unread matrix activity /// without exposing message content. #[serde(rename = "unread_count")] UnreadCount, /// Return per-room unread summaries. For rooms with exactly one /// unread notification, attempts to include the sender + truncated /// body; rooms with multiple unreads carry only the count. Used by /// `get_loose_ends` and the wake-signal formatter. #[serde(rename = "unread_summary")] UnreadSummary, /// List the matrix accounts the daemon currently has a live, /// restored session for. Account-agnostic (does not resolve a single /// client — handled before client resolution in `socket::dispatch`): /// returns each restored account's name, homeserver, user id, primary /// flag, and a `live` flag. Backs the dashboard's per-account status /// (BE-4) — turns BE-1's token-present list into true online/offline /// + backfills the homeserver BE-1 leaves null. #[serde(rename = "list_accounts")] ListAccounts, /// Liveness probe — fast "are you up?" round-trip that doesn't /// touch matrix-sdk. Not used by the in-tree stdio MCP bridge /// (which surfaces a daemon-down condition as a normal tool-call /// connect error); reserved for external clients that want an /// explicit health check without doing real work. #[serde(rename = "ping")] Ping, } /// Whether to accept or reject a pending invite in /// [`DaemonRequest::ResolveInvite`]. Serialises as `"accept"` / /// `"reject"` on the wire. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum InviteAction { /// Accept the invite — join the room (same effect as `join_room`). Accept, /// Reject the invite — decline it and leave the room. Reject, } /// One entry in the [`DaemonRequest::UnreadSummary`] response payload. #[derive(Debug, Serialize, Deserialize)] pub struct RoomUnread { /// Canonical alias (`#name:server`) or room id (`!id:server`). pub label: String, /// Server-side push-notification count for this room. Always ≥ 1. pub count: u32, /// Truncated body of the last message in the room. Present only when /// `count == 1` and the fetch succeeded; absent otherwise. #[serde(skip_serializing_if = "Option::is_none")] pub last_body: Option, /// Sender of the last message (`@user:server`). Present when /// `last_body` is present. #[serde(skip_serializing_if = "Option::is_none")] pub last_sender: Option, } /// Response shape: `ok` carries the payload (any JSON; the MCP bridge /// passes it back to claude as the tool result), `error` carries a /// human-readable error string. #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum DaemonResponse { Ok { payload: serde_json::Value }, Error { message: String }, } impl DaemonResponse { /// Convenience: build an Ok response from any serializable value. /// Serialisation failure (impossible for the small handler structs /// we use, but the API is generic) surfaces as a structured /// `{"serialise_error": "..."}` payload so callers see WHY the /// data is missing instead of a silent `null`. pub fn ok(payload: &T) -> Self { let payload = serde_json::to_value(payload) .unwrap_or_else(|e| serde_json::json!({ "serialise_error": e.to_string() })); Self::Ok { payload } } /// Convenience: build an Error response from any `Display` value. pub fn error(msg: impl std::fmt::Display) -> Self { Self::Error { message: msg.to_string(), } } } #[cfg(test)] mod tests { use super::*; #[test] fn envelope_round_trips_with_account() { let req = DaemonRequest { account: Some("ccc".to_owned()), op: DaemonOp::SendMessage { room: "!r:s".to_owned(), body: "hi".to_owned(), }, }; let line = serde_json::to_string(&req).unwrap(); // account + nested tagged op present on the wire. assert!(line.contains("\"account\":\"ccc\""), "wire: {line}"); assert!(line.contains("\"method\":\"send_message\""), "wire: {line}"); let back: DaemonRequest = serde_json::from_str(&line).unwrap(); assert_eq!(back.account.as_deref(), Some("ccc")); matches!(back.op, DaemonOp::SendMessage { .. }); } #[test] fn envelope_defaults_account_to_none_and_omits_it() { // Single-account callers send no `account`; it must default to // None and not appear on the wire (skip_serializing_if). let req = DaemonRequest { account: None, op: DaemonOp::ListRooms, }; let line = serde_json::to_string(&req).unwrap(); assert!( !line.contains("account"), "wire should omit account: {line}" ); let back: DaemonRequest = serde_json::from_str(&line).unwrap(); assert!(back.account.is_none()); matches!(back.op, DaemonOp::ListRooms); } #[test] fn send_redact_round_trips_with_reason() { let req = DaemonRequest { account: None, op: DaemonOp::SendRedact { room: "!r:s".to_owned(), event_id: "$e".to_owned(), reason: Some("spam".to_owned()), }, }; let line = serde_json::to_string(&req).unwrap(); assert!(line.contains("\"method\":\"send_redact\""), "wire: {line}"); let back: DaemonRequest = serde_json::from_str(&line).unwrap(); match back.op { DaemonOp::SendRedact { room, event_id, reason, } => { assert_eq!(room, "!r:s"); assert_eq!(event_id, "$e"); assert_eq!(reason.as_deref(), Some("spam")); } other => panic!("wrong variant: {other:?}"), } } #[test] fn send_redact_round_trips_without_reason() { let req = DaemonRequest { account: None, op: DaemonOp::SendRedact { room: "!r:s".to_owned(), event_id: "$e".to_owned(), reason: None, }, }; let line = serde_json::to_string(&req).unwrap(); let back: DaemonRequest = serde_json::from_str(&line).unwrap(); match back.op { DaemonOp::SendRedact { reason, .. } => assert_eq!(reason, None), other => panic!("wrong variant: {other:?}"), } } #[test] fn unit_variant_op_parses_inside_envelope() { // A bare op with no fields still parses when wrapped. let parsed: DaemonRequest = serde_json::from_str(r#"{"op":{"method":"unread_count"}}"#).unwrap(); assert!(parsed.account.is_none()); matches!(parsed.op, DaemonOp::UnreadCount); } #[test] fn list_accounts_parses_as_account_agnostic_unit_variant() { // Registry-wide op: no fields, and callers omit `account`. let parsed: DaemonRequest = serde_json::from_str(r#"{"op":{"method":"list_accounts"}}"#).unwrap(); assert!(parsed.account.is_none()); matches!(parsed.op, DaemonOp::ListAccounts); // And it serialises back to the same tagged shape. let line = serde_json::to_string(&DaemonRequest { account: None, op: DaemonOp::ListAccounts, }) .unwrap(); assert!( line.contains("\"method\":\"list_accounts\""), "wire: {line}" ); } }