Skip to main content

room_plugin_agent/
lib.rs

1pub mod personalities;
2
3use std::collections::HashMap;
4use std::fs;
5use std::path::PathBuf;
6use std::process::{Child, Command, Stdio};
7use std::sync::{Arc, Mutex};
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use room_protocol::plugin::{
13    BoxFuture, CommandContext, CommandInfo, ParamSchema, ParamType, Plugin, PluginResult,
14};
15use room_protocol::Message;
16
17/// Returns the workspace directory for a spawned agent: `~/.room/agents/<name>/`.
18///
19/// Each agent gets its own isolated working directory to prevent cwd collisions
20/// between multiple spawned agents and the host process.
21fn agent_workspace_dir(agent_name: &str) -> PathBuf {
22    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_owned());
23    PathBuf::from(home)
24        .join(".room")
25        .join("agents")
26        .join(agent_name)
27}
28
29// ── C ABI entry points for cdylib loading ─────────────────────────────────
30
31/// JSON configuration for the agent plugin when loaded dynamically.
32///
33/// ```json
34/// {
35///   "state_path": "/home/user/.room/state/agents-myroom.json",
36///   "socket_path": "/tmp/room-myroom.sock",
37///   "log_dir": "/home/user/.room/logs"
38/// }
39/// ```
40#[derive(Deserialize)]
41struct AgentConfig {
42    state_path: PathBuf,
43    socket_path: PathBuf,
44    log_dir: PathBuf,
45}
46
47/// Create an [`AgentPlugin`] from a JSON config string.
48///
49/// Falls back to temp-path defaults if config is empty (for testing).
50fn create_agent_from_config(config: &str) -> AgentPlugin {
51    if config.is_empty() {
52        AgentPlugin::new(
53            PathBuf::from("/tmp/room-agents.json"),
54            PathBuf::from("/tmp/room-default.sock"),
55            PathBuf::from("/tmp/room-logs"),
56        )
57    } else {
58        let cfg: AgentConfig =
59            serde_json::from_str(config).expect("invalid agent plugin config JSON");
60        AgentPlugin::new(cfg.state_path, cfg.socket_path, cfg.log_dir)
61    }
62}
63
64room_protocol::declare_plugin!("agent", create_agent_from_config);
65
66/// Grace period in seconds before sending SIGKILL after SIGTERM.
67const STOP_GRACE_PERIOD_SECS: u64 = 5;
68
69/// Default number of log lines to show.
70const DEFAULT_TAIL_LINES: usize = 20;
71
72/// Default threshold in seconds before an agent is considered stale.
73const DEFAULT_STALE_THRESHOLD_SECS: i64 = 300;
74
75/// Health status of a spawned agent.
76#[derive(Debug, Clone, PartialEq)]
77pub enum HealthStatus {
78    /// Agent is active — sent a message recently.
79    Healthy,
80    /// Agent has not sent any message within the stale threshold.
81    Stale,
82    /// Agent process has exited.
83    Exited(Option<i32>),
84}
85
86impl std::fmt::Display for HealthStatus {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            HealthStatus::Healthy => write!(f, "healthy"),
90            HealthStatus::Stale => write!(f, "stale"),
91            HealthStatus::Exited(Some(code)) => write!(f, "exited ({code})"),
92            HealthStatus::Exited(None) => write!(f, "exited (signal)"),
93        }
94    }
95}
96
97/// A spawned agent process tracked by the plugin.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct SpawnedAgent {
100    pub username: String,
101    pub pid: u32,
102    pub model: String,
103    #[serde(default)]
104    pub personality: String,
105    pub spawned_at: DateTime<Utc>,
106    pub log_path: PathBuf,
107    pub room_id: String,
108}
109
110/// Agent spawn/stop/list plugin.
111///
112/// Manages ralph agent processes spawned from within a room. Tracks PIDs,
113/// provides `/agent spawn`, `/agent list`, and `/agent stop` commands.
114pub struct AgentPlugin {
115    /// Running agents keyed by username.
116    agents: Arc<Mutex<HashMap<String, SpawnedAgent>>>,
117    /// Child process handles for exit code tracking (not serialized).
118    children: Arc<Mutex<HashMap<String, Child>>>,
119    /// Recorded exit codes for agents whose Child handles have been reaped.
120    exit_codes: Arc<Mutex<HashMap<String, Option<i32>>>>,
121    /// Last time each tracked agent sent a message, keyed by username.
122    last_seen_at: Arc<Mutex<HashMap<String, DateTime<Utc>>>>,
123    /// Seconds of inactivity before an agent is marked stale.
124    stale_threshold_secs: i64,
125    /// Path to persist agent state (e.g. `~/.room/state/agents-<room>.json`).
126    state_path: PathBuf,
127    /// Socket path to pass to spawned ralph processes.
128    socket_path: PathBuf,
129    /// Directory for agent log files.
130    log_dir: PathBuf,
131}
132
133impl AgentPlugin {
134    /// Create a new agent plugin.
135    ///
136    /// Loads previously persisted agent state and prunes entries whose
137    /// processes are no longer running.
138    pub fn new(state_path: PathBuf, socket_path: PathBuf, log_dir: PathBuf) -> Self {
139        let agents = load_agents(&state_path);
140        // Prune dead agents on startup
141        let agents: HashMap<String, SpawnedAgent> = agents
142            .into_iter()
143            .filter(|(_, a)| is_process_alive(a.pid))
144            .collect();
145        let plugin = Self {
146            agents: Arc::new(Mutex::new(agents)),
147            children: Arc::new(Mutex::new(HashMap::new())),
148            exit_codes: Arc::new(Mutex::new(HashMap::new())),
149            last_seen_at: Arc::new(Mutex::new(HashMap::new())),
150            stale_threshold_secs: DEFAULT_STALE_THRESHOLD_SECS,
151            state_path,
152            socket_path,
153            log_dir,
154        };
155        plugin.persist();
156        plugin
157    }
158
159    /// Returns the command info for the TUI command palette without needing
160    /// an instantiated plugin. Used by `all_known_commands()`.
161    pub fn default_commands() -> Vec<CommandInfo> {
162        vec![
163            CommandInfo {
164                name: "agent".to_owned(),
165                description: "Spawn, list, stop, or tail logs of ralph agents".to_owned(),
166                usage: "/agent <action> [args...]".to_owned(),
167                params: vec![
168                    ParamSchema {
169                        name: "action".to_owned(),
170                        param_type: ParamType::Choice(vec![
171                            "spawn".to_owned(),
172                            "list".to_owned(),
173                            "stop".to_owned(),
174                            "logs".to_owned(),
175                        ]),
176                        required: true,
177                        description: "Subcommand".to_owned(),
178                    },
179                    ParamSchema {
180                        name: "args".to_owned(),
181                        param_type: ParamType::Text,
182                        required: false,
183                        description: "Arguments for the subcommand".to_owned(),
184                    },
185                ],
186            },
187            CommandInfo {
188                name: "spawn".to_owned(),
189                description: "Spawn an agent by personality name".to_owned(),
190                usage: "/spawn <personality> [--name <username>]".to_owned(),
191                params: vec![
192                    ParamSchema {
193                        name: "personality".to_owned(),
194                        param_type: ParamType::Choice(personalities::all_personality_names()),
195                        required: true,
196                        description: "Personality preset name".to_owned(),
197                    },
198                    ParamSchema {
199                        name: "name".to_owned(),
200                        param_type: ParamType::Text,
201                        required: false,
202                        description: "Override auto-generated username".to_owned(),
203                    },
204                ],
205            },
206        ]
207    }
208
209    fn persist(&self) {
210        let agents = self.agents.lock().unwrap();
211        if let Ok(json) = serde_json::to_string_pretty(&*agents) {
212            let _ = fs::write(&self.state_path, json);
213        }
214    }
215
216    fn handle_spawn(&self, ctx: &CommandContext) -> Result<(String, serde_json::Value), String> {
217        let params = &ctx.params;
218        if params.len() < 2 {
219            return Err(
220                "usage: /agent spawn <username> [--model <model>] [--personality <name>] [--issue <N>] [--prompt <text>]"
221                    .to_owned(),
222            );
223        }
224
225        let username = &params[1];
226
227        // Validate username is not empty
228        if username.is_empty() || username.starts_with('-') {
229            return Err("invalid username".to_owned());
230        }
231
232        // Check for collision with online users
233        if ctx
234            .metadata
235            .online_users
236            .iter()
237            .any(|u| u.username == *username)
238        {
239            return Err(format!("username '{username}' is already online"));
240        }
241
242        // Check for collision with already-spawned agents
243        {
244            let agents = self.agents.lock().unwrap();
245            if agents.contains_key(username.as_str()) {
246                return Err(format!(
247                    "agent '{username}' is already running (pid {})",
248                    agents[username.as_str()].pid
249                ));
250            }
251        }
252
253        // Parse optional flags from params[2..]
254        let mut model = "sonnet".to_owned();
255        let mut personality = String::new();
256        let mut issue: Option<String> = None;
257        let mut prompt: Option<String> = None;
258
259        let mut i = 2;
260        while i < params.len() {
261            match params[i].as_str() {
262                "--model" => {
263                    i += 1;
264                    if i < params.len() {
265                        model = params[i].clone();
266                    }
267                }
268                "--personality" => {
269                    i += 1;
270                    if i < params.len() {
271                        personality = params[i].clone();
272                    }
273                }
274                "--issue" => {
275                    i += 1;
276                    if i < params.len() {
277                        issue = Some(params[i].clone());
278                    }
279                }
280                "--prompt" => {
281                    i += 1;
282                    if i < params.len() {
283                        prompt = Some(params[i].clone());
284                    }
285                }
286                _ => {}
287            }
288            i += 1;
289        }
290
291        // Create log directory
292        let _ = fs::create_dir_all(&self.log_dir);
293
294        let ts = Utc::now().format("%Y%m%d-%H%M%S");
295        let log_path = self.log_dir.join(format!("agent-{username}-{ts}.log"));
296
297        let log_file =
298            fs::File::create(&log_path).map_err(|e| format!("failed to create log file: {e}"))?;
299        let stderr_file = log_file
300            .try_clone()
301            .map_err(|e| format!("failed to clone log file handle: {e}"))?;
302
303        // Build the room-ralph command
304        let mut cmd = Command::new("room-ralph");
305        cmd.arg(&ctx.room_id)
306            .arg(username)
307            .arg("--socket")
308            .arg(&self.socket_path)
309            .arg("--model")
310            .arg(&model);
311
312        if let Some(ref iss) = issue {
313            cmd.arg("--issue").arg(iss);
314        }
315        if let Some(ref p) = prompt {
316            cmd.arg("--prompt").arg(p);
317        }
318        if !personality.is_empty() {
319            cmd.arg("--personality").arg(&personality);
320        }
321
322        // Create per-agent workspace directory for isolation
323        let workspace = agent_workspace_dir(username);
324        fs::create_dir_all(&workspace).map_err(|e| {
325            format!(
326                "failed to create agent workspace {}: {e}",
327                workspace.display()
328            )
329        })?;
330
331        cmd.stdin(Stdio::null())
332            .stdout(Stdio::from(log_file))
333            .stderr(Stdio::from(stderr_file))
334            .current_dir(&workspace);
335        set_process_group(&mut cmd);
336
337        let child = cmd
338            .spawn()
339            .map_err(|e| format!("failed to spawn room-ralph: {e}"))?;
340
341        let pid = child.id();
342
343        let agent = SpawnedAgent {
344            username: username.clone(),
345            pid,
346            model: model.clone(),
347            personality: personality.clone(),
348            spawned_at: Utc::now(),
349            log_path: log_path.clone(),
350            room_id: ctx.room_id.clone(),
351        };
352
353        {
354            let mut agents = self.agents.lock().unwrap();
355            agents.insert(username.clone(), agent);
356        }
357        {
358            let mut children = self.children.lock().unwrap();
359            children.insert(username.clone(), child);
360        }
361        self.persist();
362
363        let personality_info = if personality.is_empty() {
364            String::new()
365        } else {
366            format!(", personality: {personality}")
367        };
368        let text =
369            format!("agent {username} spawned (pid {pid}, model: {model}{personality_info})");
370        let data = serde_json::json!({
371            "action": "spawn",
372            "username": username,
373            "pid": pid,
374            "model": model,
375            "personality": personality,
376            "log_path": log_path.to_string_lossy(),
377        });
378        Ok((text, data))
379    }
380
381    /// Compute the health status for a given agent.
382    fn compute_health(
383        &self,
384        agent: &SpawnedAgent,
385        exit_codes: &HashMap<String, Option<i32>>,
386        now: DateTime<Utc>,
387    ) -> HealthStatus {
388        if !is_process_alive(agent.pid) {
389            let code = exit_codes.get(&agent.username).copied().unwrap_or(None);
390            return HealthStatus::Exited(code);
391        }
392        let last_seen = self.last_seen_at.lock().unwrap();
393        if let Some(&ts) = last_seen.get(&agent.username) {
394            let elapsed = (now - ts).num_seconds();
395            if elapsed > self.stale_threshold_secs {
396                return HealthStatus::Stale;
397            }
398        }
399        // No message tracked yet but process is alive — healthy (just spawned).
400        HealthStatus::Healthy
401    }
402
403    fn handle_list(&self) -> (String, serde_json::Value) {
404        let agents = self.agents.lock().unwrap();
405        if agents.is_empty() {
406            let data = serde_json::json!({ "action": "list", "agents": [] });
407            return ("no agents spawned".to_owned(), data);
408        }
409
410        let mut lines = vec![
411            "username     | pid   | personality | model  | uptime  | health  | status".to_owned(),
412        ];
413
414        // Try to reap exit codes from child handles.
415        {
416            let mut children = self.children.lock().unwrap();
417            let mut exit_codes = self.exit_codes.lock().unwrap();
418            let usernames: Vec<String> = children.keys().cloned().collect();
419            for name in usernames {
420                if let Some(child) = children.get_mut(&name) {
421                    if let Ok(Some(status)) = child.try_wait() {
422                        exit_codes.insert(name.clone(), status.code());
423                        children.remove(&name);
424                    }
425                }
426            }
427        }
428
429        let exit_codes = self.exit_codes.lock().unwrap();
430        let now = Utc::now();
431        let mut entries: Vec<_> = agents.values().collect();
432        entries.sort_by_key(|a| a.spawned_at);
433        let mut agent_data: Vec<serde_json::Value> = Vec::new();
434
435        for agent in entries {
436            let uptime = format_duration(now - agent.spawned_at);
437            let health = self.compute_health(agent, &exit_codes, now);
438            let status = if is_process_alive(agent.pid) {
439                "running".to_owned()
440            } else if let Some(code) = exit_codes.get(&agent.username) {
441                match code {
442                    Some(c) => format!("exited ({c})"),
443                    None => "exited (signal)".to_owned(),
444                }
445            } else {
446                "exited (unknown)".to_owned()
447            };
448            let personality_display = if agent.personality.is_empty() {
449                "-"
450            } else {
451                &agent.personality
452            };
453            let health_str = health.to_string();
454            lines.push(format!(
455                "{:<12} | {:<5} | {:<11} | {:<6} | {:<7} | {:<7} | {}",
456                agent.username,
457                agent.pid,
458                personality_display,
459                agent.model,
460                uptime,
461                health_str,
462                status,
463            ));
464            agent_data.push(serde_json::json!({
465                "username": agent.username,
466                "pid": agent.pid,
467                "model": agent.model,
468                "personality": agent.personality,
469                "uptime_secs": (now - agent.spawned_at).num_seconds(),
470                "health": health_str,
471                "status": status,
472            }));
473        }
474
475        let data = serde_json::json!({ "action": "list", "agents": agent_data });
476        (lines.join("\n"), data)
477    }
478
479    /// Handle `/spawn <personality> [--name <username>]`.
480    ///
481    /// Resolves the personality from the registry (user TOML overrides then
482    /// built-in defaults), generates a username from the name pool, and
483    /// spawns room-ralph with the personality's model, tool restrictions,
484    /// and prompt.
485    fn handle_spawn_personality(&self, ctx: &CommandContext) -> Result<String, String> {
486        if ctx.params.is_empty() {
487            return Err("usage: /spawn <personality> [--name <username>]".to_owned());
488        }
489
490        let personality_name = &ctx.params[0];
491
492        let personality = personalities::resolve_personality(personality_name)
493            .map_err(|e| format!("failed to load personality '{personality_name}': {e}"))?
494            .ok_or_else(|| {
495                let available = personalities::all_personality_names().join(", ");
496                format!("unknown personality '{personality_name}'. available: {available}")
497            })?;
498
499        // Parse --name flag
500        let mut explicit_name: Option<String> = None;
501        let mut i = 1;
502        while i < ctx.params.len() {
503            if ctx.params[i] == "--name" {
504                i += 1;
505                if i < ctx.params.len() {
506                    explicit_name = Some(ctx.params[i].clone());
507                }
508            }
509            i += 1;
510        }
511
512        // Determine username
513        let used_names: Vec<String> = {
514            let agents = self.agents.lock().unwrap();
515            let mut names: Vec<String> = agents.keys().cloned().collect();
516            names.extend(ctx.metadata.online_users.iter().map(|u| u.username.clone()));
517            names
518        };
519
520        let username = if let Some(name) = explicit_name {
521            name
522        } else {
523            personality.generate_username(&used_names)
524        };
525
526        // Validate username
527        if username.is_empty() || username.starts_with('-') {
528            return Err("invalid username".to_owned());
529        }
530
531        // Check collisions
532        if ctx
533            .metadata
534            .online_users
535            .iter()
536            .any(|u| u.username == username)
537        {
538            return Err(format!("username '{username}' is already online"));
539        }
540        {
541            let agents = self.agents.lock().unwrap();
542            if agents.contains_key(username.as_str()) {
543                return Err(format!(
544                    "agent '{username}' is already running (pid {})",
545                    agents[username.as_str()].pid
546                ));
547            }
548        }
549
550        // Create log directory
551        let _ = fs::create_dir_all(&self.log_dir);
552
553        let ts = Utc::now().format("%Y%m%d-%H%M%S");
554        let log_path = self.log_dir.join(format!("agent-{username}-{ts}.log"));
555
556        let log_file =
557            fs::File::create(&log_path).map_err(|e| format!("failed to create log file: {e}"))?;
558        let stderr_file = log_file
559            .try_clone()
560            .map_err(|e| format!("failed to clone log file handle: {e}"))?;
561
562        // Build the room-ralph command from personality config
563        let model = &personality.personality.model;
564        let mut cmd = Command::new("room-ralph");
565        cmd.arg(&ctx.room_id)
566            .arg(&username)
567            .arg("--socket")
568            .arg(&self.socket_path)
569            .arg("--model")
570            .arg(model);
571
572        // Apply tool restrictions
573        if personality.tools.allow_all {
574            cmd.arg("--allow-all");
575        } else {
576            if !personality.tools.disallow.is_empty() {
577                cmd.arg("--disallow-tools")
578                    .arg(personality.tools.disallow.join(","));
579            }
580            if !personality.tools.allow.is_empty() {
581                cmd.arg("--allow-tools")
582                    .arg(personality.tools.allow.join(","));
583            }
584        }
585
586        // Apply personality template as a file — room-ralph's --personality flag
587        // reads the file and prepends its contents to the default system context
588        // (which includes room send/poll commands and the token). Using --prompt
589        // would REPLACE the default context entirely, breaking room communication.
590        if !personality.prompt.template.is_empty() {
591            let template_path = self.log_dir.join(format!("{username}-personality.txt"));
592            if let Err(e) = std::fs::write(&template_path, &personality.prompt.template) {
593                return Err(format!("failed to write personality template: {e}"));
594            }
595            cmd.arg("--personality").arg(&template_path);
596        }
597
598        // Create per-agent workspace directory for isolation
599        let workspace = agent_workspace_dir(&username);
600        fs::create_dir_all(&workspace).map_err(|e| {
601            format!(
602                "failed to create agent workspace {}: {e}",
603                workspace.display()
604            )
605        })?;
606
607        cmd.stdin(Stdio::null())
608            .stdout(Stdio::from(log_file))
609            .stderr(Stdio::from(stderr_file))
610            .current_dir(&workspace);
611        set_process_group(&mut cmd);
612
613        let child = cmd
614            .spawn()
615            .map_err(|e| format!("failed to spawn room-ralph: {e}"))?;
616
617        let pid = child.id();
618
619        let agent = SpawnedAgent {
620            username: username.clone(),
621            pid,
622            model: model.clone(),
623            personality: personality_name.to_owned(),
624            spawned_at: Utc::now(),
625            log_path,
626            room_id: ctx.room_id.clone(),
627        };
628
629        {
630            let mut agents = self.agents.lock().unwrap();
631            agents.insert(username.clone(), agent);
632        }
633        {
634            let mut children = self.children.lock().unwrap();
635            children.insert(username.clone(), child);
636        }
637        self.persist();
638
639        Ok(format!(
640            "agent {username} spawned via /spawn {personality_name} (pid {pid}, model: {model})"
641        ))
642    }
643
644    fn handle_stop(&self, ctx: &CommandContext) -> Result<(String, serde_json::Value), String> {
645        if ctx.params.len() < 2 {
646            return Err("usage: /agent stop <username>".to_owned());
647        }
648
649        // Host-only permission check
650        if let Some(ref host) = ctx.metadata.host {
651            if ctx.sender != *host {
652                return Err("permission denied: only the host can stop agents".to_owned());
653            }
654        }
655
656        let username = &ctx.params[1];
657
658        let agent = {
659            let agents = self.agents.lock().unwrap();
660            agents.get(username.as_str()).cloned()
661        };
662
663        let Some(agent) = agent else {
664            return Err(format!("no agent named '{username}'"));
665        };
666
667        // Check if already exited before attempting to stop
668        let was_alive = is_process_alive(agent.pid);
669        if was_alive {
670            // Always use process group kill to terminate ralph AND all child
671            // claude processes. child.kill() only kills the direct child,
672            // leaving orphaned claude processes responding to messages (#777).
673            stop_process(agent.pid, STOP_GRACE_PERIOD_SECS);
674            // Clean up the Child handle if we have one.
675            let mut child = {
676                let mut children = self.children.lock().unwrap();
677                children.remove(username.as_str())
678            };
679            if let Some(ref mut child) = child {
680                let _ = child.wait();
681            }
682        }
683
684        {
685            let mut agents = self.agents.lock().unwrap();
686            agents.remove(username.as_str());
687        }
688        {
689            let mut exit_codes = self.exit_codes.lock().unwrap();
690            exit_codes.remove(username.as_str());
691        }
692        self.persist();
693
694        let data = serde_json::json!({
695            "action": "stop",
696            "username": username,
697            "pid": agent.pid,
698            "was_alive": was_alive,
699            "stopped_by": ctx.sender,
700        });
701        if was_alive {
702            Ok((
703                format!(
704                    "agent {} stopped by {} (was pid {})",
705                    username, ctx.sender, agent.pid
706                ),
707                data,
708            ))
709        } else {
710            Ok((
711                format!(
712                    "agent {} removed (already exited, was pid {})",
713                    username, agent.pid
714                ),
715                data,
716            ))
717        }
718    }
719
720    fn handle_logs(&self, ctx: &CommandContext) -> Result<String, String> {
721        if ctx.params.len() < 2 {
722            return Err("usage: /agent logs <username> [--tail <N>]".to_owned());
723        }
724
725        let username = &ctx.params[1];
726
727        // Parse optional --tail flag
728        let mut tail_lines = DEFAULT_TAIL_LINES;
729        let mut i = 2;
730        while i < ctx.params.len() {
731            if ctx.params[i] == "--tail" {
732                i += 1;
733                if i < ctx.params.len() {
734                    tail_lines = ctx.params[i]
735                        .parse::<usize>()
736                        .map_err(|_| format!("invalid --tail value: {}", ctx.params[i]))?;
737                    if tail_lines == 0 {
738                        return Err("--tail must be at least 1".to_owned());
739                    }
740                }
741            }
742            i += 1;
743        }
744
745        // Look up the agent
746        let agent = {
747            let agents = self.agents.lock().unwrap();
748            agents.get(username.as_str()).cloned()
749        };
750
751        let Some(agent) = agent else {
752            return Err(format!("no agent named '{username}'"));
753        };
754
755        // Read the log file
756        let content = fs::read_to_string(&agent.log_path)
757            .map_err(|e| format!("cannot read log file {}: {e}", agent.log_path.display()))?;
758
759        if content.is_empty() {
760            return Ok(format!("agent {username}: log file is empty"));
761        }
762
763        // Take last N lines
764        let lines: Vec<&str> = content.lines().collect();
765        let start = lines.len().saturating_sub(tail_lines);
766        let tail: Vec<&str> = lines[start..].to_vec();
767
768        let header = format!(
769            "agent {username} logs (last {} of {} lines):",
770            tail.len(),
771            lines.len()
772        );
773        Ok(format!("{header}\n{}", tail.join("\n")))
774    }
775}
776
777impl Plugin for AgentPlugin {
778    fn name(&self) -> &str {
779        "agent"
780    }
781
782    fn version(&self) -> &str {
783        env!("CARGO_PKG_VERSION")
784    }
785
786    fn commands(&self) -> Vec<CommandInfo> {
787        Self::default_commands()
788    }
789
790    fn on_message(&self, msg: &Message) {
791        let user = msg.user();
792        let agents = self.agents.lock().unwrap();
793        if agents.contains_key(user) {
794            drop(agents);
795            let now = Utc::now();
796            let mut last_seen = self.last_seen_at.lock().unwrap();
797            last_seen.insert(user.to_owned(), now);
798        }
799    }
800
801    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
802        Box::pin(async move {
803            // `/spawn <personality>` is dispatched here with command == "spawn".
804            if ctx.command == "spawn" {
805                return match self.handle_spawn_personality(&ctx) {
806                    Ok(msg) => Ok(PluginResult::Broadcast(msg, None)),
807                    Err(e) => Ok(PluginResult::Reply(e, None)),
808                };
809            }
810
811            // `/agent <action> [args...]`
812            let action = ctx.params.first().map(|s| s.as_str()).unwrap_or("");
813
814            match action {
815                "spawn" => match self.handle_spawn(&ctx) {
816                    Ok((msg, data)) => Ok(PluginResult::Broadcast(msg, Some(data))),
817                    Err(e) => Ok(PluginResult::Reply(e, None)),
818                },
819                "list" => {
820                    let (text, data) = self.handle_list();
821                    Ok(PluginResult::Reply(text, Some(data)))
822                }
823                "stop" => match self.handle_stop(&ctx) {
824                    Ok((msg, data)) => Ok(PluginResult::Broadcast(msg, Some(data))),
825                    Err(e) => Ok(PluginResult::Reply(e, None)),
826                },
827                "logs" => match self.handle_logs(&ctx) {
828                    Ok(msg) => Ok(PluginResult::Reply(msg, None)),
829                    Err(e) => Ok(PluginResult::Reply(e, None)),
830                },
831                _ => Ok(PluginResult::Reply(
832                    "unknown action. usage: /agent spawn|list|stop|logs".to_owned(),
833                    None,
834                )),
835            }
836        })
837    }
838}
839
840/// Configure a [`Command`] to spawn in its own process group.
841///
842/// This ensures `/agent stop` can kill the entire process tree (ralph + all
843/// child claude processes) with a single `kill(-pgid, SIGTERM)`.
844#[cfg(unix)]
845fn set_process_group(cmd: &mut Command) {
846    use std::os::unix::process::CommandExt;
847    // Safety: setsid() is async-signal-safe.
848    unsafe {
849        cmd.pre_exec(|| {
850            libc::setsid();
851            Ok(())
852        });
853    }
854}
855
856/// No-op on non-Unix platforms.
857#[cfg(not(unix))]
858fn set_process_group(_cmd: &mut Command) {}
859
860/// Check whether a process with the given PID is still running.
861fn is_process_alive(pid: u32) -> bool {
862    #[cfg(unix)]
863    {
864        // kill(pid, 0) checks existence without sending a signal
865        unsafe { libc::kill(pid as i32, 0) == 0 }
866    }
867    #[cfg(not(unix))]
868    {
869        let _ = pid;
870        false
871    }
872}
873
874/// Send SIGTERM to a process group, wait `grace_secs`, then SIGKILL if the
875/// leader is still alive.
876///
877/// Uses `kill(-pid, ...)` to signal the entire process group, ensuring child
878/// processes (e.g. claude spawned by room-ralph) are also terminated.
879fn stop_process(pid: u32, grace_secs: u64) {
880    #[cfg(unix)]
881    {
882        // Negative PID signals the entire process group.
883        let pgid = -(pid as i32);
884        unsafe {
885            libc::kill(pgid, libc::SIGTERM);
886        }
887        std::thread::sleep(std::time::Duration::from_secs(grace_secs));
888        if is_process_alive(pid) {
889            unsafe {
890                libc::kill(pgid, libc::SIGKILL);
891            }
892        }
893    }
894    #[cfg(not(unix))]
895    {
896        let _ = (pid, grace_secs);
897    }
898}
899
900/// Format a chrono Duration as a human-readable string (e.g. "14m", "2h").
901fn format_duration(d: chrono::Duration) -> String {
902    let secs = d.num_seconds();
903    if secs < 60 {
904        format!("{secs}s")
905    } else if secs < 3600 {
906        format!("{}m", secs / 60)
907    } else {
908        format!("{}h", secs / 3600)
909    }
910}
911
912/// Load agent state from a JSON file, returning an empty map on error.
913fn load_agents(path: &std::path::Path) -> HashMap<String, SpawnedAgent> {
914    match fs::read_to_string(path) {
915        Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
916        Err(_) => HashMap::new(),
917    }
918}
919
920// ── Tests ─────────────────────────────────────────────────────────────────────
921
922#[cfg(test)]
923mod tests {
924    use super::*;
925    use room_protocol::plugin::{RoomMetadata, UserInfo};
926
927    fn test_plugin(dir: &std::path::Path) -> AgentPlugin {
928        AgentPlugin::new(
929            dir.join("agents.json"),
930            dir.join("room.sock"),
931            dir.join("logs"),
932        )
933    }
934
935    fn make_ctx(_plugin: &AgentPlugin, params: Vec<&str>, online: Vec<&str>) -> CommandContext {
936        CommandContext {
937            command: "agent".to_owned(),
938            params: params.into_iter().map(|s| s.to_owned()).collect(),
939            sender: "host".to_owned(),
940            room_id: "test-room".to_owned(),
941            message_id: "msg-1".to_owned(),
942            timestamp: Utc::now(),
943            history: Box::new(NoopHistory),
944            writer: Box::new(NoopWriter),
945            metadata: RoomMetadata {
946                online_users: online
947                    .into_iter()
948                    .map(|u| UserInfo {
949                        username: u.to_owned(),
950                        status: String::new(),
951                    })
952                    .collect(),
953                host: Some("host".to_owned()),
954                message_count: 0,
955            },
956            available_commands: vec![],
957            team_access: None,
958        }
959    }
960
961    // Noop implementations for test contexts
962    struct NoopHistory;
963    impl room_protocol::plugin::HistoryAccess for NoopHistory {
964        fn all(&self) -> BoxFuture<'_, anyhow::Result<Vec<room_protocol::Message>>> {
965            Box::pin(async { Ok(vec![]) })
966        }
967        fn tail(&self, _n: usize) -> BoxFuture<'_, anyhow::Result<Vec<room_protocol::Message>>> {
968            Box::pin(async { Ok(vec![]) })
969        }
970        fn since(
971            &self,
972            _message_id: &str,
973        ) -> BoxFuture<'_, anyhow::Result<Vec<room_protocol::Message>>> {
974            Box::pin(async { Ok(vec![]) })
975        }
976        fn count(&self) -> BoxFuture<'_, anyhow::Result<usize>> {
977            Box::pin(async { Ok(0) })
978        }
979    }
980
981    struct NoopWriter;
982    impl room_protocol::plugin::MessageWriter for NoopWriter {
983        fn broadcast(&self, _content: &str) -> BoxFuture<'_, anyhow::Result<()>> {
984            Box::pin(async { Ok(()) })
985        }
986        fn reply_to(&self, _user: &str, _content: &str) -> BoxFuture<'_, anyhow::Result<()>> {
987            Box::pin(async { Ok(()) })
988        }
989        fn emit_event(
990            &self,
991            _event_type: room_protocol::EventType,
992            _content: &str,
993            _params: Option<serde_json::Value>,
994        ) -> BoxFuture<'_, anyhow::Result<()>> {
995            Box::pin(async { Ok(()) })
996        }
997    }
998
999    #[test]
1000    fn spawn_missing_username() {
1001        let dir = tempfile::tempdir().unwrap();
1002        let plugin = test_plugin(dir.path());
1003        let ctx = make_ctx(&plugin, vec!["spawn"], vec![]);
1004        let result = plugin.handle_spawn(&ctx);
1005        assert!(result.is_err());
1006        assert!(result.unwrap_err().contains("usage"));
1007    }
1008
1009    #[test]
1010    fn spawn_invalid_username() {
1011        let dir = tempfile::tempdir().unwrap();
1012        let plugin = test_plugin(dir.path());
1013        let ctx = make_ctx(&plugin, vec!["spawn", "--badname"], vec![]);
1014        let result = plugin.handle_spawn(&ctx);
1015        assert!(result.is_err());
1016        assert!(result.unwrap_err().contains("invalid username"));
1017    }
1018
1019    #[test]
1020    fn spawn_username_collision_with_online_user() {
1021        let dir = tempfile::tempdir().unwrap();
1022        let plugin = test_plugin(dir.path());
1023        let ctx = make_ctx(&plugin, vec!["spawn", "alice"], vec!["alice", "bob"]);
1024        let result = plugin.handle_spawn(&ctx);
1025        assert!(result.is_err());
1026        assert!(result.unwrap_err().contains("already online"));
1027    }
1028
1029    #[test]
1030    fn spawn_username_collision_with_running_agent() {
1031        let dir = tempfile::tempdir().unwrap();
1032        let plugin = test_plugin(dir.path());
1033
1034        // Manually insert a fake running agent (use our own PID so it appears alive)
1035        {
1036            let mut agents = plugin.agents.lock().unwrap();
1037            agents.insert(
1038                "bot1".to_owned(),
1039                SpawnedAgent {
1040                    username: "bot1".to_owned(),
1041                    pid: std::process::id(),
1042                    model: "sonnet".to_owned(),
1043                    personality: String::new(),
1044                    spawned_at: Utc::now(),
1045                    log_path: PathBuf::from("/tmp/test.log"),
1046                    room_id: "test-room".to_owned(),
1047                },
1048            );
1049        }
1050
1051        let ctx = make_ctx(&plugin, vec!["spawn", "bot1"], vec![]);
1052        let result = plugin.handle_spawn(&ctx);
1053        assert!(result.is_err());
1054        assert!(result.unwrap_err().contains("already running"));
1055    }
1056
1057    #[test]
1058    fn list_empty() {
1059        let dir = tempfile::tempdir().unwrap();
1060        let plugin = test_plugin(dir.path());
1061        assert_eq!(plugin.handle_list().0, "no agents spawned");
1062    }
1063
1064    #[test]
1065    fn list_with_agents() {
1066        let dir = tempfile::tempdir().unwrap();
1067        let plugin = test_plugin(dir.path());
1068
1069        {
1070            let mut agents = plugin.agents.lock().unwrap();
1071            agents.insert(
1072                "bot1".to_owned(),
1073                SpawnedAgent {
1074                    username: "bot1".to_owned(),
1075                    pid: 99999,
1076                    model: "opus".to_owned(),
1077                    personality: String::new(),
1078                    spawned_at: Utc::now(),
1079                    log_path: PathBuf::from("/tmp/test.log"),
1080                    room_id: "test-room".to_owned(),
1081                },
1082            );
1083        }
1084
1085        let (output, _data) = plugin.handle_list();
1086        assert!(output.contains("bot1"));
1087        assert!(output.contains("opus"));
1088        assert!(output.contains("99999"));
1089    }
1090
1091    #[test]
1092    fn stop_missing_username() {
1093        let dir = tempfile::tempdir().unwrap();
1094        let plugin = test_plugin(dir.path());
1095        let ctx = make_ctx(&plugin, vec!["stop"], vec![]);
1096        let result = plugin.handle_stop(&ctx);
1097        assert!(result.is_err());
1098        assert!(result.unwrap_err().contains("usage"));
1099    }
1100
1101    #[test]
1102    fn stop_unknown_agent() {
1103        let dir = tempfile::tempdir().unwrap();
1104        let plugin = test_plugin(dir.path());
1105        let ctx = make_ctx(&plugin, vec!["stop", "nobody"], vec![]);
1106        let result = plugin.handle_stop(&ctx);
1107        assert!(result.is_err());
1108        assert!(result.unwrap_err().contains("no agent named"));
1109    }
1110
1111    #[test]
1112    fn stop_non_host_denied() {
1113        let dir = tempfile::tempdir().unwrap();
1114        let plugin = test_plugin(dir.path());
1115
1116        // Insert a fake agent
1117        {
1118            let mut agents = plugin.agents.lock().unwrap();
1119            agents.insert(
1120                "bot1".to_owned(),
1121                SpawnedAgent {
1122                    username: "bot1".to_owned(),
1123                    pid: std::process::id(),
1124                    model: "sonnet".to_owned(),
1125                    personality: String::new(),
1126                    spawned_at: Utc::now(),
1127                    log_path: PathBuf::from("/tmp/test.log"),
1128                    room_id: "test-room".to_owned(),
1129                },
1130            );
1131        }
1132
1133        // Create context with sender != host
1134        let mut ctx = make_ctx(&plugin, vec!["stop", "bot1"], vec![]);
1135        ctx.sender = "not-host".to_owned();
1136        let result = plugin.handle_stop(&ctx);
1137        assert!(result.is_err());
1138        assert!(result.unwrap_err().contains("permission denied"));
1139    }
1140
1141    #[test]
1142    fn stop_already_exited_agent() {
1143        let dir = tempfile::tempdir().unwrap();
1144        let plugin = test_plugin(dir.path());
1145
1146        // Insert an agent with a dead PID
1147        {
1148            let mut agents = plugin.agents.lock().unwrap();
1149            agents.insert(
1150                "dead-bot".to_owned(),
1151                SpawnedAgent {
1152                    username: "dead-bot".to_owned(),
1153                    pid: 999_999_999,
1154                    model: "haiku".to_owned(),
1155                    personality: String::new(),
1156                    spawned_at: Utc::now(),
1157                    log_path: PathBuf::from("/tmp/test.log"),
1158                    room_id: "test-room".to_owned(),
1159                },
1160            );
1161        }
1162
1163        let ctx = make_ctx(&plugin, vec!["stop", "dead-bot"], vec![]);
1164        let result = plugin.handle_stop(&ctx);
1165        assert!(result.is_ok());
1166        let (msg, _data) = result.unwrap();
1167        assert!(msg.contains("already exited"));
1168        assert!(msg.contains("removed"));
1169
1170        // Agent should be removed from tracking
1171        let agents = plugin.agents.lock().unwrap();
1172        assert!(!agents.contains_key("dead-bot"));
1173    }
1174
1175    #[test]
1176    fn stop_host_can_stop_agent() {
1177        let dir = tempfile::tempdir().unwrap();
1178        let plugin = test_plugin(dir.path());
1179
1180        // Insert an agent with a dead PID (safe to "stop")
1181        {
1182            let mut agents = plugin.agents.lock().unwrap();
1183            agents.insert(
1184                "bot1".to_owned(),
1185                SpawnedAgent {
1186                    username: "bot1".to_owned(),
1187                    pid: 999_999_999,
1188                    model: "sonnet".to_owned(),
1189                    personality: String::new(),
1190                    spawned_at: Utc::now(),
1191                    log_path: PathBuf::from("/tmp/test.log"),
1192                    room_id: "test-room".to_owned(),
1193                },
1194            );
1195        }
1196
1197        // Host (default sender) should be able to stop
1198        let ctx = make_ctx(&plugin, vec!["stop", "bot1"], vec![]);
1199        let result = plugin.handle_stop(&ctx);
1200        assert!(result.is_ok());
1201
1202        let agents = plugin.agents.lock().unwrap();
1203        assert!(!agents.contains_key("bot1"));
1204    }
1205
1206    #[test]
1207    fn persist_and_load_roundtrip() {
1208        let dir = tempfile::tempdir().unwrap();
1209        let state_path = dir.path().join("agents.json");
1210
1211        // Create plugin and add an agent
1212        let plugin = AgentPlugin::new(
1213            state_path.clone(),
1214            dir.path().join("room.sock"),
1215            dir.path().join("logs"),
1216        );
1217        {
1218            let mut agents = plugin.agents.lock().unwrap();
1219            agents.insert(
1220                "bot1".to_owned(),
1221                SpawnedAgent {
1222                    username: "bot1".to_owned(),
1223                    pid: std::process::id(), // use own PID so it appears alive
1224                    model: "sonnet".to_owned(),
1225                    personality: String::new(),
1226                    spawned_at: Utc::now(),
1227                    log_path: PathBuf::from("/tmp/test.log"),
1228                    room_id: "test-room".to_owned(),
1229                },
1230            );
1231        }
1232        plugin.persist();
1233
1234        // Load a new plugin from same state — should find the agent
1235        let plugin2 = AgentPlugin::new(
1236            state_path,
1237            dir.path().join("room.sock"),
1238            dir.path().join("logs"),
1239        );
1240        let agents = plugin2.agents.lock().unwrap();
1241        assert!(agents.contains_key("bot1"));
1242    }
1243
1244    #[test]
1245    fn prune_dead_agents_on_load() {
1246        let dir = tempfile::tempdir().unwrap();
1247        let state_path = dir.path().join("agents.json");
1248
1249        // Write a state file with a dead PID
1250        let mut agents = HashMap::new();
1251        agents.insert(
1252            "dead-bot".to_owned(),
1253            SpawnedAgent {
1254                username: "dead-bot".to_owned(),
1255                pid: 999_999_999, // very unlikely to be alive
1256                model: "haiku".to_owned(),
1257                personality: String::new(),
1258                spawned_at: Utc::now(),
1259                log_path: PathBuf::from("/tmp/test.log"),
1260                room_id: "test-room".to_owned(),
1261            },
1262        );
1263        fs::write(&state_path, serde_json::to_string(&agents).unwrap()).unwrap();
1264
1265        // New plugin should prune the dead agent
1266        let plugin = AgentPlugin::new(
1267            state_path,
1268            dir.path().join("room.sock"),
1269            dir.path().join("logs"),
1270        );
1271        let agents = plugin.agents.lock().unwrap();
1272        assert!(agents.is_empty(), "dead agents should be pruned on load");
1273    }
1274
1275    // ── /spawn command schema tests ─────────────────────────────────────
1276
1277    #[test]
1278    fn default_commands_includes_spawn() {
1279        let cmds = AgentPlugin::default_commands();
1280        let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
1281        assert!(
1282            names.contains(&"spawn"),
1283            "default_commands must include spawn"
1284        );
1285    }
1286
1287    #[test]
1288    fn spawn_command_has_personality_choice_param() {
1289        let cmds = AgentPlugin::default_commands();
1290        let spawn = cmds.iter().find(|c| c.name == "spawn").unwrap();
1291        assert_eq!(spawn.params.len(), 2);
1292        match &spawn.params[0].param_type {
1293            ParamType::Choice(values) => {
1294                assert!(values.contains(&"coder".to_owned()));
1295                assert!(values.contains(&"reviewer".to_owned()));
1296                assert!(values.contains(&"scout".to_owned()));
1297                assert!(values.contains(&"qa".to_owned()));
1298                assert!(values.contains(&"coordinator".to_owned()));
1299                assert_eq!(values.len(), 5);
1300            }
1301            other => panic!("expected Choice, got {:?}", other),
1302        }
1303    }
1304
1305    #[test]
1306    fn spawn_personality_unknown_returns_error() {
1307        let dir = tempfile::tempdir().unwrap();
1308        let plugin = test_plugin(dir.path());
1309        let mut ctx = make_ctx(&plugin, vec!["hacker"], vec![]);
1310        ctx.command = "spawn".to_owned();
1311        let result = plugin.handle_spawn_personality(&ctx);
1312        assert!(result.is_err());
1313        assert!(result.unwrap_err().contains("unknown personality"));
1314    }
1315
1316    #[test]
1317    fn spawn_personality_missing_returns_usage() {
1318        let dir = tempfile::tempdir().unwrap();
1319        let plugin = test_plugin(dir.path());
1320        let mut ctx = make_ctx(&plugin, vec![] as Vec<&str>, vec![]);
1321        ctx.command = "spawn".to_owned();
1322        let result = plugin.handle_spawn_personality(&ctx);
1323        assert!(result.is_err());
1324        assert!(result.unwrap_err().contains("usage"));
1325    }
1326
1327    #[test]
1328    fn spawn_personality_collision_with_online_user() {
1329        let dir = tempfile::tempdir().unwrap();
1330        let plugin = test_plugin(dir.path());
1331        let mut ctx = make_ctx(&plugin, vec!["coder", "--name", "alice"], vec!["alice"]);
1332        ctx.command = "spawn".to_owned();
1333        let result = plugin.handle_spawn_personality(&ctx);
1334        assert!(result.is_err());
1335        assert!(result.unwrap_err().contains("already online"));
1336    }
1337
1338    #[test]
1339    fn spawn_personality_auto_name_skips_used() {
1340        let dir = tempfile::tempdir().unwrap();
1341        let plugin = test_plugin(dir.path());
1342
1343        // Pre-insert agents with the first pool names to force later picks
1344        let coder = personalities::resolve_personality("coder")
1345            .unwrap()
1346            .unwrap();
1347        let first_name = format!("coder-{}", coder.naming.name_pool[0]);
1348        {
1349            let mut agents = plugin.agents.lock().unwrap();
1350            agents.insert(
1351                first_name.clone(),
1352                SpawnedAgent {
1353                    username: first_name.clone(),
1354                    pid: std::process::id(),
1355                    model: "opus".to_owned(),
1356                    personality: "coder".to_owned(),
1357                    spawned_at: Utc::now(),
1358                    log_path: PathBuf::from("/tmp/test.log"),
1359                    room_id: "test-room".to_owned(),
1360                },
1361            );
1362        }
1363
1364        // The name pool should skip the first name and pick the second
1365        let used: Vec<String> = {
1366            let agents = plugin.agents.lock().unwrap();
1367            agents.keys().cloned().collect()
1368        };
1369        let generated = coder.generate_username(&used);
1370        assert_ne!(generated, first_name);
1371        assert!(generated.starts_with("coder-"));
1372    }
1373
1374    #[test]
1375    fn logs_missing_username() {
1376        let dir = tempfile::tempdir().unwrap();
1377        let plugin = test_plugin(dir.path());
1378        let ctx = make_ctx(&plugin, vec!["logs"], vec![]);
1379        let result = plugin.handle_logs(&ctx);
1380        assert!(result.is_err());
1381        assert!(result.unwrap_err().contains("usage"));
1382    }
1383
1384    #[test]
1385    fn logs_unknown_agent() {
1386        let dir = tempfile::tempdir().unwrap();
1387        let plugin = test_plugin(dir.path());
1388        let ctx = make_ctx(&plugin, vec!["logs", "nobody"], vec![]);
1389        let result = plugin.handle_logs(&ctx);
1390        assert!(result.is_err());
1391        assert!(result.unwrap_err().contains("no agent named"));
1392    }
1393
1394    #[test]
1395    fn logs_empty_file() {
1396        let dir = tempfile::tempdir().unwrap();
1397        let plugin = test_plugin(dir.path());
1398        let log_path = dir.path().join("empty.log");
1399        fs::write(&log_path, "").unwrap();
1400
1401        {
1402            let mut agents = plugin.agents.lock().unwrap();
1403            agents.insert(
1404                "bot1".to_owned(),
1405                SpawnedAgent {
1406                    username: "bot1".to_owned(),
1407                    pid: std::process::id(),
1408                    model: "sonnet".to_owned(),
1409                    personality: String::new(),
1410                    spawned_at: Utc::now(),
1411                    log_path: log_path.clone(),
1412                    room_id: "test-room".to_owned(),
1413                },
1414            );
1415        }
1416
1417        let ctx = make_ctx(&plugin, vec!["logs", "bot1"], vec![]);
1418        let result = plugin.handle_logs(&ctx).unwrap();
1419        assert!(result.contains("empty"));
1420    }
1421
1422    #[test]
1423    fn logs_default_tail() {
1424        let dir = tempfile::tempdir().unwrap();
1425        let plugin = test_plugin(dir.path());
1426        let log_path = dir.path().join("agent.log");
1427
1428        // Write 30 lines
1429        let lines: Vec<String> = (1..=30).map(|i| format!("line {i}")).collect();
1430        fs::write(&log_path, lines.join("\n")).unwrap();
1431
1432        {
1433            let mut agents = plugin.agents.lock().unwrap();
1434            agents.insert(
1435                "bot1".to_owned(),
1436                SpawnedAgent {
1437                    username: "bot1".to_owned(),
1438                    pid: std::process::id(),
1439                    model: "sonnet".to_owned(),
1440                    personality: String::new(),
1441                    spawned_at: Utc::now(),
1442                    log_path: log_path.clone(),
1443                    room_id: "test-room".to_owned(),
1444                },
1445            );
1446        }
1447
1448        let ctx = make_ctx(&plugin, vec!["logs", "bot1"], vec![]);
1449        let result = plugin.handle_logs(&ctx).unwrap();
1450        assert!(result.contains("last 20 of 30 lines"));
1451        assert!(result.contains("line 11"));
1452        assert!(result.contains("line 30"));
1453        assert!(!result.contains("line 10\n"));
1454    }
1455
1456    #[test]
1457    fn logs_custom_tail() {
1458        let dir = tempfile::tempdir().unwrap();
1459        let plugin = test_plugin(dir.path());
1460        let log_path = dir.path().join("agent.log");
1461
1462        let lines: Vec<String> = (1..=10).map(|i| format!("line {i}")).collect();
1463        fs::write(&log_path, lines.join("\n")).unwrap();
1464
1465        {
1466            let mut agents = plugin.agents.lock().unwrap();
1467            agents.insert(
1468                "bot1".to_owned(),
1469                SpawnedAgent {
1470                    username: "bot1".to_owned(),
1471                    pid: std::process::id(),
1472                    model: "sonnet".to_owned(),
1473                    personality: String::new(),
1474                    spawned_at: Utc::now(),
1475                    log_path: log_path.clone(),
1476                    room_id: "test-room".to_owned(),
1477                },
1478            );
1479        }
1480
1481        let ctx = make_ctx(&plugin, vec!["logs", "bot1", "--tail", "3"], vec![]);
1482        let result = plugin.handle_logs(&ctx).unwrap();
1483        assert!(result.contains("last 3 of 10 lines"));
1484        assert!(result.contains("line 8"));
1485        assert!(result.contains("line 10"));
1486        assert!(!result.contains("line 7\n"));
1487    }
1488
1489    #[test]
1490    fn logs_tail_larger_than_file() {
1491        let dir = tempfile::tempdir().unwrap();
1492        let plugin = test_plugin(dir.path());
1493        let log_path = dir.path().join("agent.log");
1494
1495        fs::write(&log_path, "only one line").unwrap();
1496
1497        {
1498            let mut agents = plugin.agents.lock().unwrap();
1499            agents.insert(
1500                "bot1".to_owned(),
1501                SpawnedAgent {
1502                    username: "bot1".to_owned(),
1503                    pid: std::process::id(),
1504                    model: "sonnet".to_owned(),
1505                    personality: String::new(),
1506                    spawned_at: Utc::now(),
1507                    log_path: log_path.clone(),
1508                    room_id: "test-room".to_owned(),
1509                },
1510            );
1511        }
1512
1513        let ctx = make_ctx(&plugin, vec!["logs", "bot1", "--tail", "50"], vec![]);
1514        let result = plugin.handle_logs(&ctx).unwrap();
1515        assert!(result.contains("last 1 of 1 lines"));
1516        assert!(result.contains("only one line"));
1517    }
1518
1519    #[test]
1520    fn logs_missing_log_file() {
1521        let dir = tempfile::tempdir().unwrap();
1522        let plugin = test_plugin(dir.path());
1523
1524        {
1525            let mut agents = plugin.agents.lock().unwrap();
1526            agents.insert(
1527                "bot1".to_owned(),
1528                SpawnedAgent {
1529                    username: "bot1".to_owned(),
1530                    pid: std::process::id(),
1531                    model: "sonnet".to_owned(),
1532                    personality: String::new(),
1533                    spawned_at: Utc::now(),
1534                    log_path: PathBuf::from("/nonexistent/path/agent.log"),
1535                    room_id: "test-room".to_owned(),
1536                },
1537            );
1538        }
1539
1540        let ctx = make_ctx(&plugin, vec!["logs", "bot1"], vec![]);
1541        let result = plugin.handle_logs(&ctx);
1542        assert!(result.is_err());
1543        assert!(result.unwrap_err().contains("cannot read log file"));
1544    }
1545
1546    #[test]
1547    fn logs_invalid_tail_value() {
1548        let dir = tempfile::tempdir().unwrap();
1549        let plugin = test_plugin(dir.path());
1550
1551        {
1552            let mut agents = plugin.agents.lock().unwrap();
1553            agents.insert(
1554                "bot1".to_owned(),
1555                SpawnedAgent {
1556                    username: "bot1".to_owned(),
1557                    pid: std::process::id(),
1558                    model: "sonnet".to_owned(),
1559                    personality: String::new(),
1560                    spawned_at: Utc::now(),
1561                    log_path: PathBuf::from("/tmp/test.log"),
1562                    room_id: "test-room".to_owned(),
1563                },
1564            );
1565        }
1566
1567        let ctx = make_ctx(&plugin, vec!["logs", "bot1", "--tail", "abc"], vec![]);
1568        let result = plugin.handle_logs(&ctx);
1569        assert!(result.is_err());
1570        assert!(result.unwrap_err().contains("invalid --tail value"));
1571    }
1572
1573    #[test]
1574    fn logs_zero_tail_rejected() {
1575        let dir = tempfile::tempdir().unwrap();
1576        let plugin = test_plugin(dir.path());
1577
1578        {
1579            let mut agents = plugin.agents.lock().unwrap();
1580            agents.insert(
1581                "bot1".to_owned(),
1582                SpawnedAgent {
1583                    username: "bot1".to_owned(),
1584                    pid: std::process::id(),
1585                    model: "sonnet".to_owned(),
1586                    personality: String::new(),
1587                    spawned_at: Utc::now(),
1588                    log_path: PathBuf::from("/tmp/test.log"),
1589                    room_id: "test-room".to_owned(),
1590                },
1591            );
1592        }
1593
1594        let ctx = make_ctx(&plugin, vec!["logs", "bot1", "--tail", "0"], vec![]);
1595        let result = plugin.handle_logs(&ctx);
1596        assert!(result.is_err());
1597        assert!(result.unwrap_err().contains("--tail must be at least 1"));
1598    }
1599
1600    #[test]
1601    fn unknown_action_returns_usage() {
1602        let dir = tempfile::tempdir().unwrap();
1603        let plugin = test_plugin(dir.path());
1604        let ctx = make_ctx(&plugin, vec!["frobnicate"], vec![]);
1605
1606        let rt = tokio::runtime::Builder::new_current_thread()
1607            .enable_all()
1608            .build()
1609            .unwrap();
1610        let result = rt.block_on(plugin.handle(ctx)).unwrap();
1611        match result {
1612            PluginResult::Reply(msg, _) => assert!(msg.contains("unknown action")),
1613            PluginResult::Broadcast(..) => panic!("expected Reply, got Broadcast"),
1614            PluginResult::Handled => panic!("expected Reply, got Handled"),
1615        }
1616    }
1617
1618    // ── /agent list tests (#689) ──────────────────────────────────────────
1619
1620    #[test]
1621    fn list_header_includes_personality_column() {
1622        let dir = tempfile::tempdir().unwrap();
1623        let plugin = test_plugin(dir.path());
1624
1625        {
1626            let mut agents = plugin.agents.lock().unwrap();
1627            agents.insert(
1628                "bot1".to_owned(),
1629                SpawnedAgent {
1630                    username: "bot1".to_owned(),
1631                    pid: std::process::id(),
1632                    model: "sonnet".to_owned(),
1633                    personality: "coder".to_owned(),
1634                    spawned_at: Utc::now(),
1635                    log_path: PathBuf::from("/tmp/test.log"),
1636                    room_id: "test-room".to_owned(),
1637                },
1638            );
1639        }
1640
1641        let (output, _data) = plugin.handle_list();
1642        let header = output.lines().next().unwrap();
1643        assert!(
1644            header.contains("personality"),
1645            "header must include personality column"
1646        );
1647        assert!(output.contains("coder"), "personality value must appear");
1648    }
1649
1650    #[test]
1651    fn list_shows_dash_for_empty_personality() {
1652        let dir = tempfile::tempdir().unwrap();
1653        let plugin = test_plugin(dir.path());
1654
1655        {
1656            let mut agents = plugin.agents.lock().unwrap();
1657            agents.insert(
1658                "bot1".to_owned(),
1659                SpawnedAgent {
1660                    username: "bot1".to_owned(),
1661                    pid: std::process::id(),
1662                    model: "opus".to_owned(),
1663                    personality: String::new(),
1664                    spawned_at: Utc::now(),
1665                    log_path: PathBuf::from("/tmp/test.log"),
1666                    room_id: "test-room".to_owned(),
1667                },
1668            );
1669        }
1670
1671        let (output, _data) = plugin.handle_list();
1672        // The personality column should show "-" for empty personality.
1673        let data_line = output.lines().nth(1).unwrap();
1674        assert!(
1675            data_line.contains("| -"),
1676            "empty personality should show '-'"
1677        );
1678    }
1679
1680    #[test]
1681    fn list_shows_running_for_alive_process() {
1682        let dir = tempfile::tempdir().unwrap();
1683        let plugin = test_plugin(dir.path());
1684
1685        {
1686            let mut agents = plugin.agents.lock().unwrap();
1687            agents.insert(
1688                "bot1".to_owned(),
1689                SpawnedAgent {
1690                    username: "bot1".to_owned(),
1691                    pid: std::process::id(), // our own PID — always alive
1692                    model: "sonnet".to_owned(),
1693                    personality: String::new(),
1694                    spawned_at: Utc::now(),
1695                    log_path: PathBuf::from("/tmp/test.log"),
1696                    room_id: "test-room".to_owned(),
1697                },
1698            );
1699        }
1700
1701        let (output, _data) = plugin.handle_list();
1702        assert!(
1703            output.contains("running"),
1704            "alive process should show 'running'"
1705        );
1706    }
1707
1708    #[test]
1709    fn list_shows_exited_unknown_for_dead_process_without_child() {
1710        let dir = tempfile::tempdir().unwrap();
1711        let plugin = test_plugin(dir.path());
1712
1713        {
1714            let mut agents = plugin.agents.lock().unwrap();
1715            agents.insert(
1716                "bot1".to_owned(),
1717                SpawnedAgent {
1718                    username: "bot1".to_owned(),
1719                    pid: 999_999_999, // not alive
1720                    model: "haiku".to_owned(),
1721                    personality: "scout".to_owned(),
1722                    spawned_at: Utc::now(),
1723                    log_path: PathBuf::from("/tmp/test.log"),
1724                    room_id: "test-room".to_owned(),
1725                },
1726            );
1727        }
1728
1729        let (output, _data) = plugin.handle_list();
1730        assert!(
1731            output.contains("exited (unknown)"),
1732            "dead process without child handle should show 'exited (unknown)'"
1733        );
1734    }
1735
1736    #[test]
1737    fn list_shows_exit_code_when_recorded() {
1738        let dir = tempfile::tempdir().unwrap();
1739        let plugin = test_plugin(dir.path());
1740
1741        {
1742            let mut agents = plugin.agents.lock().unwrap();
1743            agents.insert(
1744                "bot1".to_owned(),
1745                SpawnedAgent {
1746                    username: "bot1".to_owned(),
1747                    pid: 999_999_999,
1748                    model: "sonnet".to_owned(),
1749                    personality: "coder".to_owned(),
1750                    spawned_at: Utc::now(),
1751                    log_path: PathBuf::from("/tmp/test.log"),
1752                    room_id: "test-room".to_owned(),
1753                },
1754            );
1755        }
1756        {
1757            let mut exit_codes = plugin.exit_codes.lock().unwrap();
1758            exit_codes.insert("bot1".to_owned(), Some(0));
1759        }
1760
1761        let (output, _data) = plugin.handle_list();
1762        assert!(
1763            output.contains("exited (0)"),
1764            "recorded exit code should appear in output"
1765        );
1766    }
1767
1768    #[test]
1769    fn list_shows_signal_when_no_exit_code() {
1770        let dir = tempfile::tempdir().unwrap();
1771        let plugin = test_plugin(dir.path());
1772
1773        {
1774            let mut agents = plugin.agents.lock().unwrap();
1775            agents.insert(
1776                "bot1".to_owned(),
1777                SpawnedAgent {
1778                    username: "bot1".to_owned(),
1779                    pid: 999_999_999,
1780                    model: "sonnet".to_owned(),
1781                    personality: String::new(),
1782                    spawned_at: Utc::now(),
1783                    log_path: PathBuf::from("/tmp/test.log"),
1784                    room_id: "test-room".to_owned(),
1785                },
1786            );
1787        }
1788        {
1789            // None exit code = killed by signal
1790            let mut exit_codes = plugin.exit_codes.lock().unwrap();
1791            exit_codes.insert("bot1".to_owned(), None);
1792        }
1793
1794        let (output, _data) = plugin.handle_list();
1795        assert!(
1796            output.contains("exited (signal)"),
1797            "signal death should show 'exited (signal)'"
1798        );
1799    }
1800
1801    #[test]
1802    fn list_sorts_by_spawn_time() {
1803        let dir = tempfile::tempdir().unwrap();
1804        let plugin = test_plugin(dir.path());
1805        let now = Utc::now();
1806
1807        {
1808            let mut agents = plugin.agents.lock().unwrap();
1809            agents.insert(
1810                "second".to_owned(),
1811                SpawnedAgent {
1812                    username: "second".to_owned(),
1813                    pid: std::process::id(),
1814                    model: "opus".to_owned(),
1815                    personality: String::new(),
1816                    spawned_at: now,
1817                    log_path: PathBuf::from("/tmp/test.log"),
1818                    room_id: "test-room".to_owned(),
1819                },
1820            );
1821            agents.insert(
1822                "first".to_owned(),
1823                SpawnedAgent {
1824                    username: "first".to_owned(),
1825                    pid: std::process::id(),
1826                    model: "sonnet".to_owned(),
1827                    personality: String::new(),
1828                    spawned_at: now - chrono::Duration::minutes(5),
1829                    log_path: PathBuf::from("/tmp/test.log"),
1830                    room_id: "test-room".to_owned(),
1831                },
1832            );
1833        }
1834
1835        let (output, _data) = plugin.handle_list();
1836        let lines: Vec<&str> = output.lines().collect();
1837        // Skip header (line 0), first data line should be "first", second "second".
1838        assert!(
1839            lines[1].contains("first"),
1840            "older agent should appear first"
1841        );
1842        assert!(
1843            lines[2].contains("second"),
1844            "newer agent should appear second"
1845        );
1846    }
1847
1848    #[test]
1849    fn list_with_personality_and_exit_code_full_row() {
1850        let dir = tempfile::tempdir().unwrap();
1851        let plugin = test_plugin(dir.path());
1852
1853        {
1854            let mut agents = plugin.agents.lock().unwrap();
1855            agents.insert(
1856                "reviewer-a1".to_owned(),
1857                SpawnedAgent {
1858                    username: "reviewer-a1".to_owned(),
1859                    pid: 999_999_999,
1860                    model: "sonnet".to_owned(),
1861                    personality: "reviewer".to_owned(),
1862                    spawned_at: Utc::now(),
1863                    log_path: PathBuf::from("/tmp/test.log"),
1864                    room_id: "test-room".to_owned(),
1865                },
1866            );
1867        }
1868        {
1869            let mut exit_codes = plugin.exit_codes.lock().unwrap();
1870            exit_codes.insert("reviewer-a1".to_owned(), Some(0));
1871        }
1872
1873        let (output, _data) = plugin.handle_list();
1874        assert!(output.contains("reviewer-a1"));
1875        assert!(output.contains("reviewer"));
1876        assert!(output.contains("sonnet"));
1877        assert!(output.contains("exited (0)"));
1878    }
1879
1880    #[test]
1881    fn persist_roundtrip_with_personality() {
1882        let dir = tempfile::tempdir().unwrap();
1883        let state_path = dir.path().join("agents.json");
1884
1885        let plugin = AgentPlugin::new(
1886            state_path.clone(),
1887            dir.path().join("room.sock"),
1888            dir.path().join("logs"),
1889        );
1890        {
1891            let mut agents = plugin.agents.lock().unwrap();
1892            agents.insert(
1893                "bot1".to_owned(),
1894                SpawnedAgent {
1895                    username: "bot1".to_owned(),
1896                    pid: std::process::id(),
1897                    model: "sonnet".to_owned(),
1898                    personality: "coder".to_owned(),
1899                    spawned_at: Utc::now(),
1900                    log_path: PathBuf::from("/tmp/test.log"),
1901                    room_id: "test-room".to_owned(),
1902                },
1903            );
1904        }
1905        plugin.persist();
1906
1907        // Reload — personality should survive roundtrip.
1908        let plugin2 = AgentPlugin::new(
1909            state_path,
1910            dir.path().join("room.sock"),
1911            dir.path().join("logs"),
1912        );
1913        let agents = plugin2.agents.lock().unwrap();
1914        assert_eq!(agents["bot1"].personality, "coder");
1915    }
1916
1917    // ── structured data tests (#697) ─────────────────────────────────────
1918
1919    #[test]
1920    fn list_data_contains_agents_array() {
1921        let dir = tempfile::tempdir().unwrap();
1922        let plugin = test_plugin(dir.path());
1923
1924        {
1925            let mut agents = plugin.agents.lock().unwrap();
1926            agents.insert(
1927                "bot1".to_owned(),
1928                SpawnedAgent {
1929                    username: "bot1".to_owned(),
1930                    pid: std::process::id(),
1931                    model: "opus".to_owned(),
1932                    personality: "coder".to_owned(),
1933                    spawned_at: Utc::now(),
1934                    log_path: PathBuf::from("/tmp/test.log"),
1935                    room_id: "test-room".to_owned(),
1936                },
1937            );
1938        }
1939
1940        let (_text, data) = plugin.handle_list();
1941        assert_eq!(data["action"], "list");
1942        let agents = data["agents"].as_array().expect("agents should be array");
1943        assert_eq!(agents.len(), 1);
1944        assert_eq!(agents[0]["username"], "bot1");
1945        assert_eq!(agents[0]["model"], "opus");
1946        assert_eq!(agents[0]["personality"], "coder");
1947        assert_eq!(agents[0]["status"], "running");
1948    }
1949
1950    #[test]
1951    fn list_empty_data_has_empty_agents() {
1952        let dir = tempfile::tempdir().unwrap();
1953        let plugin = test_plugin(dir.path());
1954
1955        let (_text, data) = plugin.handle_list();
1956        assert_eq!(data["action"], "list");
1957        let agents = data["agents"].as_array().expect("agents should be array");
1958        assert!(agents.is_empty());
1959    }
1960
1961    #[test]
1962    fn stop_data_includes_action_and_username() {
1963        let dir = tempfile::tempdir().unwrap();
1964        let plugin = test_plugin(dir.path());
1965
1966        {
1967            let mut agents = plugin.agents.lock().unwrap();
1968            agents.insert(
1969                "bot1".to_owned(),
1970                SpawnedAgent {
1971                    username: "bot1".to_owned(),
1972                    pid: 999_999_999,
1973                    model: "sonnet".to_owned(),
1974                    personality: String::new(),
1975                    spawned_at: Utc::now(),
1976                    log_path: PathBuf::from("/tmp/test.log"),
1977                    room_id: "test-room".to_owned(),
1978                },
1979            );
1980        }
1981
1982        let ctx = make_ctx(&plugin, vec!["stop", "bot1"], vec![]);
1983        let (text, data) = plugin.handle_stop(&ctx).unwrap();
1984        assert!(text.contains("bot1"));
1985        assert_eq!(data["action"], "stop");
1986        assert_eq!(data["username"], "bot1");
1987        assert_eq!(data["was_alive"], false);
1988    }
1989
1990    // ── ABI entry point tests ────────────────────────────────────────────
1991
1992    #[test]
1993    fn abi_declaration_matches_plugin() {
1994        let decl = &ROOM_PLUGIN_DECLARATION;
1995        assert_eq!(decl.api_version, room_protocol::plugin::PLUGIN_API_VERSION);
1996        unsafe {
1997            assert_eq!(decl.name().unwrap(), "agent");
1998            assert_eq!(decl.version().unwrap(), env!("CARGO_PKG_VERSION"));
1999            assert_eq!(decl.min_protocol().unwrap(), "0.0.0");
2000        }
2001    }
2002
2003    #[test]
2004    fn abi_create_with_empty_config() {
2005        let plugin_ptr = unsafe { room_plugin_create(std::ptr::null(), 0) };
2006        assert!(!plugin_ptr.is_null());
2007        let plugin = unsafe { Box::from_raw(plugin_ptr) };
2008        assert_eq!(plugin.name(), "agent");
2009        assert_eq!(plugin.version(), env!("CARGO_PKG_VERSION"));
2010    }
2011
2012    #[test]
2013    fn abi_create_with_json_config() {
2014        let dir = tempfile::tempdir().unwrap();
2015        let config = format!(
2016            r#"{{"state_path":"{}","socket_path":"{}","log_dir":"{}"}}"#,
2017            dir.path().join("agents.json").display(),
2018            dir.path().join("room.sock").display(),
2019            dir.path().join("logs").display()
2020        );
2021        let plugin_ptr = unsafe { room_plugin_create(config.as_ptr(), config.len()) };
2022        assert!(!plugin_ptr.is_null());
2023        let plugin = unsafe { Box::from_raw(plugin_ptr) };
2024        assert_eq!(plugin.name(), "agent");
2025    }
2026
2027    #[test]
2028    fn abi_destroy_frees_plugin() {
2029        let plugin_ptr = unsafe { room_plugin_create(std::ptr::null(), 0) };
2030        assert!(!plugin_ptr.is_null());
2031        unsafe { room_plugin_destroy(plugin_ptr) };
2032    }
2033
2034    #[test]
2035    fn abi_destroy_null_is_safe() {
2036        unsafe { room_plugin_destroy(std::ptr::null_mut()) };
2037    }
2038
2039    // ── HealthStatus tests ───────────────────────────────────────────────
2040
2041    #[test]
2042    fn health_status_display_healthy() {
2043        assert_eq!(HealthStatus::Healthy.to_string(), "healthy");
2044    }
2045
2046    #[test]
2047    fn health_status_display_stale() {
2048        assert_eq!(HealthStatus::Stale.to_string(), "stale");
2049    }
2050
2051    #[test]
2052    fn health_status_display_exited_code() {
2053        assert_eq!(HealthStatus::Exited(Some(0)).to_string(), "exited (0)");
2054        assert_eq!(HealthStatus::Exited(Some(1)).to_string(), "exited (1)");
2055    }
2056
2057    #[test]
2058    fn health_status_display_exited_signal() {
2059        assert_eq!(HealthStatus::Exited(None).to_string(), "exited (signal)");
2060    }
2061
2062    #[test]
2063    fn health_status_equality() {
2064        assert_eq!(HealthStatus::Healthy, HealthStatus::Healthy);
2065        assert_eq!(HealthStatus::Stale, HealthStatus::Stale);
2066        assert_ne!(HealthStatus::Healthy, HealthStatus::Stale);
2067        assert_ne!(HealthStatus::Exited(Some(0)), HealthStatus::Exited(Some(1)));
2068        assert_eq!(HealthStatus::Exited(None), HealthStatus::Exited(None));
2069    }
2070
2071    #[test]
2072    fn compute_health_exited_process() {
2073        let dir = tempfile::tempdir().unwrap();
2074        let plugin = test_plugin(dir.path());
2075
2076        let agent = SpawnedAgent {
2077            username: "dead-bot".to_owned(),
2078            pid: 999_999_999, // non-existent PID
2079            model: "sonnet".to_owned(),
2080            personality: "coder".to_owned(),
2081            spawned_at: Utc::now() - chrono::Duration::minutes(10),
2082            log_path: dir.path().join("dead-bot.log"),
2083            room_id: "test-room".to_owned(),
2084        };
2085
2086        let exit_codes = HashMap::new();
2087        let health = plugin.compute_health(&agent, &exit_codes, Utc::now());
2088        assert_eq!(health, HealthStatus::Exited(None));
2089    }
2090
2091    #[test]
2092    fn compute_health_exited_with_code() {
2093        let dir = tempfile::tempdir().unwrap();
2094        let plugin = test_plugin(dir.path());
2095
2096        let agent = SpawnedAgent {
2097            username: "dead-bot".to_owned(),
2098            pid: 999_999_999,
2099            model: "sonnet".to_owned(),
2100            personality: "coder".to_owned(),
2101            spawned_at: Utc::now() - chrono::Duration::minutes(10),
2102            log_path: dir.path().join("dead-bot.log"),
2103            room_id: "test-room".to_owned(),
2104        };
2105
2106        let mut exit_codes = HashMap::new();
2107        exit_codes.insert("dead-bot".to_owned(), Some(1));
2108        let health = plugin.compute_health(&agent, &exit_codes, Utc::now());
2109        assert_eq!(health, HealthStatus::Exited(Some(1)));
2110    }
2111
2112    #[test]
2113    fn on_message_updates_last_seen() {
2114        let dir = tempfile::tempdir().unwrap();
2115        let plugin = test_plugin(dir.path());
2116
2117        // Insert a tracked agent.
2118        {
2119            let mut agents = plugin.agents.lock().unwrap();
2120            agents.insert(
2121                "tracked-bot".to_owned(),
2122                SpawnedAgent {
2123                    username: "tracked-bot".to_owned(),
2124                    pid: std::process::id(),
2125                    model: "sonnet".to_owned(),
2126                    personality: "coder".to_owned(),
2127                    spawned_at: Utc::now(),
2128                    log_path: dir.path().join("bot.log"),
2129                    room_id: "test-room".to_owned(),
2130                },
2131            );
2132        }
2133
2134        // Before any message, last_seen should be empty.
2135        assert!(plugin.last_seen_at.lock().unwrap().is_empty());
2136
2137        // Simulate a message from the tracked agent.
2138        let msg = room_protocol::make_message("test-room", "tracked-bot", "hello");
2139        plugin.on_message(&msg);
2140
2141        let last_seen = plugin.last_seen_at.lock().unwrap();
2142        assert!(last_seen.contains_key("tracked-bot"));
2143    }
2144
2145    #[test]
2146    fn on_message_ignores_untracked_users() {
2147        let dir = tempfile::tempdir().unwrap();
2148        let plugin = test_plugin(dir.path());
2149
2150        // No agents registered — message from random user should be ignored.
2151        let msg = room_protocol::make_message("test-room", "random-user", "hello");
2152        plugin.on_message(&msg);
2153
2154        assert!(plugin.last_seen_at.lock().unwrap().is_empty());
2155    }
2156
2157    #[test]
2158    fn stale_threshold_default_is_five_minutes() {
2159        let dir = tempfile::tempdir().unwrap();
2160        let plugin = test_plugin(dir.path());
2161        assert_eq!(plugin.stale_threshold_secs, 300);
2162    }
2163
2164    #[test]
2165    fn health_stale_when_last_seen_exceeds_threshold() {
2166        let dir = tempfile::tempdir().unwrap();
2167        let mut plugin = test_plugin(dir.path());
2168        plugin.stale_threshold_secs = 60; // 1 minute for test
2169
2170        let agent = SpawnedAgent {
2171            username: "stale-bot".to_owned(),
2172            pid: std::process::id(), // current process = alive
2173            model: "sonnet".to_owned(),
2174            personality: "coder".to_owned(),
2175            spawned_at: Utc::now() - chrono::Duration::minutes(10),
2176            log_path: dir.path().join("stale-bot.log"),
2177            room_id: "test-room".to_owned(),
2178        };
2179
2180        // Set last_seen to 2 minutes ago (exceeds 1 minute threshold).
2181        {
2182            let mut last_seen = plugin.last_seen_at.lock().unwrap();
2183            last_seen.insert(
2184                "stale-bot".to_owned(),
2185                Utc::now() - chrono::Duration::seconds(120),
2186            );
2187        }
2188
2189        let exit_codes = HashMap::new();
2190        let health = plugin.compute_health(&agent, &exit_codes, Utc::now());
2191        assert_eq!(health, HealthStatus::Stale);
2192    }
2193
2194    #[test]
2195    fn health_healthy_when_recently_seen() {
2196        let dir = tempfile::tempdir().unwrap();
2197        let mut plugin = test_plugin(dir.path());
2198        plugin.stale_threshold_secs = 60;
2199
2200        let agent = SpawnedAgent {
2201            username: "active-bot".to_owned(),
2202            pid: std::process::id(),
2203            model: "sonnet".to_owned(),
2204            personality: "coder".to_owned(),
2205            spawned_at: Utc::now() - chrono::Duration::minutes(10),
2206            log_path: dir.path().join("active-bot.log"),
2207            room_id: "test-room".to_owned(),
2208        };
2209
2210        // Set last_seen to 30 seconds ago (within 1 minute threshold).
2211        {
2212            let mut last_seen = plugin.last_seen_at.lock().unwrap();
2213            last_seen.insert(
2214                "active-bot".to_owned(),
2215                Utc::now() - chrono::Duration::seconds(30),
2216            );
2217        }
2218
2219        let exit_codes = HashMap::new();
2220        let health = plugin.compute_health(&agent, &exit_codes, Utc::now());
2221        assert_eq!(health, HealthStatus::Healthy);
2222    }
2223
2224    #[test]
2225    fn health_healthy_when_never_seen_but_alive() {
2226        let dir = tempfile::tempdir().unwrap();
2227        let plugin = test_plugin(dir.path());
2228
2229        let agent = SpawnedAgent {
2230            username: "new-bot".to_owned(),
2231            pid: std::process::id(), // current process = alive
2232            model: "sonnet".to_owned(),
2233            personality: "coder".to_owned(),
2234            spawned_at: Utc::now(),
2235            log_path: dir.path().join("new-bot.log"),
2236            room_id: "test-room".to_owned(),
2237        };
2238
2239        let exit_codes = HashMap::new();
2240        let health = plugin.compute_health(&agent, &exit_codes, Utc::now());
2241        assert_eq!(health, HealthStatus::Healthy);
2242    }
2243
2244    #[test]
2245    fn handle_list_includes_health_column() {
2246        let dir = tempfile::tempdir().unwrap();
2247        let plugin = test_plugin(dir.path());
2248
2249        // Insert an agent with the current PID so it shows as alive.
2250        {
2251            let mut agents = plugin.agents.lock().unwrap();
2252            agents.insert(
2253                "test-bot".to_owned(),
2254                SpawnedAgent {
2255                    username: "test-bot".to_owned(),
2256                    pid: std::process::id(),
2257                    model: "sonnet".to_owned(),
2258                    personality: "coder".to_owned(),
2259                    spawned_at: Utc::now(),
2260                    log_path: dir.path().join("test-bot.log"),
2261                    room_id: "test-room".to_owned(),
2262                },
2263            );
2264        }
2265
2266        let (text, data) = plugin.handle_list();
2267        // Header should include health column.
2268        assert!(text.contains("health"));
2269        // Agent data should include health field.
2270        let agents = data["agents"].as_array().unwrap();
2271        assert_eq!(agents.len(), 1);
2272        assert_eq!(agents[0]["health"], "healthy");
2273    }
2274}