1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
4#[serde(rename_all = "kebab-case")]
5pub enum AgentEventKind {
6 SessionStart,
7 PromptSubmit,
8 ToolStart,
9 ToolComplete,
10 PermissionRequest,
11 QuestionAsked,
12 Notification,
13 Stop,
14 SessionEnd,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
18#[serde(rename_all = "lowercase")]
19pub enum AgentStatus {
20 #[default]
21 Idle,
22 Working,
23 Waiting,
24 Done,
25}
26
27impl AgentStatus {
28 pub fn as_str(self) -> &'static str {
29 match self {
30 Self::Idle => "idle",
31 Self::Working => "working",
32 Self::Waiting => "waiting",
33 Self::Done => "done",
34 }
35 }
36
37 pub fn is_busy(self) -> bool {
38 matches!(self, Self::Working | Self::Waiting)
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
43#[serde(rename_all = "lowercase")]
44pub enum Source {
45 #[default]
46 Cli,
47 Desktop,
48}
49
50impl Source {
51 pub fn as_str(self) -> &'static str {
52 match self {
53 Self::Cli => "cli",
54 Self::Desktop => "desktop",
55 }
56 }
57
58 pub fn from_label(value: &str) -> Self {
59 if value.to_ascii_lowercase().contains("desktop") {
60 Self::Desktop
61 } else {
62 Self::Cli
63 }
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct AgentEvent {
69 pub session_id: String,
70 pub kind: AgentEventKind,
71 pub source: Source,
72 pub pid: Option<u32>,
73 pub parent_id: Option<String>,
74 pub background_running: bool,
75}
76
77impl AgentEvent {
78 pub fn new(session_id: impl Into<String>, kind: AgentEventKind) -> Self {
79 Self {
80 session_id: session_id.into(),
81 kind,
82 source: Source::Cli,
83 pid: None,
84 parent_id: None,
85 background_running: false,
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct AgentSession {
92 #[serde(default)]
93 pub status: AgentStatus,
94 #[serde(default)]
95 pub source: Source,
96 #[serde(default)]
97 pub pid: Option<u32>,
98 #[serde(default)]
99 pub parent_id: Option<String>,
100 #[serde(default)]
101 pub background_only: bool,
102 #[serde(default)]
103 pub cwd: Option<String>,
104 #[serde(default)]
105 pub cmdline: Vec<String>,
106 #[serde(default)]
107 pub created_ms: u64,
108 #[serde(default)]
109 pub last_report_ms: u64,
110 #[serde(default)]
111 pub exited: bool,
112 #[serde(default)]
113 pub discovered: bool,
114 #[serde(default)]
115 pub title: Option<String>,
116}
117
118impl Default for AgentSession {
119 fn default() -> Self {
120 Self {
121 status: AgentStatus::Idle,
122 source: Source::Cli,
123 pid: None,
124 parent_id: None,
125 background_only: false,
126 cwd: None,
127 cmdline: Vec::new(),
128 created_ms: 0,
129 last_report_ms: 0,
130 exited: false,
131 discovered: false,
132 title: None,
133 }
134 }
135}
136
137impl AgentSession {
138 pub fn apply(&mut self, event: &AgentEvent) {
139 self.source = event.source;
140 if event.pid.is_some() {
141 self.pid = event.pid;
142 }
143 if event.parent_id.is_some() {
144 self.parent_id = event.parent_id.clone();
145 }
146 match event.kind {
147 AgentEventKind::PromptSubmit => {
148 self.status = AgentStatus::Working;
149 self.background_only = false;
150 }
151 AgentEventKind::ToolStart => {
152 if self.status == AgentStatus::Idle {
153 self.status = AgentStatus::Working;
154 }
155 if self.status == AgentStatus::Working {
156 self.background_only = false;
157 }
158 }
159 AgentEventKind::ToolComplete => {
160 if self.status == AgentStatus::Waiting {
161 self.status = AgentStatus::Working;
162 }
163 if self.status == AgentStatus::Working {
164 self.background_only = false;
165 }
166 }
167 AgentEventKind::PermissionRequest | AgentEventKind::QuestionAsked => {
168 self.status = AgentStatus::Waiting;
169 }
170 AgentEventKind::Notification => {
171 if self.status == AgentStatus::Working {
172 self.status = AgentStatus::Waiting;
173 }
174 }
175 AgentEventKind::Stop => {
176 self.status = if event.background_running {
177 AgentStatus::Working
178 } else {
179 AgentStatus::Done
180 };
181 self.background_only = event.background_running;
182 }
183 AgentEventKind::SessionStart | AgentEventKind::SessionEnd => {}
184 }
185 }
186}
187
188pub fn prune(
189 sessions: &mut std::collections::BTreeMap<String, AgentSession>,
190 keep: Option<&str>,
191 source: Option<Source>,
192 pid: Option<u32>,
193) {
194 sessions.retain(|sid, item| {
195 if Some(sid.as_str()) == keep || item.status.is_busy() {
196 return true;
197 }
198 if source.is_some() && Some(item.source) != source {
199 return true;
200 }
201 item.pid != pid
202 });
203}
204
205pub fn apply_event(
206 sessions: &mut std::collections::BTreeMap<String, AgentSession>,
207 event: AgentEvent,
208) {
209 let sid = event.session_id.clone();
210 let kind = event.kind;
211 let existing_pid = sessions.get(&sid).and_then(|item| item.pid);
212 let pid = event.pid.or(existing_pid);
213 if kind == AgentEventKind::SessionEnd {
214 sessions.remove(&sid);
215 sessions.retain(|_, child| child.parent_id.as_deref() != Some(sid.as_str()));
216 return;
217 }
218 if kind == AgentEventKind::SessionStart {
219 prune(sessions, Some(&sid), Some(event.source), pid);
220 return;
221 }
222 if !sessions.contains_key(&sid) {
223 if matches!(
224 kind,
225 AgentEventKind::ToolComplete | AgentEventKind::Notification
226 ) {
227 return;
228 }
229 if kind == AgentEventKind::Stop && !event.background_running {
230 return;
231 }
232 sessions.insert(
233 sid.clone(),
234 AgentSession {
235 created_ms: crate::store::now_ms(),
236 ..AgentSession::default()
237 },
238 );
239 }
240 let parent_id;
241 let source;
242 let session_pid;
243 {
244 let item = sessions.get_mut(&sid).expect("session inserted");
245 item.apply(&event);
246 parent_id = item.parent_id.clone();
247 source = item.source;
248 session_pid = item.pid;
249 }
250 if parent_id.is_some() {
251 if sessions
252 .get(&sid)
253 .is_some_and(|item| item.status == AgentStatus::Done)
254 {
255 sessions.remove(&sid);
256 }
257 } else if matches!(kind, AgentEventKind::PromptSubmit | AgentEventKind::Stop) {
258 prune(sessions, Some(&sid), Some(source), session_pid);
259 }
260}
261
262pub fn session_key(payload: &serde_json::Value) -> Option<String> {
263 let sid = string_field(payload, &["session_id", "sessionId"]);
264 let agent = string_field(payload, &["agent_id", "agentId"]);
265 match (sid, agent) {
266 (Some(sid), Some(agent)) => Some(format!("{sid}:{agent}")),
267 (None, Some(agent)) => Some(agent.to_string()),
268 (Some(sid), None) => Some(sid.to_string()),
269 (None, None) => None,
270 }
271}
272
273pub fn string_field<'a>(payload: &'a serde_json::Value, names: &[&str]) -> Option<&'a str> {
274 let obj = payload.as_object()?;
275 for name in names {
276 match obj.get(*name) {
277 Some(serde_json::Value::String(value)) if !value.is_empty() => return Some(value),
278 _ => {}
279 }
280 }
281 None
282}
283
284pub fn u32_field(payload: &serde_json::Value, names: &[&str]) -> Option<u32> {
285 let obj = payload.as_object()?;
286 for name in names {
287 match obj.get(*name) {
288 Some(serde_json::Value::Number(n)) => {
289 if let Some(v) = n.as_u64() {
290 return u32::try_from(v).ok();
291 }
292 }
293 Some(serde_json::Value::String(s)) => {
294 if let Ok(v) = s.parse() {
295 return Some(v);
296 }
297 }
298 _ => {}
299 }
300 }
301 None
302}
303
304pub fn title_field(payload: &serde_json::Value) -> Option<String> {
305 string_field(
306 payload,
307 &[
308 "title",
309 "name",
310 "thread_name",
311 "session_name",
312 "conversation_title",
313 "display_name",
314 ],
315 )
316 .map(str::to_string)
317}
318
319pub fn cwd_field(payload: &serde_json::Value) -> Option<String> {
320 string_field(
321 payload,
322 &[
323 "cwd",
324 "workdir",
325 "workDir",
326 "working_directory",
327 "work_dir",
328 "directory",
329 ],
330 )
331 .map(str::to_string)
332}
333
334#[cfg(test)]
335#[path = "status_tests.rs"]
336mod tests;