//! Daemon state types — `AppStateRest`, `DaemonState`, and all supporting //! data structures for the background daemon session. //! //! `AppStateRest` is the single source-of-truth struct mutated in-place from //! [`handler::apply_action`](crate::handler::apply_action) and the IPC handler. //! `DaemonState` wraps it with IPC socket metadata. //! //! Also contains [`create_session()`] adapted from the legacy `main.rs`. use std::collections::VecDeque; use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; use anyhow::Result; use tokio::sync::RwLock; use zesdex_domain::cms::EditLog; use zesdex_domain::AppConfigRepository; use zesdex_domain::EditLogRepository; use zesdex_domain::Session; use zesdex_domain::SessionLockRepository; use zesdex_domain::SessionRepository; use zesdex_domain::Settings; use zesdex_domain::SettingsRepository; use zesdex_infrastructure::mcp::manager::McpManager; use zesdex_infrastructure::persistence::FileSystemSessionLockRepository; use zesdex_infrastructure::persistence::JsonAppConfigRepository; use zesdex_infrastructure::persistence::JsonSettingsRepository; use zesdex_infrastructure::persistence::JsonlEditLogRepository; use zesdex_infrastructure::AppConfig; use zesdex_infrastructure::DirCache; use zesdex_infrastructure::MentionIndex; use zesdex_infrastructure::SessionRuntime; use zesdex_infrastructure::Toast; use zesdex_infrastructure::ToastKind; use zesdex_infrastructure::TurnEvent; // --------------------------------------------------------------------------- // Supporting types // --------------------------------------------------------------------------- /// A single transcript entry rendered in the TUI chat pane. #[derive(Debug, Clone, PartialEq)] pub struct ChatMessageDisplay { /// Message author: User, Assistant, System, or Tool. pub role: RoleWrapper, /// Rendered text content (plain text, no markdown). pub content: String, /// Millisecond timestamp when this display entry was created. pub timestamp: i64, } /// Simple string-backed role wrapper for transcript display (avoids a direct /// dependency on the domain's `Role` enum which may not round-trip all wire /// strings). #[derive(Debug, Clone, PartialEq)] pub enum RoleWrapper { User, Assistant, System, Tool, } impl ChatMessageDisplay { /// Build a display entry, stamping it with the current time. pub fn new(role: RoleWrapper, content: String) -> Self { tracing::debug!( "ChatMessageDisplay::new — role={:?}, content_len={}", role, content.len() ); ChatMessageDisplay { role, content, timestamp: chrono::Utc::now().timestamp_millis(), } } } /// Which modal overlay, if any, is currently shown over the main TUI view. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Overlay { /// No overlay; the main chat view is shown. None, /// Key bindings help screen. Help, /// Settings/configuration panel. Settings, /// Background bash job viewer. Bash, /// "Are you sure you want to quit?" confirmation. QuitConfirm, /// Raw key-code input capture (for binding custom keys). KeyInput, /// Inline editor (opened via `/edit`). Editor, /// Reasoning effort level selector. Effort, /// MCP server management panel. Mcp, /// Task list overlay. Todo, /// Session rewind / history scrubber. Rewind, /// Learning / lesson management panel. Learning, /// Token usage statistics panel. Usage, /// Generic loading spinner overlay. Loading, /// Model selector dropdown. ModelSelector, /// "Clear conversation?" confirmation (distinct from QuitConfirm). ClearConfirm, } impl Overlay { /// Human-readable name for this overlay variant. pub fn as_str(self) -> &'static str { match self { Overlay::None => "none", Overlay::Help => "help", Overlay::Settings => "settings", Overlay::Bash => "bash", Overlay::QuitConfirm => "quit_confirm", Overlay::KeyInput => "key_input", Overlay::Editor => "editor", Overlay::Effort => "effort", Overlay::Mcp => "mcp", Overlay::Todo => "todo", Overlay::Rewind => "rewind", Overlay::Learning => "learning", Overlay::Usage => "usage", Overlay::Loading => "loading", Overlay::ModelSelector => "model_selector", Overlay::ClearConfirm => "clear_confirm", } } /// Whether any overlay (i.e. anything other than `None`) is active. pub fn is_active(self) -> bool { !matches!(self, Overlay::None) } } impl std::fmt::Display for Overlay { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } /// Bounded ring of recent chat messages used to render the transcript view. #[derive(Debug, Clone)] pub struct TranscriptCache { /// Ordered display messages (newest appended, oldest evicted when full). pub messages: Vec, /// Maximum messages to retain before evicting the oldest. pub max_lines: usize, /// Whether the cache has changed since the last render sweep. pub dirty: bool, } impl TranscriptCache { /// Create an empty transcript cache holding at most `max_lines` messages. pub fn new(max_lines: usize) -> Self { TranscriptCache { messages: Vec::new(), max_lines, dirty: true, } } } /// Which source populated the autocomplete dropdown. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AutocompleteKind { /// Builtin slash-command (e.g. `/model`, `/help`). Command, /// `@file` mention from the workspace file index. FileMention, } /// The user's input buffer, cursor position, history, and autocomplete state. #[derive(Debug, Clone)] pub struct InputState { /// Raw UTF-8 input buffer content. pub buffer: String, /// Byte offset of the cursor within `buffer`. pub cursor: usize, /// Previously submitted input lines, oldest-first. pub history: Vec, /// Index into `history` when browsing (None = at the current input). pub history_idx: Option, /// The prefix string used to filter candidates for autocomplete. pub autocomplete_prefix: String, /// Current autocomplete candidate list. pub autocomplete_candidates: Vec, /// Focused index within `autocomplete_candidates`. pub autocomplete_idx: usize, /// Whether the autocomplete dropdown is visible. pub autocomplete_visible: bool, /// Which kind of autocomplete is active. pub autocomplete_kind: AutocompleteKind, /// Byte offset of the `@` character that triggered file mention autocomplete. pub mention_start: usize, /// Optional path to a persistent history file. pub history_file: Option, } impl InputState { /// Create an empty input state with no buffer, no history, and no autocomplete. pub fn new() -> Self { InputState { buffer: String::new(), cursor: 0, history: Vec::new(), history_idx: None, autocomplete_prefix: String::new(), autocomplete_candidates: Vec::new(), autocomplete_idx: 0, autocomplete_visible: false, autocomplete_kind: AutocompleteKind::Command, mention_start: 0, history_file: None, } } /// Close the autocomplete dropdown. pub fn close_autocomplete(&mut self) { self.autocomplete_visible = false; self.autocomplete_candidates.clear(); self.autocomplete_prefix.clear(); } /// Open the command-autocomplete dropdown. pub fn open_autocomplete(&mut self) { self.autocomplete_kind = AutocompleteKind::Command; self.autocomplete_visible = true; } } impl Default for InputState { fn default() -> Self { Self::new() } } /// Viewport scroll state: current offset and visible-line count. #[derive(Debug, Clone)] pub struct ScrollState { /// Current scroll offset (how many lines have been scrolled past). pub offset: usize, /// Maximum number of lines that fit in the visible viewport area. pub max_visible: usize, } impl ScrollState { /// Create a `ScrollState` with zero offset and 30 rows visible. pub fn new() -> Self { ScrollState { offset: 0, max_visible: 30, } } /// Scroll the viewport up by `amount` lines (increasing the offset). pub fn scroll_up(&mut self, amount: usize) { self.offset = self.offset.saturating_add(amount); } /// Scroll the viewport down by `amount` lines (decreasing the offset). pub fn scroll_down(&mut self, amount: usize) { self.offset = self.offset.saturating_sub(amount); } /// Update the maximum number of visible lines in the viewport. pub fn set_max_visible(&mut self, max: usize) { self.max_visible = max; } } impl Default for ScrollState { fn default() -> Self { Self::new() } } /// The "miscellaneous" slice of app state: which overlay is showing, /// toasts, thinking flags, editor state, and tick. #[derive(Debug, Clone)] pub struct MiscState { /// Currently active modal overlay (None = main chat view). pub overlay: Overlay, /// Active toast notifications (expired ones removed on each tick). pub toasts: Vec, /// Whether the agent is currently "thinking". pub thinking: bool, /// Current LLM reasoning effort level (1-5). pub effort_level: usize, /// Whether the API connection is established. pub api_connected: bool, /// Currently focused index in list-type overlays. pub selected_index: usize, /// Monotonically increasing tick count, incremented each render frame. pub tick_count: u64, /// Cached content of the TODO file, shown in the overlay. pub todo_content: String, /// Whether a lesson background task is currently running. pub lesson_running: bool, /// Text waiting to be written to the system clipboard. pub pending_clipboard_copy: Option, /// Inline editor state, if the editor overlay is active. pub editor: Option, } impl MiscState { /// Create a fresh `MiscState` with no overlay, no toasts, and default effort level 1. pub fn new() -> Self { MiscState { overlay: Overlay::None, toasts: Vec::new(), thinking: false, effort_level: 1, api_connected: false, selected_index: 0, tick_count: 0, todo_content: String::new(), lesson_running: false, pending_clipboard_copy: None, editor: None, } } /// Append a toast notification to the active list. pub fn push_toast(&mut self, toast: Toast) { self.toasts.push(toast); } /// Remove and return all toasts whose lifetime has expired at `now_ms`. pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec { let expired: Vec<_> = self .toasts .iter() .filter(|t| t.expired(now_ms)) .cloned() .collect(); self.toasts.retain(|t| !t.expired(now_ms)); expired } } impl Default for MiscState { fn default() -> Self { Self::new() } } /// State of a single workflow agent. #[derive(Debug, Clone, Default)] pub struct AgentState { pub id: String, pub status: String, pub current_tool: String, } /// Workflow engine state tracking agents in hive-mind orchestration. #[derive(Debug, Clone, Default)] pub struct WorkflowEngine { /// List of running workflow agent states. pub agents: Vec, } impl WorkflowEngine { /// Create an empty workflow engine. pub fn new() -> Self { Self { agents: Vec::new() } } } /// Simple inline editor state for the TUI. #[derive(Debug, Clone)] pub struct EditorState { /// Path to the file being edited. pub path: PathBuf, /// Current buffer content. pub content: String, /// Cursor position (byte offset). pub cursor: usize, } impl EditorState { /// Create a new editor state for the given path. pub fn new(path: PathBuf, content: String) -> Self { let cursor = content.len(); EditorState { path, content, cursor, } } /// Return the full buffer content. pub fn as_string(&self) -> String { self.content.clone() } } // --------------------------------------------------------------------------- // AppStateRest — single source-of-truth application state // --------------------------------------------------------------------------- /// The single source-of-truth state struct for the daemon. /// /// Mutated in-place from two locations: `handler::apply_action` /// and the IPC client handler in `handler::handle_daemon_client`. /// Read-only from every other module. #[derive(Clone)] pub struct AppStateRest { /// Persistent user settings (loaded from JSON store at startup). pub settings: Settings, /// Per-project app configuration (loaded from JSON store at startup). pub app_config: AppConfig, /// Absolute paths to each open workspace root directory. pub workspace_roots: Vec, /// Unique session identifier. pub session_id: String, /// Path to the session's data directory. pub session_dir: PathBuf, /// Path to the session memory directory (lessons, review history). pub memory_dir: PathBuf, /// Path to the git worktrees directory (for sandboxed agent experiments). pub worktrees_dir: PathBuf, /// Shared async cache of directory listings. pub dir_cache: Arc>, /// Shared workspace file-path index for `@file` mention autocomplete. pub mention_index: MentionIndex, /// Persistent edit history log (appended on every tool write). pub edit_log: EditLog, /// Optional per-session runtime state. pub session_runtime: Option, /// Active IAM sessions linked to this app instance. pub sessions: Vec, /// Ring buffer of recent chat messages for the TUI transcript pane. pub transcript_cache: TranscriptCache, /// Viewport scroll offset tracker. pub scroll: ScrollState, /// Chat input buffer, cursor, history, and autocomplete. pub input: InputState, /// Miscellaneous state: overlay, toasts, flags, tick. pub misc: MiscState, /// Queue of events emitted by the running agent turn. pub turn_events: Arc>>, /// Whether an agent turn is currently in flight. pub turn_in_flight: Arc>, /// Atomic flag set when the user aborts the current turn (Ctrl-C / Escape). pub abort_flag: Arc, /// Workflow engine state for multi-agent hive-mind orchestration. pub workflow_engine: WorkflowEngine, /// MCP server manager. pub mcp_manager: McpManager, /// Whether the state has been modified since the last render sweep. pub dirty: bool, /// Whether the application has been requested to quit. pub quit: bool, } impl AppStateRest { /// Construct the initial application state for a session. /// /// Flow: load settings/config → derive download/worktree dirs from /// `memory_dir`'s parent → derive `session_id` from the session dir's /// file name → build the sub-state structs. /// /// Why: falls back to `memory_dir` itself (with a warning) when it has /// no parent, and to an empty session id when the dir name can't be /// read, so construction never fails. pub fn new( workspace_roots: Vec, session_dir: &std::path::Path, memory_dir: PathBuf, ) -> Self { let store_base_dir = zesdex_infrastructure::Store::new().base_dir; let settings = JsonSettingsRepository::new() .load(&store_base_dir) .unwrap_or_default(); let app_config = JsonAppConfigRepository::new() .load(&store_base_dir) .unwrap_or_default(); let worktrees_dir = memory_dir .parent() .unwrap_or_else(|| { tracing::warn!( "[state] memory_dir '{}' has no parent, using it for worktrees", memory_dir.display() ); &memory_dir }) .join("worktrees"); let dir_cache = DirCache::new(); let session_id = session_dir.file_name().map_or_else( || { tracing::warn!( "[state] session_dir has no file_name component, using empty session_id" ); String::new() }, |n| n.to_string_lossy().to_string(), ); let mut state = AppStateRest { settings, app_config, workspace_roots, session_id, session_dir: session_dir.to_path_buf(), memory_dir: memory_dir.clone(), worktrees_dir, turn_events: Arc::new(Mutex::new(VecDeque::new())), turn_in_flight: Arc::new(Mutex::new(false)), abort_flag: Arc::new(AtomicBool::new(false)), dir_cache: Arc::new(RwLock::new(dir_cache)), mention_index: MentionIndex::new(), edit_log: JsonlEditLogRepository::new() .open(session_dir) .unwrap_or_else(|e| { tracing::warn!( "[state] failed to open edit log at '{}': {e}", session_dir.display() ); EditLog::new() }), session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())), workflow_engine: WorkflowEngine::new(), mcp_manager: McpManager::new(), sessions: Vec::new(), transcript_cache: TranscriptCache::new(200), scroll: ScrollState::new(), input: InputState::new(), misc: MiscState::new(), dirty: true, quit: false, }; // Load project-specific input-line history from a file keyed by // the first workspace root's SHA256 hash. let base_dir = state.memory_dir.parent().unwrap_or(&state.memory_dir); if let Some(root) = state.workspace_roots.first() { if let Ok(abs_root) = std::fs::canonicalize(root) { use sha2::Digest; let mut hasher = sha2::Sha256::new(); hasher.update(abs_root.to_string_lossy().as_bytes()); let hash_hex = hex::encode(hasher.finalize()); let folder_name = abs_root .file_name() .map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string()); let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]); let history_dir = base_dir.join("history"); let _ = std::fs::create_dir_all(&history_dir); let history_file = history_dir.join(history_filename); if let Ok(content) = std::fs::read_to_string(&history_file) { let history: Vec = content .lines() .map(std::string::ToString::to_string) .filter(|s| !s.is_empty()) .collect(); state.input.history = history; } state.input.history_file = Some(history_file); } } state } /// Spawn the background thread that walks every workspace root and /// populates `mention_index` for `@file` mention autocomplete. /// /// Callers that DO need the index (single-process mode, the daemon) /// call this explicitly after construction. pub fn spawn_mention_index_build(&self) { let mention_index = self.mention_index.clone(); let roots = self.workspace_roots.clone(); std::thread::spawn(move || { const MAX_MENTION_ENTRIES: usize = 50_000; let mut paths = Vec::new(); 'roots: for (i, root) in roots.iter().enumerate() { for entry in ignore::Walk::new(root).flatten() { if !entry.path().is_file() { continue; } let rel = entry.path().strip_prefix(root).unwrap_or(entry.path()); let rel_str = rel.display().to_string(); let formatted = if i == 0 { rel_str } else { format!("[{i}]{rel_str}") }; paths.push(formatted); if paths.len() >= MAX_MENTION_ENTRIES { break 'roots; } } } mention_index.set(paths); }); } /// Whether an agent turn is currently running. pub fn turn_in_flight(&self) -> bool { self.turn_in_flight.lock().map_or_else( |_| { tracing::warn!("[state] turn_in_flight mutex poisoned"); false }, |g| *g, ) } /// Append a message to the transcript, evicting the oldest entry once /// `max_lines` is exceeded. pub fn push_transcript(&mut self, msg: ChatMessageDisplay) { self.transcript_cache.messages.push(msg); if self.transcript_cache.messages.len() > self.transcript_cache.max_lines { self.transcript_cache.messages.remove(0); } self.transcript_cache.dirty = true; self.dirty = true; } /// Mark the app state as dirty, triggering a TUI re-render on the next frame. pub fn mark_dirty(&mut self) { self.dirty = true; } /// Queue a toast notification for display and mark the app dirty. pub fn push_toast(&mut self, toast: Toast) { self.misc.push_toast(toast); self.mark_dirty(); } /// Push an info toast with the given message. pub fn toast_info(&mut self, msg: impl Into) { self.push_toast(Toast::new(ToastKind::Info, msg.into())); } /// Push a success toast with the given message. pub fn toast_success(&mut self, msg: impl Into) { self.push_toast(Toast::new(ToastKind::Success, msg.into())); } /// Push a warning toast with the given message. pub fn toast_warning(&mut self, msg: impl Into) { self.push_toast(Toast::new(ToastKind::Warning, msg.into())); } /// Push an error toast with the given message. pub fn toast_error(&mut self, msg: impl Into) { self.push_toast(Toast::new(ToastKind::Error, msg.into())); } /// Resolve the base directory that stores this session (grandparent of /// `session_dir`). pub fn store_base_dir(&self) -> PathBuf { self.session_dir .parent() .and_then(|p| p.parent()) .map_or_else( || { tracing::warn!( "[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display() ); self.session_dir.parent().map_or_else( || { tracing::warn!( "[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display() ); self.session_dir.clone() }, std::path::Path::to_path_buf, ) }, std::path::Path::to_path_buf, ) } /// Persist the current settings to the store and swallow any error. pub fn save_settings(&self) { let _ = JsonSettingsRepository::new().save(&self.store_base_dir(), &self.settings); } } // --------------------------------------------------------------------------- // SessionLockGuard — RAII guard that releases a session lock on drop // --------------------------------------------------------------------------- /// RAII guard that releases a session lock on drop. pub struct SessionLockGuard { lock_repo: FileSystemSessionLockRepository, session_dir: PathBuf, } impl SessionLockGuard { /// Create a new guard. Caller must have already acquired the lock. pub fn new(lock_repo: FileSystemSessionLockRepository, session_dir: PathBuf) -> Self { tracing::debug!("acquired session lock for {:?}", session_dir); Self { lock_repo, session_dir, } } } impl Drop for SessionLockGuard { fn drop(&mut self) { tracing::debug!("releasing session lock for {:?}", self.session_dir); let _ = self.lock_repo.unlock(&self.session_dir); } } // --------------------------------------------------------------------------- // DaemonState — wraps AppStateRest with IPC socket metadata // --------------------------------------------------------------------------- /// The daemon's overall state: owns the application state and the IPC socket /// metadata for client connections. pub struct DaemonState { /// The canonical application state for this daemon session. pub app_state: AppStateRest, /// The daemon session's unique identifier (same as `app_state.session_id`). pub session_id: String, /// Path to the bound Unix socket, if any. pub socket_path: Option, } impl DaemonState { /// Wrap an `AppStateRest` into a `DaemonState`. pub fn new(app_state: AppStateRest) -> Self { let session_id = app_state.session_id.clone(); DaemonState { app_state, session_id, socket_path: None, } } /// Set the socket path after binding. pub fn set_socket_path(&mut self, path: String) { self.socket_path = Some(path); } } // --------------------------------------------------------------------------- // Session creation // --------------------------------------------------------------------------- /// Create a new daemon session: store, session directory, exclusive lock, /// application state, and tokio runtime. /// /// Flow: create the store → create a new session directory → attempt an /// exclusive lock → build `AppStateRest` → spawn mention-index builder → /// load session list → start a tokio runtime. /// /// Return: (store, lock guard, app_state, tokio_runtime). pub fn create_session() -> Result<( zesdex_infrastructure::Store, SessionLockGuard, AppStateRest, tokio::runtime::Runtime, )> { tracing::info!("creating new daemon session"); let store = zesdex_infrastructure::Store::new(); store.ensure_dirs()?; let session_id = uuid::Uuid::new_v4().to_string(); let session_dir = store.base_dir.join("sessions").join(&session_id); std::fs::create_dir_all(&session_dir)?; let lock_repo = FileSystemSessionLockRepository::new(); if !lock_repo.try_lock(&session_dir)? { anyhow::bail!( "session already active (another zesdex process holds the lock for this session directory)" ); } let session_lock_guard = SessionLockGuard::new(lock_repo, session_dir.clone()); let workspace_roots = vec![std::env::current_dir()?]; let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone()); state.spawn_mention_index_build(); let session_repo = zesdex_infrastructure::persistence::FileSystemSessionRepository::new(); state.sessions = session_repo .list_sessions(&store.base_dir) .unwrap_or_default(); let rt = tokio::runtime::Runtime::new()?; Ok((store, session_lock_guard, state, rt)) }