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