Skip to main content

omni_dev/cli/
sessions.rs

1//! `omni-dev sessions` — track the Claude Code sessions running across every
2//! terminal and VS Code window, via the daemon's `sessions` service.
3//!
4//! Four subcommands, split by role:
5//! - `list` is a **read** client (like `omni-dev worktrees list`): it asks the
6//!   daemon's `sessions` service for the live set and renders it.
7//! - `hook` is the **feed sink**: Claude Code runs it per hook event; it reads
8//!   the hook JSON on stdin, maps it to an `observe`/`end` op, and fire-and-forgets
9//!   it to the daemon socket. It must **never** block or fail a Claude turn — a
10//!   missing daemon, a bad payload, or any other error is swallowed and it always
11//!   exits 0.
12//! - `install-hooks` / `uninstall-hooks` idempotently merge (or remove) the hook
13//!   block in `~/.claude/settings.json`, preserving any hooks already there.
14//!
15//! The register/heartbeat feed from the companion VS Code extension talks to the
16//! socket directly (like the worktrees companion), not through this CLI.
17
18use std::io::Read;
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21
22use anyhow::{bail, Context, Result};
23use chrono::Utc;
24use clap::{Parser, Subcommand};
25use serde::Deserialize;
26use serde_json::{json, Value};
27
28use crate::cli::format::TableOrJson;
29use crate::daemon::client::DaemonClient;
30use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
31use crate::daemon::server;
32use crate::sessions::{NotificationKind, ObserveRequest, SessionEvent};
33
34/// The `sessions` service routing key on the daemon control socket.
35const SERVICE: &str = "sessions";
36
37/// How long the fire-and-forget `hook` sink waits for the daemon before giving
38/// up — short, so a slow or wedged daemon never stalls a Claude turn.
39const HOOK_TIMEOUT: Duration = Duration::from_secs(2);
40
41/// Sessions: see the Claude Code sessions running across every terminal and
42/// VS Code window, kept live by the daemon.
43#[derive(Parser)]
44pub struct SessionsCommand {
45    /// The sessions subcommand to execute.
46    #[command(subcommand)]
47    pub command: SessionsSubcommands,
48}
49
50/// Sessions subcommands.
51#[derive(Subcommand)]
52pub enum SessionsSubcommands {
53    /// List the Claude Code sessions currently running across all windows.
54    List(ListCommand),
55    /// Claude Code hook sink: read a hook event on stdin and report it to the
56    /// daemon (run by Claude Code, not by hand).
57    Hook(HookCommand),
58    /// Install the Claude Code hooks that feed the sessions tracker into
59    /// `~/.claude/settings.json` (idempotent).
60    InstallHooks(InstallHooksCommand),
61    /// Remove the sessions-tracker hooks from `~/.claude/settings.json`.
62    UninstallHooks(UninstallHooksCommand),
63}
64
65impl SessionsCommand {
66    /// Executes the sessions command.
67    pub async fn execute(self) -> Result<()> {
68        match self.command {
69            SessionsSubcommands::List(cmd) => cmd.execute().await,
70            SessionsSubcommands::Hook(cmd) => cmd.execute().await,
71            SessionsSubcommands::InstallHooks(cmd) => cmd.execute(),
72            SessionsSubcommands::UninstallHooks(cmd) => cmd.execute(),
73        }
74    }
75}
76
77// --- list --------------------------------------------------------------------
78
79/// Lists the live cross-window set of running Claude sessions.
80#[derive(Parser)]
81pub struct ListCommand {
82    /// Control-socket path. Defaults to the per-user runtime location.
83    #[arg(long, value_name = "PATH")]
84    pub socket: Option<PathBuf>,
85    /// Output format.
86    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
87    pub output: TableOrJson,
88}
89
90impl ListCommand {
91    /// Executes the list command.
92    pub async fn execute(self) -> Result<()> {
93        let socket = server::resolve_socket(self.socket)?;
94        let result = call(&socket, "list", Value::Null).await?;
95        match self.output {
96            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
97            TableOrJson::Table => println!("{}", render_sessions(&result)),
98        }
99        Ok(())
100    }
101}
102
103// --- hook --------------------------------------------------------------------
104
105/// The Claude Code hook sink: reads one hook event's JSON on stdin and reports it
106/// to the daemon. Fire-and-forget and infallible-by-design.
107#[derive(Parser)]
108pub struct HookCommand {
109    /// Control-socket path. Defaults to the per-user runtime location.
110    #[arg(long, value_name = "PATH")]
111    pub socket: Option<PathBuf>,
112}
113
114impl HookCommand {
115    /// Executes the hook sink. Always returns `Ok(())` (exit 0): a hook must
116    /// never block or fail a Claude turn, so every error — no daemon, bad JSON,
117    /// an unknown event — is swallowed after a best-effort report.
118    pub async fn execute(self) -> Result<()> {
119        let mut input = String::new();
120        if std::io::stdin().read_to_string(&mut input).is_err() {
121            return Ok(());
122        }
123        self.report(&input).await;
124        Ok(())
125    }
126
127    /// Parses the hook JSON, maps it to an op, and best-effort sends it. Split
128    /// out so tests can exercise the send path against a fake socket.
129    async fn report(&self, input: &str) {
130        let Ok(hook) = serde_json::from_str::<HookPayload>(input) else {
131            return;
132        };
133        let Some((op, payload)) = hook.to_op() else {
134            return;
135        };
136        let Ok(socket) = server::resolve_socket(self.socket.clone()) else {
137            return;
138        };
139        // Bounded, and every failure ignored: the daemon may be down, and that
140        // must be a silent no-op.
141        let env = DaemonEnvelope::service(SERVICE, op, payload);
142        let _ = tokio::time::timeout(HOOK_TIMEOUT, DaemonClient::new(&socket).request(env)).await;
143    }
144}
145
146/// The subset of a Claude Code hook payload the sink reads. Every field is
147/// optional and defaulted, so an unexpected or future payload shape never fails
148/// to parse (the sink then simply produces no op). See the hooks docs.
149#[derive(Debug, Clone, Default, Deserialize)]
150struct HookPayload {
151    #[serde(default)]
152    session_id: Option<String>,
153    #[serde(default)]
154    transcript_path: Option<PathBuf>,
155    #[serde(default)]
156    cwd: Option<PathBuf>,
157    #[serde(default)]
158    hook_event_name: Option<String>,
159    /// Present on `Notification` events — the message classified into a
160    /// [`NotificationKind`].
161    #[serde(default)]
162    message: Option<String>,
163    /// Best-effort model id, when a payload carries one.
164    #[serde(default)]
165    model: Option<String>,
166}
167
168impl HookPayload {
169    /// Maps this hook payload to a `(op, payload)` for the daemon, or `None` when
170    /// it carries no `session_id` or names an event the tracker ignores.
171    fn to_op(&self) -> Option<(&'static str, Value)> {
172        let session_id = self.session_id.clone().filter(|s| !s.trim().is_empty())?;
173        let event_name = self.hook_event_name.as_deref()?;
174        if event_name == "SessionEnd" {
175            let mut payload = json!({ "session_id": session_id });
176            if let Some(reason) = &self.message {
177                payload["reason"] = Value::String(reason.clone());
178            }
179            return Some(("end", payload));
180        }
181        let event = session_event_for(event_name, self.message.as_deref())?;
182        let request = ObserveRequest {
183            session_id,
184            cwd: self.cwd.clone(),
185            transcript_path: self.transcript_path.clone(),
186            event,
187            repo: None,
188            model: self.model.clone(),
189        };
190        Some(("observe", serde_json::to_value(request).ok()?))
191    }
192}
193
194/// Maps a Claude Code hook event name to the [`SessionEvent`] it implies, or
195/// `None` for an event the tracker does not act on. `SessionEnd` is handled
196/// separately (it maps to the `end` op, not `observe`).
197fn session_event_for(event_name: &str, message: Option<&str>) -> Option<SessionEvent> {
198    Some(match event_name {
199        "SessionStart" => SessionEvent::SessionStart,
200        "UserPromptSubmit" => SessionEvent::UserPromptSubmit,
201        "PreToolUse" => SessionEvent::PreToolUse,
202        "PostToolUse" => SessionEvent::PostToolUse,
203        "Stop" => SessionEvent::Stop,
204        "Notification" => SessionEvent::Notification(classify_notification(message)),
205        _ => return None,
206    })
207}
208
209/// Classifies a `Notification` message into a [`NotificationKind`]. Best-effort
210/// substring matching — the message text is version-unstable, so an unrecognised
211/// message falls back to [`NotificationKind::Other`] (which carries no state
212/// signal and leaves the session's state unchanged).
213fn classify_notification(message: Option<&str>) -> NotificationKind {
214    let Some(message) = message else {
215        return NotificationKind::Other;
216    };
217    let lower = message.to_lowercase();
218    if lower.contains("permission") || lower.contains("approve") || lower.contains("allow") {
219        NotificationKind::PermissionPrompt
220    } else if lower.contains("waiting for your input")
221        || lower.contains("idle")
222        || lower.contains("needs your input")
223    {
224        NotificationKind::IdlePrompt
225    } else {
226        NotificationKind::Other
227    }
228}
229
230// --- install-hooks / uninstall-hooks ----------------------------------------
231
232/// Installs the sessions-tracker hooks into `~/.claude/settings.json`.
233#[derive(Parser)]
234pub struct InstallHooksCommand {
235    /// Path to the Claude settings file. Defaults to `~/.claude/settings.json`
236    /// (respecting `$CLAUDE_CONFIG_DIR`).
237    #[arg(long, value_name = "PATH")]
238    pub settings: Option<PathBuf>,
239}
240
241impl InstallHooksCommand {
242    /// Executes the install: merges the hook block idempotently, preserving any
243    /// hooks already present.
244    pub fn execute(self) -> Result<()> {
245        let path = settings_path(self.settings)?;
246        let mut settings = read_settings(&path)?;
247        let command = hook_command();
248        let added = merge_hooks(&mut settings, &command);
249        write_settings(&path, &settings)?;
250        if added == 0 {
251            println!(
252                "sessions hooks already installed in {} (no change)",
253                path.display()
254            );
255        } else {
256            println!(
257                "installed {added} sessions hook event(s) into {}\ncommand: {command}",
258                path.display()
259            );
260        }
261        Ok(())
262    }
263}
264
265/// Removes the sessions-tracker hooks from `~/.claude/settings.json`.
266#[derive(Parser)]
267pub struct UninstallHooksCommand {
268    /// Path to the Claude settings file. Defaults to `~/.claude/settings.json`
269    /// (respecting `$CLAUDE_CONFIG_DIR`).
270    #[arg(long, value_name = "PATH")]
271    pub settings: Option<PathBuf>,
272}
273
274impl UninstallHooksCommand {
275    /// Executes the uninstall: removes any hook entries whose command is ours,
276    /// leaving every other hook untouched.
277    pub fn execute(self) -> Result<()> {
278        let path = settings_path(self.settings)?;
279        if !path.exists() {
280            println!("no settings file at {} (nothing to remove)", path.display());
281            return Ok(());
282        }
283        let mut settings = read_settings(&path)?;
284        let removed = remove_hooks(&mut settings, &hook_command());
285        write_settings(&path, &settings)?;
286        println!(
287            "removed {removed} sessions hook entry(ies) from {}",
288            path.display()
289        );
290        Ok(())
291    }
292}
293
294/// The Claude Code hook events the tracker installs, paired with whether the
295/// event's hook group needs a tool `matcher` (`PreToolUse`/`PostToolUse` match on
296/// tool name; the rest have no matcher). `SessionEnd` is included — it maps to
297/// the `end` op in the sink.
298const HOOK_EVENTS: &[(&str, bool)] = &[
299    ("SessionStart", false),
300    ("UserPromptSubmit", false),
301    ("PreToolUse", true),
302    ("PostToolUse", true),
303    ("Notification", false),
304    ("Stop", false),
305    ("SessionEnd", false),
306];
307
308/// The hook command string written into settings.json: the absolute path of the
309/// running binary plus `sessions hook`, so Claude Code invokes *this* omni-dev
310/// regardless of its hook `PATH`. Falls back to the bare `omni-dev sessions hook`
311/// when the executable path cannot be resolved (documented as the portable form).
312fn hook_command() -> String {
313    match std::env::current_exe() {
314        Ok(exe) => format!("{} sessions hook", exe.display()),
315        Err(_) => "omni-dev sessions hook".to_string(),
316    }
317}
318
319/// The Claude settings file path: an explicit `--settings`, else
320/// `$CLAUDE_CONFIG_DIR/settings.json`, else `~/.claude/settings.json`.
321fn settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
322    if let Some(path) = explicit {
323        return Ok(path);
324    }
325    if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
326        return Ok(PathBuf::from(dir).join("settings.json"));
327    }
328    let home = dirs::home_dir().context("could not resolve the home directory")?;
329    Ok(home.join(".claude").join("settings.json"))
330}
331
332/// Reads and parses `path` into a JSON object, treating a missing file as an
333/// empty object. Errors (rather than clobbering) when the file exists but is not
334/// valid JSON, or is valid JSON that is not an object.
335fn read_settings(path: &Path) -> Result<Value> {
336    if !path.exists() {
337        return Ok(json!({}));
338    }
339    let text = std::fs::read_to_string(path)
340        .with_context(|| format!("failed to read {}", path.display()))?;
341    if text.trim().is_empty() {
342        return Ok(json!({}));
343    }
344    let value: Value = serde_json::from_str(&text).with_context(|| {
345        format!(
346            "{} is not valid JSON; refusing to overwrite it",
347            path.display()
348        )
349    })?;
350    if !value.is_object() {
351        bail!(
352            "{} is not a JSON object; refusing to overwrite it",
353            path.display()
354        );
355    }
356    Ok(value)
357}
358
359/// Serializes `settings` back to `path`, pretty-printed with a trailing newline,
360/// creating the parent directory if needed.
361fn write_settings(path: &Path, settings: &Value) -> Result<()> {
362    if let Some(parent) = path.parent() {
363        std::fs::create_dir_all(parent)
364            .with_context(|| format!("failed to create {}", parent.display()))?;
365    }
366    let mut text = serde_json::to_string_pretty(settings)?;
367    text.push('\n');
368    std::fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))?;
369    Ok(())
370}
371
372/// Merges the sessions-tracker hook `command` into a settings object under each
373/// event in [`HOOK_EVENTS`], returning how many events were newly added.
374/// Idempotent (an event that already has a group running `command` is skipped)
375/// and additive (it never touches other hooks). Creates `hooks` and any per-event
376/// array as needed.
377fn merge_hooks(settings: &mut Value, command: &str) -> usize {
378    // `read_settings` guarantees an object, but degrade gracefully rather than
379    // panic if a caller passes something else.
380    let Some(root) = settings.as_object_mut() else {
381        return 0;
382    };
383    let hooks = root
384        .entry("hooks")
385        .or_insert_with(|| json!({}))
386        .as_object_mut();
387    let Some(hooks) = hooks else {
388        // `hooks` exists but is not an object; leave the file alone rather than
389        // clobber a user's unexpected shape.
390        return 0;
391    };
392    let mut added = 0;
393    for (event, needs_matcher) in HOOK_EVENTS {
394        let groups = hooks
395            .entry((*event).to_string())
396            .or_insert_with(|| json!([]));
397        let Some(groups) = groups.as_array_mut() else {
398            continue;
399        };
400        if groups.iter().any(|g| group_has_command(g, command)) {
401            continue; // already installed for this event
402        }
403        groups.push(hook_group(command, *needs_matcher));
404        added += 1;
405    }
406    added
407}
408
409/// Removes every hook entry whose command is `command` from a settings object,
410/// pruning any group and per-event array left empty, and returning how many hook
411/// entries were removed. Leaves all other hooks in place.
412fn remove_hooks(settings: &mut Value, command: &str) -> usize {
413    let Some(root) = settings.as_object_mut() else {
414        return 0;
415    };
416    let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
417        return 0;
418    };
419    let mut removed = 0;
420    let mut empty_events = Vec::new();
421    for (event, groups) in hooks.iter_mut() {
422        let Some(groups) = groups.as_array_mut() else {
423            continue;
424        };
425        for group in groups.iter_mut() {
426            if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
427                let before = inner.len();
428                inner.retain(|h| !hook_has_command(h, command));
429                removed += before - inner.len();
430            }
431        }
432        // Drop groups whose hook list is now empty, then the event if no groups
433        // remain, so an uninstall leaves no empty scaffolding behind.
434        groups.retain(|g| {
435            g.get("hooks")
436                .and_then(Value::as_array)
437                .map_or(true, |h| !h.is_empty())
438        });
439        if groups.is_empty() {
440            empty_events.push(event.clone());
441        }
442    }
443    for event in empty_events {
444        hooks.remove(&event);
445    }
446    removed
447}
448
449/// One hook group as written into an event array: `{ "hooks": [{ "type":
450/// "command", "command": … }] }`, with a `"matcher": "*"` when the event matches
451/// on tool name.
452fn hook_group(command: &str, needs_matcher: bool) -> Value {
453    let mut group = json!({
454        "hooks": [{ "type": "command", "command": command }],
455    });
456    if needs_matcher {
457        group["matcher"] = Value::String("*".to_string());
458    }
459    group
460}
461
462/// Whether a hook `group` already contains a hook running `command`.
463fn group_has_command(group: &Value, command: &str) -> bool {
464    group
465        .get("hooks")
466        .and_then(Value::as_array)
467        .is_some_and(|hooks| hooks.iter().any(|h| hook_has_command(h, command)))
468}
469
470/// Whether a single hook entry runs `command`.
471fn hook_has_command(hook: &Value, command: &str) -> bool {
472    hook.get("command").and_then(Value::as_str) == Some(command)
473}
474
475// --- shared socket + rendering ----------------------------------------------
476
477/// Sends one `sessions` service op over the control socket, returning its
478/// payload or turning an `ok: false` reply into an error.
479async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
480    let reply = DaemonClient::new(socket)
481        .request(DaemonEnvelope::service(SERVICE, op, payload))
482        .await?;
483    reply_payload(reply)
484}
485
486/// Unwraps a daemon reply into its payload, turning an `ok: false` reply into an
487/// error. Pure (no socket), so both mappings are unit-testable.
488fn reply_payload(reply: DaemonReply) -> Result<Value> {
489    if reply.ok {
490        Ok(reply.payload)
491    } else {
492        bail!(
493            "daemon returned an error: {}",
494            reply.error.as_deref().unwrap_or("unknown error")
495        )
496    }
497}
498
499/// Renders a `list` reply as a human-readable table: a header and one row per
500/// live session (state, source, repo, working directory, and age). Returns a
501/// placeholder line when nothing is running.
502fn render_sessions(result: &Value) -> String {
503    let sessions = result
504        .get("sessions")
505        .and_then(Value::as_array)
506        .map(Vec::as_slice)
507        .unwrap_or_default();
508    if sessions.is_empty() {
509        return "No active Claude Code sessions.".to_string();
510    }
511    // CWD is last so a long path never misaligns the columns after it.
512    let mut out = format!(
513        "{:<13} {:<8} {:<20} {:>5}  {}",
514        "STATE", "SOURCE", "REPO", "AGE", "CWD"
515    );
516    for session in sessions {
517        let state = state_display(session.get("state").and_then(Value::as_str).unwrap_or("-"));
518        let source = source_label(session);
519        let repo = sanitize(session.get("repo").and_then(Value::as_str).unwrap_or("-"));
520        let cwd = sanitize(session.get("cwd").and_then(Value::as_str).unwrap_or("-"));
521        let age = age_secs(session.get("last_seen").and_then(Value::as_str));
522        out.push_str(&format!(
523            "\n{state:<13} {source:<8} {repo:<20} {age:>4}s  {cwd}"
524        ));
525    }
526    out
527}
528
529/// A compact, fixed-width-friendly label for a session state, so the wide
530/// `waiting_for_permission` does not overflow the STATE column. Falls through to
531/// the raw (sanitized) string for any unexpected value.
532fn state_display(state: &str) -> String {
533    match state {
534        "waiting_for_permission" => "waiting-perm".to_string(),
535        "waiting_for_input" => "waiting-input".to_string(),
536        other => sanitize(other),
537    }
538}
539
540/// The short source label for a session: `vscode` when embedded in a VS Code
541/// window, else `terminal`.
542fn source_label(session: &Value) -> &'static str {
543    match session.pointer("/source/kind").and_then(Value::as_str) {
544        Some("vs_code") => "vscode",
545        _ => "terminal",
546    }
547}
548
549/// Strips control characters from an untrusted registry string so a crafted
550/// payload cannot inject terminal escape sequences into the rendered table (the
551/// worktrees `sanitize` precedent, #1137). The `--json` path stays verbatim.
552fn sanitize(s: &str) -> String {
553    s.chars().filter(|c| !c.is_control()).collect()
554}
555
556/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
557fn age_secs(ts: Option<&str>) -> i64 {
558    ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
559        .map_or(0, |t| {
560            (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
561        })
562}
563
564#[cfg(test)]
565#[allow(clippy::unwrap_used, clippy::expect_used)]
566mod tests {
567    use super::*;
568
569    /// Mirrors the `omni-dev sessions` argv surface for parse tests.
570    #[derive(Parser)]
571    struct Wrapper {
572        #[command(subcommand)]
573        cmd: SessionsSubcommands,
574    }
575
576    fn parse(args: &[&str]) -> SessionsSubcommands {
577        let mut full = vec!["omni-dev"];
578        full.extend_from_slice(args);
579        Wrapper::try_parse_from(full).unwrap().cmd
580    }
581
582    #[test]
583    fn subcommands_parse() {
584        assert!(matches!(parse(&["list"]), SessionsSubcommands::List(_)));
585        assert!(matches!(parse(&["hook"]), SessionsSubcommands::Hook(_)));
586        assert!(matches!(
587            parse(&["install-hooks"]),
588            SessionsSubcommands::InstallHooks(_)
589        ));
590        assert!(matches!(
591            parse(&["uninstall-hooks"]),
592            SessionsSubcommands::UninstallHooks(_)
593        ));
594    }
595
596    #[test]
597    fn list_parses_flags() {
598        let cmd =
599            ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
600        assert_eq!(cmd.output, TableOrJson::Json);
601        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
602    }
603
604    // --- hook mapping --------------------------------------------------------
605
606    fn hook_op(json_str: &str) -> Option<(&'static str, Value)> {
607        serde_json::from_str::<HookPayload>(json_str)
608            .unwrap()
609            .to_op()
610    }
611
612    #[test]
613    fn hook_maps_lifecycle_events_to_observe() {
614        let (op, payload) = hook_op(
615            r#"{"session_id":"s1","cwd":"/p","transcript_path":"/t.jsonl","hook_event_name":"PreToolUse"}"#,
616        )
617        .unwrap();
618        assert_eq!(op, "observe");
619        assert_eq!(payload["session_id"], "s1");
620        assert_eq!(payload["cwd"], "/p");
621        assert_eq!(payload["event"], "pre_tool_use");
622    }
623
624    #[test]
625    fn hook_maps_session_start_and_stop() {
626        assert_eq!(
627            hook_op(r#"{"session_id":"s1","hook_event_name":"SessionStart"}"#)
628                .unwrap()
629                .1["event"],
630            "session_start"
631        );
632        assert_eq!(
633            hook_op(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
634                .unwrap()
635                .1["event"],
636            "stop"
637        );
638    }
639
640    #[test]
641    fn hook_maps_session_end_to_end_op() {
642        let (op, payload) =
643            hook_op(r#"{"session_id":"s1","hook_event_name":"SessionEnd","message":"exit"}"#)
644                .unwrap();
645        assert_eq!(op, "end");
646        assert_eq!(payload["session_id"], "s1");
647        assert_eq!(payload["reason"], "exit");
648    }
649
650    #[test]
651    fn hook_classifies_notifications() {
652        let permission = hook_op(
653            r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}"#,
654        )
655        .unwrap();
656        assert_eq!(permission.1["event"]["notification"], "permission_prompt");
657
658        let idle = hook_op(
659            r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude is waiting for your input"}"#,
660        )
661        .unwrap();
662        assert_eq!(idle.1["event"]["notification"], "idle_prompt");
663
664        let other = hook_op(
665            r#"{"session_id":"s1","hook_event_name":"Notification","message":"something else"}"#,
666        )
667        .unwrap();
668        assert_eq!(other.1["event"]["notification"], "other");
669    }
670
671    #[test]
672    fn hook_ignores_unknown_events_and_missing_session_id() {
673        // No session_id → no op.
674        assert!(hook_op(r#"{"hook_event_name":"Stop"}"#).is_none());
675        // Blank session_id → no op.
676        assert!(hook_op(r#"{"session_id":"  ","hook_event_name":"Stop"}"#).is_none());
677        // Unknown event → no op.
678        assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"PreCompact"}"#).is_none());
679        // Garbage that still parses as an (empty) payload → no op.
680        assert!(hook_op("{}").is_none());
681    }
682
683    #[test]
684    fn classify_notification_covers_cases() {
685        assert_eq!(
686            classify_notification(Some("Please approve this")),
687            NotificationKind::PermissionPrompt
688        );
689        assert_eq!(
690            classify_notification(Some("Claude is idle")),
691            NotificationKind::IdlePrompt
692        );
693        assert_eq!(classify_notification(None), NotificationKind::Other);
694    }
695
696    // --- install / uninstall hooks ------------------------------------------
697
698    #[test]
699    fn merge_hooks_is_idempotent_and_additive() {
700        // A pre-existing unrelated hook must survive the merge.
701        let mut settings = json!({
702            "hooks": {
703                "PreToolUse": [
704                    { "matcher": "Bash", "hooks": [{ "type": "command", "command": "other-tool" }] }
705                ]
706            },
707            "model": "sonnet"
708        });
709        let cmd = "/usr/bin/omni-dev sessions hook";
710        let added = merge_hooks(&mut settings, cmd);
711        assert_eq!(added, HOOK_EVENTS.len());
712
713        // Our command landed under every event, and the unrelated hook stands.
714        for (event, _) in HOOK_EVENTS {
715            let groups = settings["hooks"][event].as_array().unwrap();
716            assert!(
717                groups.iter().any(|g| group_has_command(g, cmd)),
718                "missing under {event}"
719            );
720        }
721        let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
722        assert!(pre.iter().any(|g| group_has_command(g, "other-tool")));
723        assert_eq!(settings["model"], "sonnet");
724
725        // A second merge is a no-op.
726        assert_eq!(merge_hooks(&mut settings, cmd), 0);
727    }
728
729    #[test]
730    fn merge_then_remove_round_trips_and_preserves_others() {
731        let mut settings = json!({
732            "hooks": {
733                "PreToolUse": [
734                    { "matcher": "Bash", "hooks": [{ "type": "command", "command": "keep-me" }] }
735                ]
736            }
737        });
738        let cmd = "/usr/bin/omni-dev sessions hook";
739        merge_hooks(&mut settings, cmd);
740        let removed = remove_hooks(&mut settings, cmd);
741        assert_eq!(removed, HOOK_EVENTS.len());
742
743        // Every one of our entries is gone...
744        for (event, _) in HOOK_EVENTS {
745            let empty = settings["hooks"]
746                .get(event)
747                .and_then(Value::as_array)
748                .map_or(true, |g| g.iter().all(|g| !group_has_command(g, cmd)));
749            assert!(empty, "our hook survived under {event}");
750        }
751        // ...but the unrelated PreToolUse hook remains.
752        let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
753        assert!(pre.iter().any(|g| group_has_command(g, "keep-me")));
754    }
755
756    #[test]
757    fn remove_hooks_prunes_empty_events_entirely() {
758        let mut settings = json!({});
759        let cmd = "cmd sessions hook";
760        merge_hooks(&mut settings, cmd);
761        remove_hooks(&mut settings, cmd);
762        // With no other hooks, every event array empties and is pruned.
763        let hooks = settings["hooks"].as_object().unwrap();
764        assert!(hooks.is_empty(), "expected all events pruned: {hooks:?}");
765    }
766
767    #[test]
768    fn install_uninstall_via_files_round_trips() {
769        let tmp = tempfile::tempdir().unwrap();
770        let path = tmp.path().join("settings.json");
771        // Install into a missing file, then uninstall.
772        let mut settings = read_settings(&path).unwrap();
773        merge_hooks(&mut settings, "cmd sessions hook");
774        write_settings(&path, &settings).unwrap();
775        assert!(path.exists());
776
777        let reloaded = read_settings(&path).unwrap();
778        assert!(reloaded["hooks"]["Stop"].is_array());
779
780        let mut settings = read_settings(&path).unwrap();
781        remove_hooks(&mut settings, "cmd sessions hook");
782        write_settings(&path, &settings).unwrap();
783        let reloaded = read_settings(&path).unwrap();
784        assert!(reloaded["hooks"].as_object().unwrap().is_empty());
785    }
786
787    #[test]
788    fn read_settings_rejects_non_json() {
789        let tmp = tempfile::tempdir().unwrap();
790        let path = tmp.path().join("settings.json");
791        std::fs::write(&path, "not json {").unwrap();
792        let err = read_settings(&path).unwrap_err();
793        assert!(err.to_string().contains("not valid JSON"), "{err}");
794    }
795
796    #[test]
797    fn read_settings_handles_empty_and_non_object() {
798        let tmp = tempfile::tempdir().unwrap();
799        let path = tmp.path().join("settings.json");
800        // An empty (or whitespace-only) file reads as an empty object.
801        std::fs::write(&path, "   \n").unwrap();
802        assert_eq!(read_settings(&path).unwrap(), json!({}));
803        // Valid JSON that is not an object is refused rather than clobbered.
804        std::fs::write(&path, "[1, 2, 3]").unwrap();
805        let err = read_settings(&path).unwrap_err();
806        assert!(err.to_string().contains("not a JSON object"), "{err}");
807    }
808
809    #[test]
810    fn hook_command_targets_sessions_hook() {
811        assert!(hook_command().ends_with("sessions hook"));
812    }
813
814    // --- rendering -----------------------------------------------------------
815
816    #[test]
817    fn render_sessions_handles_empty() {
818        assert_eq!(
819            render_sessions(&json!({ "sessions": [] })),
820            "No active Claude Code sessions."
821        );
822        assert_eq!(
823            render_sessions(&json!({})),
824            "No active Claude Code sessions."
825        );
826    }
827
828    #[test]
829    fn render_sessions_renders_rows_and_source() {
830        let result = json!({ "sessions": [{
831            "session_id": "s1",
832            "state": "working",
833            "source": { "kind": "vs_code", "window_key": "w1" },
834            "repo": "omni-dev",
835            "cwd": "/home/me/omni-dev",
836            "last_seen": "2000-01-01T00:00:00Z",
837        }]});
838        let table = render_sessions(&result);
839        assert!(table.contains("working"), "{table}");
840        assert!(table.contains("vscode"), "{table}");
841        assert!(table.contains("omni-dev"), "{table}");
842        // Header plus one data row.
843        assert_eq!(table.lines().count(), 2, "{table}");
844    }
845
846    #[test]
847    fn render_sessions_strips_control_bytes() {
848        let result = json!({ "sessions": [{
849            "session_id": "s1",
850            "state": "wor\x1b[31mking",
851            "source": { "kind": "terminal" },
852            "repo": "ev\x07il",
853            "cwd": "/tmp/a\rb",
854            "last_seen": "2000-01-01T00:00:00Z",
855        }]});
856        let table = render_sessions(&result);
857        assert!(
858            !table.contains(|c: char| c.is_control() && c != '\n'),
859            "{table:?}"
860        );
861        // Embedded CR cannot forge a row: header plus one data row.
862        assert_eq!(table.lines().count(), 2, "{table:?}");
863    }
864
865    #[test]
866    fn source_label_maps_kinds() {
867        assert_eq!(
868            source_label(&json!({ "source": { "kind": "vs_code", "window_key": "w" } })),
869            "vscode"
870        );
871        assert_eq!(
872            source_label(&json!({ "source": { "kind": "terminal" } })),
873            "terminal"
874        );
875        assert_eq!(source_label(&json!({})), "terminal");
876    }
877
878    #[test]
879    fn reply_payload_unwraps_ok_and_maps_errors() {
880        assert_eq!(
881            reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
882            json!({ "a": 1 })
883        );
884        let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
885        assert!(err.to_string().contains("boom"), "{err}");
886    }
887
888    // --- command execute() paths -------------------------------------------
889
890    #[test]
891    fn install_then_uninstall_command_execute_round_trips() {
892        let tmp = tempfile::tempdir().unwrap();
893        let path = tmp.path().join("settings.json");
894        // Install into a missing file: the hook block lands.
895        InstallHooksCommand {
896            settings: Some(path.clone()),
897        }
898        .execute()
899        .unwrap();
900        assert!(read_settings(&path).unwrap()["hooks"]["Stop"].is_array());
901        // A second install is the idempotent "no change" branch.
902        InstallHooksCommand {
903            settings: Some(path.clone()),
904        }
905        .execute()
906        .unwrap();
907        // Uninstall removes our block, leaving an empty hooks object.
908        UninstallHooksCommand {
909            settings: Some(path.clone()),
910        }
911        .execute()
912        .unwrap();
913        assert!(read_settings(&path).unwrap()["hooks"]
914            .as_object()
915            .unwrap()
916            .is_empty());
917    }
918
919    #[test]
920    fn uninstall_command_on_a_missing_file_is_a_noop() {
921        let tmp = tempfile::tempdir().unwrap();
922        let path = tmp.path().join("does-not-exist.json");
923        // The no-file branch: nothing to remove, still Ok, and no file created.
924        UninstallHooksCommand {
925            settings: Some(path.clone()),
926        }
927        .execute()
928        .unwrap();
929        assert!(!path.exists());
930    }
931
932    #[tokio::test]
933    async fn sessions_command_dispatches_to_subcommands() {
934        // Cover the outer dispatch for the two file-backed arms (no socket/stdin).
935        let tmp = tempfile::tempdir().unwrap();
936        let path = tmp.path().join("settings.json");
937        SessionsCommand {
938            command: SessionsSubcommands::InstallHooks(InstallHooksCommand {
939                settings: Some(path.clone()),
940            }),
941        }
942        .execute()
943        .await
944        .unwrap();
945        SessionsCommand {
946            command: SessionsSubcommands::UninstallHooks(UninstallHooksCommand {
947                settings: Some(path.clone()),
948            }),
949        }
950        .execute()
951        .await
952        .unwrap();
953    }
954
955    #[tokio::test]
956    async fn hook_report_is_silent_when_the_daemon_is_down() {
957        // A valid hook event but no daemon at the socket: the send fails and is
958        // swallowed (never panics, never errors).
959        let tmp = tempfile::tempdir_in("/tmp").unwrap();
960        let sock = tmp.path().join("nope.sock");
961        let cmd = HookCommand { socket: Some(sock) };
962        cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
963            .await;
964        // Unmappable input returns before any socket work.
965        cmd.report("not json").await;
966        cmd.report(r#"{"hook_event_name":"Stop"}"#).await; // no session_id → no op
967    }
968
969    /// Spawns a minimal fake daemon on a short-path Unix socket that answers one
970    /// request with `reply`. Returns the temp dir (kept alive), the socket path,
971    /// and the server task.
972    fn fake_daemon(reply: Value) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
973        use futures::{SinkExt, StreamExt};
974        use tokio::net::UnixListener;
975        use tokio_util::codec::{Framed, LinesCodec};
976
977        let dir = tempfile::tempdir_in("/tmp").unwrap();
978        let sock = dir.path().join("d.sock");
979        let listener = UnixListener::bind(&sock).unwrap();
980        let server = tokio::spawn(async move {
981            let (stream, _) = listener.accept().await.unwrap();
982            let mut framed = Framed::new(stream, LinesCodec::new());
983            let _req = framed.next().await.unwrap().unwrap();
984            framed
985                .send(serde_json::to_string(&reply).unwrap())
986                .await
987                .unwrap();
988        });
989        (dir, sock, server)
990    }
991
992    #[tokio::test]
993    async fn list_command_execute_renders_from_a_socket() {
994        let payload = json!({
995            "ok": true,
996            "payload": { "sessions": [{
997                "session_id": "s1", "state": "working",
998                "source": { "kind": "terminal" }, "repo": "omni-dev",
999                "cwd": "/home/me/omni-dev", "last_seen": "2000-01-01T00:00:00Z"
1000            }]}
1001        });
1002        // Table output, dispatched through the outer `SessionsCommand` so the
1003        // `List` arm of the dispatch is covered too.
1004        let (_dir, sock, server) = fake_daemon(payload.clone());
1005        SessionsCommand {
1006            command: SessionsSubcommands::List(ListCommand {
1007                socket: Some(sock),
1008                output: TableOrJson::Table,
1009            }),
1010        }
1011        .execute()
1012        .await
1013        .unwrap();
1014        server.await.unwrap();
1015
1016        // JSON output goes through the other branch of the renderer.
1017        let (_dir, sock, server) = fake_daemon(payload);
1018        ListCommand {
1019            socket: Some(sock),
1020            output: TableOrJson::Json,
1021        }
1022        .execute()
1023        .await
1024        .unwrap();
1025        server.await.unwrap();
1026    }
1027
1028    #[test]
1029    fn merge_and_remove_hooks_tolerate_malformed_shapes() {
1030        let cmd = "cmd sessions hook";
1031        // Non-object settings: both are no-ops rather than panics.
1032        assert_eq!(merge_hooks(&mut json!([]), cmd), 0);
1033        assert_eq!(remove_hooks(&mut json!([]), cmd), 0);
1034        // `hooks` present but not an object → merge leaves it alone.
1035        assert_eq!(merge_hooks(&mut json!({ "hooks": 5 }), cmd), 0);
1036        // No `hooks` key → remove has nothing to do.
1037        assert_eq!(remove_hooks(&mut json!({}), cmd), 0);
1038        // A per-event value that is not an array is skipped, not indexed.
1039        assert_eq!(merge_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd), 6);
1040        assert_eq!(remove_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd), 0);
1041    }
1042}