pub mod app;
pub mod companion;
pub mod launch;
pub mod window;
use crate::approval::ipc::ApprovalIpc;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct GuiOptions {
pub socket: PathBuf,
pub smoke_frames: Option<u32>,
pub watch: bool,
pub request_id: Option<String>,
pub discover: bool,
}
pub fn run_gui(opts: GuiOptions) -> i32 {
let socket = opts.socket.clone();
let (event_tx, event_rx) = std::sync::mpsc::channel::<companion::CompanionEvent>();
let (choice_tx, choice_rx) = tokio::sync::mpsc::channel(8);
let state = app::ViewState::new(opts.socket.clone());
let smoke = opts.smoke_frames;
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([680.0, if opts.watch { 620.0 } else { 390.0 }])
.with_min_inner_size([560.0, 390.0])
.with_visible(opts.watch || opts.smoke_frames.is_some())
.with_title("sequel-mcp approvals"),
..Default::default()
};
match eframe::run_native(
"sequel-mcp approvals",
native_options,
Box::new(move |cc| {
let ctx = cc.egui_ctx.clone();
let events = companion::EventSender::new(event_tx, move || ctx.request_repaint());
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
std::thread::Builder::new()
.name("approval-companion".into())
.spawn(move || {
rt.block_on(companion::companion_loop(
socket,
events,
choice_rx,
companion::CompanionOptions {
once: !opts.watch,
request_id: opts.request_id,
discover: opts.discover,
},
))
})?;
Ok(Box::new(window::ApprovalsApp::new(
state, choice_tx, event_rx, smoke, opts.watch,
)))
}),
) {
Ok(()) => 0,
Err(e) => {
eprintln!("sequel-mcp gui: window creation failed: {e}");
1
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SessionInfo {
pub pid: u32,
pub socket: String,
pub started: String,
pub alive: bool,
}
pub fn live_sessions() -> Vec<SessionInfo> {
live_sessions_at(&crate::app::paths::runtime_dir().join("sessions"))
}
pub fn live_sessions_at(dir: &std::path::Path) -> Vec<SessionInfo> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
continue;
};
let Some(pid) = value["pid"].as_u64() else {
continue;
};
out.push(SessionInfo {
pid: u32::try_from(pid).unwrap_or(0),
socket: value["socket"].as_str().unwrap_or("").to_string(),
started: value["started"].as_str().unwrap_or("").to_string(),
alive: pid_alive(pid),
});
}
out.sort_by(|a, b| b.started.cmp(&a.started));
out
}
fn pid_alive(pid: u64) -> bool {
if pid == 0 {
return false;
}
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
pub fn default_socket() -> PathBuf {
ApprovalIpc::default_socket_path()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn live_sessions_parses_and_flags_liveness() {
let dir = tempfile::TempDir::new().unwrap();
let sessions = dir.path().join("sessions");
std::fs::create_dir_all(&sessions).unwrap();
std::fs::write(
sessions.join("100.json"),
serde_json::json!({
"pid": std::process::id(),
"socket": "/runtime/approval.sock",
"started": "2026-08-23T01:00:00Z",
})
.to_string(),
)
.unwrap();
std::fs::write(
sessions.join("200.json"),
serde_json::json!({
"pid": 4_000_000u64,
"socket": "/runtime/approval.sock",
"started": "2026-08-23T02:00:00Z",
})
.to_string(),
)
.unwrap();
std::fs::write(sessions.join("300.json"), "not json").unwrap();
std::fs::write(sessions.join("readme.txt"), "hello").unwrap();
let found = live_sessions_at(&sessions);
assert_eq!(found.len(), 2, "{found:?}");
assert_eq!(found[0].pid, 4_000_000);
assert!(!found[0].alive, "pid 4000000 must read as dead");
assert_eq!(found[1].pid, std::process::id());
assert!(found[1].alive, "own pid must read as alive");
assert_eq!(found[1].socket, "/runtime/approval.sock");
}
#[test]
fn live_sessions_missing_dir_is_empty() {
assert!(live_sessions_at(std::path::Path::new("/nonexistent/nowhere")).is_empty());
}
}