1use std::collections::{BTreeMap, BTreeSet};
2use std::time::{Duration, SystemTime, UNIX_EPOCH};
3
4use anyhow::Result;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::process::pid_alive;
9use crate::providers;
10use crate::status::{
11 AgentSession, AgentStatus, Source, cwd_field, string_field, title_field, u32_field,
12};
13
14pub const PLUGIN_STALE: Duration = Duration::from_secs(5);
15pub const SESSION_RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60);
16
17#[derive(Debug, Clone)]
18pub enum Change {
19 Hooks { provider: String },
20 Snapshot { provider: String, instance: String },
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum SessionKind {
26 Hook,
27 Plugin,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PluginSnapshot {
32 #[serde(default)]
33 pub status: BTreeMap<String, String>,
34 #[serde(default)]
35 pub blocking: Vec<String>,
36 #[serde(default)]
37 pub titles: BTreeMap<String, String>,
38 #[serde(default)]
39 pub cwd: Option<String>,
40 #[serde(default)]
41 pub cmdline: Vec<String>,
42 #[serde(default)]
43 pub pid: Option<u32>,
44 #[serde(default)]
45 pub created_ms: u64,
46 #[serde(default)]
47 pub last_report_ms: u64,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct Store {
52 #[serde(default)]
53 pub last_heartbeat_ms: u64,
54 #[serde(default)]
55 pub previous_heartbeat_ms: Option<u64>,
56 #[serde(default)]
57 pub hooks: BTreeMap<String, BTreeMap<String, AgentSession>>,
58 #[serde(default)]
59 pub snapshots: BTreeMap<String, BTreeMap<String, PluginSnapshot>>,
60 #[serde(default)]
61 pub removed: BTreeMap<String, BTreeMap<String, u64>>,
62}
63
64impl Default for Store {
65 fn default() -> Self {
66 Self {
67 last_heartbeat_ms: now_ms(),
68 previous_heartbeat_ms: None,
69 hooks: BTreeMap::new(),
70 snapshots: BTreeMap::new(),
71 removed: BTreeMap::new(),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ListedSession {
78 pub provider: String,
79 pub session_id: String,
80 pub status: AgentStatus,
81 pub source: Source,
82 pub cwd: Option<String>,
83 pub cmdline: Vec<String>,
84 pub pid: Option<u32>,
85 pub created_ms: u64,
86 pub last_report_ms: u64,
87 pub kind: SessionKind,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub parent_id: Option<String>,
90 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
91 pub exited: bool,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub title: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub transcript_path: Option<String>,
96}
97
98impl Store {
99 pub fn on_server_start(&mut self) {
100 let now = now_ms();
101 if self.last_heartbeat_ms > 0 {
102 self.previous_heartbeat_ms = Some(self.last_heartbeat_ms);
103 }
104 self.last_heartbeat_ms = now;
105 }
106
107 pub fn heartbeat(&mut self) {
108 self.last_heartbeat_ms = now_ms();
109 }
110
111 pub fn update(&mut self, provider: &str, payload: Value) -> Result<Change> {
112 if !payload.is_object() {
113 anyhow::bail!("provide a JSON object");
114 }
115 if payload.get("hook_event_name").is_some() || payload.get("hookEventName").is_some() {
116 let sid = crate::status::session_key(&payload);
117 if let Some(sid) = &sid {
118 self.clear_removed(provider, sid);
119 }
120 let bucket = self.hooks.entry(provider.to_string()).or_default();
121 providers::apply_hook(provider, bucket, &payload);
122 if let Some(sid) = sid {
123 if let Some(session) = bucket.get_mut(&sid) {
124 touch_session(session, &payload, provider);
125 if session.exited {
126 bucket.remove(&sid);
127 }
128 } else if crate::status::string_field(
129 &payload,
130 &["hook_event_name", "hookEventName"],
131 )
132 .is_some_and(|name| name.eq_ignore_ascii_case("SessionEnd"))
133 {
134 }
136 }
137 return Ok(Change::Hooks {
138 provider: provider.to_string(),
139 });
140 }
141 let instance = payload
142 .get("id")
143 .and_then(Value::as_str)
144 .filter(|s| !s.is_empty())
145 .ok_or_else(|| anyhow::anyhow!("provide a string id"))?;
146 let raw_status = payload
147 .get("status")
148 .ok_or_else(|| anyhow::anyhow!("status must be an object"))?;
149 let obj = raw_status
150 .as_object()
151 .ok_or_else(|| anyhow::anyhow!("status must be an object"))?;
152 let mut status = BTreeMap::new();
153 for (sid, kind) in obj {
154 let kind = kind
155 .as_str()
156 .ok_or_else(|| anyhow::anyhow!("status values must be idle, busy, or retry"))?;
157 if !matches!(kind, "idle" | "busy" | "retry") {
158 anyhow::bail!("status values must be idle, busy, or retry");
159 }
160 status.insert(sid.clone(), kind.to_string());
161 }
162 for sid in status.keys() {
163 self.clear_removed(provider, sid);
164 }
165 let blocking = match payload.get("blocking") {
166 None => Vec::new(),
167 Some(Value::Array(items)) => {
168 let mut out = Vec::new();
169 for item in items {
170 let sid = item
171 .as_str()
172 .ok_or_else(|| anyhow::anyhow!("blocking must be an array of strings"))?;
173 out.push(sid.to_string());
174 }
175 out
176 }
177 Some(_) => anyhow::bail!("blocking must be an array of strings"),
178 };
179 let titles = match payload.get("titles") {
180 None => BTreeMap::new(),
181 Some(Value::Object(items)) => {
182 let mut out = BTreeMap::new();
183 for (sid, title) in items {
184 let title = title
185 .as_str()
186 .ok_or_else(|| anyhow::anyhow!("titles must be an object of strings"))?;
187 out.insert(sid.clone(), providers::normalize_title(provider, title));
188 }
189 out
190 }
191 Some(_) => anyhow::bail!("titles must be an object of strings"),
192 };
193 let pid = u32_field(&payload, &["pid"]).or_else(|| instance.parse().ok());
194 let created_ms = self
195 .snapshots
196 .get(provider)
197 .and_then(|instances| instances.get(instance))
198 .map(|existing| existing.created_ms)
199 .filter(|created| *created > 0)
200 .unwrap_or_else(now_ms);
201 let snapshot = PluginSnapshot {
202 status,
203 blocking,
204 titles,
205 cwd: cwd_field(&payload),
206 cmdline: string_list(&payload, "cmdline"),
207 pid,
208 created_ms,
209 last_report_ms: now_ms(),
210 };
211 self.snapshots
212 .entry(provider.to_string())
213 .or_default()
214 .insert(instance.to_string(), snapshot);
215 Ok(Change::Snapshot {
216 provider: provider.to_string(),
217 instance: instance.to_string(),
218 })
219 }
220
221 pub fn mark_removed(&mut self, provider: &str, session_id: &str) -> u64 {
222 let at = now_ms();
223 self.removed
224 .entry(provider.to_string())
225 .or_default()
226 .insert(session_id.to_string(), at);
227 at
228 }
229
230 pub fn clear_removed(&mut self, provider: &str, session_id: &str) {
231 if let Some(bucket) = self.removed.get_mut(provider) {
232 bucket.remove(session_id);
233 }
234 }
235
236 pub fn is_removed(&self, provider: &str, session_id: &str) -> bool {
237 self.removed
238 .get(provider)
239 .is_some_and(|bucket| bucket.contains_key(session_id))
240 }
241
242 pub fn discover(&mut self, ctx: &crate::paths::Context) -> bool {
243 let claude =
244 crate::providers::discover_claude(ctx, self.hooks.entry("claude".into()).or_default());
245 let codex =
246 crate::providers::discover_codex(ctx, self.hooks.entry("codex".into()).or_default());
247 claude || codex
248 }
249
250 pub fn listed(&self) -> Vec<ListedSession> {
251 let mut out = Vec::new();
252 for (provider, sessions) in &self.hooks {
253 for (sid, session) in sessions {
254 let mut cmdline = session.cmdline.clone();
255 if cmdline.is_empty() {
256 cmdline = providers::resume_cmd(provider, sid);
257 }
258 out.push(ListedSession {
259 provider: provider.clone(),
260 session_id: sid.clone(),
261 status: session.status,
262 source: session.source,
263 cwd: session.cwd.clone(),
264 cmdline,
265 pid: session.pid,
266 created_ms: created_or(session.created_ms, session.last_report_ms),
267 last_report_ms: session.last_report_ms,
268 kind: SessionKind::Hook,
269 parent_id: session.parent_id.clone(),
270 exited: session.exited,
271 title: session.title.clone(),
272 transcript_path: session.transcript_path.clone(),
273 });
274 }
275 }
276 for (provider, instances) in &self.snapshots {
277 for snapshot in instances.values() {
278 let blocking: std::collections::BTreeSet<_> =
279 snapshot.blocking.iter().cloned().collect();
280 for (sid, kind) in &snapshot.status {
281 let status = if blocking.contains(sid) {
282 AgentStatus::Waiting
283 } else if matches!(kind.as_str(), "busy" | "retry") {
284 AgentStatus::Running
285 } else {
286 AgentStatus::Idle
287 };
288 let mut cmdline = snapshot.cmdline.clone();
289 if cmdline.is_empty() {
290 cmdline = providers::resume_cmd(provider, sid);
291 }
292 out.push(ListedSession {
293 provider: provider.clone(),
294 session_id: sid.clone(),
295 status,
296 source: Source::Cli,
297 cwd: snapshot.cwd.clone(),
298 cmdline,
299 pid: snapshot.pid,
300 created_ms: created_or(snapshot.created_ms, snapshot.last_report_ms),
301 last_report_ms: snapshot.last_report_ms,
302 kind: SessionKind::Plugin,
303 parent_id: None,
304 exited: false,
305 title: snapshot.titles.get(sid).cloned(),
306 transcript_path: None,
307 });
308 }
309 }
310 }
311 out.retain(|session| !self.is_removed(&session.provider, &session.session_id));
312 let mut best: BTreeMap<(String, String, SessionKind), ListedSession> = BTreeMap::new();
313 for session in out {
314 let key = (
315 session.provider.clone(),
316 session.session_id.clone(),
317 session.kind,
318 );
319 match best.get(&key) {
320 Some(existing) if existing.last_report_ms >= session.last_report_ms => {}
321 _ => {
322 best.insert(key, session);
323 }
324 }
325 }
326 let mut out: Vec<ListedSession> = best.into_values().collect();
327 out.sort_by(|a, b| (&a.provider, &a.session_id).cmp(&(&b.provider, &b.session_id)));
328 out
329 }
330
331 pub fn active(&self) -> Vec<ListedSession> {
332 let now = now_ms();
333 self.listed()
334 .into_iter()
335 .filter(|session| session.is_active(now))
336 .collect()
337 }
338
339 pub fn resumable(&self, idle: Option<Duration>) -> Vec<ListedSession> {
340 let now = now_ms();
341 self.listed()
342 .into_iter()
343 .filter(|session| session.is_resumable(self, now, idle))
344 .collect()
345 }
346
347 pub fn prune(&mut self, now_ms: u64, retention: Duration) -> bool {
348 let cutoff = now_ms.saturating_sub(retention.as_millis() as u64);
349 let mut changed = false;
350
351 for bucket in self.hooks.values_mut() {
352 let before = bucket.len();
353 bucket.retain(|_, session| {
354 session.pid.is_some_and(pid_alive) || session.last_report_ms >= cutoff
355 });
356 changed |= bucket.len() != before;
357 }
358 self.hooks.retain(|_, bucket| !bucket.is_empty());
359
360 for bucket in self.snapshots.values_mut() {
361 let before = bucket.len();
362 bucket.retain(|_, snapshot| {
363 snapshot.pid.is_some_and(pid_alive) || snapshot.last_report_ms >= cutoff
364 });
365 changed |= bucket.len() != before;
366 }
367 self.snapshots.retain(|_, bucket| !bucket.is_empty());
368
369 let mut live: BTreeSet<(String, String)> = BTreeSet::new();
370 for (provider, bucket) in &self.hooks {
371 for sid in bucket.keys() {
372 live.insert((provider.clone(), sid.clone()));
373 }
374 }
375 for (provider, bucket) in &self.snapshots {
376 for snapshot in bucket.values() {
377 for sid in snapshot.status.keys() {
378 live.insert((provider.clone(), sid.clone()));
379 }
380 }
381 }
382 for (provider, bucket) in self.removed.iter_mut() {
383 let before = bucket.len();
384 bucket.retain(|sid, _| live.contains(&(provider.clone(), sid.clone())));
385 changed |= bucket.len() != before;
386 }
387 self.removed.retain(|_, bucket| !bucket.is_empty());
388 changed
389 }
390}
391
392impl ListedSession {
393 pub fn is_active(&self, now_ms: u64) -> bool {
394 if self.exited || self.parent_id.is_some() {
395 return false;
396 }
397 match self.kind {
398 SessionKind::Plugin => {
399 now_ms.saturating_sub(self.last_report_ms) <= PLUGIN_STALE.as_millis() as u64
400 }
401 SessionKind::Hook => {
402 self.status.is_busy()
403 || self.pid.is_some_and(pid_alive)
404 || (self.source == Source::Desktop && !self.exited)
405 }
406 }
407 }
408
409 pub fn process_attached(&self) -> bool {
410 self.pid.is_some_and(pid_alive)
411 }
412
413 pub fn is_resumable(&self, store: &Store, now_ms: u64, idle: Option<Duration>) -> bool {
414 if self.exited || self.parent_id.is_some() {
415 return false;
416 }
417 if self.process_attached() {
418 return false;
419 }
420 if self.cwd.as_ref().is_none_or(|cwd| cwd.is_empty()) {
421 return false;
422 }
423 if self.status.is_busy() {
424 return true;
425 }
426 if !matches!(self.status, AgentStatus::Idle | AgentStatus::Done) {
427 return false;
428 }
429 let Some(idle) = idle else {
430 return false;
431 };
432 idle_age_ms(self, store, now_ms) <= idle.as_millis() as u64
433 }
434}
435
436fn created_or(created_ms: u64, fallback_ms: u64) -> u64 {
437 if created_ms > 0 {
438 created_ms
439 } else {
440 fallback_ms
441 }
442}
443
444fn idle_age_ms(session: &ListedSession, store: &Store, now_ms: u64) -> u64 {
445 let anchor = store.previous_heartbeat_ms.unwrap_or(now_ms);
446 if session.last_report_ms > anchor {
447 now_ms.saturating_sub(session.last_report_ms)
448 } else {
449 anchor.saturating_sub(session.last_report_ms)
450 }
451}
452
453fn touch_session(session: &mut AgentSession, payload: &Value, provider: &str) {
454 let keys: &[&str] = if session.parent_id.is_some() {
457 &["agent_transcript_path"]
458 } else {
459 &["transcript_path"]
460 };
461 if matches!(provider, "claude" | "codex")
462 && let Some(path) = string_field(payload, keys).filter(|path| !path.is_empty())
463 {
464 session.transcript_path = Some(path.to_string());
465 }
466 session.last_report_ms = now_ms();
467 if let Some(cwd) = cwd_field(payload) {
468 session.cwd = Some(cwd);
469 }
470 if let Some(title) = title_field(payload) {
471 session.title = Some(title);
472 }
473 if let Some(pid) = u32_field(payload, &["pid"]) {
474 session.pid = Some(pid);
475 }
476 if session.cmdline.is_empty()
477 && let Some(sid) = crate::status::session_key(payload)
478 {
479 session.cmdline = providers::resume_cmd(provider, &sid);
480 }
481 let name = string_field(payload, &["hook_event_name", "hookEventName"]).unwrap_or("");
482 if name.eq_ignore_ascii_case("SessionEnd") {
483 session.exited = true;
484 }
485}
486
487fn string_list(payload: &Value, key: &str) -> Vec<String> {
488 payload
489 .get(key)
490 .and_then(Value::as_array)
491 .map(|items| {
492 items
493 .iter()
494 .filter_map(Value::as_str)
495 .map(str::to_string)
496 .collect()
497 })
498 .unwrap_or_default()
499}
500
501pub fn now_ms() -> u64 {
502 SystemTime::now()
503 .duration_since(UNIX_EPOCH)
504 .map(|d| d.as_millis() as u64)
505 .unwrap_or(0)
506}
507
508#[cfg(test)]
509#[path = "store_tests.rs"]
510mod tests;