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