1use crate::paths::AgentsHome;
21use crate::state::{self, Registry};
22use serde_json::{json, Value};
23use std::collections::HashMap;
24use std::io::{Read, Seek, SeekFrom, Write};
25use std::time::Duration;
26
27const POLL_INTERVAL: Duration = Duration::from_millis(250);
28
29#[derive(Debug, Clone, PartialEq)]
33pub struct Transition {
34 pub kind: String,
36 pub category: &'static str,
38 pub authority: &'static str,
40 pub agent: Option<String>,
42 pub session_id: Option<String>,
44 pub state: String,
46 pub seq: Option<u64>,
48}
49
50pub fn classify(v: &Value) -> Option<Transition> {
53 let kind = v
60 .get("type")
61 .or_else(|| v.get("kind"))
62 .and_then(|x| x.as_str())?;
63 let payload = v.get("data").unwrap_or(v);
64 let str_field = |k: &str| payload.get(k).and_then(|x| x.as_str()).map(str::to_string);
65 let seq = payload.get("seq").and_then(|x| x.as_u64());
66 match kind {
67 "inside_leg_report" => Some(Transition {
69 kind: kind.to_string(),
70 category: "state",
71 authority: "hook",
72 agent: None,
73 session_id: str_field("session_id"),
74 state: str_field("state").unwrap_or_default(),
75 seq,
76 }),
77 "inside_leg_buffer_flushed" => Some(Transition {
81 kind: kind.to_string(),
82 category: "state",
83 authority: "hook",
84 agent: str_field("name"),
85 session_id: str_field("session_id"),
86 state: str_field("state").unwrap_or_default(),
87 seq,
88 }),
89 "inside_leg_completed" => Some(Transition {
91 kind: kind.to_string(),
92 category: "exit",
93 authority: "hook",
94 agent: str_field("name"),
95 session_id: str_field("session_id"),
96 state: str_field("final_state").unwrap_or_else(|| "done".to_string()),
97 seq,
98 }),
99 "screen_state_change" => {
104 let name = payload.get("name").and_then(|x| x.as_str())?;
105 let cleared = payload
106 .get("cleared")
107 .and_then(|c| c.as_bool())
108 .unwrap_or(false);
109 let state = if cleared {
110 "idle".to_string()
111 } else {
112 str_field("state").unwrap_or_else(|| "idle".to_string())
113 };
114 Some(Transition {
115 kind: kind.to_string(),
116 category: "state",
117 authority: "screen",
118 agent: Some(name.to_string()),
119 session_id: None,
120 state,
121 seq,
122 })
123 }
124 _ => None,
125 }
126}
127
128fn resolve_name(reg: &Registry, session_id: &str) -> Option<String> {
131 reg.entries
132 .iter()
133 .find(|e| crate::daemon::entry_holds_session(e, session_id))
134 .map(|e| e.name.clone())
135}
136
137struct Filters {
139 agent: Option<String>,
140 want_state: bool,
141 want_exit: bool,
142}
143
144fn ino_of(m: std::fs::Metadata) -> u64 {
145 use std::os::unix::fs::MetadataExt;
146 m.ino()
147}
148
149fn process_line(
154 line: &str,
155 home: &AgentsHome,
156 filters: &Filters,
157 reg: &mut Option<Registry>,
158 last_state: &mut HashMap<String, String>,
159) {
160 let Ok(v) = serde_json::from_str::<Value>(line) else {
161 return;
162 };
163 let Some(t) = classify(&v) else { return };
164 match t.category {
165 "state" if !filters.want_state => return,
166 "exit" if !filters.want_exit => return,
167 _ => {}
168 }
169 let agent = match (t.agent.clone(), &t.session_id) {
171 (Some(name), _) => Some(name),
172 (None, Some(sid)) => {
173 let mut found = reg.as_ref().and_then(|r| resolve_name(r, sid));
174 if found.is_none() {
175 *reg = state::load_registry(&home.registry_json()).ok();
176 found = reg.as_ref().and_then(|r| resolve_name(r, sid));
177 }
178 found
179 }
180 (None, None) => None,
181 };
182 if let Some(want) = &filters.agent {
184 if agent.as_deref() != Some(want.as_str()) {
185 return;
186 }
187 }
188 let old = agent.as_ref().and_then(|a| last_state.get(a).cloned());
189 let out_line = json!({
190 "agent": agent,
191 "event": t.category,
192 "state": t.state,
193 "old_state": old,
194 "authority": t.authority,
195 "seq": t.seq,
196 "kind": t.kind,
197 })
198 .to_string();
199 let mut out = std::io::stdout().lock();
200 let _ = writeln!(out, "{out_line}");
201 let _ = out.flush();
202 if let Some(a) = agent {
203 last_state.insert(a, t.state);
204 }
205}
206
207fn drain_fd(
210 file: &mut std::fs::File,
211 carry: &mut String,
212 home: &AgentsHome,
213 filters: &Filters,
214 reg: &mut Option<Registry>,
215 last_state: &mut HashMap<String, String>,
216) {
217 let mut buf = String::new();
218 if file.read_to_string(&mut buf).is_err() || buf.is_empty() {
219 return;
220 }
221 carry.push_str(&buf);
222 while let Some(nl) = carry.find('\n') {
223 let line: String = carry.drain(..=nl).collect();
224 let line = line.trim_end();
225 if !line.is_empty() {
226 process_line(line, home, filters, reg, last_state);
227 }
228 }
229}
230
231pub async fn run_subscribe(rest: &[String], home: &AgentsHome) -> i32 {
233 let mut agent_filter: Option<String> = None;
234 let mut want_state = true;
235 let mut want_exit = true;
236 let mut kinds_set = false;
237
238 let mut it = rest.iter();
239 while let Some(a) = it.next() {
240 match a.as_str() {
241 "--agent" => match it.next() {
242 Some(v) => agent_filter = Some(v.clone()),
243 None => {
244 eprintln!("fno-agents: --agent needs a value");
245 return 2;
246 }
247 },
248 "--kinds" => match it.next() {
249 Some(v) => {
250 want_state = false;
252 want_exit = false;
253 kinds_set = true;
254 for k in v.split(',').map(str::trim).filter(|k| !k.is_empty()) {
255 match k {
256 "state" => want_state = true,
257 "exit" => want_exit = true,
258 other => {
259 eprintln!("fno-agents: subscribe --kinds must be state|exit (got {other})");
260 return 2;
261 }
262 }
263 }
264 }
265 None => {
266 eprintln!("fno-agents: --kinds needs a value");
267 return 2;
268 }
269 },
270 "--json" | "-J" => {}
272 other if other.starts_with("--") => {
273 eprintln!("fno-agents: subscribe: unknown flag: {other}");
274 return 2;
275 }
276 other => {
277 eprintln!("fno-agents: subscribe: unexpected argument: {other}");
278 return 2;
279 }
280 }
281 }
282 if kinds_set && !want_state && !want_exit {
283 eprintln!("fno-agents: subscribe --kinds selected nothing (use state and/or exit)");
284 return 2;
285 }
286
287 let filters = Filters {
288 agent: agent_filter,
289 want_state,
290 want_exit,
291 };
292 let path = home.events_jsonl();
293 let mut carry = String::new();
294 let mut last_state: HashMap<String, String> = HashMap::new();
295 let mut reg: Option<Registry> = state::load_registry(&home.registry_json()).ok();
297
298 let mut file: Option<std::fs::File> = match std::fs::File::open(&path) {
304 Ok(mut f) => {
305 let _ = f.seek(SeekFrom::End(0));
306 Some(f)
307 }
308 Err(_) => None,
309 };
310 let mut fd_ino: Option<u64> = file.as_ref().and_then(|f| f.metadata().ok()).map(ino_of);
311
312 loop {
313 if file.is_none() {
316 if let Ok(f) = std::fs::File::open(&path) {
317 fd_ino = f.metadata().ok().map(ino_of);
318 file = Some(f);
319 }
320 }
321 if let Some(f) = &mut file {
323 drain_fd(f, &mut carry, home, &filters, &mut reg, &mut last_state);
324 }
325 let path_ino = std::fs::metadata(&path).ok().map(ino_of);
329 if path_ino.is_some() && path_ino != fd_ino {
330 carry.clear();
331 match std::fs::File::open(&path) {
332 Ok(f) => {
333 fd_ino = path_ino;
334 file = Some(f);
335 }
336 Err(_) => file = None,
337 }
338 }
339 tokio::time::sleep(POLL_INTERVAL).await;
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn classifies_hook_report_without_name() {
349 let t = classify(&json!({
350 "type": "inside_leg_report",
351 "data": {"session_id": "sid-1", "seq": 3, "state": "blocked"}
352 }))
353 .unwrap();
354 assert_eq!(t.category, "state");
355 assert_eq!(t.authority, "hook");
356 assert_eq!(t.agent, None);
357 assert_eq!(t.session_id.as_deref(), Some("sid-1"));
358 assert_eq!(t.state, "blocked");
359 assert_eq!(t.seq, Some(3));
360 }
361
362 #[test]
363 fn classifies_completion_as_exit() {
364 let t = classify(&json!({
365 "type": "inside_leg_completed",
366 "data": {"name": "wkA", "session_id": "sid-1", "final_state": "done", "seq": 9}
367 }))
368 .unwrap();
369 assert_eq!(t.category, "exit");
370 assert_eq!(t.agent.as_deref(), Some("wkA"));
371 assert_eq!(t.state, "done");
372 }
373
374 #[test]
375 fn cleared_screen_state_reads_idle() {
376 let t = classify(&json!({
377 "type": "screen_state_change",
378 "data": {"name": "wkA", "state": Value::Null, "rule": Value::Null, "seq": 2, "cleared": true}
379 }))
380 .unwrap();
381 assert_eq!(t.category, "state");
382 assert_eq!(t.authority, "screen");
383 assert_eq!(t.agent.as_deref(), Some("wkA"));
384 assert_eq!(t.state, "idle");
385 }
386
387 #[test]
388 fn live_screen_state_keeps_verdict() {
389 let t = classify(&json!({
390 "type": "screen_state_change",
391 "data": {"name": "wkA", "state": "blocked", "rule": "menu", "seq": 4, "cleared": false}
392 }))
393 .unwrap();
394 assert_eq!(t.state, "blocked");
395 }
396
397 #[test]
398 fn ignores_non_transition_kinds() {
399 assert!(classify(&json!({"type": "agent_spawned", "data": {"name": "wkA"}})).is_none());
400 assert!(classify(&json!({"type": "daemon_started", "data": {"pid": 1}})).is_none());
401 assert!(classify(&json!({"no_type": true})).is_none());
402 }
403
404 #[test]
405 fn classifies_buffer_flush_as_hook_state() {
406 let t = classify(&json!({
409 "type": "inside_leg_buffer_flushed",
410 "data": {"name": "wkA", "session_id": "sid-1", "state": "working", "seq": 4}
411 }))
412 .unwrap();
413 assert_eq!(t.category, "state");
414 assert_eq!(t.authority, "hook");
415 assert_eq!(t.agent.as_deref(), Some("wkA"));
416 assert_eq!(t.state, "working");
417 assert_eq!(t.seq, Some(4));
418 }
419
420 #[test]
421 fn screen_state_parse_error_variant_is_ignored() {
422 assert!(classify(&json!({
425 "type": "screen_state_change",
426 "data": {"provider": "codex", "error": "bad manifest"}
427 }))
428 .is_none());
429 }
430
431 #[test]
432 fn legacy_kind_flat_line_still_classifies_via_fallback() {
433 let t = classify(&json!({
437 "kind": "inside_leg_report", "session_id": "sid-1", "seq": 3, "state": "blocked"
438 }))
439 .unwrap();
440 assert_eq!(t.category, "state");
441 assert_eq!(t.session_id.as_deref(), Some("sid-1"));
442 assert_eq!(t.state, "blocked");
443 assert_eq!(t.seq, Some(3));
444 }
445}