Something went wrong. Try again.
daemon connecting Claude Code sessions to the JamJum JP-mini via usb or bluetooth
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591//! Daemon: reacts to pad presses, hook events and timer ticks.
use std::io;use std::path::Path;use std::sync::Arc;use std::sync::mpsc::{self, RecvTimeoutError};use std::thread;use std::time::{Duration, Instant};
use crate::context::ContextFill;use crate::desktop::Desktop;use crate::drivers::{self, Driver, Health, PressSink};use crate::event::Event;use crate::profile::{self, PadEvent, Profile};use crate::registry::Registry;use crate::session::SessionKey;use crate::terminal::Terminal;use crate::{ipc, midi};
const TICK: Duration = Duration::from_secs(2);const PRUNE_EVERY: Duration = Duration::from_secs(5 * 60);
enum Msg { Hook(Event), Press(usize), Release(usize), Context(ContextFill),}
/// Focuses the session in `slot`. An iTerm2 pane is unregistered first if iTerm2 confirms it is/// gone; a Desktop session opens in Claude Desktop, which resumes it if its process stopped./// An empty pad opens a new iTerm2 window and reserves the pad for it; pushing a reserved pad/// again focuses that window.pub fn handle_press( reg: &mut Registry, term: &dyn Terminal, desktop: &dyn Desktop, slot: usize, now: Instant,) { let uuid = match ( reg.session_at(slot).map(|s| &s.key), reg.reserved_at(slot, now), ) { (Some(SessionKey::Desktop(id)), _) => return focus_desktop(desktop, slot, id), (Some(SessionKey::ITerm(uuid)), _) => uuid.clone(), (None, Some(reserved)) => reserved.to_string(), (None, None) => return open_window(reg, term, slot), }; if term.live_sessions().is_ok_and(|live| !live.contains(&uuid)) { let registered = reg.session_at(slot).is_some(); reg.remove_slot(slot); if registered { log!("slot {slot}: pane {uuid} is gone, unregistered"); } else { log!("slot {slot}: window of pane {uuid} is gone, reservation dropped"); } return; } match term.focus(&uuid) { Ok(()) => log!("slot {slot}: focused pane {uuid}"), Err(e) => log!("slot {slot}: focusing pane {uuid} failed: {e}"), }}
fn focus_desktop(desktop: &dyn Desktop, slot: usize, id: &str) { match desktop.focus(id) { Ok(()) => log!("slot {slot}: opened Desktop session {id}"), Err(e) => log!("slot {slot}: opening Desktop session {id} failed: {e}"), }}
fn open_window(reg: &mut Registry, term: &dyn Terminal, slot: usize) { let window = match term.new_window() { Ok(w) => w, Err(e) => return log!("slot {slot}: opening a new window failed: {e}"), }; match window.session { Some(id) => { // The pulse and the 60 s count start once the window exists, not at the push. reg.reserve(slot, &id, Instant::now()); log!("slot {slot}: opened a new window, pad reserved for pane {id}"); } None => log!("slot {slot}: opened a new window"), } if let Err(e) = window.activation { log!("slot {slot}: bringing iTerm2 to the front failed: {e}"); }}
/// Unregisters iTerm2 panes the terminal confirms are gone; other kinds of sessions are not/// iTerm2's to judge. A failed listing changes nothing.pub fn prune(reg: &mut Registry, term: &dyn Terminal) -> bool { match term.live_sessions() { Ok(live) => reg.retain(|key| match key { SessionKey::ITerm(uuid) => live.contains(uuid), SessionKey::Desktop(_) => true, }), Err(e) => { log!("pruning skipped: {e}"); false } }}
/// Unregisters Desktop sessions whose Claude Code process is gone; other kinds of sessions are/// not Claude Desktop's to judge. A failed listing changes nothing.pub fn prune_desktop(reg: &mut Registry, desktop: &dyn Desktop) -> bool { match desktop.running_sessions() { Ok(running) => { let gone: Vec<String> = reg .keys() .filter_map(|key| match key { SessionKey::Desktop(id) if !running.contains(id) => Some(id.clone()), _ => None, }) .collect(); for id in &gone { log!("Desktop session {id} has no Claude Code process, unregistered"); } reg.retain(|key| match key { SessionKey::Desktop(id) => running.contains(id), SessionKey::ITerm(_) => true, }) } Err(e) => { log!("Desktop pruning skipped: {e}"); false } }}
pub fn run(socket: &Path, term: &dyn Terminal, desktop: &dyn Desktop) -> io::Result<()> { let profiles = profile::load_all(); if profiles.is_empty() { log!("no controller profiles loaded"); } // One driver per profile for the daemon's lifetime; `active` indexes the connected one. let mut controllers: Vec<(Arc<Profile>, Box<dyn Driver>)> = profiles .iter() .map(|p| (Arc::new(p.clone()), drivers::make(p))) .collect(); let mut active: Option<usize> = None; let listener = ipc::listen(socket)?; log!("listening on {}", socket.display()); let (tx, rx) = mpsc::channel();
let hook_tx = tx.clone(); thread::spawn(move || { ipc::serve(listener, |line| { let msg = Event::from_wire(&line) .map(Msg::Hook) .or_else(|| ContextFill::from_wire(&line).map(Msg::Context)); match msg { Some(m) => { let _ = hook_tx.send(m); } None => log!("dropped malformed event line"), } }) });
// The press sink of a controller: decodes its input with its profile. let sink_for = |profile: &Arc<Profile>| -> PressSink { let press_tx = tx.clone(); let profile = Arc::clone(profile); Arc::new(move |msg: &[u8]| { let msg = match profile.decode(msg) { Some(PadEvent::Press(slot)) => Msg::Press(slot), Some(PadEvent::Release(slot)) => Msg::Release(slot), None => return, }; let _ = press_tx.send(msg); }) };
let probe = midi::Probe::new().map_err(io::Error::other)?; let mut reg = Registry::new(); let mut wake: Option<Duration> = None; let mut last_probe: Option<Instant> = None; let mut last_prune = Instant::now();
loop { midi::pump(); let since_probe = last_probe.map_or(TICK, |t| t.elapsed()); let timeout = TICK .saturating_sub(since_probe) .min(wake.unwrap_or(TICK)) .max(Duration::from_millis(1)); let msg = rx.recv_timeout(timeout); let now = Instant::now(); // one clock reading per iteration reg.expire_reservations(now); let mut full = false; match msg { Ok(Msg::Hook(e)) => { reg.apply_at(&e, now); } Ok(Msg::Press(slot)) => { handle_press(&mut reg, term, desktop, slot, now); full = true; // repaint after every press: it also fixes a switched bank } Ok(Msg::Release(slot)) => { if let Some(i) = active { controllers[i].1.released(slot); } } Ok(Msg::Context(c)) => { reg.set_fill(&c.session, c.fill); } Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => return Ok(()), } if last_probe.is_none_or(|t| now.duration_since(t) >= TICK) { last_probe = Some(now); let ports = probe.ports(); if let Some(i) = active { let (profile, driver) = &mut controllers[i]; match driver.check(&ports, now) { Health::Same => {} Health::Repaint => full = true, Health::Gone => { let connection = driver.connection().unwrap_or("none"); log!("controller disconnected ({}, {connection})", profile.id); driver.disconnect(); active = None; } } } if active.is_none() { let cands = drivers::candidates(&profiles, |p| { controllers[index_of(&profiles, p)].1.available(&ports) }); active = drivers::first_connecting(&cands, |p| { let i = index_of(&profiles, p); let (profile, driver) = &mut controllers[i]; driver.connect(&ports, sink_for(profile), now).map(|()| i) }) .map(|(p, i)| { let connection = controllers[i].1.connection().unwrap_or("none"); log!("controller connected ({}, {connection})", p.id); reg.set_capacity(Some(p.pads.len())); full = true; // a new link must get the grid even if it looks unchanged i }); } if active.is_none() { reg.set_capacity(None); } } if last_prune.elapsed() >= PRUNE_EVERY { last_prune = Instant::now(); prune(&mut reg, term); prune_desktop(&mut reg, desktop); } wake = match active { None => None, Some(i) => { let (profile, driver) = &mut controllers[i]; match driver.update(®.views(now), now, reg.epoch(), full) { Ok(wake) => wake, Err(_) => { let connection = driver.connection().unwrap_or("none"); log!("send failed ({}, {connection}), will reconnect", profile.id); driver.disconnect(); active = None; None } } } }; }}
/// The position of `profile` (a candidate from `profiles`) in `profiles`; ids are unique.fn index_of(profiles: &[Profile], profile: &Profile) -> usize { profiles .iter() .position(|p| p.id == profile.id) .expect("a candidate comes from the loaded profiles")}
#[cfg(test)]mod tests { use super::*; use crate::desktop::ClaudeDesktop; use crate::event::{test_event, test_session_event}; use crate::registry::RESERVATION; use crate::terminal::NewWindow; use serde_json::json; use std::cell::RefCell; use std::collections::HashSet;
struct Fake { live: Option<HashSet<String>>, focused: RefCell<Vec<String>>, new_window: Option<Option<String>>, // None: opening fails; Some(session) windows_opened: RefCell<usize>, live_calls: RefCell<usize>, }
impl Fake { fn new(live: Option<&[&str]>) -> Fake { Fake { live: live.map(|l| l.iter().map(|s| s.to_string()).collect()), focused: RefCell::new(Vec::new()), new_window: Some(Some("NEW".to_string())), windows_opened: RefCell::new(0), live_calls: RefCell::new(0), } } }
impl Terminal for Fake { fn live_sessions(&self) -> io::Result<HashSet<String>> { *self.live_calls.borrow_mut() += 1; self.live .clone() .ok_or_else(|| io::Error::other("iTerm2 down")) } fn focus(&self, id: &str) -> io::Result<()> { self.focused.borrow_mut().push(id.to_string()); Ok(()) } fn new_window(&self) -> io::Result<NewWindow> { *self.windows_opened.borrow_mut() += 1; let session = self .new_window .clone() .ok_or_else(|| io::Error::other("iTerm2 down"))?; Ok(NewWindow { session, activation: Ok(()), }) } }
fn with_desktop(mut r: Registry, ids: &[&str]) -> Registry { for id in ids { r.apply(&test_session_event( SessionKey::Desktop(id.to_string()), "Stop", json!({}), )); } r }
#[test] fn iterm_prune_keeps_desktop_sessions() { let mut r = with_desktop(registry_with(&["A"]), &["local_a"]); assert!(prune(&mut r, &Fake::new(Some(&[])))); assert!(r.session_at(0).is_none(), "gone iTerm2 pane removed"); assert!( r.session_at(1).is_some(), "Desktop session is not iTerm2's to judge" ); }
struct FakeDesktop { running: Option<HashSet<String>>, focused: RefCell<Vec<String>>, focus_fails: bool, }
impl FakeDesktop { fn new(running: Option<&[&str]>) -> FakeDesktop { FakeDesktop { running: running.map(|l| l.iter().map(|s| s.to_string()).collect()), focused: RefCell::new(Vec::new()), focus_fails: false, } } }
impl Desktop for FakeDesktop { fn running_sessions(&self) -> io::Result<HashSet<String>> { self.running .clone() .ok_or_else(|| io::Error::other("registry unreadable")) } fn focus(&self, id: &str) -> io::Result<()> { self.focused.borrow_mut().push(id.to_string()); if self.focus_fails { Err(io::Error::other("open failed")) } else { Ok(()) } } }
#[test] fn press_on_desktop_session_opens_it_in_claude_desktop() { let mut r = with_desktop(registry_with(&["A"]), &["local_a"]); let t = Fake::new(Some(&["A"])); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 1, Instant::now()); assert_eq!(*d.focused.borrow(), vec!["local_a"]); assert!(t.focused.borrow().is_empty()); assert!( r.session_at(1).is_some(), "no liveness check before the push" ); }
#[test] fn press_on_desktop_session_never_asks_iterm() { let mut r = with_desktop(Registry::new(), &["local_a"]); let t = Fake::new(None); // iTerm2 unreachable let d = FakeDesktop::new(None); handle_press(&mut r, &t, &d, 0, Instant::now()); assert_eq!(*d.focused.borrow(), vec!["local_a"]); assert_eq!(*t.live_calls.borrow(), 0); assert!(t.focused.borrow().is_empty()); assert_eq!(*t.windows_opened.borrow(), 0); }
#[test] fn a_failed_open_keeps_the_pad() { let mut r = with_desktop(Registry::new(), &["local_a"]); let mut d = FakeDesktop::new(Some(&[])); d.focus_fails = true; handle_press(&mut r, &Fake::new(Some(&[])), &d, 0, Instant::now()); assert!(r.session_at(0).is_some()); }
#[test] fn desktop_prune_removes_only_desktop_sessions_without_a_process() { let mut r = with_desktop(registry_with(&["A"]), &["local_a", "local_b"]); assert!( !prune_desktop(&mut r, &FakeDesktop::new(None)), "unreadable registry changes nothing" ); assert!(prune_desktop(&mut r, &FakeDesktop::new(Some(&["local_b"])))); assert!( r.session_at(0).is_some(), "iTerm2 pane is not Desktop's to judge" ); assert!(r.session_at(1).is_none(), "local_a has no process"); assert!(r.session_at(2).is_some()); }
#[test] fn desktop_prune_reads_claude_codes_session_registry() { let dir = std::env::temp_dir().join(format!("padmon-prune-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let me = std::process::id(); std::fs::write( dir.join(format!("{me}.json")), json!({"pid": me, "hostSessionId": "local_live"}).to_string(), ) .unwrap(); let mut r = with_desktop(Registry::new(), &["local_live", "local_gone"]); assert!(!prune_desktop( &mut r, &ClaudeDesktop::at(dir.join("missing")) )); assert!( r.session_at(1).is_some(), "missing registry removes nothing" ); assert!(prune_desktop(&mut r, &ClaudeDesktop::at(dir.clone()))); assert!(r.session_at(0).is_some(), "its process runs"); assert!(r.session_at(1).is_none(), "no registry file"); std::fs::remove_dir_all(&dir).unwrap(); }
fn registry_with(panes: &[&str]) -> Registry { let mut r = Registry::new(); for p in panes { r.apply(&test_event(p, "Stop", json!({}))); } r }
#[test] fn press_focuses_live_pane_exactly_once() { let mut r = registry_with(&["A", "B"]); let t = Fake::new(Some(&["A", "B"])); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 1, Instant::now()); assert_eq!(*t.focused.borrow(), vec!["B"]); }
#[test] fn press_on_empty_slot_focuses_nothing_and_opens_one_window() { let mut r = registry_with(&["A"]); let t = Fake::new(Some(&["A"])); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 5, Instant::now()); assert!(t.focused.borrow().is_empty()); assert_eq!(*t.windows_opened.borrow(), 1); }
#[test] fn press_on_gone_pane_unregisters_without_focus() { let mut r = registry_with(&["A"]); let t = Fake::new(Some(&[])); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 0, Instant::now()); assert!(t.focused.borrow().is_empty()); assert!(r.session_at(0).is_none()); }
#[test] fn press_when_listing_fails_still_tries_focus_and_keeps_pane() { let mut r = registry_with(&["A"]); let t = Fake::new(None); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 0, Instant::now()); assert_eq!(*t.focused.borrow(), vec!["A"]); assert!(r.session_at(0).is_some()); }
#[test] fn prune_removes_only_confirmed_gone() { let mut r = registry_with(&["A", "B"]); assert!( !prune(&mut r, &Fake::new(None)), "listing failure changes nothing" ); assert!(r.session_at(0).is_some() && r.session_at(1).is_some()); assert!(prune(&mut r, &Fake::new(Some(&["B"])))); assert!(r.session_at(0).is_none() && r.session_at(1).is_some()); }
#[test] fn press_on_empty_slot_opens_a_window_and_reserves_the_pad() { let mut r = registry_with(&["A"]); let t = Fake::new(Some(&["A"])); let now = Instant::now(); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 5, now); assert_eq!(*t.windows_opened.borrow(), 1); assert!(t.focused.borrow().is_empty()); assert_eq!(r.reserved_at(5, now), Some("NEW")); }
#[test] fn press_on_reserved_pad_focuses_its_window_instead_of_opening_another() { let mut r = registry_with(&[]); let t = Fake::new(Some(&["NEW"])); let now = Instant::now(); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 3, now); handle_press(&mut r, &t, &d, 3, now); assert_eq!(*t.windows_opened.borrow(), 1); assert_eq!(*t.focused.borrow(), vec!["NEW"]); }
#[test] fn failed_or_unidentified_window_reserves_nothing() { let now = Instant::now(); let mut r = registry_with(&[]); let mut t = Fake::new(Some(&[])); t.new_window = None; let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 2, now); assert_eq!(r.reserved_at(2, now), None); t.new_window = Some(None); handle_press(&mut r, &t, &d, 2, now); assert_eq!(r.reserved_at(2, now), None); assert_eq!(*t.windows_opened.borrow(), 2); }
#[test] fn press_on_reserved_pad_whose_window_is_gone_drops_the_reservation() { let now = Instant::now(); let mut r = registry_with(&[]); r.reserve(4, "NEW", now); let t = Fake::new(Some(&[])); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 4, now); assert!(t.focused.borrow().is_empty()); assert_eq!(*t.windows_opened.borrow(), 0); assert_eq!(r.reserved_at(4, now), None); }
#[test] fn press_after_the_reservation_expired_opens_a_new_window() { let now = Instant::now(); let mut r = registry_with(&[]); r.reserve(4, "OLD", now); let t = Fake::new(Some(&["OLD"])); let d = FakeDesktop::new(Some(&[])); handle_press(&mut r, &t, &d, 4, now + RESERVATION); assert_eq!(*t.windows_opened.borrow(), 1); assert_eq!(r.reserved_at(4, now + RESERVATION), Some("NEW")); }}