//! Daemon mode — background process that owns the agent state, listens on a //! per-session Unix socket, and drives one attached client at a time. //! //! Also contains the `key_code_to_action` / `key_action_to_code` conversion //! functions shared between daemon and attach modes. use anyhow::Result; use app::runtime::actions::{apply_action, Action}; use app::state::rest::AppStateRest; use crossterm::event::KeyCode; use ipc::protocol::{ClientRequest, DaemonFrame, MessageEntry, StatePayload, ToastEntry}; use zesdex_cms::domain::repository::SettingsRepository; use crate::app; use crate::controller; use crate::ipc; /// Map a `crossterm` key code to the wire-serializable `KeyAction`, for /// sending key input from an attached client to the daemon. /// /// Return: `None` for key codes with no `KeyAction` equivalent (e.g. /// media keys), which are silently dropped. pub fn key_code_to_action(code: crossterm::event::KeyCode) -> Option { match code { KeyCode::Char(c) => Some(ipc::protocol::KeyAction::Char(c)), KeyCode::Enter => Some(ipc::protocol::KeyAction::Enter), KeyCode::Esc => Some(ipc::protocol::KeyAction::Escape), KeyCode::Backspace => Some(ipc::protocol::KeyAction::Backspace), KeyCode::Delete => Some(ipc::protocol::KeyAction::Delete), KeyCode::Tab => Some(ipc::protocol::KeyAction::Tab), KeyCode::Up => Some(ipc::protocol::KeyAction::Up), KeyCode::Down => Some(ipc::protocol::KeyAction::Down), KeyCode::Left => Some(ipc::protocol::KeyAction::Left), KeyCode::Right => Some(ipc::protocol::KeyAction::Right), KeyCode::Home => Some(ipc::protocol::KeyAction::Home), KeyCode::End => Some(ipc::protocol::KeyAction::End), KeyCode::PageUp => Some(ipc::protocol::KeyAction::PageUp), KeyCode::PageDown => Some(ipc::protocol::KeyAction::PageDown), KeyCode::F(n) => Some(ipc::protocol::KeyAction::Function(n)), _ => None, } } /// Inverse of `key_code_to_action`: reconstruct a `crossterm::KeyCode` /// from a `KeyAction` received over IPC, for replaying it into the /// daemon's normal key-handling path. pub fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::KeyCode { match action { ipc::protocol::KeyAction::Char(c) => KeyCode::Char(*c), ipc::protocol::KeyAction::Enter => KeyCode::Enter, ipc::protocol::KeyAction::Escape => KeyCode::Esc, ipc::protocol::KeyAction::Backspace => KeyCode::Backspace, ipc::protocol::KeyAction::Delete => KeyCode::Delete, ipc::protocol::KeyAction::Tab => KeyCode::Tab, ipc::protocol::KeyAction::Up => KeyCode::Up, ipc::protocol::KeyAction::Down => KeyCode::Down, ipc::protocol::KeyAction::Left => KeyCode::Left, ipc::protocol::KeyAction::Right => KeyCode::Right, ipc::protocol::KeyAction::Home => KeyCode::Home, ipc::protocol::KeyAction::End => KeyCode::End, ipc::protocol::KeyAction::PageUp => KeyCode::PageUp, ipc::protocol::KeyAction::PageDown => KeyCode::PageDown, ipc::protocol::KeyAction::Function(n) => KeyCode::F(*n), } } /// Flatten the daemon's `AppStateRest` into a `StatePayload` and send it /// to the attached client as a `DaemonFrame::StateUpdate`. /// /// Flow: map transcript messages/toasts to their wire DTOs → derive the /// active overlay name (or `None` if no overlay is active) → build and /// send one `DaemonFrame`. /// /// Why: the client never shares memory with the daemon, so every action /// on the daemon side is followed by a full state push rather than a diff. fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &AppStateRest) -> Result<()> { let messages: Vec = state .transcript_cache .messages .iter() .map(|m| MessageEntry { role: format!("{:?}", m.role), content: m.content.clone(), timestamp: m.timestamp, }) .collect(); let toasts: Vec = state .misc .toasts .iter() .map(|t| ToastEntry { kind: format!("{:?}", t.kind), message: t.message.clone(), created_at: t.created_at, lifetime_ms: t.lifetime_ms, }) .collect(); let overlay = if state.misc.overlay.is_active() { Some(format!("{:?}", state.misc.overlay)) } else { None }; let frame = DaemonFrame::StateUpdate(Box::new(StatePayload { session_id: state.session_id.clone(), messages, edit_count: state.edit_log.len() as u32, message_count: state.transcript_cache.messages.len(), overlay, toasts, dirty: state.dirty, input_buffer: state.input.buffer.clone(), input_cursor: state.input.cursor, })); conn.send(&frame)?; Ok(()) } /// Handle an incoming client connection for the daemon. /// /// Flow: loop reading requests, modifying state, and sending updates back. fn handle_daemon_client( mut conn: ipc::conn::Connection, state: &mut AppStateRest, ) -> Result<()> { let mut running = true; while running { match conn.receive::()? { Some(req) => { match req { ClientRequest::Tick => { apply_action(state, Action::Tick); } ClientRequest::KeyPress { key, ctrl, alt, shift, } => { let mut modifiers = crossterm::event::KeyModifiers::NONE; if ctrl { modifiers |= crossterm::event::KeyModifiers::CONTROL; } if alt { modifiers |= crossterm::event::KeyModifiers::ALT; } if shift { modifiers |= crossterm::event::KeyModifiers::SHIFT; } let key_event = crossterm::event::KeyEvent::new(key_action_to_code(&key), modifiers); let actions = controller::input::handle_key(key_event, state); for action in actions { apply_action(state, action); } apply_action(state, Action::Tick); } ClientRequest::Submit(text) => { state.input.buffer = text; let enter_event = crossterm::event::KeyEvent::new( crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::NONE, ); let actions = controller::input::handle_key(enter_event, state); for action in actions { apply_action(state, action); } apply_action(state, Action::Tick); } ClientRequest::Paste(text) => { state.input.buffer.insert_str(state.input.cursor, &text); state.input.cursor += text.len(); state.dirty = true; apply_action(state, Action::Tick); } ClientRequest::Resize(w, h) => { apply_action(state, Action::Resize(w, h)); apply_action(state, Action::Tick); } ClientRequest::ScrollUp => { apply_action(state, Action::ScrollUp); apply_action(state, Action::Tick); } ClientRequest::ScrollDown => { apply_action(state, Action::ScrollDown); apply_action(state, Action::Tick); } ClientRequest::Close => { running = false; } } if let Some(text) = state.misc.pending_clipboard_copy.take() { conn.send(&ipc::protocol::DaemonFrame::ClipboardCopy(text))?; } send_daemon_update(&mut conn, state)?; } None => { running = false; } } } Ok(()) } /// Run zesdex as a background daemon: owns the agent state, listens on a /// per-session Unix socket, and drives one attached client. /// /// Flow: create session + lock it → bind a Unix socket under /// `/run/.sock` → block for a single client to /// `accept()` → loop reading `ClientRequest`s, translating each into /// `Action`(s) via the same `controller::input`/`apply_action` path the /// single-process mode uses, then pushing a full state update back → /// on `Close` or client disconnect, clean up the socket file, save /// settings, and release the lock. /// /// Why: reuses `controller::input::handle_key` by synthesizing a /// `crossterm::KeyEvent` from the IPC `KeyAction`, so daemon and /// single-process modes share identical key-handling logic. pub fn run_daemon() -> Result<()> { let (store, _session_lock_guard, mut state, _rt) = crate::create_session()?; let run_dir = store.base_dir.join("run"); std::fs::create_dir_all(&run_dir)?; let socket_path = run_dir.join(format!("{}.sock", state.session_id)); let addr = socket_path.to_string_lossy().to_string(); let server = ipc::server::IpcServer::bind_unix(&addr)?; eprintln!("daemon: listening on {addr}"); loop { let conn = match server.accept() { Ok(c) => c, Err(e) => { eprintln!("daemon: accept error: {e}"); break; } }; eprintln!("daemon: client connected"); if let Err(e) = handle_daemon_client(conn, &mut state) { eprintln!("daemon: error handling client: {e}"); } eprintln!("daemon: client disconnected, waiting for next connection..."); let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() .save(&state.store_base_dir(), &state.settings); } let _ = std::fs::remove_file(&socket_path); Ok(()) }