Crate renames: - zesdex-entities::seaorm → domain (misleading name, no SeaORM used) - zesdex-dto → merged into zesdex-entities (100% re-exports) - zesdex-libs → zesdex-infra (vague name) Module renames: - app/harness → guard (misleading: safety gatekeeper, not test harness) - runtime/commands → action_dispatch (name clashed with controller/command) - resources → prompts (embedded prompt text, not general resources) - tool/seqthink → sequential_think (unreadable abbreviation) - msglog/query → insert (module only inserts, never queries) Dead code removal: - app/mode/help.rs (orphaned — not declared in mod.rs) - app/mode/loading.rs (orphaned — not declared in mod.rs) File splitting (71 new files, avg ~115 lines/file): - app/runtime/actions/: 1→8 files (was 2030 lines) - view/overlays/: 1→16 files (was 1167 lines) - tool/lsp/: 1→8 per-tool files (was 909 lines) - main.rs: 1→5 files (session, daemon, attach, event_loop) - workflow/engine + hive_mind: 2→10 files - subagent/engine + auto: 2→9 files - lsp/provisioner: 1→5 files - review/: 1→6 files - guard/: 1→2 files (extracted patterns) - state/misc: 1→3 files (input, scroll) - mcp/: 1→3 files (transport, adapter) - stream/json_repair extracted from turn.rs DRY: - Pattern constants (STUB_PATTERNS etc) in guard/patterns shared with subagent - 3 near-identical background spawners → 1 generic + thin wrappers - Shared spawn_subagent_with_drain() extracted - Shared create_session() in main - write_osc52 deduplicated Bug fixes: - archive_message(): sess.db → db (wrong variable name) - execute_one_tool(): wrong parameter name - check_credential_read() function was missing (restored from test expectations)
257 lines
10 KiB
Rust
257 lines
10 KiB
Rust
//! 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<ipc::protocol::KeyAction> {
|
|
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<MessageEntry> = state
|
|
.transcript_cache
|
|
.messages
|
|
.iter()
|
|
.map(|m| MessageEntry {
|
|
role: format!("{:?}", m.role),
|
|
content: m.content.clone(),
|
|
timestamp: m.timestamp,
|
|
})
|
|
.collect();
|
|
|
|
let toasts: Vec<ToastEntry> = 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::<ClientRequest>()? {
|
|
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
|
|
/// `<store>/run/<session_id>.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(())
|
|
}
|