use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::hooks::cline_app::{self, process::ProcessTable};
use crate::hooks::cline_hosts::{self, HostClass};
use crate::hooks::cline_runtime;
use super::AppState;
pub(crate) const SEEN_FILE: &str = "cline-plugin-seen.json";
const CACHE_TTL: Duration = Duration::from_secs(10 * 60);
const NEGATIVE_TTL: Duration = Duration::from_secs(60);
const CACHE_MAX: usize = 256;
const PERSIST_EVERY: Duration = Duration::from_secs(30);
pub(crate) fn seen_path(ol_dir: &Path) -> PathBuf {
ol_dir.join("state").join(SEEN_FILE)
}
pub(crate) fn read_seen(ol_dir: &Path) -> BTreeMap<HostClass, i64> {
let Ok(raw) = std::fs::read_to_string(seen_path(ol_dir)) else {
return BTreeMap::new();
};
let Ok(serde_json::Value::Object(map)) = serde_json::from_str::<serde_json::Value>(&raw) else {
return BTreeMap::new();
};
map.into_iter()
.filter_map(|(k, v)| {
let class: HostClass = serde_json::from_value(serde_json::Value::String(k)).ok()?;
Some((class, v.as_i64()?))
})
.collect()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginHosts {
pub(crate) delivery: Option<super::cline_delivery::DeliveryState>,
pub(crate) seen: BTreeMap<HostClass, i64>,
}
impl PluginHosts {
pub(crate) fn read(ol_dir: &Path) -> Self {
Self {
delivery: super::cline_delivery::read_state(ol_dir),
seen: read_seen(ol_dir),
}
}
pub(crate) fn entry(&self, class: HostClass) -> Option<&super::cline_delivery::HostDelivery> {
let delivery = self.delivery.as_ref()?;
match class {
HostClass::Hub => delivery.hub.as_ref(),
HostClass::VsCode => delivery.vscode.as_ref(),
HostClass::Cli => None,
}
}
pub(crate) fn seen_ms(&self, class: HostClass) -> Option<i64> {
self.seen.get(&class).copied()
}
pub(crate) fn loaded(&self, class: HostClass) -> bool {
match class {
HostClass::Cli => self.seen_ms(class).is_some(),
HostClass::Hub | HostClass::VsCode => {
let Some(entry) = self.entry(class) else {
return false;
};
match (entry.delivered, entry.since, self.seen_ms(class)) {
(true, Some(since), Some(seen)) => seen >= since,
_ => false,
}
}
}
}
pub(crate) fn loaded_on(&self) -> Vec<HostClass> {
HostClass::ALL
.into_iter()
.filter(|c| self.loaded(*c))
.collect()
}
pub(crate) fn not_yet(&self) -> Vec<(HostClass, String)> {
HostClass::ALL
.into_iter()
.filter(|c| !self.loaded(*c))
.filter_map(|c| {
let entry = self.entry(c)?;
let reason = entry.reason.clone().unwrap_or_else(|| {
crate::hooks::cline_runtime::reason::PENDING_RESTART.to_string()
});
Some((c, reason))
})
.collect()
}
pub(crate) fn not_yet_summary(
&self,
item: impl Fn(&str, &str) -> String,
sep: &str,
) -> Option<String> {
let items: Vec<String> = self
.not_yet()
.into_iter()
.map(|(class, reason)| item(class.as_str(), &reason))
.collect();
(!items.is_empty()).then(|| items.join(sep))
}
pub(crate) fn any_delivered(&self) -> bool {
[HostClass::Hub, HostClass::VsCode]
.into_iter()
.any(|c| self.entry(c).is_some_and(|e| e.delivered))
}
pub(crate) fn any_loaded(&self) -> bool {
!self.loaded_on().is_empty()
}
pub(crate) fn classes(&self) -> Vec<HostClass> {
HostClass::ALL
.into_iter()
.filter(|c| self.entry(*c).is_some() || self.seen_ms(*c).is_some())
.collect()
}
pub(crate) fn to_json(&self) -> serde_json::Value {
let map: serde_json::Map<String, serde_json::Value> = self
.classes()
.into_iter()
.map(|c| {
let entry = self.entry(c);
(
c.as_str().to_string(),
serde_json::json!({
"delivered": entry.is_some_and(|e| e.delivered),
"seen_ms": self.seen_ms(c),
"reason": entry.and_then(|e| e.reason.clone()),
"core": entry.and_then(|e| e.core.clone()),
}),
)
})
.collect();
serde_json::Value::Object(map)
}
}
fn write_class(ol_dir: &Path, class: HostClass, ms: i64) -> std::io::Result<()> {
let path = seen_path(ol_dir);
let mut map = std::fs::read_to_string(&path)
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.and_then(|v| match v {
serde_json::Value::Object(map) => Some(map),
_ => None,
})
.unwrap_or_default();
map.insert(class.as_str().to_string(), serde_json::Value::from(ms));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::to_string_pretty(&serde_json::Value::Object(map))
.map_err(std::io::Error::other)?;
crate::fs_secure::write_owner_only(&path, &body)
}
pub(crate) fn classify(
exe: Option<&Path>,
sidecar: Option<&Path>,
vscode: Option<&Path>,
) -> Option<HostClass> {
let exe = exe?;
if sidecar.is_some_and(|s| cline_app::same_exe(Some(exe), s)) {
return Some(HostClass::Hub);
}
if vscode.is_some_and(|v| is_under_vscode(exe, v)) || is_vscode_name(exe) {
return Some(HostClass::VsCode);
}
Some(HostClass::Cli)
}
fn is_vscode_name(exe: &Path) -> bool {
let Some(name) = exe.file_name().and_then(|n| n.to_str()) else {
return false;
};
matches!(
name,
"Code" | "code" | "Code - Insiders" | "Code.exe" | "code-insiders"
) || name.starts_with("Code Helper")
}
fn is_under_vscode(exe: &Path, vscode: &Path) -> bool {
let root = vscode
.ancestors()
.filter(|a| a.extension().is_some_and(|e| e == "app"))
.last()
.or_else(|| vscode.parent());
let Some(root) = root else { return false };
match (cline_app::canon_key(exe), cline_app::canon_key(root)) {
(Some(exe), Some(root)) => exe.starts_with(root),
_ => false,
}
}
fn is_launcher(exe: Option<&Path>) -> bool {
exe.and_then(|e| e.file_name())
.and_then(|n| n.to_str())
.is_some_and(|n| {
HostClass::ALL
.into_iter()
.any(|c| n == cline_runtime::wrapper_file_name(c, true))
})
}
fn host_exe(table: &dyn ProcessTable, pid: u32) -> Option<PathBuf> {
let mut info = table.inspect(pid)?;
if is_launcher(info.exe.as_deref()) {
info = table.inspect(info.ppid?)?;
}
info.exe
}
type DetectedHosts = (Option<PathBuf>, Option<PathBuf>);
pub(crate) fn resolve_host_class(
table: &dyn ProcessTable,
pid: u32,
detect: impl FnOnce() -> DetectedHosts,
) -> Option<HostClass> {
let exe = host_exe(table, pid)?;
if is_vscode_name(&exe) {
return Some(HostClass::VsCode);
}
let (sidecar, vscode) = detect();
classify(Some(&exe), sidecar.as_deref(), vscode.as_deref())
}
fn detect_hosts() -> DetectedHosts {
let app = cline_app::detect();
let sidecar = app.as_ref().map(|a| a.sidecar.clone());
let vscode = cline_hosts::detect_with(&crate::config::openlatch_dir(), app)
.into_iter()
.find(|h| h.class == HostClass::VsCode)
.map(|h| h.runtime);
(sidecar, vscode)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Persist {
Now,
After(Duration),
Pending,
}
#[derive(Debug, Default)]
struct ClassSlot {
latest_ms: i64,
last_persist: Option<Instant>,
pending: bool,
}
fn ttl(class: Option<HostClass>) -> Duration {
if class.is_some() {
CACHE_TTL
} else {
NEGATIVE_TTL
}
}
#[derive(Default)]
pub struct PluginSeen {
cache: Mutex<HashMap<u32, (Option<HostClass>, Instant)>>,
resolving: Mutex<HashSet<u32>>,
classes: Mutex<HashMap<HostClass, ClassSlot>>,
write_lock: Mutex<()>,
}
impl PluginSeen {
fn cached(&self, pid: u32, now: Instant) -> Option<Option<HostClass>> {
let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
cache
.get(&pid)
.filter(|(class, at)| now.duration_since(*at) < ttl(*class))
.map(|(class, _)| *class)
}
fn begin_resolve(&self, pid: u32) -> bool {
self.resolving
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(pid)
}
fn remember(&self, pid: u32, class: Option<HostClass>, now: Instant) {
{
let mut cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if cache.len() >= CACHE_MAX {
cache.retain(|_, (class, at)| now.duration_since(*at) < ttl(*class));
if cache.len() >= CACHE_MAX {
if let Some(oldest) =
cache.iter().min_by_key(|(_, (_, at))| *at).map(|(p, _)| *p)
{
cache.remove(&oldest);
}
}
}
cache.insert(pid, (class, now));
}
self.resolving
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&pid);
}
fn write(&self, ol_dir: &Path, class: HostClass, ms: i64) -> std::io::Result<()> {
let _guard = self.write_lock.lock().unwrap_or_else(|e| e.into_inner());
write_class(ol_dir, class, ms)
}
fn note(&self, class: HostClass, now_ms: i64, now: Instant) -> Persist {
let mut classes = self.classes.lock().unwrap_or_else(|e| e.into_inner());
let slot = classes.entry(class).or_default();
slot.latest_ms = slot.latest_ms.max(now_ms);
if slot.pending {
return Persist::Pending;
}
match slot.last_persist {
Some(at) if now.duration_since(at) < PERSIST_EVERY => {
slot.pending = true;
Persist::After(PERSIST_EVERY - now.duration_since(at))
}
_ => {
slot.last_persist = Some(now);
Persist::Now
}
}
}
fn flush(&self, class: HostClass, now: Instant) -> i64 {
let mut classes = self.classes.lock().unwrap_or_else(|e| e.into_inner());
let slot = classes.entry(class).or_default();
slot.pending = false;
slot.last_persist = Some(now);
slot.latest_ms
}
}
pub(crate) fn record_plugin_host(state: &Arc<AppState>, pid: u32, now_ms: i64) {
match state.plugin_seen.cached(pid, Instant::now()) {
Some(Some(class)) => note_seen(state, class, now_ms),
Some(None) => {}
None => {
if !state.plugin_seen.begin_resolve(pid) {
return;
}
let state = state.clone();
tokio::spawn(async move {
let resolved = tokio::task::spawn_blocking(move || {
resolve_host_class(&*cline_app::process::table(), pid, detect_hosts)
})
.await
.ok()
.flatten();
state.plugin_seen.remember(pid, resolved, Instant::now());
match resolved {
Some(class) => note_seen(&state, class, now_ms),
None => tracing::debug!(pid, "Cline plugin host unreadable; not recorded"),
}
});
}
}
}
fn note_seen(state: &Arc<AppState>, class: HostClass, now_ms: i64) {
match state.plugin_seen.note(class, now_ms, Instant::now()) {
Persist::Now => {
tokio::spawn(persist(
state.clone(),
crate::config::openlatch_dir(),
class,
now_ms,
));
}
Persist::After(delay) => {
let state = state.clone();
let ol_dir = crate::config::openlatch_dir();
tokio::spawn(async move {
tokio::time::sleep(delay).await;
let ms = state.plugin_seen.flush(class, Instant::now());
persist(state, ol_dir, class, ms).await;
});
}
Persist::Pending => {}
}
}
async fn persist(state: Arc<AppState>, ol_dir: PathBuf, class: HostClass, ms: i64) {
let written =
tokio::task::spawn_blocking(move || state.plugin_seen.write(&ol_dir, class, ms)).await;
if let Ok(Err(e)) = written {
tracing::warn!(
code = crate::error::ERR_CLINE_PLUGIN_NOT_LOADED,
error = %e,
host = class.as_str(),
"could not record the Cline plugin host"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hooks::cline_app::process::{test_support::FakeTable, ProcInfo};
fn proc_info(exe: Option<&Path>, ppid: Option<u32>) -> ProcInfo {
ProcInfo {
exe: exe.map(Path::to_path_buf),
args: Vec::new(),
env: None,
start: None,
ppid,
}
}
fn touch(path: &Path) -> PathBuf {
std::fs::create_dir_all(path.parent().expect("a parent")).expect("mkdir");
std::fs::write(path, b"").expect("touch");
path.to_path_buf()
}
#[test]
fn classify_host_exe() {
let root = tempfile::tempdir().expect("tempdir");
let sidecar = touch(
&root
.path()
.join("Cline.app/Contents/Resources/code-sidecar"),
);
let vscode = touch(
&root
.path()
.join("Visual Studio Code.app/Contents/MacOS/Electron"),
);
let ext_host = touch(&root.path().join(
"Visual Studio Code.app/Contents/Frameworks/Code Helper (Plugin).app/Contents/MacOS/helper",
));
let node = touch(&root.path().join("bin/node"));
let classify = |exe: Option<&Path>| classify(exe, Some(&sidecar), Some(&vscode));
assert_eq!(classify(Some(&sidecar)), Some(HostClass::Hub));
assert_eq!(
classify(Some(Path::new(
"/Applications/Visual Studio Code.app/Contents/Frameworks/Code Helper (Plugin).app/Contents/MacOS/Code Helper (Plugin)"
))),
Some(HostClass::VsCode),
"VS Code's extension host, by name"
);
for name in [
"Code",
"code",
"Code - Insiders",
"Code.exe",
"code-insiders",
] {
assert_eq!(
classify(Some(&Path::new("/opt/vscode").join(name))),
Some(HostClass::VsCode),
"{name}"
);
}
assert_eq!(
classify(Some(&ext_host)),
Some(HostClass::VsCode),
"under the detected VS Code app, whatever the helper is called"
);
assert_eq!(classify(Some(&node)), Some(HostClass::Cli));
assert_eq!(
super::classify(Some(&sidecar), None, None),
Some(HostClass::Cli),
"no Cline.app detected: a sidecar-shaped exe is not proof of the hub"
);
assert_eq!(classify(None), None, "unreadable → Unknown, never a class");
let table = FakeTable::new(1);
{
let mut procs = table.procs.lock().unwrap_or_else(|e| e.into_inner());
procs.insert(
10,
proc_info(
Some(Path::new("C:/ol/bin/cline-js-runtime-vscode.exe")),
Some(20),
),
);
procs.insert(
20,
proc_info(Some(Path::new("C:/VSCode/Code.exe")), Some(1)),
);
procs.insert(30, proc_info(Some(&sidecar), Some(1)));
procs.insert(40, proc_info(Some(&node), Some(1)));
procs.insert(
50,
proc_info(Some(Path::new("C:/ol/bin/cline-js-runtime-hub.exe")), None),
);
procs.insert(60, proc_info(None, Some(1)));
}
let resolve = |pid| {
resolve_host_class(&table, pid, || {
(Some(sidecar.clone()), Some(vscode.clone()))
})
};
assert_eq!(resolve(10), Some(HostClass::VsCode), "launcher → parent");
assert_eq!(resolve(30), Some(HostClass::Hub));
assert_eq!(resolve(40), Some(HostClass::Cli));
assert_eq!(resolve(50), None, "a launcher whose parent is unknown");
assert_eq!(resolve(60), None, "an unreadable exe");
assert_eq!(resolve(99), None, "a pid that is gone");
}
#[test]
fn concurrent_writes_keep_every_class() {
let root = tempfile::tempdir().expect("tempdir");
let ol_dir = root.path().to_path_buf();
let path = seen_path(&ol_dir);
std::fs::create_dir_all(path.parent().expect("state dir")).expect("state dir");
std::fs::write(&path, r#"{"future-host": 7}"#).expect("seed");
let seen = Arc::new(PluginSeen::default());
let barrier = Arc::new(std::sync::Barrier::new(2));
let writers: Vec<_> = [HostClass::Hub, HostClass::VsCode]
.into_iter()
.map(|class| {
let (seen, barrier, ol_dir) = (seen.clone(), barrier.clone(), ol_dir.clone());
std::thread::spawn(move || {
barrier.wait();
for ms in 1..=50 {
seen.write(&ol_dir, class, ms).expect("write");
}
})
})
.collect();
for writer in writers {
writer.join().expect("writer");
}
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).expect("read")).expect("json");
assert_eq!(raw["hub"], 50, "{raw}");
assert_eq!(raw["vscode"], 50, "{raw}");
assert_eq!(raw["future-host"], 7, "{raw}");
}
}