//! Wire types shared between the Maestro daemon and its clients (CLI, TUI, web). //! //! Maestro is single-host: one daemon owns every session on the machine, and //! clients talk to it directly over `/api/*`. There is no registry, no peer //! federation, and no authentication layer — reaching the daemon's bind address //! *is* the authorization, same trust model as tmux itself. #![deny(missing_debug_implementations)] #[cfg(feature = "client")] pub mod client; pub mod display; pub mod routes; #[cfg(feature = "client")] pub use client::{ClientError, DaemonClient}; use std::collections::HashMap; use std::path::PathBuf; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; /// The protocol version this crate implements. Advertised in `GET /api/info`. pub const PROTOCOL_VERSION: &str = env!("CARGO_PKG_VERSION"); /// True when a daemon advertising `daemon_version` speaks the same protocol as /// this build. /// /// Compared on the major component only, per semver: within a major, added /// fields are `#[serde(default)]` and so are forward-compatible. An empty or /// unparseable version is treated as compatible — daemons that predate the /// field should not trip a warning. pub fn is_compatible(daemon_version: &str) -> bool { fn major(v: &str) -> Option<&str> { let m = v.split('.').next()?; (!m.is_empty() && m.bytes().all(|b| b.is_ascii_digit())).then_some(m) } match (major(daemon_version), major(PROTOCOL_VERSION)) { (Some(theirs), Some(ours)) => theirs == ours, _ => true, } } /// A live or recently-dead tmux session managed by the daemon. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Session { pub id: Uuid, pub cwd: PathBuf, pub command: String, #[serde(default)] pub env: HashMap, #[serde(default)] pub metadata: HashMap, /// The worktree this session owns, when it was spawned with one. /// /// Persisted rather than kept in memory only: after a daemon restart the /// recovered session must still know what to clean up, and with /// deterministic worktree paths a forgotten association leaves a directory /// that blocks that branch from ever being spawned again (#54 review). #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree: Option, pub status: SessionStatus, pub created_at: DateTime, /// Current foreground command in the session's pane (e.g. `claude`, /// `bash`), as tmux reports it — distinct from the `command` it was spawned /// with. Daemon-derived (NAP-340); `None` until the daemon's activity /// watcher has observed it. All three activity fields default on the wire so /// older daemons/payloads stay forward-compatible. #[serde(default)] pub foreground: Option, /// Inferred activity dimension, orthogonal to [`SessionStatus`] /// (NAP-340). Derived from structured tmux/`/proc` state, never from the /// byte stream. #[serde(default)] pub activity: ActivityState, /// Short human-readable string an agent reported alongside its activity /// (#105) — e.g. why it is in `Error`. Shown on a sidebar card's meta line /// while the report is in force; cleared when the report lapses or is /// replaced. `None` unless an agent reported one. #[serde(default)] pub activity_detail: Option, /// Wall-clock time of the session's most recent observed PTY output. /// Drives "idle for Ns" displays. Defaults to the Unix epoch on payloads /// from daemons that predate the field. #[serde(default = "epoch")] pub last_activity_at: DateTime, /// Wall-clock time at which the session entered its current /// [`ActivityState`] (#102) — how long it has been `Active`, `Idle` or /// `AwaitingInput`. Distinct from [`Self::last_activity_at`], which only /// tracks the last observed PTY output. Populated by #104; defaults to the /// Unix epoch on payloads from daemons that predate the field. #[serde(default = "epoch")] pub activity_since: DateTime, /// The pane's *current* working directory (`#{pane_current_path}`) — distinct /// from [`Session::cwd`], the directory it was spawned in. The activity /// watcher refreshes it as the user `cd`s around; it drives the web UI's /// directory-based session tree. `None` until first observed, and on payloads /// from daemons that predate the field. #[serde(default)] pub current_cwd: Option, /// Whether the session's worktree currently holds anything that would /// block an unforced kill (#83), summarised by [`InTheWay`]'s `Display`. /// Refreshed by the daemon's watcher; `None` for sessions without a /// worktree and until the first probe. Defaults on the wire so payloads /// from daemons that predate the field stay forward-compatible. #[serde(default)] pub blocked_work: Option, } /// Default for [`Session::last_activity_at`] when an older payload omits it. fn epoch() -> DateTime { DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp") } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SessionStatus { Running, Dead, } /// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The /// daemon derives this from tmux pane state, PTY quiescence, and `/proc` /// inspection — never by parsing the terminal byte stream. /// /// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant /// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the /// whole `Session`. Non-string values still error — only variant names are /// forgiven. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)] pub enum ActivityState { /// Producing output, or the foreground process is busy. Active, /// Foreground process alive but quiescent. The forward-compatible default. #[default] Idle, /// Blocked on a `read()` from the pty — waiting on the user. AwaitingInput, /// The agent running in the session reported a failure (#102). Never /// inferred by the daemon; only set when an agent reports it (#105). Error, } impl<'de> Deserialize<'de> for ActivityState { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; Ok(match s.as_str() { "Active" => Self::Active, "AwaitingInput" => Self::AwaitingInput, "Error" => Self::Error, // Known or not, any other spelling degrades to the neutral default _ => Self::Idle, }) } } /// HTTP `POST /api/sessions/:id/activity` body (#105): an agent's report of /// its own state. The daemon holds the reported state above its own inference /// for a fixed TTL, then resumes inference. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ReportActivityRequest { /// Parsed strictly, unlike [`ActivityState`]'s own lenient `Deserialize` /// (#110). Leniency exists so a client reading a *newer* daemon's snapshot /// degrades an unrecognised state to `Idle` instead of dropping the whole /// session. A report travelling the other way has no such excuse: an agent /// posting a state this daemon does not know is a bug in the agent, and /// silently recording it as `Idle` would hide that behind a 204. #[serde(deserialize_with = "strict_activity_state")] pub state: ActivityState, /// Short human string shown next to the state (e.g. an error message). /// The daemon caps its length server-side. #[serde(default)] pub detail: Option, } /// Reject an unknown [`ActivityState`] spelling instead of degrading it to /// `Idle`. Used only by [`ReportActivityRequest`]; see the note there. fn strict_activity_state<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { use serde::de::Error as _; let s = String::deserialize(deserializer)?; match s.as_str() { "Active" => Ok(ActivityState::Active), "Idle" => Ok(ActivityState::Idle), "AwaitingInput" => Ok(ActivityState::AwaitingInput), "Error" => Ok(ActivityState::Error), other => Err(D::Error::unknown_variant( other, &["Active", "Idle", "AwaitingInput", "Error"], )), } } /// Optional worktree request attached to a `CreateSessionRequest`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorktreeSpec { pub base_repo: PathBuf, pub branch: String, /// When false, `branch` must already exist and is checked out as-is /// (#56); when true (the default, and what every payload meant before /// the field existed) the daemon creates it with `worktree add -b`. #[serde(default = "default_true")] pub create_branch: bool, } fn default_true() -> bool { true } /// The worktree a running session owns, carried on [`Session`] so it survives /// a daemon restart. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionWorktree { pub path: PathBuf, pub base_repo: PathBuf, } /// What a kill refusal reports about a worktree (#80) — the breakdown that /// blocks an unforced kill, recomputed on every probe so it reflects the tree /// now. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct InTheWay { /// Tracked files with uncommitted modifications. #[serde(default)] pub uncommitted_files: usize, /// Untracked files that are not ignored (ignored files never block). #[serde(default)] pub untracked_files: usize, /// Commits on the branch reachable from no remote-tracking ref. #[serde(default)] pub unpushed_commits: usize, /// Lines added across the tracked changes — staged and unstaged alike, /// measured against `HEAD`. Untracked files contribute nothing: counting /// their lines means reading every one off disk, and they already have /// their own count above (docs/SIDEBAR.md). #[serde(default)] pub insertions: usize, /// Lines removed across the same tracked changes. #[serde(default)] pub deletions: usize, } impl InTheWay { /// Nothing blocks any more — the work has since been committed and pushed. /// /// The line counts are not consulted: they describe the same tracked /// changes `uncommitted_files` counts, and a change with no line delta — /// a mode change, a rename — still blocks. pub fn is_clear(&self) -> bool { self.uncommitted_files == 0 && self.untracked_files == 0 && self.unpushed_commits == 0 } } /// Which fragment of the compact blocked-work stat a value is, and so which /// colour the client paints it (docs/SIDEBAR.md). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StatKind { /// Lines added. Added, /// Lines removed. Removed, /// Changed tracked files, when the change carries no line delta. Files, /// Untracked files. Untracked, /// Unpushed commits. Unpushed, } impl InTheWay { /// The breakdown as diff notation: `+124`, `-18`, `?3`, `↑2`. /// /// The line counts cover tracked changes only, so a tree whose only /// tracked change carries no line delta — a mode change, a rename — /// reports the file count (`~2`) rather than claiming `+0 -0`. Both /// sidebars render exactly these fragments; [`Display`](std::fmt::Display) /// remains the spelled-out phrasing for the kill refusal and the tooltip. pub fn stats(&self) -> Vec<(StatKind, String)> { let mut out = Vec::new(); if self.insertions > 0 || self.deletions > 0 { if self.insertions > 0 { out.push((StatKind::Added, format!("+{}", self.insertions))); } if self.deletions > 0 { out.push((StatKind::Removed, format!("-{}", self.deletions))); } } else if self.uncommitted_files > 0 { out.push((StatKind::Files, format!("~{}", self.uncommitted_files))); } if self.untracked_files > 0 { out.push((StatKind::Untracked, format!("?{}", self.untracked_files))); } if self.unpushed_commits > 0 { out.push((StatKind::Unpushed, format!("↑{}", self.unpushed_commits))); } out } } impl std::fmt::Display for InTheWay { /// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted; /// all-zero renders "no unsaved work". This is the phrasing every client /// uses for both the kill refusal and the retained list, so the two /// cannot describe the same tree differently. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let parts = [ (self.uncommitted_files, "uncommitted file"), (self.untracked_files, "untracked file"), (self.unpushed_commits, "unpushed commit"), ]; let said: Vec = parts .into_iter() .filter(|(n, _)| *n > 0) .map(|(n, unit)| { if n == 1 { format!("1 {unit}") } else { format!("{n} {unit}s") } }) .collect(); if said.is_empty() { return write!(f, "no unsaved work"); } write!(f, "{}", said.join(", ")) } } /// HTTP `POST /api/sessions` body. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CreateSessionRequest { pub cwd: PathBuf, pub command: String, #[serde(default)] pub env: HashMap, #[serde(default)] pub metadata: HashMap, #[serde(default)] pub worktree: Option, } /// Result of `GET /api/dirs`: the immediate subdirectories of one directory. /// Drives the spawn form's working-directory autocomplete (NAP-329). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DirListing { /// The resolved absolute directory that was listed, after the daemon /// expanded `~`/relative/empty inputs against its own home dir. The client /// uses it to know what an abbreviated query resolved to. pub path: PathBuf, /// Immediate subdirectory names (not full paths), sorted case-insensitively. /// Directories only — the spawn form's targets are always dirs. Hidden /// directories (dotfiles) are excluded. pub entries: Vec, /// True when `entries` was truncated at the daemon's cap; the client can /// surface "narrow your search" rather than implying the dir is small. #[serde(default)] pub truncated: bool, } /// One deduplicated recent working directory for the spawn form's "recent /// dirs" picker (#24/#26). Newest use of a `cwd`, with the command last run /// there. Mirrors the daemon-local `spawn_history::RecentDir`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecentDir { pub cwd: PathBuf, pub last_command: String, pub last_used: DateTime, } /// Result of `GET /api/recent-dirs`: recently-used working dirs, newest-first /// and deduped by `cwd` (#26). `truncated` mirrors [`DirListing`]: true when /// the daemon capped the list. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecentDirs { pub dirs: Vec, #[serde(default)] pub truncated: bool, } /// Result of a `GET /api/gitroot` probe: the enclosing git repository toplevel /// of a path, or `None` when the path isn't inside a repo (#26). Drives the /// spawn form's worktree section — a present `root` is the inferred /// `base_repo`; `None` disables the worktree toggle. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GitRoot { #[serde(default, skip_serializing_if = "Option::is_none")] pub root: Option, } /// One discovered git repository (or linked worktree) for `GET /api/projects` /// (#54). The daemon reports the timestamp; clients decide what "stale" means /// (the web UI dims entries older than 7 days). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Project { /// Absolute path of the repository/worktree toplevel. pub path: PathBuf, /// Basename of `path`. pub name: String, #[serde(default)] pub is_worktree: bool, /// The main checkout this worktree belongs to. Set only when /// [`Self::is_worktree`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub base_repo: Option, /// Later of the repo's git HEAD mtime and the newest spawn-history use of /// any directory inside it. `None` when neither is known. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_activity: Option>, /// Command last spawned in this project, from spawn history. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_command: Option, } /// One local branch of a repository for `GET /api/branches` (#55). A client /// disables the entry when `is_head` is set or `worktree` is present — git /// refuses to check the same branch out in two worktrees at once. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BranchInfo { /// Short branch name (`main`, `feature/foo`). pub name: String, /// Whether this branch is the main checkout's current HEAD. The main /// checkout's occupancy reports here rather than through `worktree`. pub is_head: bool, /// Toplevel path of the linked worktree holding this branch, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree: Option, } /// Structured failure used everywhere we don't want to leak a string-only error. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ErrorInfo { pub code: String, pub message: String, /// Set only by the kill guard (#79): the unsaved work that blocked /// `DELETE /api/sessions/:id`, so a client can render counts directly. #[serde(default, skip_serializing_if = "Option::is_none")] pub unsaved_work: Option, } /// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`. /// /// The daemon builds it, the CLI and TUI decode it to surface the daemon's own /// message, and the browser SPA uses it to recover the rejection reason from a /// failed request or WebSocket handshake. Before it /// lived here that shape was hand-rolled in four places, one of them by /// escaping quotes into a format string. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ApiError { pub error: ErrorInfo, } impl ApiError { pub fn new(code: impl Into, message: impl Into) -> Self { Self { error: ErrorInfo { code: code.into(), message: message.into(), unsaved_work: None, }, } } /// The daemon's human-readable reason, for clients that only render text. pub fn message(&self) -> &str { &self.error.message } } /// One command tile offered by the spawn form. /// /// Lives here rather than in `maestro-config` because it is on the wire inside /// [`DaemonInfo`]; `maestro-config` re-exports it so the on-disk `[spawn]` /// schema and the payload stay one type. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SpawnPreset { pub id: String, pub label: String, pub command: String, } /// Build provenance of the running daemon: the commit it was compiled /// from and when that commit was authored. /// /// Clients show it in the status indicator's tooltip, and compare `commit` /// across a reconnect to tell an upgrade apart from a plain restart — an /// upgrade means the served assets changed, so a browser reloads rather than /// resuming against new code with an old bundle. /// /// Stamped at compile time by `maestro-daemon/build.rs`, which reads the git /// checkout it is built in, or the `MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` /// environment variables when there is no checkout (the Nix build). Every field /// is empty when neither is available; clients must render that gracefully. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct BuildInfo { /// Abbreviated commit hash the binary was built from. Empty if unknown. #[serde(default)] pub commit: String, /// Commit date of `commit`, RFC 3339 (`2026-09-02T14:03:11+01:00`). Empty /// if unknown. #[serde(default)] pub commit_date: String, /// Whether the working tree carried uncommitted changes at build time. #[serde(default)] pub dirty: bool, } impl BuildInfo { /// Whether anything is known about this build. A client with `false` here /// shows the indicator without a release line rather than an empty one. pub fn is_known(&self) -> bool { !self.commit.is_empty() } } /// Payload of `GET /api/info` — daemon identity, protocol version, and the /// spawn presets clients render as one-click tiles. /// /// Previously declared three times (daemon, CLI, TypeScript), and the CLI's /// copy silently dropped `version` and `presets`. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct DaemonInfo { #[serde(default)] pub hostname: String, /// The `PROTOCOL_VERSION` the daemon implements. #[serde(default)] pub version: String, /// Home directory of the OS user the daemon runs as, so clients can prefill /// a spawn form's working directory. Empty if unresolved. #[serde(default)] pub home_dir: String, #[serde(default)] pub presets: Vec, /// Which commit this daemon was built from. #[serde(default)] pub build: BuildInfo, /// Identity of this daemon *process*, regenerated on every start. Two /// replies carrying different ids mean the daemon restarted in between — /// which is how a client that lost its socket tells a restart from a /// network blip, even when the build did not change. #[serde(default)] pub instance: String, } /// Payload of `GET /api/names?count=N` (#53) — a batch of fresh /// one-word session handles generated by the `maestro-names` crate. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NameBatch { pub names: Vec, } /// HTTP `POST /api/sessions/:id/input` body: text typed into a session's /// pane, as if the operator had typed it on an attached terminal. /// /// `submit` presses Enter after the text — what makes `maestro send` a way to /// give an agent an instruction rather than only to fill its prompt. It goes /// out as its own write a beat later, because a TUI agent that reads the text /// as one paste would otherwise swallow the newline into the message. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SendInputRequest { pub text: String, #[serde(default = "submit_default")] pub submit: bool, } fn submit_default() -> bool { true } /// Metadata key holding a session's name (#125). pub const NAME_KEY: &str = "name"; /// Metadata key holding where that name came from (#131). pub const NAME_SOURCE_KEY: &str = "name_source"; /// Where a session's [`NAME_KEY`] came from (#131), and so whether a client /// renders it as settled or provisional. /// /// Deserializes leniently, like [`ActivityState`]: an unknown spelling from a /// newer daemon reads as [`NameSource::User`], the settled variant. A name /// wrongly painted as settled is only a missed dimming; one wrongly painted /// as provisional invites the daemon to overwrite a name a person chose. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)] pub enum NameSource { /// Typed by a person — in the spawn form, or through a rename. Never /// overwritten by autonaming. The forward-compatible default, so a /// session named by a client that predates this field is left alone. #[default] User, /// The daemon's own random handle, assigned because the spawn request /// carried no name. Provisional: rendered dimmed, and the only source /// autonaming will replace. Generated, /// Derived from the session's first prompt (#131). Settled — a second /// prompt does not rename the session again. Haiku, } impl<'de> Deserialize<'de> for NameSource { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; Ok(Self::from_wire(&s)) } } impl NameSource { /// Read a stored spelling, leniently: anything unrecognised is /// [`NameSource::User`]. pub fn from_wire(s: &str) -> Self { match s { "Generated" => Self::Generated, "Haiku" => Self::Haiku, _ => Self::User, } } /// The spelling stored in `metadata`. pub fn as_str(self) -> &'static str { match self { Self::User => "User", Self::Generated => "Generated", Self::Haiku => "Haiku", } } } /// HTTP `POST /api/sessions/:id/prompt` body (#131): the first prompt an /// agent running in the session received. Agent-agnostic — Claude Code posts /// it from a `UserPromptSubmit` hook, and any other agent's hook writes the /// same shape. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionPromptRequest { pub prompt: String, } /// HTTP `POST /api/sessions/:id/focus` body: a client reports whether its /// pane is the one currently showing this session — not merely highlighted /// in a sidebar. The daemon keeps one focus clock per session, last write /// wins; a second client attached to the same session can race the first /// one's unfocus, which is accepted as a rare, harmless case (worst outcome /// is one extra tick before the glow clears). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct SetFocusRequest { pub focused: bool, } /// Longest session name the daemon stores (#125), in characters. A name is a /// sidebar card's line 1; past this it only truncates on every client anyway. pub const MAX_SESSION_NAME_LEN: usize = 64; /// HTTP `PATCH /api/sessions/:id` body (#125): rename a live session. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RenameSessionRequest { pub name: String, } /// Trim a requested session name and cap it at [`MAX_SESSION_NAME_LEN`] /// characters. `None` when nothing is left — that clears `metadata.name` and /// the card falls back to the command, the same rule the spawn form uses when /// its name field is blank. /// /// Shared so the daemon and every Rust client agree on what a name becomes; /// `web/src/types.ts` carries the same function for the browser. pub fn normalize_session_name(name: &str) -> Option { let trimmed = name.trim(); if trimmed.is_empty() { return None; } Some(trimmed.chars().take(MAX_SESSION_NAME_LEN).collect()) } // --------------------------------------------------------------------------- // Catalog events (`WS /api/events`) // --------------------------------------------------------------------------- /// Messages the daemon broadcasts to clients over `WS /api/events`. /// /// A subscriber receives a single `Snapshot` immediately on connect, then a /// stream of deltas as sessions are created, updated, and killed. Skipped /// events (broadcast channel lag) surface as `Lagged`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type")] pub enum CatalogEvent { Snapshot { sessions: Vec, }, SessionAdded { session: Session, }, SessionRemoved { id: Uuid, }, SessionUpdated { session: Session, }, /// Server-issued notification that the broadcast queue dropped frames. /// Clients should re-fetch a full snapshot. Lagged { skipped: u64, }, } // --------------------------------------------------------------------------- // Client ↔ Daemon control messages (text frames on the attach WS) // --------------------------------------------------------------------------- /// Text-frame control messages on a `WS /api/attach/:id` connection. /// PTY bytes travel in binary frames and are not modeled here. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type")] pub enum ClientControl { Resize { cols: u16, rows: u16 }, Ping, Pong, Error(ErrorInfo), } #[cfg(test)] mod tests { use super::*; #[test] fn version_compatibility_is_major_only() { assert!(is_compatible(PROTOCOL_VERSION)); let ours_major = PROTOCOL_VERSION.split('.').next().unwrap(); assert!(is_compatible(&format!("{ours_major}.99.99"))); assert!(!is_compatible("99.0.0")); } /// A daemon predating the field sends nothing; that must not read as a /// mismatch. #[test] fn version_compatibility_tolerates_unknown_versions() { assert!(is_compatible("")); assert!(is_compatible("unknown")); } /// docs/SIDEBAR.md: the sidebars render the breakdown as diff notation. #[test] fn stats_read_as_a_diff() { let w = InTheWay { uncommitted_files: 3, untracked_files: 2, unpushed_commits: 1, insertions: 124, deletions: 18, }; let text: Vec = w.stats().into_iter().map(|(_, t)| t).collect(); assert_eq!(text, ["+124", "-18", "?2", "↑1"]); let kinds: Vec = w.stats().into_iter().map(|(k, _)| k).collect(); assert_eq!( kinds, [ StatKind::Added, StatKind::Removed, StatKind::Untracked, StatKind::Unpushed ] ); } /// A change with no line delta — a mode change — still has to say /// something, and `+0 -0` would be a lie about the tree. #[test] fn stats_fall_back_to_the_file_count_without_a_line_delta() { let w = InTheWay { uncommitted_files: 2, ..InTheWay::default() }; let text: Vec = w.stats().into_iter().map(|(_, t)| t).collect(); assert_eq!(text, ["~2"]); assert!(!w.is_clear()); } /// A clear tree says nothing at all — the fragment is omitted, not empty. #[test] fn a_clear_tree_has_no_stats() { assert!(InTheWay::default().stats().is_empty()); assert!(InTheWay::default().is_clear()); } fn round_trip(value: &T) -> T where T: Serialize + for<'de> Deserialize<'de>, { let json = serde_json::to_string(value).expect("serialize"); serde_json::from_str(&json).expect("deserialize") } #[test] fn project_round_trips_and_defaults_on_legacy_payload() { let full = Project { path: PathBuf::from("/home/u/tea/maestro"), name: "maestro".into(), is_worktree: true, base_repo: Some(PathBuf::from("/home/u/tea/maestro-main")), last_activity: Some( DateTime::parse_from_rfc3339("2026-08-23T12:00:00Z") .unwrap() .with_timezone(&Utc), ), last_command: Some("claude".into()), }; assert_eq!(full, round_trip(&full)); // A payload from a daemon predating the optional fields still decodes. let legacy = serde_json::json!({ "path": "/home/u/proj", "name": "proj", }); let p: Project = serde_json::from_value(legacy).unwrap(); assert!(!p.is_worktree); assert_eq!(p.base_repo, None); assert_eq!(p.last_activity, None); assert_eq!(p.last_command, None); } fn sample_session() -> Session { Session { id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef), cwd: PathBuf::from("/home/naps62/projects/maestro"), command: "claude".into(), env: HashMap::from([("FOO".into(), "bar".into())]), metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]), worktree: None, status: SessionStatus::Running, created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z") .unwrap() .with_timezone(&Utc), foreground: Some("claude".into()), activity: ActivityState::Active, activity_detail: None, last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z") .unwrap() .with_timezone(&Utc), activity_since: DateTime::parse_from_rfc3339("2026-05-24T12:04:30Z") .unwrap() .with_timezone(&Utc), current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")), blocked_work: None, } } #[test] fn session_activity_fields_default_on_legacy_payload() { // A pre-NAP-340 daemon omits the activity fields; they must deserialize // to the forward-compat defaults rather than failing. let json = serde_json::json!({ "id": Uuid::from_u128(1), "cwd": "/tmp", "command": "bash", "status": "Running", "created_at": "2026-05-24T12:00:00Z" }); let s: Session = serde_json::from_value(json).unwrap(); assert!(s.foreground.is_none()); assert_eq!(s.activity, ActivityState::Idle); assert_eq!(s.last_activity_at, epoch()); assert!(s.current_cwd.is_none()); assert!(s.blocked_work.is_none()); } #[test] fn blocked_work_defaults_to_none_on_legacy_payload() { // A pre-#83 daemon omits the indicator; it must deserialize to `None` // rather than failing, and a present value round-trips. let json = serde_json::json!({ "id": Uuid::from_u128(1), "cwd": "/tmp", "command": "bash", "status": "Running", "created_at": "2026-05-24T12:00:00Z" }); let s: Session = serde_json::from_value(json).unwrap(); assert_eq!(s.blocked_work, None); let mut with_blocker = sample_session(); with_blocker.blocked_work = Some(InTheWay { uncommitted_files: 3, ..InTheWay::default() }); assert_eq!( with_blocker.clone().blocked_work, serde_json::from_value::(serde_json::to_value(&with_blocker).unwrap()) .unwrap() .blocked_work ); } #[test] fn activity_since_defaults_to_epoch_on_legacy_payload() { // A daemon predating #102 omits `activity_since`; it must deserialize // to the forward-compat default rather than failing, and a present // value round-trips. let json = serde_json::json!({ "id": Uuid::from_u128(1), "cwd": "/tmp", "command": "bash", "status": "Running", "created_at": "2026-05-24T12:00:00Z" }); let s: Session = serde_json::from_value(json).unwrap(); assert_eq!(s.activity_since, epoch()); let full = sample_session(); assert_eq!(full, round_trip(&full)); } // #102: nothing infers `Error`; only #105's agent-reported failure sets it. #[test] fn error_activity_state_serializes_pascal_case_and_idle_stays_default() { assert_eq!( serde_json::to_value(ActivityState::Error).unwrap(), serde_json::json!("Error") ); assert_eq!(ActivityState::default(), ActivityState::Idle); } #[test] fn activity_state_serializes_pascal_case_and_defaults_idle() { assert_eq!( serde_json::to_value(ActivityState::AwaitingInput).unwrap(), serde_json::json!("AwaitingInput") ); assert_eq!(ActivityState::default(), ActivityState::Idle); } // #110 / SIDEBAR.md Precedence: a future daemon may send an // `ActivityState` variant this client does not know; it must degrade to // the neutral default instead of failing the whole `Session`. #[test] fn unknown_activity_state_variant_deserializes_to_idle() { assert_eq!( serde_json::from_value::(serde_json::json!("Teleported")).unwrap(), ActivityState::Idle ); let mut future = serde_json::to_value(sample_session()).unwrap(); future["activity"] = serde_json::json!("QuantumEntangled"); let s: Session = serde_json::from_value(future).unwrap(); assert_eq!(s.activity, ActivityState::Idle); } #[test] fn non_string_activity_payload_still_fails_deserialization() { let json = serde_json::json!({ "id": Uuid::from_u128(1), "cwd": "/tmp", "command": "bash", "status": "Running", "created_at": "2026-05-24T12:00:00Z", "activity": 42, }); assert!(serde_json::from_value::(json).is_err()); } #[test] fn activity_detail_defaults_to_none_on_legacy_payload() { // #105: a daemon predating the detail field omits it; it must // deserialize to `None` rather than failing, and a present value // round-trips. let json = serde_json::json!({ "id": Uuid::from_u128(1), "cwd": "/tmp", "command": "bash", "status": "Running", "created_at": "2026-05-24T12:00:00Z" }); let s: Session = serde_json::from_value(json).unwrap(); assert_eq!(s.activity_detail, None); let mut reported = sample_session(); reported.activity = ActivityState::Error; reported.activity_detail = Some("build failed".into()); assert_eq!(reported.clone(), round_trip(&reported)); } #[test] fn report_activity_request_defaults_detail_and_rejects_unknown_state() { let bare: ReportActivityRequest = serde_json::from_value(serde_json::json!({ "state": "Error" })).unwrap(); assert_eq!(bare.state, ActivityState::Error); assert_eq!(bare.detail, None); let full = ReportActivityRequest { state: ActivityState::AwaitingInput, detail: Some("waiting on permission".into()), }; assert_eq!(full, round_trip(&full)); // Unknown state values are rejected rather than coerced (#105). assert!( serde_json::from_value::(serde_json::json!({ "state": "Teleported" })) .is_err() ); } #[test] fn session_round_trips() { let s = sample_session(); assert_eq!(s, round_trip(&s)); } #[test] fn session_status_serializes_pascal_case() { assert_eq!( serde_json::to_value(SessionStatus::Running).unwrap(), serde_json::json!("Running") ); assert_eq!( serde_json::to_value(SessionStatus::Dead).unwrap(), serde_json::json!("Dead") ); } #[test] fn create_session_request_with_worktree_round_trips() { let req = CreateSessionRequest { cwd: PathBuf::from("/work"), command: "bash".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: Some(WorktreeSpec { base_repo: PathBuf::from("/home/naps62/projects/maestro"), branch: "feat/foo".into(), create_branch: true, }), }; assert_eq!(req, round_trip(&req)); } #[test] fn create_session_request_omits_optional_worktree() { let json = serde_json::json!({ "cwd": "/work", "command": "bash" }); let req: CreateSessionRequest = serde_json::from_value(json).unwrap(); assert!(req.worktree.is_none()); assert!(req.env.is_empty()); assert!(req.metadata.is_empty()); } /// A client that predates the new/existing discriminator sends no /// `create_branch`; the daemon must read that as "create a new branch" — /// exactly what every payload meant before the field existed (#56). #[test] fn worktree_spec_defaults_on_legacy_payload() { let json = serde_json::json!({ "base_repo": "/home/naps62/projects/maestro", "branch": "feat/foo", }); let spec: WorktreeSpec = serde_json::from_value(json).unwrap(); assert!(spec.create_branch); } #[test] fn worktree_spec_round_trips_existing_branch_mode() { let spec = WorktreeSpec { base_repo: PathBuf::from("/work"), branch: "feat/foo".into(), create_branch: false, }; assert_eq!(spec, round_trip(&spec)); } #[test] fn catalog_event_round_trips_and_tags() { let session = sample_session(); let variants = vec![ CatalogEvent::Snapshot { sessions: vec![session.clone()], }, CatalogEvent::SessionAdded { session: session.clone(), }, CatalogEvent::SessionRemoved { id: session.id }, CatalogEvent::SessionUpdated { session }, CatalogEvent::Lagged { skipped: 3 }, ]; for v in variants { assert_eq!(v, round_trip(&v), "variant did not round-trip"); } let v = serde_json::to_value(CatalogEvent::Lagged { skipped: 3 }).unwrap(); assert_eq!(v["type"], "Lagged"); assert_eq!(v["skipped"], 3); } #[test] fn client_control_round_trips_and_tags() { let resize = ClientControl::Resize { cols: 120, rows: 40, }; let v = serde_json::to_value(&resize).unwrap(); assert_eq!(v["type"], "Resize"); assert_eq!(v["cols"], 120); assert_eq!(v["rows"], 40); for c in [ ClientControl::Ping, ClientControl::Pong, ClientControl::Error(ErrorInfo { code: "bad_frame".into(), message: "decode failed".into(), unsaved_work: None, }), resize, ] { assert_eq!(c, round_trip(&c)); } } /// #125: a rename request is trimmed and capped; a blank one clears the /// name so the card falls back to the command. #[test] fn session_names_are_trimmed_capped_and_blankable() { assert_eq!( normalize_session_name(" amber-falcon "), Some("amber-falcon".into()) ); assert_eq!(normalize_session_name(""), None); assert_eq!(normalize_session_name(" \t "), None); let long = "x".repeat(MAX_SESSION_NAME_LEN + 10); assert_eq!( normalize_session_name(&long).unwrap().chars().count(), MAX_SESSION_NAME_LEN ); } /// The cap counts characters, not bytes — a multi-byte name must not be /// sliced mid-codepoint. #[test] fn session_name_cap_counts_characters() { let name = "é".repeat(MAX_SESSION_NAME_LEN + 5); let capped = normalize_session_name(&name).unwrap(); assert_eq!(capped.chars().count(), MAX_SESSION_NAME_LEN); } /// #131: the prompt body round-trips whatever the agent sent, quotes and /// newlines included — the hook posts its payload verbatim rather than /// building JSON in shell. #[test] fn prompt_request_round_trips_awkward_text() { let req = SessionPromptRequest { prompt: "fix \"the\" parser\nthen run tests".into(), }; assert_eq!(req, round_trip(&req)); } /// The stored spelling and the parse of it agree, and anything else is /// the settled default (#131). #[test] fn name_source_spellings_round_trip_and_degrade_to_user() { for source in [NameSource::User, NameSource::Generated, NameSource::Haiku] { assert_eq!(NameSource::from_wire(source.as_str()), source); assert_eq!( serde_json::to_value(source).unwrap(), serde_json::Value::String(source.as_str().into()) ); assert_eq!(round_trip(&source), source); } assert_eq!(NameSource::from_wire("Rolled"), NameSource::User); assert_eq!(NameSource::default(), NameSource::User); } #[test] fn rename_request_round_trips() { let req = RenameSessionRequest { name: "amber-falcon".into(), }; assert_eq!(req, round_trip(&req)); assert_eq!( serde_json::to_value(&req).unwrap(), serde_json::json!({"name": "amber-falcon"}) ); } }