use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use std::time::SystemTime;
use chrono::{DateTime, Utc};
use crate::config::Config;
use crate::hooks::binding::{EndpointConvention, ProxyDelivery};
use crate::hooks::cline_app::process::ExeProcess;
use crate::model_relay::preflight::{Verdict as Path_, WiringState};
use crate::model_relay::readiness::{
self, Age, AgentReadiness, Facts, Restart, RunningSurface, SurfaceDecl, Traffic,
};
static WIRED_SINCE: std::sync::RwLock<BTreeMap<String, SystemTime>> =
std::sync::RwLock::new(BTreeMap::new());
pub(crate) const PREDATING_NOTICE: &str = "OpenLatch: this session started before OpenLatch \
connected its model calls, so they go straight to the provider and are not captured. Start a \
new session to have them captured.";
pub(crate) fn notice_event(source: &str) -> Option<crate::envelope::HookEventType> {
use crate::envelope::HookEventType;
match source {
"claude-code" => Some(HookEventType::UserPromptSubmit),
"codex-cli" | "cline" => Some(HookEventType::PreToolUse),
_ => None,
}
}
pub(crate) fn predating_notice(
registry: &crate::model_relay::session::SessionRegistry,
install_id: &str,
source: &str,
session: &str,
event: &crate::envelope::HookEventType,
) -> Option<&'static str> {
if notice_event(source).as_ref() != Some(event) {
return None;
}
let wired_since = WIRED_SINCE
.read()
.ok()
.and_then(|m| m.get(source).copied())?;
registry
.take_predating_notice(install_id, source, session, wired_since)
.then_some(PREDATING_NOTICE)
}
const READINESS_TICK: std::time::Duration = std::time::Duration::from_secs(20);
const STARTUP_TICK: std::time::Duration = std::time::Duration::from_secs(2);
const STARTUP_WINDOW: std::time::Duration = std::time::Duration::from_secs(90);
pub(super) fn spawn(
config: Arc<Config>,
port: u16,
wiring: Arc<WiringState>,
mut shutdown: tokio::sync::watch::Receiver<bool>,
) {
let ol_dir = crate::config::openlatch_dir();
let untracked = untracked_upgrade(&ol_dir);
tokio::spawn(async move {
let mut ledger = readiness::read_traffic(&ol_dir);
let started = std::time::Instant::now();
let mut delay = std::time::Duration::ZERO;
loop {
tokio::select! {
_ = tokio::time::sleep(delay) => {}
_ = shutdown.changed() => return,
}
delay = if started.elapsed() < STARTUP_WINDOW {
STARTUP_TICK
} else {
READINESS_TICK
};
let (config, wiring, dir, prior) = (
config.clone(),
wiring.clone(),
ol_dir.clone(),
ledger.clone(),
);
let pass = tokio::task::spawn_blocking(move || {
let mut ledger = prior;
let agents = pass(&config, port, &wiring, &dir, &mut ledger, untracked);
readiness::write_if_changed(&dir, agents);
ledger
})
.await;
match pass {
Ok(next) => {
if let Ok(mut m) = WIRED_SINCE.write() {
*m = next
.iter()
.map(|(a, t)| (a.clone(), SystemTime::from(t.wired_since)))
.collect();
}
if next != ledger {
if let Err(e) = readiness::write_traffic(&ol_dir, &next) {
tracing::warn!(error = %e, "could not write the relay traffic ledger");
}
ledger = next;
}
}
Err(e) => tracing::warn!(error = %e, "relay readiness pass failed"),
}
}
});
}
fn pass(
config: &Config,
port: u16,
wiring: &WiringState,
ol_dir: &Path,
ledger: &mut BTreeMap<String, Traffic>,
untracked: bool,
) -> BTreeMap<String, AgentReadiness> {
let now = SystemTime::now();
let traffic = wiring.traffic();
let agents: Vec<_> = crate::hooks::detect_agents()
.into_iter()
.filter(|a| a.binding.model_relay_wiring().is_some())
.collect();
let decls: Vec<(&'static str, SurfaceDecl)> = agents
.iter()
.filter(|a| wiring.is_wired(a.agent_type()))
.flat_map(|a| {
let agent = a.agent_type();
a.binding
.relay_surfaces()
.into_iter()
.map(move |d| (agent, d))
})
.collect();
let table = list_processes(&decls);
let mut out = BTreeMap::new();
for a in &agents {
let agent = a.agent_type();
let path = wiring.verdict(agent);
let (wired_since, captured_at) = match &path {
Path_::Failed(_) => {
ledger.remove(agent);
(DateTime::<Utc>::from(now), None)
}
Path_::Ok if wiring.is_wired(agent) => {
let record = record_for(ledger, agent, || {
first_wiring_time(untracked, wiring.wired_at(agent).unwrap_or(now))
});
if record.captured_at.is_none() {
record.captured_at = traffic
.get(agent)
.copied()
.map(DateTime::<Utc>::from)
.filter(|t| *t >= record.wired_since);
}
(record.wired_since, record.captured_at)
}
_ => continue,
};
let mut running = table
.as_ref()
.map(|rows| surfaces_of(agent, &decls, rows))
.map_err(Clone::clone);
if let Ok(r) = running.as_mut() {
r.extend(hub_surfaces(config, &*a.binding));
}
let facts = Facts {
path,
delivery: delivery(&*a.binding, port, ol_dir),
wired_since: wired_since.into(),
captured_at: captured_at.map(Into::into),
running,
};
if let Some(verdict) = readiness::judge(&facts) {
out.insert(
agent.to_string(),
AgentReadiness {
verdict,
wired_since,
},
);
}
}
out
}
fn record_for<'a>(
ledger: &'a mut BTreeMap<String, Traffic>,
agent: &str,
wired_at: impl FnOnce() -> SystemTime,
) -> &'a mut Traffic {
ledger.entry(agent.to_string()).or_insert_with(|| Traffic {
captured_at: None,
wired_since: DateTime::<Utc>::from(wired_at()),
})
}
fn untracked_upgrade(ol_dir: &Path) -> bool {
!readiness::traffic_path(ol_dir).exists() && ol_dir.join("model-relay-endpoints.json").exists()
}
fn first_wiring_time(untracked: bool, wired_at: SystemTime) -> SystemTime {
if untracked {
SystemTime::UNIX_EPOCH
} else {
wired_at
}
}
fn list_processes(decls: &[(&'static str, SurfaceDecl)]) -> Result<Vec<ExeProcess>, String> {
let roots: Vec<std::path::PathBuf> = decls
.iter()
.flat_map(|(_, d)| d.roots.iter().cloned())
.collect();
crate::hooks::cline_app::process::processes_under(&roots)
}
fn surfaces_of(
agent: &str,
decls: &[(&'static str, SurfaceDecl)],
rows: &[ExeProcess],
) -> Vec<RunningSurface> {
let key = crate::core::path_compat::dedup_key;
decls
.iter()
.filter(|(a, _)| *a == agent)
.flat_map(|(_, d)| {
let roots: Vec<_> = d.roots.iter().map(|r| key(r)).collect();
rows.iter()
.filter(move |p| roots.iter().any(|r| key(&p.exe).starts_with(r)))
.filter(|p| (d.counts)(p))
.filter_map(move |p| {
p.started.map(|t| RunningSurface {
name: d.name,
age: Age::Started(t),
picks_up_live: d.picks_up_live,
restart: d.restart,
})
})
})
.collect()
}
fn hub_surfaces(
config: &Config,
binding: &dyn crate::hooks::binding::AgentBinding,
) -> Vec<RunningSurface> {
use crate::hooks::cline_app::HubFinding;
let agent = binding.agent_type();
if agent != crate::hooks::bindings::cline::ClineBinding::AGENT_TYPE
|| !super::cline_hub::manages_hub(config, binding)
{
return Vec::new();
}
let finding = crate::hooks::cline_app::hub_finding(
crate::hooks::proxy_env_file::live_proxy_url(agent).is_some(),
&super::cline_delivery::read_hub_desired(&crate::config::openlatch_dir()),
);
let (name, restart) = match finding {
Some(HubFinding::WaitingForRestart { .. } | HubFinding::OlderApp { .. }) => {
("Cline.app hub", Restart::ByOpenLatch)
}
Some(HubFinding::AppKeptItsOwn { .. }) => ("Cline.app", Restart::QuitAndReopen),
_ => return Vec::new(),
};
vec![RunningSurface {
name,
age: Age::Predates,
picks_up_live: false,
restart,
}]
}
fn delivery(
binding: &dyn crate::hooks::binding::AgentBinding,
port: u16,
ol_dir: &Path,
) -> Result<(), (String, String)> {
let Some(EndpointConvention::ProxyEnv { delivery, .. }) =
binding.model_relay_wiring().map(|w| w.endpoint)
else {
return Ok(());
};
let agent = binding.agent_type();
let name = binding.display_name();
for d in delivery {
match d {
ProxyDelivery::SettingsKey { file, key, .. } => {
let Some(file) = file() else { continue };
use crate::hooks::proxy_settings::{observe_key, Observed};
match observe_key(&file, key, agent) {
Ok(Observed::Foreign(_)) => {
return Err((
format!(
"`{key}` in {} names another proxy, so {name} goes direct",
file.display()
),
format!(
"OpenLatch never overwrites a proxy you set. Remove `{key}` to \
route {name} through OpenLatch."
),
))
}
Ok(Observed::Unparseable(_)) => {
return Err((
format!(
"{} cannot be read safely, so `{key}` is not set",
file.display()
),
"Fix the file's syntax, then run `openlatch doctor --fix`.".into(),
))
}
_ => {}
}
}
ProxyDelivery::EnvFile { .. } => {
let ours = format!("http://127.0.0.1:{port}");
if crate::hooks::proxy_env_file::live_proxy_url(agent).as_deref() != Some(&ours) {
return Err((
format!("{name}'s launcher environment is not in place"),
"Run `openlatch doctor --fix`.".into(),
));
}
let ca = crate::model_relay::ca::ca_dir(ol_dir);
if crate::model_relay::ca::inspect(&ca).is_none() {
return Err((
format!(
"{} holds no certificate, so {name} would refuse the relay",
crate::model_relay::ca::ca_pem_path(&ca).display()
),
"Run `openlatch doctor --fix`.".into(),
));
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::Duration;
fn t(secs: u64) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_800_000_000 + secs)
}
fn decl(name: &'static str, root: &str, restart: Restart) -> SurfaceDecl {
SurfaceDecl {
name,
roots: vec![PathBuf::from(root)],
counts: |_| true,
picks_up_live: false,
restart,
}
}
fn row(exe: &str, started: u64) -> ExeProcess {
ExeProcess {
exe: PathBuf::from(exe),
args: vec![exe.to_string()],
started: Some(t(started)),
}
}
#[test]
fn surfaces_are_assigned_to_their_own_agent_only() {
let decls = vec![
(
"cursor",
decl("Cursor", "/Applications/Cursor.app", Restart::QuitAndReopen),
),
(
"claude-code",
decl(
"Claude Code session",
"/h/.local/share/claude",
Restart::NewSession,
),
),
];
let rows = vec![
row("/Applications/Cursor.app/Contents/MacOS/Cursor", 10),
row("/h/.local/share/claude/versions/2.1.0", 20),
row("/usr/bin/vim", 30),
];
let cursor = surfaces_of("cursor", &decls, &rows);
assert_eq!(cursor.len(), 1);
assert_eq!(cursor[0].name, "Cursor");
let claude = surfaces_of("claude-code", &decls, &rows);
assert_eq!(claude.len(), 1);
assert_eq!(claude[0].age, Age::Started(t(20)));
assert!(surfaces_of("codex-cli", &decls, &rows).is_empty());
}
#[test]
fn a_process_with_no_start_time_is_never_judged() {
let decls = vec![("cursor", decl("Cursor", "/A", Restart::QuitAndReopen))];
let rows = vec![ExeProcess {
exe: PathBuf::from("/A/x"),
args: vec![],
started: None,
}];
assert!(surfaces_of("cursor", &decls, &rows).is_empty());
}
#[test]
fn a_new_wiring_gets_a_record_and_an_old_one_keeps_it() {
let mut ledger = BTreeMap::new();
let first = record_for(&mut ledger, "cursor", || t(100)).wired_since;
assert_eq!(first, DateTime::<Utc>::from(t(100)));
let again = record_for(&mut ledger, "cursor", || t(500)).wired_since;
assert_eq!(again, DateTime::<Utc>::from(t(100)));
}
#[test]
fn a_session_live_before_the_wiring_is_told_once_on_its_notice_event() {
use crate::envelope::HookEventType;
let reg = crate::model_relay::session::SessionRegistry::default();
reg.upsert("agt", "agt", "claude-code", "old");
std::thread::sleep(Duration::from_millis(5));
WIRED_SINCE
.write()
.unwrap()
.insert("claude-code".into(), SystemTime::now());
std::thread::sleep(Duration::from_millis(5));
reg.upsert("agt", "agt", "claude-code", "new");
let prompt = HookEventType::UserPromptSubmit;
assert_eq!(
predating_notice(
®,
"agt",
"claude-code",
"old",
&HookEventType::PreToolUse
),
None,
"Claude Code is told on a prompt, never on a tool call"
);
assert_eq!(
predating_notice(®, "agt", "claude-code", "old", &prompt),
Some(PREDATING_NOTICE)
);
assert_eq!(
predating_notice(®, "agt", "claude-code", "old", &prompt),
None,
"once per session"
);
assert_eq!(
predating_notice(®, "agt", "claude-code", "new", &prompt),
None
);
assert_eq!(
predating_notice(®, "agt", "cursor", "old", &prompt),
None,
"no notice channel"
);
}
#[test]
fn an_upgrade_counts_running_processes_as_current_and_a_first_install_does_not() {
let dir = tempfile::tempdir().unwrap();
assert!(
!untracked_upgrade(dir.path()),
"first install: no endpoint ledger yet"
);
std::fs::write(dir.path().join("model-relay-endpoints.json"), "{}").unwrap();
assert!(
untracked_upgrade(dir.path()),
"wired before, never tracked: an upgrade"
);
readiness::write_traffic(dir.path(), &BTreeMap::new()).unwrap();
assert!(
!untracked_upgrade(dir.path()),
"tracked since: the ledger dates it"
);
assert_eq!(first_wiring_time(true, t(5)), SystemTime::UNIX_EPOCH);
assert_eq!(first_wiring_time(false, t(5)), t(5));
}
#[test]
fn a_base_url_agent_has_no_delivery_to_check() {
let b = crate::hooks::bindings::claude_code::ClaudeCodeBinding {
claude_dir: PathBuf::from("/nonexistent/.claude"),
settings_path: PathBuf::from("/nonexistent/.claude/settings.json"),
};
assert_eq!(delivery(&b, 7600, Path::new("/nonexistent")), Ok(()));
}
}