Skip to main content

omni_dev/cli/
sessions.rs

1//! `omni-dev sessions` — track the Claude Code, Codex and pi.dev sessions running
2//! across every terminal and VS Code window, via the daemon's `sessions` service.
3//!
4//! The 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 (or Codex, with `--agent codex`)
8//!   runs it per hook event; it reads
9//!   the hook JSON on stdin, maps it to an `observe`/`end` op, and fire-and-forgets
10//!   it to the daemon socket. It must **never** block or fail a Claude turn — a
11//!   missing daemon, a bad payload, or any other error is swallowed and it always
12//!   exits 0.
13//! - `install-hooks` / `uninstall-hooks` idempotently merge (or remove) the hook
14//!   block in `~/.claude/settings.json`, preserving any hooks already there. When
15//!   pi.dev is installed they also write (or remove) the generated pi extension
16//!   in `~/.pi/agent/extensions/`, which reports straight to the socket (#1901),
17//!   and when Codex is installed the same hook block, tagged `--agent codex`, in
18//!   `$CODEX_HOME/hooks.json` — position-stably, since Codex trusts a hook by
19//!   its index (#1907).
20//! - `install-wrapper` / `uninstall-wrapper` are the same idea for Feed 4: they
21//!   write the shim that VS Code's Claude extension launches
22//!   [`omni-dev claude-wrap`](crate::cli::claude_wrap) through, and point the
23//!   extension's `claudeCode.claudeProcessWrapper` setting at it.
24//!
25//! The register/heartbeat feed from the companion VS Code extension talks to the
26//! socket directly (like the worktrees companion), not through this CLI.
27
28use std::io::Read;
29use std::path::{Path, PathBuf};
30use std::time::Duration;
31
32use anyhow::{anyhow, bail, Context, Result};
33use chrono::Utc;
34use clap::{Parser, Subcommand, ValueEnum};
35use serde::Deserialize;
36use serde_json::{json, Value};
37
38use crate::cli::format::{sanitize_for_terminal, TableOrJson};
39use crate::daemon::client::DaemonClient;
40use crate::daemon::paths;
41use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
42use crate::daemon::server;
43use crate::sessions::{Agent, NotificationKind, ObserveRequest, SessionEvent};
44
45/// The `sessions` service routing key on the daemon control socket.
46const SERVICE: &str = "sessions";
47
48/// How long the fire-and-forget `hook` sink waits for the daemon before giving
49/// up — short, so a slow or wedged daemon never stalls a Claude turn.
50const HOOK_TIMEOUT: Duration = Duration::from_secs(2);
51
52/// Sessions: see the Claude Code sessions running across every terminal and
53/// VS Code window, kept live by the daemon.
54#[derive(Parser)]
55pub struct SessionsCommand {
56    /// The sessions subcommand to execute.
57    #[command(subcommand)]
58    pub command: SessionsSubcommands,
59}
60
61/// Sessions subcommands.
62#[derive(Subcommand)]
63pub enum SessionsSubcommands {
64    /// List the Claude Code, Codex and pi.dev sessions currently running across
65    /// all windows.
66    List(ListCommand),
67    /// Claude Code / Codex hook sink: read a hook event on stdin and report it to
68    /// the daemon (run by the agent, not by hand).
69    Hook(HookCommand),
70    /// Install the Claude Code hooks that feed the sessions tracker into
71    /// `~/.claude/settings.json`, plus the pi.dev extension when pi is installed
72    /// and the Codex hooks when Codex is (idempotent).
73    InstallHooks(InstallHooksCommand),
74    /// Remove the sessions-tracker hooks from `~/.claude/settings.json`, the
75    /// pi.dev extension, and the Codex hooks.
76    UninstallHooks(UninstallHooksCommand),
77    /// Install the `claude-wrap` shim and point VS Code's Claude extension at it
78    /// (idempotent).
79    InstallWrapper(InstallWrapperCommand),
80    /// Remove the `claude-wrap` shim and VS Code's wrapper setting.
81    UninstallWrapper(UninstallWrapperCommand),
82    /// Report a window's Claude tab/terminal counts (companion feed op).
83    Window(WindowCommand),
84    /// Remove a window's embedding report (companion feed op).
85    WindowUnregister(WindowUnregisterCommand),
86}
87
88impl SessionsCommand {
89    /// Executes the sessions command.
90    pub async fn execute(self) -> Result<()> {
91        match self.command {
92            SessionsSubcommands::List(cmd) => cmd.execute().await,
93            SessionsSubcommands::Hook(cmd) => cmd.execute().await,
94            SessionsSubcommands::InstallHooks(cmd) => cmd.execute(),
95            SessionsSubcommands::UninstallHooks(cmd) => cmd.execute(),
96            SessionsSubcommands::InstallWrapper(cmd) => cmd.execute(),
97            SessionsSubcommands::UninstallWrapper(cmd) => cmd.execute(),
98            SessionsSubcommands::Window(cmd) => cmd.execute().await,
99            SessionsSubcommands::WindowUnregister(cmd) => cmd.execute().await,
100        }
101    }
102}
103
104// --- list --------------------------------------------------------------------
105
106/// Lists the live cross-window set of running Claude sessions.
107#[derive(Parser)]
108pub struct ListCommand {
109    /// Control-socket path. Defaults to the per-user runtime location.
110    #[arg(long, value_name = "PATH")]
111    pub socket: Option<PathBuf>,
112    /// Output format.
113    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
114    pub output: TableOrJson,
115}
116
117impl ListCommand {
118    /// Executes the list command.
119    pub async fn execute(self) -> Result<()> {
120        let socket = server::resolve_socket(self.socket)?;
121        let result = call(&socket, "list", Value::Null).await?;
122        match self.output {
123            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
124            TableOrJson::Table => println!("{}", render_sessions(&result)),
125        }
126        Ok(())
127    }
128}
129
130// --- window feed -------------------------------------------------------------
131
132/// Reports a window's Claude embedding counts (the companion `window` feed op).
133///
134/// Exposed as a typed command so scripted/headless reporters and integration
135/// tests can drive the sessions registry the way the VS Code companion does.
136/// Mirrors `WindowReport`.
137#[derive(Parser)]
138pub struct WindowCommand {
139    /// Stable per-window identity (the companion generates a per-activate UUID).
140    #[arg(long, value_name = "KEY")]
141    pub key: String,
142    /// A workspace-folder path (repeatable) — used to join sessions by `cwd`.
143    #[arg(long = "folder", value_name = "PATH")]
144    pub folders: Vec<PathBuf>,
145    /// How many Claude editor tabs the window has.
146    #[arg(long, value_name = "N", default_value_t = 0)]
147    pub tabs: usize,
148    /// How many Claude Code integrated terminals the window has.
149    #[arg(long, value_name = "N", default_value_t = 0)]
150    pub terminals: usize,
151    /// Control-socket path. Defaults to the per-user runtime location.
152    #[arg(long, value_name = "PATH")]
153    pub socket: Option<PathBuf>,
154}
155
156impl WindowCommand {
157    /// Executes the window command.
158    pub async fn execute(self) -> Result<()> {
159        let socket = server::resolve_socket(self.socket)?;
160        let payload = json!({
161            "key": self.key,
162            "folders": self.folders,
163            "tabs": self.tabs,
164            "terminals": self.terminals,
165        });
166        call(&socket, "window", payload).await?;
167        println!("Reported window {}", self.key);
168        Ok(())
169    }
170}
171
172/// Removes a window's embedding report — the companion `window-unregister` feed
173/// op made typed. Prints whether an entry was actually removed.
174#[derive(Parser)]
175pub struct WindowUnregisterCommand {
176    /// The window key to unregister.
177    #[arg(long, value_name = "KEY")]
178    pub key: String,
179    /// Control-socket path. Defaults to the per-user runtime location.
180    #[arg(long, value_name = "PATH")]
181    pub socket: Option<PathBuf>,
182}
183
184impl WindowUnregisterCommand {
185    /// Executes the window-unregister command.
186    pub async fn execute(self) -> Result<()> {
187        let socket = server::resolve_socket(self.socket)?;
188        let reply = call(&socket, "window-unregister", json!({ "key": self.key })).await?;
189        let removed = reply
190            .get("removed")
191            .and_then(Value::as_bool)
192            .unwrap_or(false);
193        println!("removed: {removed}");
194        Ok(())
195    }
196}
197
198// --- hook --------------------------------------------------------------------
199
200/// The Claude Code / Codex hook sink: reads one hook event's JSON on stdin and
201/// reports it to the daemon. Fire-and-forget and infallible-by-design.
202#[derive(Parser)]
203pub struct HookCommand {
204    /// Control-socket path. Defaults to the per-user runtime location.
205    #[arg(long, value_name = "PATH")]
206    pub socket: Option<PathBuf>,
207
208    /// Which agent runs this hook. The payloads share field names, so the agent
209    /// cannot be inferred from them; it selects the event mapping and tags the
210    /// session.
211    #[arg(long, value_enum, default_value_t = HookAgent::Claude)]
212    pub agent: HookAgent,
213}
214
215/// The agents whose hooks run [`HookCommand`]. pi.dev reports through its own
216/// extension, so it has no hook variant.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
218pub enum HookAgent {
219    /// Claude Code (`~/.claude/settings.json`).
220    Claude,
221    /// OpenAI Codex (`$CODEX_HOME/hooks.json`).
222    Codex,
223}
224
225impl HookAgent {
226    /// The registry tag sessions reported by this agent's hooks carry.
227    fn agent(self) -> Agent {
228        match self {
229            Self::Claude => Agent::Claude,
230            Self::Codex => Agent::Codex,
231        }
232    }
233}
234
235impl HookCommand {
236    /// Executes the hook sink. Always returns `Ok(())` (exit 0): a hook must
237    /// never block or fail a Claude turn, so every error — no daemon, bad JSON,
238    /// an unknown event — is swallowed after a best-effort report.
239    pub async fn execute(self) -> Result<()> {
240        // Read before stdin, while the agent that spawned this hook is surely
241        // still alive to be our parent.
242        let pid = agent_pid(std::os::unix::process::parent_id());
243        let mut input = String::new();
244        if std::io::stdin().read_to_string(&mut input).is_err() {
245            return Ok(());
246        }
247        self.report(&input, pid).await;
248        Ok(())
249    }
250
251    /// Parses the hook JSON, maps it to an op, and best-effort sends it. Split
252    /// out so tests can exercise the send path against a fake socket.
253    async fn report(&self, input: &str, pid: Option<u32>) {
254        let Ok(hook) = serde_json::from_str::<HookPayload>(input) else {
255            return;
256        };
257        let Some((op, payload)) = hook.to_op(self.agent, pid) else {
258            return;
259        };
260        let Ok(socket) = server::resolve_socket(self.socket.clone()) else {
261            return;
262        };
263        // Bounded, and every failure ignored: the daemon may be down, and that
264        // must be a silent no-op.
265        let env = DaemonEnvelope::service(SERVICE, op, payload);
266        let _ = tokio::time::timeout(HOOK_TIMEOUT, DaemonClient::new(&socket).request(env)).await;
267    }
268}
269
270/// The pid of the agent process that ran this hook, from the sink's parent pid.
271///
272/// The agent runs the installed hook command directly (a simple command, so no
273/// per-hook shell sits in between), making the parent the `claude` / `codex`
274/// process itself. The pid tells a resumed session's new process from the old
275/// one it replaced (#1948). Pid 1 means the hook was orphaned — its agent already
276/// exited and init/launchd adopted it — so it identifies nothing.
277fn agent_pid(parent: u32) -> Option<u32> {
278    (parent > 1).then_some(parent)
279}
280
281/// The subset of a Claude Code hook payload the sink reads. Every field is
282/// optional and defaulted, so an unexpected or future payload shape never fails
283/// to parse (the sink then simply produces no op). See the hooks docs.
284#[derive(Debug, Clone, Default, Deserialize)]
285struct HookPayload {
286    #[serde(default)]
287    session_id: Option<String>,
288    #[serde(default)]
289    transcript_path: Option<PathBuf>,
290    #[serde(default)]
291    cwd: Option<PathBuf>,
292    #[serde(default)]
293    hook_event_name: Option<String>,
294    /// Present on `Notification` events — the message classified into a
295    /// [`NotificationKind`] when `notification_type` is absent or unrecognised.
296    #[serde(default)]
297    message: Option<String>,
298    /// Present on `Notification` events — Claude Code's own classification
299    /// (`permission_prompt`, `idle_prompt`, …), preferred over `message`.
300    #[serde(default)]
301    notification_type: Option<String>,
302    /// Present on `SessionEnd` — why the session ended.
303    #[serde(default)]
304    reason: Option<String>,
305    /// Present on `SessionStart` — why it fired (`startup`, `resume`, `clear`,
306    /// `compact`). A `compact` start is not a new session.
307    #[serde(default)]
308    source: Option<String>,
309    /// Present on tool events. Codex's clarifying-question UI is the
310    /// `request_user_input` tool, so its `PreToolUse` is a wait for input.
311    #[serde(default)]
312    tool_name: Option<String>,
313    /// Best-effort model id, when a payload carries one.
314    #[serde(default)]
315    model: Option<String>,
316}
317
318impl HookPayload {
319    /// Maps this hook payload to a `(op, payload)` for the daemon, or `None` when
320    /// it carries no `session_id` or names an event the tracker ignores. `pid`
321    /// (the [`agent_pid`]) rides along on both ops when known — it is also the
322    /// seed for pid-based liveness (#1916), whose identity-token reading is
323    /// entirely the daemon's own [`pid_watcher`](crate::sessions::pid_watcher)
324    /// work, never this sink's.
325    fn to_op(&self, agent: HookAgent, pid: Option<u32>) -> Option<(&'static str, Value)> {
326        let session_id = self.session_id.clone().filter(|s| !s.trim().is_empty())?;
327        let event_name = self.hook_event_name.as_deref()?;
328        if event_name == "SessionEnd" {
329            let mut payload = json!({ "session_id": session_id });
330            if let Some(reason) = self.reason.as_ref().or(self.message.as_ref()) {
331                payload["reason"] = Value::String(reason.clone());
332            }
333            if let Some(pid) = pid {
334                payload["pid"] = Value::from(pid);
335            }
336            return Some(("end", payload));
337        }
338        let event = match agent {
339            HookAgent::Claude => session_event_for(
340                event_name,
341                self.source.as_deref(),
342                self.notification_type.as_deref(),
343                self.message.as_deref(),
344            )?,
345            HookAgent::Codex => codex_session_event_for(
346                event_name,
347                self.source.as_deref(),
348                self.tool_name.as_deref(),
349            )?,
350        };
351        let request = ObserveRequest {
352            agent: agent.agent(),
353            session_id,
354            cwd: self.cwd.clone(),
355            transcript_path: self.transcript_path.clone(),
356            event,
357            repo: None,
358            model: self.model.clone(),
359            pid,
360        };
361        Some(("observe", serde_json::to_value(request).ok()?))
362    }
363}
364
365/// Maps a Claude Code hook event name to the [`SessionEvent`] it implies, or
366/// `None` for an event the tracker does not act on. `SessionEnd` is handled
367/// separately (it maps to the `end` op, not `observe`).
368///
369/// Everything lands on the **existing** events, so the engine's state machine
370/// is unchanged (the Codex precedent, ADR-0087):
371///
372/// - `PermissionRequest` is the dedicated "a prompt is about to be shown"
373///   event, so it sets the permission wait without matching `Notification`
374///   text. The sink must never answer it — a `PermissionRequest` hook that
375///   prints a decision approves or denies on the user's behalf — and it cannot:
376///   [`HookCommand`] writes nothing to stdout;
377/// - `Elicitation` (an MCP server asking the user a question) is a wait for
378///   input, released by its `ElicitationResult`;
379/// - `PostToolUseFailure` and `PermissionDenied` (auto mode refused a call and
380///   the turn goes on) are post-tool signals: `working`;
381/// - `StopFailure` ends a turn on an API error, which fires no `Stop`;
382/// - `SubagentStart` means a subagent is being spawned inside a turn: `working`;
383/// - `SubagentStop`, `PreCompact` and `PostCompact` can all fire while the
384///   session is idle — a background subagent finishing after the turn's `Stop`,
385///   a manual `/compact` — and nothing after them would release a `working`, so
386///   they are the state-preserving
387///   [`TranscriptDiscovered`](SessionEvent::TranscriptDiscovered) sighting,
388///   which refreshes liveness alone;
389/// - `SessionStart` with `source: "compact"` follows a compaction, which can run
390///   mid-turn, and is not a new session. Since growth no longer moves a
391///   `starting` session (#1946), mapping it to `SessionStart` would hold a
392///   working session at `starting` until its next tool hook, so it is the same
393///   state-preserving sighting. Every other source (`startup`, `resume`,
394///   `clear`) is a real start.
395///
396/// `PostToolBatch` is deliberately not installed: every tool in a batch has
397/// already reported its own `PostToolUse` / `PostToolUseFailure`, so it would be
398/// a process spawn per batch carrying no new state.
399///
400/// No event fires when the user answers a permission prompt, either way; the
401/// wait is released by whatever hook comes next.
402fn session_event_for(
403    event_name: &str,
404    source: Option<&str>,
405    notification_type: Option<&str>,
406    message: Option<&str>,
407) -> Option<SessionEvent> {
408    Some(match event_name {
409        "SessionStart" if source == Some("compact") => SessionEvent::TranscriptDiscovered,
410        "SessionStart" => SessionEvent::SessionStart,
411        "UserPromptSubmit" => SessionEvent::UserPromptSubmit,
412        "PreToolUse" => SessionEvent::PreToolUse,
413        "PostToolUse" | "PostToolUseFailure" | "PermissionDenied" | "ElicitationResult"
414        | "SubagentStart" => SessionEvent::PostToolUse,
415        "SubagentStop" | "PreCompact" | "PostCompact" => SessionEvent::TranscriptDiscovered,
416        "Stop" | "StopFailure" => SessionEvent::Stop,
417        "PermissionRequest" => SessionEvent::Notification(NotificationKind::PermissionPrompt),
418        "Elicitation" => SessionEvent::Notification(NotificationKind::AgentNeedsInput),
419        "Notification" => {
420            SessionEvent::Notification(classify_notification(notification_type, message))
421        }
422        _ => return None,
423    })
424}
425
426/// Maps a Codex hook event name to the [`SessionEvent`] it implies, or `None` for
427/// an event the tracker does not act on (ADR-0087). Codex's payload uses Claude
428/// Code's field names, but adds events Claude lacks, and all of them land on the
429/// **existing** events so the engine's state machine is unchanged:
430///
431/// - `PermissionRequest` is Codex's dedicated "waiting on an approval" event, so
432///   it is the reliable permission signal (its resolution is inferred from the
433///   next `PostToolUse`, or from the turn ending);
434/// - `PreToolUse` of the `request_user_input` tool is the clarifying-question UI,
435///   a wait for input until its `PostToolUse`;
436/// - `Interrupt` (Esc / Ctrl-C mid-turn, or a declined approval in the TUI) ends
437///   the turn without a `Stop`, so it is one;
438/// - the compaction and subagent events carry the parent session's id and mean
439///   only that the session is busy — a working-state heartbeat;
440/// - a `SessionStart` with `source: "compact"` is not a new session, so, as for
441///   Claude, it preserves the state rather than resetting it to `starting`
442///   (#1946).
443///
444/// `SessionEnd` is handled by the caller, as for Claude.
445fn codex_session_event_for(
446    event_name: &str,
447    source: Option<&str>,
448    tool_name: Option<&str>,
449) -> Option<SessionEvent> {
450    Some(match event_name {
451        "SessionStart" if source == Some("compact") => SessionEvent::TranscriptDiscovered,
452        "SessionStart" => SessionEvent::SessionStart,
453        "UserPromptSubmit" => SessionEvent::UserPromptSubmit,
454        "PreToolUse" if tool_name == Some("request_user_input") => {
455            SessionEvent::Notification(NotificationKind::AgentNeedsInput)
456        }
457        "PreToolUse" => SessionEvent::PreToolUse,
458        "PostToolUse" | "SubagentStart" | "SubagentStop" | "PreCompact" | "PostCompact" => {
459            SessionEvent::PostToolUse
460        }
461        "PermissionRequest" => SessionEvent::Notification(NotificationKind::PermissionPrompt),
462        "Stop" | "Interrupt" => SessionEvent::Stop,
463        _ => return None,
464    })
465}
466
467/// Classifies a `Notification` into a [`NotificationKind`]. A version that sends
468/// Claude Code's own `notification_type` is classified by it alone — a type we
469/// do not map (`auth_success`, …) is [`NotificationKind::Other`] rather than a
470/// guess at its message, which could mention "allow" or "permission" without
471/// asking for anything. Only an older version that sends no type falls back to
472/// best-effort substring matching on the version-unstable message text, where an
473/// unrecognised message is likewise [`NotificationKind::Other`] (which carries no
474/// state signal and leaves the session's state unchanged).
475fn classify_notification(
476    notification_type: Option<&str>,
477    message: Option<&str>,
478) -> NotificationKind {
479    match notification_type {
480        Some("permission_prompt") => return NotificationKind::PermissionPrompt,
481        Some("idle_prompt") => return NotificationKind::IdlePrompt,
482        Some("elicitation_dialog") => return NotificationKind::AgentNeedsInput,
483        Some(_) => return NotificationKind::Other,
484        None => {}
485    }
486    let Some(message) = message else {
487        return NotificationKind::Other;
488    };
489    let lower = message.to_lowercase();
490    if lower.contains("permission") || lower.contains("approve") || lower.contains("allow") {
491        NotificationKind::PermissionPrompt
492    } else if lower.contains("waiting for your input")
493        || lower.contains("idle")
494        || lower.contains("needs your input")
495    {
496        NotificationKind::IdlePrompt
497    } else {
498        NotificationKind::Other
499    }
500}
501
502// --- install-hooks / uninstall-hooks ----------------------------------------
503
504/// Installs the sessions-tracker hooks into `~/.claude/settings.json`, plus the
505/// pi.dev extension when pi is installed and the Codex hooks when Codex is.
506#[derive(Parser)]
507pub struct InstallHooksCommand {
508    /// Path to the Claude settings file. Defaults to `~/.claude/settings.json`
509    /// (respecting `$CLAUDE_CONFIG_DIR`).
510    #[arg(long, value_name = "PATH")]
511    pub settings: Option<PathBuf>,
512
513    /// pi.dev's agent directory. Defaults to `$PI_CODING_AGENT_DIR`, else
514    /// `~/.pi/agent`. Passing it installs the pi extension even when pi is not
515    /// detected.
516    #[arg(long, value_name = "PATH")]
517    pub pi_agent_dir: Option<PathBuf>,
518
519    /// Codex's home directory, holding `hooks.json`. Defaults to `$CODEX_HOME`,
520    /// else `~/.codex`. Passing it installs the Codex hooks even when Codex is
521    /// not detected.
522    #[arg(long, value_name = "PATH")]
523    pub codex_home: Option<PathBuf>,
524}
525
526impl InstallHooksCommand {
527    /// Executes the install: merges the hook block idempotently, preserving any
528    /// hooks already present, then writes the pi extension if pi is present and
529    /// the Codex hooks if Codex is.
530    pub fn execute(self) -> Result<()> {
531        let path = settings_path(self.settings)?;
532        let mut settings = read_settings(&path)?;
533        let command = hook_command();
534        let added = merge_hooks(&mut settings, &command, HOOK_EVENTS);
535        write_settings(&path, &settings)?;
536        if added == 0 {
537            println!(
538                "sessions hooks already installed in {} (no change)",
539                path.display()
540            );
541        } else {
542            println!(
543                "installed {added} sessions hook event(s) into {}\ncommand: {command}",
544                path.display()
545            );
546        }
547        install_pi_extension(self.pi_agent_dir)?;
548        install_codex_hooks(self.codex_home)
549    }
550}
551
552/// Removes the sessions-tracker hooks from `~/.claude/settings.json`, the pi.dev
553/// extension, and the Codex hooks.
554#[derive(Parser)]
555pub struct UninstallHooksCommand {
556    /// Path to the Claude settings file. Defaults to `~/.claude/settings.json`
557    /// (respecting `$CLAUDE_CONFIG_DIR`).
558    #[arg(long, value_name = "PATH")]
559    pub settings: Option<PathBuf>,
560
561    /// pi.dev's agent directory. Defaults to `$PI_CODING_AGENT_DIR`, else
562    /// `~/.pi/agent`.
563    #[arg(long, value_name = "PATH")]
564    pub pi_agent_dir: Option<PathBuf>,
565
566    /// Codex's home directory, holding `hooks.json`. Defaults to `$CODEX_HOME`,
567    /// else `~/.codex`.
568    #[arg(long, value_name = "PATH")]
569    pub codex_home: Option<PathBuf>,
570}
571
572impl UninstallHooksCommand {
573    /// Executes the uninstall: removes any hook entries whose command is ours,
574    /// leaving every other hook untouched, then removes our pi extension and
575    /// Codex hooks.
576    pub fn execute(self) -> Result<()> {
577        let path = settings_path(self.settings)?;
578        if path.exists() {
579            let mut settings = read_settings(&path)?;
580            let removed = remove_hooks(&mut settings, &hook_command());
581            write_settings(&path, &settings)?;
582            println!(
583                "removed {removed} sessions hook entry(ies) from {}",
584                path.display()
585            );
586        } else {
587            println!("no settings file at {} (nothing to remove)", path.display());
588        }
589        uninstall_pi_extension(self.pi_agent_dir)?;
590        uninstall_codex_hooks(self.codex_home)
591    }
592}
593
594// --- pi.dev extension ---------------------------------------------------------
595
596/// Filename of the generated extension inside pi's global extensions directory.
597const PI_EXTENSION_NAME: &str = "omni-dev-sessions.ts";
598
599/// The generated extension's source, with [`PI_SOCKET_PLACEHOLDER`] still in it.
600const PI_EXTENSION_TEMPLATE: &str = include_str!("../templates/pi-sessions-extension.ts");
601
602/// Stands in for the socket path (a JSON string literal) in the template.
603const PI_SOCKET_PLACEHOLDER: &str = "__OMNI_DEV_SOCKET__";
604
605/// The template's first line. A file that does not start with it is not ours, so
606/// install refuses to overwrite it and uninstall leaves it alone.
607fn pi_extension_marker() -> &'static str {
608    PI_EXTENSION_TEMPLATE.lines().next().unwrap_or_default()
609}
610
611/// The extension source with the daemon socket baked in, as `install-hooks`
612/// bakes in the absolute exe path: pi's Node process has no other way to find
613/// the socket, and resolving it there would duplicate `dirs::data_dir()`.
614fn render_pi_extension(socket: &Path) -> String {
615    let literal =
616        serde_json::to_string(&socket.display().to_string()).unwrap_or_else(|_| "\"\"".to_string());
617    PI_EXTENSION_TEMPLATE.replace(PI_SOCKET_PLACEHOLDER, &literal)
618}
619
620/// pi's default agent directory: `$PI_CODING_AGENT_DIR`, else `~/.pi/agent`.
621fn default_pi_agent_dir() -> Result<PathBuf> {
622    if let Some(dir) = std::env::var_os("PI_CODING_AGENT_DIR").filter(|d| !d.is_empty()) {
623        return Ok(PathBuf::from(dir));
624    }
625    let home = dirs::home_dir().context("could not resolve the home directory")?;
626    Ok(home.join(".pi").join("agent"))
627}
628
629/// Whether pi.dev is installed: its agent directory exists (pi has run), or a
630/// `pi` executable is on `path` (installed but never run).
631fn pi_is_present(agent_dir: &Path, path: Option<&std::ffi::OsStr>) -> bool {
632    agent_dir.is_dir()
633        || path.is_some_and(|path| {
634            std::env::split_paths(path).any(|dir| is_executable(&dir.join("pi")))
635        })
636}
637
638/// Whether `path` is a file with an execute bit set.
639fn is_executable(path: &Path) -> bool {
640    use std::os::unix::fs::PermissionsExt;
641
642    std::fs::metadata(path)
643        .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
644}
645
646/// What [`write_pi_extension`] did.
647#[derive(Debug, PartialEq, Eq)]
648enum PiInstall {
649    /// Wrote a new or updated extension.
650    Written,
651    /// The file already had exactly this content.
652    Unchanged,
653}
654
655/// Writes `contents` to `<agent_dir>/extensions/omni-dev-sessions.ts`, creating
656/// the directory. Refuses to replace a file of that name that is not ours.
657fn write_pi_extension(agent_dir: &Path, contents: &str) -> Result<(PathBuf, PiInstall)> {
658    let dir = agent_dir.join("extensions");
659    let file = dir.join(PI_EXTENSION_NAME);
660    if file.exists() {
661        let existing = std::fs::read_to_string(&file)
662            .with_context(|| format!("failed to read {}", file.display()))?;
663        if existing == contents {
664            return Ok((file, PiInstall::Unchanged));
665        }
666        if !existing.starts_with(pi_extension_marker()) {
667            bail!(
668                "{} exists and was not written by omni-dev; refusing to overwrite it",
669                file.display()
670            );
671        }
672    }
673    std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
674    std::fs::write(&file, contents)
675        .with_context(|| format!("failed to write {}", file.display()))?;
676    Ok((file, PiInstall::Written))
677}
678
679/// Removes our extension from `<agent_dir>/extensions/`, returning its path when
680/// it removed it. A file of that name that is not ours is left alone.
681fn remove_pi_extension(agent_dir: &Path) -> Result<Option<PathBuf>> {
682    let file = agent_dir.join("extensions").join(PI_EXTENSION_NAME);
683    let Ok(existing) = std::fs::read_to_string(&file) else {
684        return Ok(None);
685    };
686    if !existing.starts_with(pi_extension_marker()) {
687        return Ok(None);
688    }
689    std::fs::remove_file(&file).with_context(|| format!("failed to remove {}", file.display()))?;
690    Ok(Some(file))
691}
692
693/// The pi half of `install-hooks`. Without `--pi-agent-dir` it installs only when
694/// pi is present, so a machine without pi gets nothing under `~/.pi`.
695fn install_pi_extension(explicit: Option<PathBuf>) -> Result<()> {
696    let agent_dir = if let Some(dir) = explicit {
697        dir
698    } else {
699        let dir = default_pi_agent_dir()?;
700        if !pi_is_present(&dir, std::env::var_os("PATH").as_deref()) {
701            println!(
702                "pi.dev not found (no {} and no `pi` on PATH); skipped its extension",
703                dir.display()
704            );
705            return Ok(());
706        }
707        dir
708    };
709    let socket = server::resolve_socket(None)?;
710    let (file, outcome) = write_pi_extension(&agent_dir, &render_pi_extension(&socket))?;
711    match outcome {
712        PiInstall::Unchanged => println!(
713            "pi.dev extension already installed at {} (no change)",
714            file.display()
715        ),
716        PiInstall::Written => {
717            println!(
718                "installed the pi.dev sessions extension at {}",
719                file.display()
720            );
721            println!("pi sessions started after this report their state");
722        }
723    }
724    Ok(())
725}
726
727/// The pi half of `uninstall-hooks`. Runs whether or not pi is present, so an
728/// uninstalled pi does not strand the file.
729fn uninstall_pi_extension(explicit: Option<PathBuf>) -> Result<()> {
730    let agent_dir = match explicit {
731        Some(dir) => dir,
732        None => default_pi_agent_dir()?,
733    };
734    if let Some(file) = remove_pi_extension(&agent_dir)? {
735        println!("removed {}", file.display());
736    }
737    Ok(())
738}
739
740// --- install-wrapper / uninstall-wrapper -------------------------------------
741
742/// The VS Code setting the Claude Code extension reads to decide what to launch
743/// Claude through. Machine-scoped, so it belongs in the *user* settings file.
744const WRAPPER_SETTING_KEY: &str = "claudeCode.claudeProcessWrapper";
745
746/// Filename of the shim written into omni-dev's runtime directory.
747const SHIM_NAME: &str = "claude-wrap";
748
749/// Installs the `claude-wrap` shim and points VS Code's Claude extension at it.
750#[derive(Parser)]
751pub struct InstallWrapperCommand {
752    /// Path to the VS Code user settings file. Defaults to the platform's
753    /// `Code/User/settings.json`.
754    #[arg(long, value_name = "PATH")]
755    pub settings: Option<PathBuf>,
756
757    /// Path to write the shim to. Defaults to `<data-dir>/omni-dev/claude-wrap`.
758    #[arg(long, value_name = "PATH")]
759    pub shim: Option<PathBuf>,
760}
761
762impl InstallWrapperCommand {
763    /// Executes the install: writes the shim, then idempotently sets the
764    /// extension's wrapper setting to point at it.
765    pub fn execute(self) -> Result<()> {
766        let shim = shim_path(self.shim)?;
767        write_shim(&shim)?;
768        let path = vscode_settings_path(self.settings)?;
769        let target = shim.display().to_string();
770
771        let mut settings =
772            read_settings(&path).map_err(|error| manual_setup_hint(&error, &target))?;
773        let changed = set_wrapper(&mut settings, &target);
774        write_settings(&path, &settings)?;
775
776        println!("wrapper shim: {target}");
777        if changed {
778            println!("set {WRAPPER_SETTING_KEY} in {}", path.display());
779            println!("reload VS Code; Claude tabs opened after that report their exact state");
780        } else {
781            println!(
782                "{WRAPPER_SETTING_KEY} already points there in {} (no change)",
783                path.display()
784            );
785        }
786        Ok(())
787    }
788}
789
790/// Removes the wrapper setting and the shim it points at.
791#[derive(Parser)]
792pub struct UninstallWrapperCommand {
793    /// Path to the VS Code user settings file. Defaults to the platform's
794    /// `Code/User/settings.json`.
795    #[arg(long, value_name = "PATH")]
796    pub settings: Option<PathBuf>,
797
798    /// Path the shim was written to. Defaults to
799    /// `<data-dir>/omni-dev/claude-wrap`.
800    #[arg(long, value_name = "PATH")]
801    pub shim: Option<PathBuf>,
802}
803
804impl UninstallWrapperCommand {
805    /// Executes the uninstall: clears the setting when (and only when) it still
806    /// points at our shim, then removes the shim file.
807    pub fn execute(self) -> Result<()> {
808        let shim = shim_path(self.shim)?;
809        let target = shim.display().to_string();
810        let path = vscode_settings_path(self.settings)?;
811
812        if path.exists() {
813            let mut settings =
814                read_settings(&path).map_err(|error| manual_setup_hint(&error, &target))?;
815            if clear_wrapper(&mut settings, &target) {
816                write_settings(&path, &settings)?;
817                println!("cleared {WRAPPER_SETTING_KEY} in {}", path.display());
818            } else {
819                println!(
820                    "{WRAPPER_SETTING_KEY} in {} does not point at our shim; left alone",
821                    path.display()
822                );
823            }
824        } else {
825            println!("no settings file at {} (nothing to clear)", path.display());
826        }
827
828        if shim.exists() {
829            std::fs::remove_file(&shim)
830                .with_context(|| format!("failed to remove {}", shim.display()))?;
831            println!("removed {target}");
832        }
833        Ok(())
834    }
835}
836
837/// The shim's path: an explicit `--shim`, else `claude-wrap` in omni-dev's
838/// runtime directory (beside the daemon socket).
839fn shim_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
840    match explicit {
841        Some(path) => Ok(path),
842        None => Ok(paths::runtime_dir()?.join(SHIM_NAME)),
843    }
844}
845
846/// The VS Code **user** settings path: an explicit `--settings`, else
847/// `<config-dir>/Code/User/settings.json` — `~/Library/Application Support/…` on
848/// macOS and `~/.config/…` on Linux, which is where a machine-scoped setting has
849/// to live. (The runtime directory is `data_dir()`; this one is deliberately
850/// `config_dir()`.)
851fn vscode_settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
852    if let Some(path) = explicit {
853        return Ok(path);
854    }
855    let base = dirs::config_dir().context("could not determine the user config directory")?;
856    Ok(base.join("Code").join("User").join("settings.json"))
857}
858
859/// The shim script's contents.
860///
861/// The extension spawns the configured wrapper directly — no shell, no argument
862/// splitting — so the setting has to name a single executable file. This is that
863/// file: a one-line `exec` into `omni-dev claude-wrap`, using the absolute path
864/// of the running binary so it does not depend on VS Code's `PATH`.
865fn shim_contents() -> String {
866    let exe = std::env::current_exe()
867        .map_or_else(|_| "omni-dev".to_string(), |exe| exe.display().to_string());
868    format!("#!/bin/sh\nexec \"{exe}\" claude-wrap -- \"$@\"\n")
869}
870
871/// Writes the shim, creating its directory and marking it owner-executable.
872fn write_shim(path: &Path) -> Result<()> {
873    use std::os::unix::fs::PermissionsExt;
874
875    if let Some(parent) = path.parent() {
876        paths::ensure_dir_0700(parent)?;
877    }
878    std::fs::write(path, shim_contents())
879        .with_context(|| format!("failed to write {}", path.display()))?;
880    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
881        .with_context(|| format!("failed to make {} executable", path.display()))
882}
883
884/// Points the wrapper setting at `shim`, returning whether anything changed.
885fn set_wrapper(settings: &mut Value, shim: &str) -> bool {
886    let Some(root) = settings.as_object_mut() else {
887        return false;
888    };
889    if root.get(WRAPPER_SETTING_KEY).and_then(Value::as_str) == Some(shim) {
890        return false;
891    }
892    root.insert(WRAPPER_SETTING_KEY.to_string(), json!(shim));
893    true
894}
895
896/// Clears the wrapper setting when it points at `shim`, returning whether it
897/// did. A setting pointing somewhere else is someone else's and is left alone.
898fn clear_wrapper(settings: &mut Value, shim: &str) -> bool {
899    let Some(root) = settings.as_object_mut() else {
900        return false;
901    };
902    if root.get(WRAPPER_SETTING_KEY).and_then(Value::as_str) != Some(shim) {
903        return false;
904    }
905    root.remove(WRAPPER_SETTING_KEY);
906    true
907}
908
909/// Turns an unparseable-settings error into one that tells the user what to do.
910///
911/// VS Code settings files are JSON**C** — comments and trailing commas are
912/// legal there and common in practice, and [`read_settings`] rightly refuses to
913/// rewrite what it cannot parse. The shim is already in place by then, so the
914/// only thing left is one line to paste.
915fn manual_setup_hint(error: &anyhow::Error, shim: &str) -> anyhow::Error {
916    anyhow!(
917        "{error:#}\n\n\
918         VS Code settings files may contain comments or trailing commas, which cannot be \
919         rewritten safely. The shim is installed, so add this line by hand instead:\n\n    \
920         \"{WRAPPER_SETTING_KEY}\": \"{shim}\""
921    )
922}
923
924/// One hook event the tracker installs.
925#[derive(Debug, Clone, Copy)]
926struct HookSpec {
927    /// The event name, as the settings file keys it.
928    event: &'static str,
929    /// Whether the event's group gets the `*` tool `matcher` (the tool events —
930    /// `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`,
931    /// `PermissionDenied` — match on tool name; the rest are left unmatched,
932    /// which matches every occurrence).
933    matcher: bool,
934    /// A per-hook `timeout` in seconds, where the agent caps the event below
935    /// its default.
936    timeout: Option<u64>,
937}
938
939impl HookSpec {
940    const fn new(event: &'static str) -> Self {
941        Self {
942            event,
943            matcher: false,
944            timeout: None,
945        }
946    }
947
948    const fn matched(event: &'static str) -> Self {
949        Self {
950            matcher: true,
951            ..Self::new(event)
952        }
953    }
954
955    const fn capped(event: &'static str, secs: u64) -> Self {
956        Self {
957            timeout: Some(secs),
958            ..Self::new(event)
959        }
960    }
961}
962
963/// The Claude Code hook events the tracker installs. `SessionEnd` is included —
964/// it maps to the `end` op in the sink. The events after `SessionEnd` narrow
965/// the hooks-only state gaps (#1915). Claude Code 2.1.280 drops a hook event it
966/// does not know with a warning rather than rejecting the settings file; how
967/// older versions treat one is unverified (docs/sessions-service.md).
968const HOOK_EVENTS: &[HookSpec] = &[
969    HookSpec::new("SessionStart"),
970    HookSpec::new("UserPromptSubmit"),
971    HookSpec::matched("PreToolUse"),
972    HookSpec::matched("PostToolUse"),
973    HookSpec::new("Notification"),
974    HookSpec::new("Stop"),
975    HookSpec::new("SessionEnd"),
976    HookSpec::matched("PermissionRequest"),
977    HookSpec::matched("PermissionDenied"),
978    HookSpec::matched("PostToolUseFailure"),
979    HookSpec::new("StopFailure"),
980    HookSpec::new("Elicitation"),
981    HookSpec::new("ElicitationResult"),
982    HookSpec::new("SubagentStart"),
983    HookSpec::new("SubagentStop"),
984    HookSpec::new("PreCompact"),
985    HookSpec::new("PostCompact"),
986];
987
988/// The Codex hook events the tracker installs into `$CODEX_HOME/hooks.json`:
989/// Claude's set minus `Notification` (Codex has none), plus Codex's
990/// `PermissionRequest` and `Interrupt` and the compaction and subagent events
991/// (ADR-0087). Codex caps `SessionEnd` and `Interrupt` hooks at 3 s.
992const CODEX_HOOK_EVENTS: &[HookSpec] = &[
993    HookSpec::new("SessionStart"),
994    HookSpec::new("UserPromptSubmit"),
995    HookSpec::matched("PreToolUse"),
996    HookSpec::matched("PostToolUse"),
997    HookSpec::new("PermissionRequest"),
998    HookSpec::new("Stop"),
999    HookSpec::capped("Interrupt", 3),
1000    HookSpec::capped("SessionEnd", 3),
1001    HookSpec::new("PreCompact"),
1002    HookSpec::new("PostCompact"),
1003    HookSpec::new("SubagentStart"),
1004    HookSpec::new("SubagentStop"),
1005];
1006
1007/// The hook command string written into settings.json: the absolute path of the
1008/// running binary plus `sessions hook`, so Claude Code invokes *this* omni-dev
1009/// regardless of its hook `PATH`. Falls back to the bare `omni-dev sessions hook`
1010/// when the executable path cannot be resolved (documented as the portable form).
1011fn hook_command() -> String {
1012    match std::env::current_exe() {
1013        Ok(exe) => format!("{} sessions hook", exe.display()),
1014        Err(_) => "omni-dev sessions hook".to_string(),
1015    }
1016}
1017
1018/// The Claude settings file path: an explicit `--settings`, else
1019/// `$CLAUDE_CONFIG_DIR/settings.json`, else `~/.claude/settings.json`.
1020fn settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
1021    if let Some(path) = explicit {
1022        return Ok(path);
1023    }
1024    if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
1025        return Ok(PathBuf::from(dir).join("settings.json"));
1026    }
1027    let home = dirs::home_dir().context("could not resolve the home directory")?;
1028    Ok(home.join(".claude").join("settings.json"))
1029}
1030
1031/// Reads and parses `path` into a JSON object, treating a missing file as an
1032/// empty object. Errors (rather than clobbering) when the file exists but is not
1033/// valid JSON, or is valid JSON that is not an object.
1034fn read_settings(path: &Path) -> Result<Value> {
1035    if !path.exists() {
1036        return Ok(json!({}));
1037    }
1038    let text = std::fs::read_to_string(path)
1039        .with_context(|| format!("failed to read {}", path.display()))?;
1040    if text.trim().is_empty() {
1041        return Ok(json!({}));
1042    }
1043    let value: Value = serde_json::from_str(&text).with_context(|| {
1044        format!(
1045            "{} is not valid JSON; refusing to overwrite it",
1046            path.display()
1047        )
1048    })?;
1049    if !value.is_object() {
1050        bail!(
1051            "{} is not a JSON object; refusing to overwrite it",
1052            path.display()
1053        );
1054    }
1055    Ok(value)
1056}
1057
1058/// Serializes `settings` back to `path`, pretty-printed with a trailing newline,
1059/// creating the parent directory if needed.
1060fn write_settings(path: &Path, settings: &Value) -> Result<()> {
1061    if let Some(parent) = path.parent() {
1062        std::fs::create_dir_all(parent)
1063            .with_context(|| format!("failed to create {}", parent.display()))?;
1064    }
1065    let mut text = serde_json::to_string_pretty(settings)?;
1066    text.push('\n');
1067    std::fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))?;
1068    Ok(())
1069}
1070
1071/// Merges the sessions-tracker hook `command` into a settings object under each
1072/// event in `specs`, returning how many events were newly added. Idempotent (an
1073/// event that already has a group running `command` is skipped) and additive (it
1074/// never touches other hooks, and appending moves no existing group). Creates
1075/// `hooks` and any per-event array as needed.
1076fn merge_hooks(settings: &mut Value, command: &str, specs: &[HookSpec]) -> usize {
1077    // `read_settings` guarantees an object, but degrade gracefully rather than
1078    // panic if a caller passes something else.
1079    let Some(root) = settings.as_object_mut() else {
1080        return 0;
1081    };
1082    let hooks = root
1083        .entry("hooks")
1084        .or_insert_with(|| json!({}))
1085        .as_object_mut();
1086    let Some(hooks) = hooks else {
1087        // `hooks` exists but is not an object; leave the file alone rather than
1088        // clobber a user's unexpected shape.
1089        return 0;
1090    };
1091    let mut added = 0;
1092    for spec in specs {
1093        let groups = hooks
1094            .entry(spec.event.to_string())
1095            .or_insert_with(|| json!([]));
1096        let Some(groups) = groups.as_array_mut() else {
1097            continue;
1098        };
1099        if groups.iter().any(|g| group_has_command(g, command)) {
1100            continue; // already installed for this event
1101        }
1102        groups.push(hook_group(command, spec));
1103        added += 1;
1104    }
1105    added
1106}
1107
1108/// Removes every hook entry whose command is `command` from a settings object,
1109/// pruning any group and per-event array left empty, and returning how many hook
1110/// entries were removed. Leaves all other hooks in place.
1111fn remove_hooks(settings: &mut Value, command: &str) -> usize {
1112    let Some(root) = settings.as_object_mut() else {
1113        return 0;
1114    };
1115    let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
1116        return 0;
1117    };
1118    let mut removed = 0;
1119    let mut empty_events = Vec::new();
1120    for (event, groups) in hooks.iter_mut() {
1121        let Some(groups) = groups.as_array_mut() else {
1122            continue;
1123        };
1124        for group in groups.iter_mut() {
1125            if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
1126                let before = inner.len();
1127                inner.retain(|h| !hook_has_command(h, command));
1128                removed += before - inner.len();
1129            }
1130        }
1131        // Drop groups whose hook list is now empty, then the event if no groups
1132        // remain, so an uninstall leaves no empty scaffolding behind.
1133        groups.retain(|g| {
1134            g.get("hooks")
1135                .and_then(Value::as_array)
1136                .is_none_or(|h| !h.is_empty())
1137        });
1138        if groups.is_empty() {
1139            empty_events.push(event.clone());
1140        }
1141    }
1142    for event in empty_events {
1143        hooks.remove(&event);
1144    }
1145    removed
1146}
1147
1148/// One hook group as written into an event array: `{ "hooks": [{ "type":
1149/// "command", "command": … }] }`, with a `"matcher": "*"` when the event matches
1150/// on tool name and a `timeout` where the spec caps one.
1151fn hook_group(command: &str, spec: &HookSpec) -> Value {
1152    let mut group = json!({ "hooks": [hook_entry(command, spec)] });
1153    if spec.matcher {
1154        group["matcher"] = Value::String("*".to_string());
1155    }
1156    group
1157}
1158
1159/// One hook entry: `{ "type": "command", "command": … }`, plus a `timeout`
1160/// where the spec caps one.
1161fn hook_entry(command: &str, spec: &HookSpec) -> Value {
1162    let mut hook = json!({ "type": "command", "command": command });
1163    if let Some(secs) = spec.timeout {
1164        hook["timeout"] = json!(secs);
1165    }
1166    hook
1167}
1168
1169/// Whether a hook `group` already contains a hook running `command`.
1170fn group_has_command(group: &Value, command: &str) -> bool {
1171    group
1172        .get("hooks")
1173        .and_then(Value::as_array)
1174        .is_some_and(|hooks| hooks.iter().any(|h| hook_has_command(h, command)))
1175}
1176
1177/// Whether a single hook entry runs `command`.
1178fn hook_has_command(hook: &Value, command: &str) -> bool {
1179    hook.get("command").and_then(Value::as_str) == Some(command)
1180}
1181
1182// --- Codex hooks.json ----------------------------------------------------------
1183
1184/// The one-time trust step every Codex install needs. Codex skips an untrusted
1185/// hook without any message, and trusts a hook by its position *and* content, so
1186/// a changed command string needs trusting again.
1187const CODEX_TRUST_STEP: &str =
1188    "Codex skips untrusted hooks silently: open the Codex CLI, run `/hooks`, \
1189and trust the omni-dev entries (once, and again after the command changes). \
1190One approval covers the VS Code extension and Codex Desktop.";
1191
1192/// The Codex hook command: [`hook_command`] tagged `--agent codex`.
1193fn codex_hook_command() -> String {
1194    format!("{} --agent codex", hook_command())
1195}
1196
1197/// Codex's home directory: `$CODEX_HOME`, else `~/.codex`.
1198fn default_codex_home() -> Result<PathBuf> {
1199    if let Some(dir) = std::env::var_os("CODEX_HOME").filter(|d| !d.is_empty()) {
1200        return Ok(PathBuf::from(dir));
1201    }
1202    let home = dirs::home_dir().context("could not resolve the home directory")?;
1203    Ok(home.join(".codex"))
1204}
1205
1206/// Whether Codex is installed: its home directory exists (Codex has run), or a
1207/// `codex` executable is on `path`.
1208fn codex_is_present(codex_home: &Path, path: Option<&std::ffi::OsStr>) -> bool {
1209    codex_home.is_dir()
1210        || path.is_some_and(|path| {
1211            std::env::split_paths(path).any(|dir| is_executable(&dir.join("codex")))
1212        })
1213}
1214
1215/// Whether `command` runs the `omni-dev sessions hook` sink, from any path to an
1216/// `omni-dev` binary and with any arguments. In Codex's `hooks.json` every such
1217/// entry other than the current [`codex_hook_command`] is stale: an untagged or
1218/// `--agent claude` one (a user who wired Codex by hand) reports Codex sessions
1219/// as Claude's, and a tagged one from another path is left by an upgrade that
1220/// moved the binary.
1221fn is_sessions_sink(command: &str) -> bool {
1222    let command = command.trim();
1223    let Some(at) = command.find(" sessions hook") else {
1224        return false;
1225    };
1226    let rest = &command[at + " sessions hook".len()..];
1227    (rest.is_empty() || rest.starts_with(char::is_whitespace))
1228        && Path::new(command[..at].trim())
1229            .file_name()
1230            .is_some_and(|name| name == "omni-dev")
1231}
1232
1233/// Rewrites every stale sink entry (see [`is_sessions_sink`]) in `settings` to
1234/// `command` **where it sits**, returning how many it rewrote. Left beside the
1235/// tagged entry, an untagged one would race it to be a session's first sighting
1236/// and could fix the session's tag as `claude` for good; replacing it in place
1237/// moves no other hook, so nothing else loses its Codex trust (ADR-0087). The
1238/// replaced entry takes the spec's `timeout` for its event. Extra arguments on
1239/// the old entry (a `--socket`, say) are not carried over: the canonical command
1240/// is what makes a second install recognise it.
1241fn replace_stale_sinks(settings: &mut Value, command: &str, specs: &[HookSpec]) -> usize {
1242    let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else {
1243        return 0;
1244    };
1245    let mut replaced = 0;
1246    for (event, groups) in hooks.iter_mut() {
1247        let spec = specs.iter().find(|s| s.event == event);
1248        let Some(groups) = groups.as_array_mut() else {
1249            continue;
1250        };
1251        for hook in groups
1252            .iter_mut()
1253            .filter_map(|g| g.get_mut("hooks").and_then(Value::as_array_mut))
1254            .flatten()
1255        {
1256            let stale = hook
1257                .get("command")
1258                .and_then(Value::as_str)
1259                .is_some_and(|c| c != command && is_sessions_sink(c));
1260            if !stale {
1261                continue;
1262            }
1263            hook["command"] = Value::String(command.to_string());
1264            if let Some(secs) = spec.and_then(|s| s.timeout) {
1265                hook["timeout"] = json!(secs);
1266            }
1267            replaced += 1;
1268        }
1269    }
1270    replaced
1271}
1272
1273/// What [`remove_hooks_stable`] did.
1274#[derive(Debug, Default, PartialEq, Eq)]
1275struct Removal {
1276    /// How many hook entries were removed.
1277    removed: usize,
1278    /// Whether a surviving hook moved to a lower index **within its group** — the
1279    /// one shift a group placeholder cannot prevent, since a hook list has no
1280    /// empty-entry form. That hook needs trusting again.
1281    shifted: bool,
1282}
1283
1284/// Removes every hook entry `is_ours` matches, **without shifting any surviving
1285/// group** — the position-stable counterpart of [`remove_hooks`] for Codex, which
1286/// trusts a hook by its `<event>:<group>:<hook>` index. A group this empties keeps
1287/// its slot as an inert `{ "hooks": [] }` placeholder unless no group follows it,
1288/// so a later group never moves into its index and silently loses its trust;
1289/// trailing empty groups (and then an empty event) are dropped, since nothing
1290/// can shift into them.
1291fn remove_hooks_stable(settings: &mut Value, is_ours: impl Fn(&str) -> bool) -> Removal {
1292    let mut outcome = Removal::default();
1293    let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else {
1294        return outcome;
1295    };
1296    let mut empty_events = Vec::new();
1297    for (event, groups) in hooks.iter_mut() {
1298        let Some(groups) = groups.as_array_mut() else {
1299            continue;
1300        };
1301        for group in groups.iter_mut() {
1302            let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) else {
1303                continue;
1304            };
1305            let mut removed_before = false;
1306            inner.retain(|h| {
1307                let ours = h
1308                    .get("command")
1309                    .and_then(Value::as_str)
1310                    .is_some_and(&is_ours);
1311                if ours {
1312                    removed_before = true;
1313                    outcome.removed += 1;
1314                } else if removed_before {
1315                    outcome.shifted = true;
1316                }
1317                !ours
1318            });
1319            if removed_before && inner.is_empty() {
1320                *group = json!({ "hooks": [] });
1321            }
1322        }
1323        while groups.last().is_some_and(is_empty_group) {
1324            groups.pop();
1325        }
1326        if groups.is_empty() {
1327            empty_events.push(event.clone());
1328        }
1329    }
1330    for event in empty_events {
1331        hooks.remove(&event);
1332    }
1333    outcome
1334}
1335
1336/// Whether `group` is a group with an empty `hooks` list.
1337fn is_empty_group(group: &Value) -> bool {
1338    group
1339        .get("hooks")
1340        .and_then(Value::as_array)
1341        .is_some_and(Vec::is_empty)
1342}
1343
1344/// The Codex half of `install-hooks`. Without `--codex-home` it installs only
1345/// when Codex is present, so a machine without Codex gets nothing under
1346/// `~/.codex`.
1347fn install_codex_hooks(explicit: Option<PathBuf>) -> Result<()> {
1348    let codex_home = if let Some(dir) = explicit {
1349        dir
1350    } else {
1351        let dir = default_codex_home()?;
1352        if !codex_is_present(&dir, std::env::var_os("PATH").as_deref()) {
1353            println!(
1354                "Codex not found (no {} and no `codex` on PATH); skipped its hooks",
1355                dir.display()
1356            );
1357            return Ok(());
1358        }
1359        dir
1360    };
1361    let path = codex_home.join("hooks.json");
1362    let mut hooks = read_settings(&path)?;
1363    let command = codex_hook_command();
1364    let replaced = replace_stale_sinks(&mut hooks, &command, CODEX_HOOK_EVENTS);
1365    let added = merge_hooks(&mut hooks, &command, CODEX_HOOK_EVENTS);
1366    if replaced == 0 && added == 0 {
1367        println!(
1368            "Codex sessions hooks already installed in {} (no change)",
1369            path.display()
1370        );
1371        return Ok(());
1372    }
1373    write_settings(&path, &hooks)?;
1374    if replaced > 0 {
1375        println!(
1376            "rewrote {replaced} existing `sessions hook` entry(ies) in {} in place",
1377            path.display()
1378        );
1379    }
1380    if added > 0 {
1381        println!(
1382            "installed {added} Codex sessions hook event(s) into {}",
1383            path.display()
1384        );
1385    }
1386    println!("command: {command}\n{CODEX_TRUST_STEP}");
1387    Ok(())
1388}
1389
1390/// The Codex half of `uninstall-hooks`. Runs whether or not Codex is present, so
1391/// an uninstalled Codex does not strand the entries. Removes every sink entry,
1392/// tagged or not and from any path (install would have rewritten it),
1393/// position-stably.
1394fn uninstall_codex_hooks(explicit: Option<PathBuf>) -> Result<()> {
1395    let codex_home = match explicit {
1396        Some(dir) => dir,
1397        None => default_codex_home()?,
1398    };
1399    let path = codex_home.join("hooks.json");
1400    if !path.exists() {
1401        return Ok(());
1402    }
1403    let mut hooks = read_settings(&path)?;
1404    // The exact current command too, in case this binary is not named `omni-dev`.
1405    let current = codex_hook_command();
1406    let Removal { removed, shifted } =
1407        remove_hooks_stable(&mut hooks, |c| c == current || is_sessions_sink(c));
1408    if removed == 0 {
1409        return Ok(());
1410    }
1411    write_settings(&path, &hooks)?;
1412    println!(
1413        "removed {removed} Codex sessions hook entry(ies) from {}",
1414        path.display()
1415    );
1416    if shifted {
1417        println!(
1418            "a hook that shared a group with ours moved up within it, so Codex no \
1419             longer trusts it: run `/hooks` in the Codex CLI to trust it again"
1420        );
1421    } else {
1422        println!("no other Codex hook moved, so their `/hooks` trust stands");
1423    }
1424    Ok(())
1425}
1426
1427// --- shared socket + rendering ----------------------------------------------
1428
1429/// Sends one `sessions` service op over the control socket, returning its
1430/// payload or turning an `ok: false` reply into an error.
1431async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
1432    let reply = DaemonClient::new(socket)
1433        .request(DaemonEnvelope::service(SERVICE, op, payload))
1434        .await?;
1435    reply_payload(reply)
1436}
1437
1438/// Unwraps a daemon reply into its payload, turning an `ok: false` reply into an
1439/// error. Pure (no socket), so both mappings are unit-testable.
1440fn reply_payload(reply: DaemonReply) -> Result<Value> {
1441    if reply.ok {
1442        Ok(reply.payload)
1443    } else {
1444        bail!(
1445            "daemon returned an error: {}",
1446            reply.error.as_deref().unwrap_or("unknown error")
1447        )
1448    }
1449}
1450
1451/// Renders a `list` reply as a human-readable table: a header and one row per
1452/// live session (state, source, repo, working directory, and age). Returns a
1453/// placeholder line when nothing is running.
1454fn render_sessions(result: &Value) -> String {
1455    let sessions = result
1456        .get("sessions")
1457        .and_then(Value::as_array)
1458        .map(Vec::as_slice)
1459        .unwrap_or_default();
1460    if sessions.is_empty() {
1461        return "No active agent sessions.".to_string();
1462    }
1463    // CWD is last so a long path never misaligns the columns after it.
1464    let mut out = format!(
1465        "{:<13} {:<6} {:<8} {:<20} {:>5}  {}",
1466        "STATE", "AGENT", "SOURCE", "REPO", "AGE", "CWD"
1467    );
1468    for session in sessions {
1469        let state = state_display(session.get("state").and_then(Value::as_str).unwrap_or("-"));
1470        let agent = agent_label(session);
1471        let source = source_label(session);
1472        let repo = sanitize(session.get("repo").and_then(Value::as_str).unwrap_or("-"));
1473        let cwd = sanitize(session.get("cwd").and_then(Value::as_str).unwrap_or("-"));
1474        let age = age_secs(session.get("last_seen").and_then(Value::as_str));
1475        out.push_str(&format!(
1476            "\n{state:<13} {agent:<6} {source:<8} {repo:<20} {age:>4}s  {cwd}"
1477        ));
1478    }
1479    out
1480}
1481
1482/// A compact, fixed-width-friendly label for a session state, so the wide
1483/// `waiting_for_permission` does not overflow the STATE column. Falls through to
1484/// the raw (sanitized) string for any unexpected value.
1485fn state_display(state: &str) -> String {
1486    match state {
1487        "waiting_for_permission" => "waiting-perm".to_string(),
1488        "waiting_for_input" => "waiting-input".to_string(),
1489        other => sanitize(other),
1490    }
1491}
1492
1493/// The agent label for a session: `pi` for pi.dev, `codex` for Codex, else
1494/// `claude` (a daemon that predates the `agent` field only ever reports Claude
1495/// Code sessions).
1496fn agent_label(session: &Value) -> &'static str {
1497    match session.get("agent").and_then(Value::as_str) {
1498        Some("pi") => "pi",
1499        Some("codex") => "codex",
1500        _ => "claude",
1501    }
1502}
1503
1504/// The short source label for a session: `vscode` when embedded in a VS Code
1505/// window, else `terminal`.
1506fn source_label(session: &Value) -> &'static str {
1507    match session.pointer("/source/kind").and_then(Value::as_str) {
1508        Some("vs_code") => "vscode",
1509        _ => "terminal",
1510    }
1511}
1512
1513/// Strips control characters from an untrusted registry string so a crafted
1514/// payload cannot inject terminal escape sequences into the rendered table
1515/// (#1137). The `--json` path stays verbatim.
1516fn sanitize(s: &str) -> String {
1517    sanitize_for_terminal(s)
1518}
1519
1520/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
1521fn age_secs(ts: Option<&str>) -> i64 {
1522    ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1523        .map_or(0, |t| {
1524            (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
1525        })
1526}
1527
1528#[cfg(test)]
1529#[allow(clippy::unwrap_used, clippy::expect_used)]
1530mod tests {
1531    use super::*;
1532    use crate::sessions::SessionState;
1533
1534    /// Mirrors the `omni-dev sessions` argv surface for parse tests.
1535    #[derive(Parser)]
1536    struct Wrapper {
1537        #[command(subcommand)]
1538        cmd: SessionsSubcommands,
1539    }
1540
1541    fn parse(args: &[&str]) -> SessionsSubcommands {
1542        let mut full = vec!["omni-dev"];
1543        full.extend_from_slice(args);
1544        Wrapper::try_parse_from(full).unwrap().cmd
1545    }
1546
1547    #[test]
1548    fn subcommands_parse() {
1549        assert!(matches!(parse(&["list"]), SessionsSubcommands::List(_)));
1550        assert!(matches!(parse(&["hook"]), SessionsSubcommands::Hook(_)));
1551        assert!(matches!(
1552            parse(&["install-hooks"]),
1553            SessionsSubcommands::InstallHooks(_)
1554        ));
1555        assert!(matches!(
1556            parse(&["uninstall-hooks"]),
1557            SessionsSubcommands::UninstallHooks(_)
1558        ));
1559        assert!(matches!(
1560            parse(&["install-wrapper"]),
1561            SessionsSubcommands::InstallWrapper(_)
1562        ));
1563        assert!(matches!(
1564            parse(&["uninstall-wrapper"]),
1565            SessionsSubcommands::UninstallWrapper(_)
1566        ));
1567    }
1568
1569    #[test]
1570    fn list_parses_flags() {
1571        let cmd =
1572            ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
1573        assert_eq!(cmd.output, TableOrJson::Json);
1574        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
1575    }
1576
1577    // --- hook mapping --------------------------------------------------------
1578
1579    fn hook_op(json_str: &str) -> Option<(&'static str, Value)> {
1580        serde_json::from_str::<HookPayload>(json_str)
1581            .unwrap()
1582            .to_op(HookAgent::Claude, None)
1583    }
1584
1585    #[test]
1586    fn hook_maps_lifecycle_events_to_observe() {
1587        let (op, payload) = hook_op(
1588            r#"{"session_id":"s1","cwd":"/p","transcript_path":"/t.jsonl","hook_event_name":"PreToolUse"}"#,
1589        )
1590        .unwrap();
1591        assert_eq!(op, "observe");
1592        assert_eq!(payload["session_id"], "s1");
1593        assert_eq!(payload["cwd"], "/p");
1594        assert_eq!(payload["event"], "pre_tool_use");
1595    }
1596
1597    #[test]
1598    fn hook_maps_session_start_and_stop() {
1599        assert_eq!(
1600            hook_op(r#"{"session_id":"s1","hook_event_name":"SessionStart"}"#)
1601                .unwrap()
1602                .1["event"],
1603            "session_start"
1604        );
1605        assert_eq!(
1606            hook_op(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
1607                .unwrap()
1608                .1["event"],
1609            "stop"
1610        );
1611    }
1612
1613    #[test]
1614    fn hook_maps_session_end_to_end_op() {
1615        let (op, payload) =
1616            hook_op(r#"{"session_id":"s1","hook_event_name":"SessionEnd","message":"exit"}"#)
1617                .unwrap();
1618        assert_eq!(op, "end");
1619        assert_eq!(payload["session_id"], "s1");
1620        assert_eq!(payload["reason"], "exit");
1621    }
1622
1623    #[test]
1624    fn hook_sends_the_agent_pid_on_both_ops_when_known() {
1625        let hook = |json_str: &str, pid| {
1626            serde_json::from_str::<HookPayload>(json_str)
1627                .unwrap()
1628                .to_op(HookAgent::Claude, pid)
1629                .unwrap()
1630        };
1631        let start = r#"{"session_id":"s1","hook_event_name":"SessionStart"}"#;
1632        let end = r#"{"session_id":"s1","hook_event_name":"SessionEnd"}"#;
1633        assert_eq!(hook(start, Some(42)).1["pid"], 42);
1634        assert_eq!(hook(end, Some(42)).1["pid"], 42);
1635        // Unknown: omitted from the wire, exactly as before #1948.
1636        assert!(hook(start, None).1.get("pid").is_none());
1637        assert!(hook(end, None).1.get("pid").is_none());
1638    }
1639
1640    #[test]
1641    fn agent_pid_treats_an_orphaned_hook_as_unknown() {
1642        assert_eq!(agent_pid(4242), Some(4242));
1643        assert_eq!(agent_pid(1), None);
1644        assert_eq!(agent_pid(0), None);
1645    }
1646
1647    #[test]
1648    fn hook_classifies_notifications() {
1649        let permission = hook_op(
1650            r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}"#,
1651        )
1652        .unwrap();
1653        assert_eq!(permission.1["event"]["notification"], "permission_prompt");
1654
1655        let idle = hook_op(
1656            r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude is waiting for your input"}"#,
1657        )
1658        .unwrap();
1659        assert_eq!(idle.1["event"]["notification"], "idle_prompt");
1660
1661        let other = hook_op(
1662            r#"{"session_id":"s1","hook_event_name":"Notification","message":"something else"}"#,
1663        )
1664        .unwrap();
1665        assert_eq!(other.1["event"]["notification"], "other");
1666    }
1667
1668    #[test]
1669    fn hook_ignores_unknown_events_and_missing_session_id() {
1670        // No session_id → no op.
1671        assert!(hook_op(r#"{"hook_event_name":"Stop"}"#).is_none());
1672        // Blank session_id → no op.
1673        assert!(hook_op(r#"{"session_id":"  ","hook_event_name":"Stop"}"#).is_none());
1674        // Unknown event → no op.
1675        assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"CwdChanged"}"#).is_none());
1676        // Garbage that still parses as an (empty) payload → no op.
1677        assert!(hook_op("{}").is_none());
1678    }
1679
1680    fn codex_op(json_str: &str) -> Option<(&'static str, Value)> {
1681        serde_json::from_str::<HookPayload>(json_str)
1682            .unwrap()
1683            .to_op(HookAgent::Codex, None)
1684    }
1685
1686    fn codex_event(event: &str, tool: Option<&str>) -> Value {
1687        let mut hook = json!({ "session_id": "c1", "hook_event_name": event });
1688        if let Some(tool) = tool {
1689            hook["tool_name"] = json!(tool);
1690        }
1691        codex_op(&hook.to_string()).unwrap().1["event"].clone()
1692    }
1693
1694    #[test]
1695    fn codex_hooks_are_tagged_from_the_first_event() {
1696        let (op, payload) = codex_op(
1697            r#"{"session_id":"c1","cwd":"/p","model":"gpt-5.5","hook_event_name":"SessionStart","source":"startup"}"#,
1698        )
1699        .unwrap();
1700        assert_eq!(op, "observe");
1701        assert_eq!(payload["agent"], "codex");
1702        assert_eq!(payload["model"], "gpt-5.5");
1703        assert_eq!(payload["event"], "session_start");
1704        // The Claude sink stays untagged on the wire.
1705        let claude = hook_op(r#"{"session_id":"s1","hook_event_name":"Stop"}"#).unwrap();
1706        assert!(claude.1.get("agent").is_none());
1707    }
1708
1709    #[test]
1710    fn codex_events_map_onto_the_existing_session_events() {
1711        assert_eq!(codex_event("UserPromptSubmit", None), "user_prompt_submit");
1712        assert_eq!(codex_event("PreToolUse", Some("Bash")), "pre_tool_use");
1713        assert_eq!(codex_event("PostToolUse", Some("Bash")), "post_tool_use");
1714        assert_eq!(codex_event("Stop", None), "stop");
1715        let compact =
1716            json!({ "session_id": "c1", "hook_event_name": "SessionStart", "source": "compact" });
1717        assert_eq!(
1718            codex_op(&compact.to_string()).unwrap().1["event"],
1719            "transcript_discovered",
1720            "a compaction start is not a new session (#1946)"
1721        );
1722        assert_eq!(
1723            codex_event("PermissionRequest", Some("Bash"))["notification"],
1724            "permission_prompt"
1725        );
1726        assert_eq!(
1727            codex_event("PreToolUse", Some("request_user_input"))["notification"],
1728            "agent_needs_input"
1729        );
1730        // Its resolution is an ordinary PostToolUse.
1731        assert_eq!(
1732            codex_event("PostToolUse", Some("request_user_input")),
1733            "post_tool_use"
1734        );
1735        assert_eq!(codex_event("Interrupt", None), "stop");
1736        for heartbeat in ["SubagentStart", "SubagentStop", "PreCompact", "PostCompact"] {
1737            assert_eq!(codex_event(heartbeat, None), "post_tool_use", "{heartbeat}");
1738        }
1739        // Codex has no Notification event; an unknown one is ignored.
1740        assert!(codex_op(r#"{"session_id":"c1","hook_event_name":"Notification"}"#).is_none());
1741    }
1742
1743    #[test]
1744    fn codex_session_end_is_the_end_op_with_its_reason() {
1745        let (op, payload) =
1746            codex_op(r#"{"session_id":"c1","hook_event_name":"SessionEnd","reason":"other"}"#)
1747                .unwrap();
1748        assert_eq!(op, "end");
1749        assert_eq!(payload["reason"], "other");
1750    }
1751
1752    #[test]
1753    fn the_claude_sink_ignores_codex_only_events() {
1754        // Codex's `Interrupt` and its `request_user_input` tool have no Claude
1755        // equivalent, so the untagged sink keeps its own mapping for both.
1756        assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"Interrupt"}"#).is_none());
1757        assert_eq!(
1758            hook_op(r#"{"session_id":"s1","hook_event_name":"PreToolUse","tool_name":"request_user_input"}"#)
1759                .unwrap()
1760                .1["event"],
1761            "pre_tool_use"
1762        );
1763    }
1764
1765    fn claude_event(event: &str) -> Value {
1766        let hook = json!({ "session_id": "s1", "hook_event_name": event });
1767        hook_op(&hook.to_string()).unwrap().1["event"].clone()
1768    }
1769
1770    #[test]
1771    fn claude_newer_events_map_onto_the_existing_session_events() {
1772        assert_eq!(
1773            claude_event("PermissionRequest")["notification"],
1774            "permission_prompt"
1775        );
1776        assert_eq!(
1777            claude_event("Elicitation")["notification"],
1778            "agent_needs_input"
1779        );
1780        for working in [
1781            "PostToolUseFailure",
1782            "PermissionDenied",
1783            "ElicitationResult",
1784            "SubagentStart",
1785        ] {
1786            assert_eq!(claude_event(working), "post_tool_use", "{working}");
1787        }
1788        assert_eq!(claude_event("StopFailure"), "stop");
1789        // Liveness-only sightings: each can fire while the session is idle (a
1790        // background subagent, a manual /compact) and must not turn it working.
1791        for preserving in ["SubagentStop", "PreCompact", "PostCompact"] {
1792            assert_eq!(
1793                claude_event(preserving),
1794                "transcript_discovered",
1795                "{preserving}"
1796            );
1797        }
1798    }
1799
1800    #[test]
1801    fn every_installed_claude_event_is_mapped_by_the_sink() {
1802        // An installed event the sink ignores would spawn a process per event
1803        // for nothing.
1804        for HookSpec { event, .. } in HOOK_EVENTS {
1805            let hook = json!({ "session_id": "s1", "hook_event_name": event });
1806            assert!(hook_op(&hook.to_string()).is_some(), "{event} is unmapped");
1807        }
1808    }
1809
1810    #[test]
1811    fn a_finished_subagent_or_compaction_leaves_an_idle_session_idle() {
1812        for preserving in ["SubagentStop", "PreCompact", "PostCompact"] {
1813            let hook = json!({ "session_id": "s1", "hook_event_name": preserving });
1814            let (_, payload) = hook_op(&hook.to_string()).unwrap();
1815            let event: SessionEvent = serde_json::from_value(payload["event"].clone()).unwrap();
1816            assert_eq!(
1817                SessionState::for_event(&event, Some(SessionState::Idle)),
1818                SessionState::Idle,
1819                "{preserving}"
1820            );
1821            assert_eq!(
1822                SessionState::for_event(&event, Some(SessionState::WaitingForPermission)),
1823                SessionState::WaitingForPermission,
1824                "{preserving}"
1825            );
1826        }
1827    }
1828
1829    #[test]
1830    fn a_compaction_session_start_preserves_state() {
1831        let event_for = |source: Option<&str>| {
1832            let mut hook = json!({ "session_id": "s1", "hook_event_name": "SessionStart" });
1833            if let Some(source) = source {
1834                hook["source"] = json!(source);
1835            }
1836            let (_, payload) = hook_op(&hook.to_string()).unwrap();
1837            serde_json::from_value::<SessionEvent>(payload["event"].clone()).unwrap()
1838        };
1839        // Claude fires it after a compaction, possibly mid-turn: a working
1840        // session must stay working, an idle one idle (#1946).
1841        let compact = event_for(Some("compact"));
1842        assert_eq!(compact, SessionEvent::TranscriptDiscovered);
1843        for current in [SessionState::Working, SessionState::Idle] {
1844            assert_eq!(SessionState::for_event(&compact, Some(current)), current);
1845        }
1846        for start in [Some("startup"), Some("resume"), Some("clear"), None] {
1847            assert_eq!(event_for(start), SessionEvent::SessionStart, "{start:?}");
1848        }
1849    }
1850
1851    #[test]
1852    fn hook_classifies_notifications_by_type_before_message() {
1853        let typed = |ty: &str, msg: &str| {
1854            let hook = json!({
1855                "session_id": "s1",
1856                "hook_event_name": "Notification",
1857                "notification_type": ty,
1858                "message": msg,
1859            });
1860            hook_op(&hook.to_string()).unwrap().1["event"]["notification"].clone()
1861        };
1862        // The type wins over a message the substring match would misread.
1863        assert_eq!(
1864            typed("permission_prompt", "something else"),
1865            "permission_prompt"
1866        );
1867        assert_eq!(typed("idle_prompt", "Please approve"), "idle_prompt");
1868        assert_eq!(typed("elicitation_dialog", "x"), "agent_needs_input");
1869        // A type we do not map is `other`, never a guess at its message.
1870        assert_eq!(typed("auth_success", "You can now allow access"), "other");
1871        // With no type at all (an older version), the message is classified.
1872        let untyped = hook_op(
1873            r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude needs your permission"}"#,
1874        )
1875        .unwrap();
1876        assert_eq!(untyped.1["event"]["notification"], "permission_prompt");
1877    }
1878
1879    #[test]
1880    fn hook_parses_the_agent_flag() {
1881        match parse(&["hook", "--agent", "codex"]) {
1882            SessionsSubcommands::Hook(cmd) => assert_eq!(cmd.agent, HookAgent::Codex),
1883            _ => panic!("expected hook"),
1884        }
1885        match parse(&["hook"]) {
1886            SessionsSubcommands::Hook(cmd) => assert_eq!(cmd.agent, HookAgent::Claude),
1887            _ => panic!("expected hook"),
1888        }
1889        match parse(&["install-hooks", "--codex-home", "/x/codex"]) {
1890            SessionsSubcommands::InstallHooks(cmd) => {
1891                assert_eq!(cmd.codex_home, Some(PathBuf::from("/x/codex")));
1892            }
1893            _ => panic!("expected install-hooks"),
1894        }
1895    }
1896
1897    // --- Codex hooks.json ---------------------------------------------------
1898
1899    const TAGGED: &str = "/bin/omni-dev sessions hook --agent codex";
1900
1901    /// The `(group index, commands)` layout of one event, for position checks.
1902    fn layout(hooks: &Value, event: &str) -> Vec<Vec<String>> {
1903        hooks["hooks"][event]
1904            .as_array()
1905            .map(|groups| {
1906                groups
1907                    .iter()
1908                    .map(|g| {
1909                        g["hooks"]
1910                            .as_array()
1911                            .unwrap()
1912                            .iter()
1913                            .map(|h| h["command"].as_str().unwrap().to_string())
1914                            .collect()
1915                    })
1916                    .collect()
1917            })
1918            .unwrap_or_default()
1919    }
1920
1921    #[test]
1922    fn is_sessions_sink_matches_any_omni_dev_sink_and_nothing_else() {
1923        assert!(is_sessions_sink(
1924            "/Users/me/.cargo/bin/omni-dev sessions hook"
1925        ));
1926        assert!(is_sessions_sink("omni-dev sessions hook"));
1927        assert!(is_sessions_sink(TAGGED));
1928        assert!(is_sessions_sink("omni-dev sessions hook --socket /x"));
1929        assert!(is_sessions_sink(
1930            "/old/omni-dev sessions hook --agent codex"
1931        ));
1932        assert!(!is_sessions_sink("/bin/other sessions hook"));
1933        assert!(!is_sessions_sink("omni-dev sessions list"));
1934        assert!(!is_sessions_sink("omni-dev sessions hooks"));
1935        assert!(!is_sessions_sink("my-omni-dev sessions hook"));
1936    }
1937
1938    #[test]
1939    fn codex_install_rewrites_a_moved_binarys_tagged_entry_in_place() {
1940        let mut hooks = json!({ "hooks": { "Stop": [
1941            { "hooks": [{ "type": "command", "command": "/old/omni-dev sessions hook --agent codex" }] },
1942            { "hooks": [{ "type": "command", "command": "omni-dev sessions hook --socket /x" }] },
1943            { "hooks": [{ "type": "command", "command": "mine" }] }
1944        ] } });
1945        assert_eq!(
1946            replace_stale_sinks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS),
1947            2
1948        );
1949        assert_eq!(
1950            layout(&hooks, "Stop"),
1951            vec![vec![TAGGED], vec![TAGGED], vec!["mine"]]
1952        );
1953        // Already tagged by the current binary: nothing added under Stop.
1954        merge_hooks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS);
1955        assert_eq!(layout(&hooks, "Stop").len(), 3);
1956    }
1957
1958    #[test]
1959    fn codex_install_replaces_untagged_sinks_in_place_and_never_reorders() {
1960        let mut hooks = json!({ "hooks": {
1961            "PreToolUse": [
1962                { "matcher": "Bash", "hooks": [{ "type": "command", "command": "guard" }] },
1963                { "matcher": "*", "hooks": [{ "type": "command", "command": "/old/omni-dev sessions hook" }] },
1964                { "hooks": [{ "type": "command", "command": "audit" }] }
1965            ],
1966            "SessionEnd": [
1967                { "hooks": [{ "type": "command", "command": "omni-dev sessions hook" }] }
1968            ]
1969        }});
1970        let replaced = replace_stale_sinks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS);
1971        assert_eq!(replaced, 2);
1972        let added = merge_hooks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS);
1973        // Only the events without a (now tagged) sink got a new group.
1974        assert_eq!(added, CODEX_HOOK_EVENTS.len() - 2);
1975        // The untagged sink became the tagged one where it sat; nothing moved.
1976        assert_eq!(
1977            layout(&hooks, "PreToolUse"),
1978            vec![vec!["guard"], vec![TAGGED], vec!["audit"]]
1979        );
1980        assert_eq!(hooks["hooks"]["PreToolUse"][1]["matcher"], "*");
1981        // The capped event picked up its timeout in place.
1982        assert_eq!(hooks["hooks"]["SessionEnd"][0]["hooks"][0]["timeout"], 3);
1983        // Appended groups carry the matcher and timeout their event needs.
1984        assert_eq!(hooks["hooks"]["PostToolUse"][0]["matcher"], "*");
1985        assert_eq!(hooks["hooks"]["Interrupt"][0]["hooks"][0]["timeout"], 3);
1986        assert!(hooks["hooks"]["Stop"][0]["hooks"][0]
1987            .get("timeout")
1988            .is_none());
1989        assert!(hooks["hooks"].get("Notification").is_none());
1990        // Idempotent.
1991        assert_eq!(
1992            replace_stale_sinks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS),
1993            0
1994        );
1995        assert_eq!(merge_hooks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS), 0);
1996    }
1997
1998    #[test]
1999    fn codex_uninstall_never_shifts_a_surviving_group() {
2000        let mut hooks = json!({ "hooks": {
2001            "Stop": [
2002                { "hooks": [{ "type": "command", "command": TAGGED }] },
2003                { "hooks": [{ "type": "command", "command": "user-a" }] },
2004                { "hooks": [{ "type": "command", "command": "omni-dev sessions hook" }] },
2005                { "hooks": [{ "type": "command", "command": "user-b" }] },
2006                { "hooks": [{ "type": "command", "command": TAGGED }] }
2007            ],
2008            "PreToolUse": [
2009                { "hooks": [
2010                    { "type": "command", "command": TAGGED },
2011                    { "type": "command", "command": "shared" }
2012                ] }
2013            ],
2014            "Interrupt": [
2015                { "hooks": [] },
2016                { "hooks": [{ "type": "command", "command": TAGGED }] }
2017            ],
2018            "PostToolUse": [
2019                { "hooks": [{ "type": "command", "command": "keep" }] }
2020            ]
2021        }});
2022        let removal = remove_hooks_stable(&mut hooks, is_sessions_sink);
2023        assert_eq!(removal.removed, 5);
2024        // `shared` moved from hook index 1 to 0 within its group.
2025        assert!(removal.shifted);
2026        // Emptied non-trailing groups stay as placeholders, so user-a and user-b
2027        // keep their indices (1 and 3); the trailing emptied group is dropped.
2028        let stop = hooks["hooks"]["Stop"].as_array().unwrap();
2029        assert_eq!(stop.len(), 4);
2030        assert_eq!(stop[0], json!({ "hooks": [] }));
2031        assert_eq!(layout(&hooks, "Stop")[1], vec!["user-a"]);
2032        assert_eq!(stop[2], json!({ "hooks": [] }));
2033        assert_eq!(layout(&hooks, "Stop")[3], vec!["user-b"]);
2034        // A group with other hooks keeps them.
2035        assert_eq!(layout(&hooks, "PreToolUse"), vec![vec!["shared"]]);
2036        // Trailing placeholders, then the empty event, are dropped.
2037        assert!(hooks["hooks"].get("Interrupt").is_none());
2038        assert_eq!(layout(&hooks, "PostToolUse"), vec![vec!["keep"]]);
2039        // A second pass removes nothing.
2040        assert_eq!(
2041            remove_hooks_stable(&mut hooks, is_sessions_sink),
2042            Removal::default()
2043        );
2044    }
2045
2046    #[test]
2047    fn remove_hooks_stable_does_not_report_a_shift_for_a_trailing_hook() {
2048        let mut hooks = json!({ "hooks": { "Stop": [ { "hooks": [
2049            { "type": "command", "command": "shared" },
2050            { "type": "command", "command": TAGGED }
2051        ] } ] } });
2052        let removal = remove_hooks_stable(&mut hooks, is_sessions_sink);
2053        assert_eq!(
2054            removal,
2055            Removal {
2056                removed: 1,
2057                shifted: false
2058            }
2059        );
2060        assert_eq!(layout(&hooks, "Stop"), vec![vec!["shared"]]);
2061    }
2062
2063    #[test]
2064    fn remove_hooks_stable_tolerates_malformed_shapes() {
2065        let none = Removal::default();
2066        assert_eq!(remove_hooks_stable(&mut json!([]), |_| true), none);
2067        assert_eq!(remove_hooks_stable(&mut json!({}), |_| true), none);
2068        let mut odd = json!({ "hooks": { "Stop": 5, "Start": [ { "matcher": "x" } ] } });
2069        assert_eq!(remove_hooks_stable(&mut odd, |_| true), none);
2070        assert_eq!(odd["hooks"]["Stop"], 5);
2071        assert_eq!(
2072            replace_stale_sinks(&mut json!({}), TAGGED, CODEX_HOOK_EVENTS),
2073            0
2074        );
2075        assert_eq!(
2076            replace_stale_sinks(
2077                &mut json!({ "hooks": { "Stop": 5 } }),
2078                TAGGED,
2079                CODEX_HOOK_EVENTS
2080            ),
2081            0
2082        );
2083    }
2084
2085    #[test]
2086    fn codex_is_present_via_its_home_or_a_codex_executable_on_path() {
2087        use std::os::unix::fs::PermissionsExt;
2088
2089        let tmp = tempfile::tempdir().unwrap();
2090        let home = tmp.path().join("codex-home");
2091        let bin = tmp.path().join("bin");
2092        std::fs::create_dir_all(&bin).unwrap();
2093        let path = std::env::join_paths([&bin]).unwrap();
2094        assert!(!codex_is_present(&home, Some(&path)));
2095        assert!(!codex_is_present(&home, None));
2096        let exe = bin.join("codex");
2097        std::fs::write(&exe, "#!/bin/sh\n").unwrap();
2098        std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
2099        assert!(codex_is_present(&home, Some(&path)));
2100        std::fs::create_dir_all(&home).unwrap();
2101        assert!(codex_is_present(&home, None));
2102    }
2103
2104    #[test]
2105    fn codex_hook_command_is_the_sink_tagged_codex() {
2106        assert_eq!(
2107            codex_hook_command(),
2108            format!("{} --agent codex", hook_command())
2109        );
2110        assert!(codex_hook_command().ends_with(" sessions hook --agent codex"));
2111    }
2112
2113    #[test]
2114    fn codex_install_and_uninstall_round_trip_through_the_file() {
2115        let tmp = tempfile::tempdir().unwrap();
2116        let home = tmp.path().join("codex-home");
2117        std::fs::create_dir_all(&home).unwrap();
2118        let file = home.join("hooks.json");
2119        std::fs::write(
2120            &file,
2121            json!({ "hooks": { "Stop": [
2122                { "hooks": [{ "type": "command", "command": "omni-dev sessions hook" }] },
2123                { "hooks": [{ "type": "command", "command": "mine" }] }
2124            ] } })
2125            .to_string(),
2126        )
2127        .unwrap();
2128        install_codex_hooks(Some(home.clone())).unwrap();
2129        let tagged = codex_hook_command();
2130        let installed = read_settings(&file).unwrap();
2131        assert_eq!(
2132            layout(&installed, "Stop"),
2133            vec![vec![tagged.clone()], vec!["mine".to_string()]]
2134        );
2135        assert_eq!(layout(&installed, "Interrupt"), vec![vec![tagged]]);
2136        // A second install is the no-change branch and leaves the file as is.
2137        install_codex_hooks(Some(home.clone())).unwrap();
2138        assert_eq!(read_settings(&file).unwrap(), installed);
2139
2140        uninstall_codex_hooks(Some(home.clone())).unwrap();
2141        let removed = read_settings(&file).unwrap();
2142        assert_eq!(
2143            removed,
2144            json!({ "hooks": { "Stop": [ { "hooks": [] }, { "hooks": [{ "type": "command", "command": "mine" }] } ] } })
2145        );
2146        // Nothing left to remove, and a missing home is a no-op.
2147        uninstall_codex_hooks(Some(home)).unwrap();
2148        uninstall_codex_hooks(Some(tmp.path().join("absent"))).unwrap();
2149    }
2150
2151    #[test]
2152    fn install_codex_hooks_via_the_default_home_skips_absent_and_writes_present() {
2153        let _guard = PI_ENV_LOCK
2154            .lock()
2155            .unwrap_or_else(std::sync::PoisonError::into_inner);
2156        let saved = ["CODEX_HOME", "PATH", "HOME"].map(|k| (k, std::env::var_os(k)));
2157
2158        let tmp = tempfile::tempdir().unwrap();
2159        let home = tmp.path().join("codex-home");
2160        let empty_bin = tmp.path().join("bin");
2161        std::fs::create_dir_all(&empty_bin).unwrap();
2162        std::env::set_var("CODEX_HOME", &home);
2163        std::env::set_var("PATH", std::env::join_paths([&empty_bin]).unwrap());
2164
2165        assert_eq!(default_codex_home().unwrap(), home);
2166        install_codex_hooks(None).unwrap();
2167        assert!(
2168            !home.exists(),
2169            "install must not create an absent Codex home"
2170        );
2171        uninstall_codex_hooks(None).unwrap();
2172
2173        std::fs::create_dir_all(&home).unwrap();
2174        install_codex_hooks(None).unwrap();
2175        assert!(home.join("hooks.json").exists());
2176        uninstall_codex_hooks(None).unwrap();
2177        assert_eq!(
2178            read_settings(&home.join("hooks.json")).unwrap(),
2179            json!({ "hooks": {} })
2180        );
2181
2182        // An empty CODEX_HOME falls back to `$HOME/.codex`.
2183        std::env::set_var("CODEX_HOME", "");
2184        std::env::set_var("HOME", "/home/tester");
2185        assert_eq!(
2186            default_codex_home().unwrap(),
2187            PathBuf::from("/home/tester/.codex")
2188        );
2189
2190        for (k, v) in saved {
2191            match v {
2192                Some(val) => std::env::set_var(k, val),
2193                None => std::env::remove_var(k),
2194            }
2195        }
2196    }
2197
2198    #[test]
2199    fn classify_notification_covers_cases() {
2200        assert_eq!(
2201            classify_notification(None, Some("Please approve this")),
2202            NotificationKind::PermissionPrompt
2203        );
2204        assert_eq!(
2205            classify_notification(None, Some("Claude is idle")),
2206            NotificationKind::IdlePrompt
2207        );
2208        assert_eq!(classify_notification(None, None), NotificationKind::Other);
2209        assert_eq!(
2210            classify_notification(Some("idle_prompt"), None),
2211            NotificationKind::IdlePrompt
2212        );
2213    }
2214
2215    // --- install / uninstall hooks ------------------------------------------
2216
2217    #[test]
2218    fn merge_hooks_is_idempotent_and_additive() {
2219        // A pre-existing unrelated hook must survive the merge.
2220        let mut settings = json!({
2221            "hooks": {
2222                "PreToolUse": [
2223                    { "matcher": "Bash", "hooks": [{ "type": "command", "command": "other-tool" }] }
2224                ]
2225            },
2226            "model": "sonnet"
2227        });
2228        let cmd = "/usr/bin/omni-dev sessions hook";
2229        let added = merge_hooks(&mut settings, cmd, HOOK_EVENTS);
2230        assert_eq!(added, HOOK_EVENTS.len());
2231
2232        // Our command landed under every event, and the unrelated hook stands.
2233        for HookSpec { event, .. } in HOOK_EVENTS {
2234            let groups = settings["hooks"][event].as_array().unwrap();
2235            assert!(
2236                groups.iter().any(|g| group_has_command(g, cmd)),
2237                "missing under {event}"
2238            );
2239        }
2240        let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
2241        assert!(pre.iter().any(|g| group_has_command(g, "other-tool")));
2242        assert_eq!(settings["model"], "sonnet");
2243
2244        // A second merge is a no-op.
2245        assert_eq!(merge_hooks(&mut settings, cmd, HOOK_EVENTS), 0);
2246    }
2247
2248    #[test]
2249    fn reinstalling_over_the_original_seven_events_adds_only_the_new_ones() {
2250        // An install from before #1915 carries only the first seven events.
2251        let cmd = "/usr/bin/omni-dev sessions hook";
2252        let original = &HOOK_EVENTS[..7];
2253        assert_eq!(original.last().unwrap().event, "SessionEnd");
2254        let mut settings = json!({});
2255        merge_hooks(&mut settings, cmd, original);
2256        let before = settings.clone();
2257
2258        let added = merge_hooks(&mut settings, cmd, HOOK_EVENTS);
2259        assert_eq!(added, HOOK_EVENTS.len() - 7);
2260        // The existing groups are untouched: one group each, as before.
2261        for HookSpec { event, .. } in original {
2262            assert_eq!(settings["hooks"][event], before["hooks"][event], "{event}");
2263        }
2264        // Tool events get the `*` matcher; the others none.
2265        assert_eq!(settings["hooks"]["PermissionRequest"][0]["matcher"], "*");
2266        assert!(settings["hooks"]["StopFailure"][0].get("matcher").is_none());
2267
2268        // Uninstall removes every event, old and new.
2269        assert_eq!(remove_hooks(&mut settings, cmd), HOOK_EVENTS.len());
2270        assert!(settings["hooks"].as_object().unwrap().is_empty());
2271    }
2272
2273    #[test]
2274    fn merge_then_remove_round_trips_and_preserves_others() {
2275        let mut settings = json!({
2276            "hooks": {
2277                "PreToolUse": [
2278                    { "matcher": "Bash", "hooks": [{ "type": "command", "command": "keep-me" }] }
2279                ]
2280            }
2281        });
2282        let cmd = "/usr/bin/omni-dev sessions hook";
2283        merge_hooks(&mut settings, cmd, HOOK_EVENTS);
2284        let removed = remove_hooks(&mut settings, cmd);
2285        assert_eq!(removed, HOOK_EVENTS.len());
2286
2287        // Every one of our entries is gone...
2288        for HookSpec { event, .. } in HOOK_EVENTS {
2289            let empty = settings["hooks"]
2290                .get(event)
2291                .and_then(Value::as_array)
2292                .is_none_or(|g| g.iter().all(|g| !group_has_command(g, cmd)));
2293            assert!(empty, "our hook survived under {event}");
2294        }
2295        // ...but the unrelated PreToolUse hook remains.
2296        let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
2297        assert!(pre.iter().any(|g| group_has_command(g, "keep-me")));
2298    }
2299
2300    #[test]
2301    fn remove_hooks_prunes_empty_events_entirely() {
2302        let mut settings = json!({});
2303        let cmd = "cmd sessions hook";
2304        merge_hooks(&mut settings, cmd, HOOK_EVENTS);
2305        remove_hooks(&mut settings, cmd);
2306        // With no other hooks, every event array empties and is pruned.
2307        let hooks = settings["hooks"].as_object().unwrap();
2308        assert!(hooks.is_empty(), "expected all events pruned: {hooks:?}");
2309    }
2310
2311    #[test]
2312    fn install_uninstall_via_files_round_trips() {
2313        let tmp = tempfile::tempdir().unwrap();
2314        let path = tmp.path().join("settings.json");
2315        // Install into a missing file, then uninstall.
2316        let mut settings = read_settings(&path).unwrap();
2317        merge_hooks(&mut settings, "cmd sessions hook", HOOK_EVENTS);
2318        write_settings(&path, &settings).unwrap();
2319        assert!(path.exists());
2320
2321        let reloaded = read_settings(&path).unwrap();
2322        assert!(reloaded["hooks"]["Stop"].is_array());
2323
2324        let mut settings = read_settings(&path).unwrap();
2325        remove_hooks(&mut settings, "cmd sessions hook");
2326        write_settings(&path, &settings).unwrap();
2327        let reloaded = read_settings(&path).unwrap();
2328        assert!(reloaded["hooks"].as_object().unwrap().is_empty());
2329    }
2330
2331    #[test]
2332    fn read_settings_rejects_non_json() {
2333        let tmp = tempfile::tempdir().unwrap();
2334        let path = tmp.path().join("settings.json");
2335        std::fs::write(&path, "not json {").unwrap();
2336        let err = read_settings(&path).unwrap_err();
2337        assert!(err.to_string().contains("not valid JSON"), "{err}");
2338    }
2339
2340    #[test]
2341    fn read_settings_handles_empty_and_non_object() {
2342        let tmp = tempfile::tempdir().unwrap();
2343        let path = tmp.path().join("settings.json");
2344        // An empty (or whitespace-only) file reads as an empty object.
2345        std::fs::write(&path, "   \n").unwrap();
2346        assert_eq!(read_settings(&path).unwrap(), json!({}));
2347        // Valid JSON that is not an object is refused rather than clobbered.
2348        std::fs::write(&path, "[1, 2, 3]").unwrap();
2349        let err = read_settings(&path).unwrap_err();
2350        assert!(err.to_string().contains("not a JSON object"), "{err}");
2351    }
2352
2353    #[test]
2354    fn hook_command_targets_sessions_hook() {
2355        assert!(hook_command().ends_with("sessions hook"));
2356    }
2357
2358    // --- rendering -----------------------------------------------------------
2359
2360    #[test]
2361    fn render_sessions_handles_empty() {
2362        assert_eq!(
2363            render_sessions(&json!({ "sessions": [] })),
2364            "No active agent sessions."
2365        );
2366        assert_eq!(render_sessions(&json!({})), "No active agent sessions.");
2367    }
2368
2369    #[test]
2370    fn render_sessions_renders_rows_and_source() {
2371        let result = json!({ "sessions": [{
2372            "session_id": "s1",
2373            "state": "working",
2374            "source": { "kind": "vs_code", "window_key": "w1" },
2375            "repo": "omni-dev",
2376            "cwd": "/home/me/omni-dev",
2377            "last_seen": "2000-01-01T00:00:00Z",
2378        }]});
2379        let table = render_sessions(&result);
2380        assert!(table.contains("working"), "{table}");
2381        assert!(table.contains("vscode"), "{table}");
2382        assert!(table.contains("omni-dev"), "{table}");
2383        assert!(table.contains("claude"), "{table}");
2384        // Header plus one data row.
2385        assert_eq!(table.lines().count(), 2, "{table}");
2386    }
2387
2388    #[test]
2389    fn render_sessions_strips_control_bytes() {
2390        let result = json!({ "sessions": [{
2391            "session_id": "s1",
2392            "state": "wor\x1b[31mking",
2393            "source": { "kind": "terminal" },
2394            "repo": "ev\x07il",
2395            "cwd": "/tmp/a\rb",
2396            "last_seen": "2000-01-01T00:00:00Z",
2397        }]});
2398        let table = render_sessions(&result);
2399        assert!(
2400            !table.contains(|c: char| c.is_control() && c != '\n'),
2401            "{table:?}"
2402        );
2403        // Embedded CR cannot forge a row: header plus one data row.
2404        assert_eq!(table.lines().count(), 2, "{table:?}");
2405    }
2406
2407    #[test]
2408    fn source_label_maps_kinds() {
2409        assert_eq!(
2410            source_label(&json!({ "source": { "kind": "vs_code", "window_key": "w" } })),
2411            "vscode"
2412        );
2413        assert_eq!(
2414            source_label(&json!({ "source": { "kind": "terminal" } })),
2415            "terminal"
2416        );
2417        assert_eq!(source_label(&json!({})), "terminal");
2418    }
2419
2420    #[test]
2421    fn reply_payload_unwraps_ok_and_maps_errors() {
2422        assert_eq!(
2423            reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
2424            json!({ "a": 1 })
2425        );
2426        let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
2427        assert!(err.to_string().contains("boom"), "{err}");
2428    }
2429
2430    // --- pi.dev extension ----------------------------------------------------
2431
2432    #[test]
2433    fn render_pi_extension_bakes_in_the_socket_as_a_json_string() {
2434        let rendered = render_pi_extension(Path::new("/tmp/we \"ird\"/daemon.sock"));
2435        assert!(
2436            !rendered.contains(PI_SOCKET_PLACEHOLDER),
2437            "placeholder left behind"
2438        );
2439        assert!(
2440            rendered.contains(r#"const SOCKET: string = "/tmp/we \"ird\"/daemon.sock";"#),
2441            "socket not escaped as a JSON string"
2442        );
2443        assert!(rendered.starts_with(pi_extension_marker()));
2444    }
2445
2446    #[test]
2447    fn the_template_reports_only_state_and_tags_the_agent() {
2448        // Each state the mapping can produce, the agent tag, and the `end` op.
2449        for needle in [
2450            r#""working""#,
2451            r#""idle""#,
2452            r#""waiting_for_input""#,
2453            r#"agent: "pi""#,
2454            "stream_state: next",
2455            r#"send("end""#,
2456            r#""agent_settled""#,
2457            r#""ui_prompt_start""#,
2458            r#""session_shutdown""#,
2459        ] {
2460            assert!(
2461                PI_EXTENSION_TEMPLATE.contains(needle),
2462                "template lacks {needle}"
2463            );
2464        }
2465        // pi has no permission prompt, so the template must never claim one.
2466        assert!(!PI_EXTENSION_TEMPLATE.contains("waiting_for_permission"));
2467        // pi's chat input is already live at `session_start`, so the template
2468        // must report `idle` there, never the hook-only `starting` state, which
2469        // means "launched, chat surface not up yet" (reporting it once left every
2470        // pi session showing as busy from launch until the first prompt, #1905).
2471        assert!(!PI_EXTENSION_TEMPLATE.contains("\"starting\""));
2472        assert_eq!(
2473            PI_EXTENSION_TEMPLATE.matches(PI_SOCKET_PLACEHOLDER).count(),
2474            1
2475        );
2476    }
2477
2478    #[test]
2479    fn pi_is_present_via_its_agent_dir_or_a_pi_executable_on_path() {
2480        use std::os::unix::fs::PermissionsExt;
2481
2482        let tmp = tempfile::tempdir().unwrap();
2483        let agent = tmp.path().join("agent");
2484        let bin = tmp.path().join("bin");
2485        std::fs::create_dir_all(&bin).unwrap();
2486        let path = std::env::join_paths([bin.clone()]).unwrap();
2487
2488        // Neither: absent.
2489        assert!(!pi_is_present(&agent, Some(&path)));
2490        assert!(!pi_is_present(&agent, None));
2491
2492        // A non-executable `pi` does not count.
2493        let pi = bin.join("pi");
2494        std::fs::write(&pi, "#!/bin/sh\n").unwrap();
2495        std::fs::set_permissions(&pi, std::fs::Permissions::from_mode(0o600)).unwrap();
2496        assert!(!pi_is_present(&agent, Some(&path)));
2497
2498        // An executable one does.
2499        std::fs::set_permissions(&pi, std::fs::Permissions::from_mode(0o755)).unwrap();
2500        assert!(pi_is_present(&agent, Some(&path)));
2501
2502        // So does the agent directory alone.
2503        std::fs::create_dir_all(&agent).unwrap();
2504        assert!(pi_is_present(&agent, None));
2505    }
2506
2507    #[test]
2508    fn write_pi_extension_is_idempotent_and_updates_its_own_file() {
2509        let tmp = tempfile::tempdir().unwrap();
2510        let v1 = render_pi_extension(Path::new("/a.sock"));
2511        let (file, outcome) = write_pi_extension(tmp.path(), &v1).unwrap();
2512        assert_eq!(outcome, PiInstall::Written);
2513        assert_eq!(file, tmp.path().join("extensions").join(PI_EXTENSION_NAME));
2514        assert_eq!(std::fs::read_to_string(&file).unwrap(), v1);
2515
2516        assert_eq!(
2517            write_pi_extension(tmp.path(), &v1).unwrap().1,
2518            PiInstall::Unchanged
2519        );
2520
2521        // A new socket path (or a newer template) replaces our own file.
2522        let v2 = render_pi_extension(Path::new("/b.sock"));
2523        assert_eq!(
2524            write_pi_extension(tmp.path(), &v2).unwrap().1,
2525            PiInstall::Written
2526        );
2527        assert_eq!(std::fs::read_to_string(&file).unwrap(), v2);
2528    }
2529
2530    #[test]
2531    fn pi_extension_install_and_remove_leave_foreign_files_alone() {
2532        let tmp = tempfile::tempdir().unwrap();
2533        let dir = tmp.path().join("extensions");
2534        std::fs::create_dir_all(&dir).unwrap();
2535        let other = dir.join("someone-elses.ts");
2536        std::fs::write(&other, "export default () => {};\n").unwrap();
2537
2538        // A same-named file that is not ours is neither overwritten nor removed.
2539        let ours = dir.join(PI_EXTENSION_NAME);
2540        std::fs::write(&ours, "// hand-written\n").unwrap();
2541        let err = write_pi_extension(tmp.path(), &render_pi_extension(Path::new("/s")))
2542            .unwrap_err()
2543            .to_string();
2544        assert!(err.contains("not written by omni-dev"), "{err}");
2545        assert_eq!(remove_pi_extension(tmp.path()).unwrap(), None);
2546        assert_eq!(std::fs::read_to_string(&ours).unwrap(), "// hand-written\n");
2547
2548        // Ours is removed; the neighbour survives.
2549        std::fs::remove_file(&ours).unwrap();
2550        write_pi_extension(tmp.path(), &render_pi_extension(Path::new("/s"))).unwrap();
2551        assert_eq!(remove_pi_extension(tmp.path()).unwrap(), Some(ours.clone()));
2552        assert!(!ours.exists());
2553        assert!(other.exists());
2554
2555        // Removing again, or from a directory that never existed, is a no-op.
2556        assert_eq!(remove_pi_extension(tmp.path()).unwrap(), None);
2557        assert_eq!(remove_pi_extension(&tmp.path().join("nope")).unwrap(), None);
2558    }
2559
2560    #[test]
2561    fn hook_commands_parse_the_pi_agent_dir_flag() {
2562        match parse(&["install-hooks", "--pi-agent-dir", "/x/agent"]) {
2563            SessionsSubcommands::InstallHooks(cmd) => {
2564                assert_eq!(cmd.pi_agent_dir, Some(PathBuf::from("/x/agent")));
2565            }
2566            _ => panic!("expected install-hooks"), // omni-dev: coverage ignore-line reason="the arm above always matches: parse() above always parses an install-hooks argv into SessionsSubcommands::InstallHooks"
2567        }
2568        match parse(&["uninstall-hooks"]) {
2569            SessionsSubcommands::UninstallHooks(cmd) => assert_eq!(cmd.pi_agent_dir, None),
2570            _ => panic!("expected uninstall-hooks"), // omni-dev: coverage ignore-line reason="the arm above always matches: parse() above always parses an uninstall-hooks argv into SessionsSubcommands::UninstallHooks"
2571        }
2572    }
2573
2574    /// Env-isolation lock for tests that mutate `PI_CODING_AGENT_DIR`, `PATH`,
2575    /// or `HOME` to exercise [`default_pi_agent_dir`]'s and
2576    /// [`install_pi_extension`]'s env-driven branches. Aliases the crate-wide
2577    /// [`crate::test_support::HOME_ENV_MUTEX`] per its own doc comment
2578    /// (issue #1465), rather than a module-local `Mutex<()>`.
2579    static PI_ENV_LOCK: &std::sync::Mutex<()> = &crate::test_support::HOME_ENV_MUTEX;
2580
2581    fn snapshot_pi_env() -> [(&'static str, Option<std::ffi::OsString>); 3] {
2582        ["PI_CODING_AGENT_DIR", "PATH", "HOME"].map(|k| (k, std::env::var_os(k)))
2583    }
2584
2585    fn restore_pi_env(snap: [(&'static str, Option<std::ffi::OsString>); 3]) {
2586        for (k, v) in snap {
2587            match v {
2588                Some(val) => std::env::set_var(k, val),
2589                None => std::env::remove_var(k),
2590            }
2591        }
2592    }
2593
2594    #[test]
2595    fn default_pi_agent_dir_honors_the_env_var_and_falls_back_to_home() {
2596        let _guard = PI_ENV_LOCK
2597            .lock()
2598            .unwrap_or_else(std::sync::PoisonError::into_inner);
2599        let snap = snapshot_pi_env();
2600
2601        // An explicit, non-empty env var wins.
2602        std::env::set_var("PI_CODING_AGENT_DIR", "/explicit/agent");
2603        assert_eq!(
2604            default_pi_agent_dir().unwrap(),
2605            PathBuf::from("/explicit/agent")
2606        );
2607
2608        // An empty env var is treated as unset, falling back to `$HOME/.pi/agent`.
2609        std::env::set_var("PI_CODING_AGENT_DIR", "");
2610        std::env::set_var("HOME", "/home/tester");
2611        assert_eq!(
2612            default_pi_agent_dir().unwrap(),
2613            PathBuf::from("/home/tester/.pi/agent")
2614        );
2615
2616        // A wholly unset env var falls back the same way.
2617        std::env::remove_var("PI_CODING_AGENT_DIR");
2618        assert_eq!(
2619            default_pi_agent_dir().unwrap(),
2620            PathBuf::from("/home/tester/.pi/agent")
2621        );
2622
2623        restore_pi_env(snap);
2624    }
2625
2626    #[test]
2627    fn install_pi_extension_via_the_default_dir_skips_absent_and_writes_present() {
2628        let _guard = PI_ENV_LOCK
2629            .lock()
2630            .unwrap_or_else(std::sync::PoisonError::into_inner);
2631        let snap = snapshot_pi_env();
2632
2633        let tmp = tempfile::tempdir().unwrap();
2634        let agent_dir = tmp.path().join("nonexistent-agent");
2635        let empty_bin = tmp.path().join("bin");
2636        std::fs::create_dir_all(&empty_bin).unwrap();
2637
2638        std::env::set_var("PI_CODING_AGENT_DIR", &agent_dir);
2639        std::env::set_var("PATH", std::env::join_paths([&empty_bin]).unwrap());
2640
2641        // Neither the agent dir exists nor is `pi` on PATH: install skips,
2642        // via default_pi_agent_dir()'s env-var branch.
2643        install_pi_extension(None).unwrap();
2644        assert!(
2645            !agent_dir.exists(),
2646            "install must not create the agent dir when pi is absent"
2647        );
2648
2649        // Uninstall with no explicit dir also resolves via default_pi_agent_dir()
2650        // and is a no-op since nothing was ever installed there.
2651        uninstall_pi_extension(None).unwrap();
2652
2653        // Once the agent dir exists, pi counts as present: install falls
2654        // through to actually write the extension, still via the env-resolved
2655        // default dir (no explicit `--pi-agent-dir`).
2656        std::fs::create_dir_all(&agent_dir).unwrap();
2657        install_pi_extension(None).unwrap();
2658        let extension = agent_dir.join("extensions").join(PI_EXTENSION_NAME);
2659        assert!(extension.exists());
2660
2661        // Uninstall with no explicit dir removes what install just wrote.
2662        uninstall_pi_extension(None).unwrap();
2663        assert!(!extension.exists());
2664
2665        restore_pi_env(snap);
2666    }
2667
2668    #[test]
2669    fn agent_label_defaults_to_claude() {
2670        assert_eq!(agent_label(&json!({ "agent": "pi" })), "pi");
2671        assert_eq!(agent_label(&json!({ "agent": "codex" })), "codex");
2672        assert_eq!(agent_label(&json!({ "agent": "claude" })), "claude");
2673        assert_eq!(agent_label(&json!({})), "claude");
2674    }
2675
2676    // --- command execute() paths -------------------------------------------
2677
2678    #[test]
2679    fn install_then_uninstall_command_execute_round_trips() {
2680        let tmp = tempfile::tempdir().unwrap();
2681        let path = tmp.path().join("settings.json");
2682        // Install into a missing file: the hook block lands.
2683        InstallHooksCommand {
2684            settings: Some(path.clone()),
2685            pi_agent_dir: Some(tmp.path().join("pi-agent")),
2686            codex_home: Some(tmp.path().join("codex-home")),
2687        }
2688        .execute()
2689        .unwrap();
2690        assert!(read_settings(&path).unwrap()["hooks"]["Stop"].is_array());
2691        let extension = tmp
2692            .path()
2693            .join("pi-agent/extensions")
2694            .join(PI_EXTENSION_NAME);
2695        assert!(extension.exists());
2696        // A second install is the idempotent "no change" branch.
2697        InstallHooksCommand {
2698            settings: Some(path.clone()),
2699            pi_agent_dir: Some(tmp.path().join("pi-agent")),
2700            codex_home: Some(tmp.path().join("codex-home")),
2701        }
2702        .execute()
2703        .unwrap();
2704        // Uninstall removes our block, leaving an empty hooks object.
2705        UninstallHooksCommand {
2706            settings: Some(path.clone()),
2707            pi_agent_dir: Some(tmp.path().join("pi-agent")),
2708            codex_home: Some(tmp.path().join("codex-home")),
2709        }
2710        .execute()
2711        .unwrap();
2712        assert!(read_settings(&path).unwrap()["hooks"]
2713            .as_object()
2714            .unwrap()
2715            .is_empty());
2716        assert!(!extension.exists());
2717    }
2718
2719    // --- install-wrapper / uninstall-wrapper --------------------------------
2720
2721    /// An install/uninstall pair pointed entirely inside `tmp`, so nothing
2722    /// touches the developer's real data or config directories.
2723    fn wrapper_commands(tmp: &Path) -> (InstallWrapperCommand, UninstallWrapperCommand) {
2724        let settings = tmp.join("settings.json");
2725        let shim = tmp.join("bin").join("claude-wrap");
2726        (
2727            InstallWrapperCommand {
2728                settings: Some(settings.clone()),
2729                shim: Some(shim.clone()),
2730            },
2731            UninstallWrapperCommand {
2732                settings: Some(settings),
2733                shim: Some(shim),
2734            },
2735        )
2736    }
2737
2738    #[test]
2739    fn install_wrapper_writes_an_executable_shim_and_sets_the_setting() {
2740        use std::os::unix::fs::PermissionsExt;
2741
2742        let tmp = tempfile::tempdir().unwrap();
2743        let (install, _) = wrapper_commands(tmp.path());
2744        let shim = install.shim.clone().unwrap();
2745        let settings = install.settings.clone().unwrap();
2746        install.execute().unwrap();
2747
2748        // The shim is a one-line exec into `claude-wrap`, owner-executable, and
2749        // names an absolute binary so it does not depend on VS Code's PATH.
2750        let script = std::fs::read_to_string(&shim).unwrap();
2751        assert!(script.starts_with("#!/bin/sh\nexec \"/"), "{script}");
2752        assert!(script.contains(" claude-wrap -- \"$@\""), "{script}");
2753        let mode = std::fs::metadata(&shim).unwrap().permissions().mode();
2754        assert_eq!(mode & 0o777, 0o700);
2755
2756        assert_eq!(
2757            read_settings(&settings).unwrap()[WRAPPER_SETTING_KEY],
2758            json!(shim.display().to_string())
2759        );
2760    }
2761
2762    #[test]
2763    fn install_wrapper_is_idempotent_and_preserves_other_settings() {
2764        let tmp = tempfile::tempdir().unwrap();
2765        let (install, _) = wrapper_commands(tmp.path());
2766        let settings_path = install.settings.clone().unwrap();
2767        write_settings(&settings_path, &json!({ "editor.fontSize": 13 })).unwrap();
2768
2769        install.execute().unwrap();
2770        let (again, _) = wrapper_commands(tmp.path());
2771        again.execute().unwrap();
2772
2773        let settings = read_settings(&settings_path).unwrap();
2774        assert_eq!(settings["editor.fontSize"], json!(13));
2775        assert!(settings[WRAPPER_SETTING_KEY].is_string());
2776    }
2777
2778    #[test]
2779    fn uninstall_wrapper_clears_only_our_own_setting() {
2780        let tmp = tempfile::tempdir().unwrap();
2781        let (install, uninstall) = wrapper_commands(tmp.path());
2782        let settings_path = install.settings.clone().unwrap();
2783        let shim = install.shim.clone().unwrap();
2784        install.execute().unwrap();
2785        uninstall.execute().unwrap();
2786
2787        assert!(read_settings(&settings_path).unwrap()[WRAPPER_SETTING_KEY].is_null());
2788        assert!(!shim.exists());
2789
2790        // A setting someone else owns survives, and so does their file.
2791        write_settings(
2792            &settings_path,
2793            &json!({ WRAPPER_SETTING_KEY: "/opt/someone-elses-wrapper" }),
2794        )
2795        .unwrap();
2796        let (_, uninstall) = wrapper_commands(tmp.path());
2797        uninstall.execute().unwrap();
2798        assert_eq!(
2799            read_settings(&settings_path).unwrap()[WRAPPER_SETTING_KEY],
2800            json!("/opt/someone-elses-wrapper")
2801        );
2802    }
2803
2804    #[test]
2805    fn uninstall_wrapper_on_a_missing_settings_file_is_a_noop() {
2806        let tmp = tempfile::tempdir().unwrap();
2807        let (_, uninstall) = wrapper_commands(tmp.path());
2808        let settings = uninstall.settings.clone().unwrap();
2809        uninstall.execute().unwrap();
2810        assert!(!settings.exists());
2811    }
2812
2813    #[test]
2814    fn install_wrapper_on_a_jsonc_settings_file_explains_the_manual_step() {
2815        let tmp = tempfile::tempdir().unwrap();
2816        let (install, _) = wrapper_commands(tmp.path());
2817        let settings = install.settings.clone().unwrap();
2818        let shim = install.shim.clone().unwrap();
2819        // Comments are legal in VS Code settings and cannot be rewritten safely.
2820        std::fs::write(
2821            &settings,
2822            "{\n  // a comment\n  \"editor.fontSize\": 13\n}\n",
2823        )
2824        .unwrap();
2825
2826        let error = format!("{:#}", install.execute().unwrap_err());
2827        assert!(error.contains("not valid JSON"), "{error}");
2828        assert!(error.contains(WRAPPER_SETTING_KEY), "{error}");
2829        // The shim is written first, so the printed line is ready to paste.
2830        assert!(error.contains(&shim.display().to_string()), "{error}");
2831        assert!(shim.exists());
2832    }
2833
2834    #[test]
2835    fn install_wrapper_fails_when_the_shim_directory_cannot_be_made() {
2836        let tmp = tempfile::tempdir().unwrap();
2837        // A regular file where the shim's parent directory would have to go, so
2838        // creating it cannot succeed — a mistyped `--shim` in practice.
2839        let blocker = tmp.path().join("not-a-dir");
2840        std::fs::write(&blocker, b"").unwrap();
2841        let error = InstallWrapperCommand {
2842            settings: Some(tmp.path().join("settings.json")),
2843            shim: Some(blocker.join("claude-wrap")),
2844        }
2845        .execute()
2846        .unwrap_err();
2847        assert!(format!("{error:#}").contains("not-a-dir"), "{error:#}");
2848        // The settings file is left untouched when the shim cannot be written.
2849        assert!(!tmp.path().join("settings.json").exists());
2850    }
2851
2852    #[test]
2853    fn set_and_clear_wrapper_tolerate_a_non_object_settings_value() {
2854        // `read_settings` guarantees an object, but these degrade rather than
2855        // panic if a caller hands them anything else (the `merge_hooks` rule).
2856        let mut scalar = json!("not an object");
2857        assert!(!set_wrapper(&mut scalar, "/shim"));
2858        assert!(!clear_wrapper(&mut scalar, "/shim"));
2859        assert_eq!(scalar, json!("not an object"));
2860    }
2861
2862    #[test]
2863    fn the_wrapper_paths_default_outside_the_claude_config_dir() {
2864        // The shim lives beside the daemon socket; the setting lives in VS Code's
2865        // *config* directory, which is a different base on every platform.
2866        let shim = shim_path(None).unwrap();
2867        assert_eq!(shim.file_name().unwrap(), SHIM_NAME);
2868        assert_eq!(shim.parent().unwrap(), paths::runtime_dir().unwrap());
2869        let settings = vscode_settings_path(None).unwrap();
2870        assert!(
2871            settings.ends_with("Code/User/settings.json"),
2872            "{settings:?}"
2873        );
2874    }
2875
2876    #[test]
2877    fn uninstall_command_on_a_missing_file_is_a_noop() {
2878        let tmp = tempfile::tempdir().unwrap();
2879        let path = tmp.path().join("does-not-exist.json");
2880        // The no-file branch: nothing to remove, still Ok, and no file created.
2881        UninstallHooksCommand {
2882            settings: Some(path.clone()),
2883            pi_agent_dir: Some(tmp.path().join("pi-agent")),
2884            codex_home: Some(tmp.path().join("codex-home")),
2885        }
2886        .execute()
2887        .unwrap();
2888        assert!(!path.exists());
2889    }
2890
2891    #[tokio::test]
2892    async fn sessions_command_dispatches_to_subcommands() {
2893        // Cover the outer dispatch for the two file-backed arms (no socket/stdin).
2894        let tmp = tempfile::tempdir().unwrap();
2895        let path = tmp.path().join("settings.json");
2896        SessionsCommand {
2897            command: SessionsSubcommands::InstallHooks(InstallHooksCommand {
2898                settings: Some(path.clone()),
2899                pi_agent_dir: Some(tmp.path().join("pi-agent")),
2900                codex_home: Some(tmp.path().join("codex-home")),
2901            }),
2902        }
2903        .execute()
2904        .await
2905        .unwrap();
2906        SessionsCommand {
2907            command: SessionsSubcommands::UninstallHooks(UninstallHooksCommand {
2908                settings: Some(path.clone()),
2909                pi_agent_dir: Some(tmp.path().join("pi-agent")),
2910                codex_home: Some(tmp.path().join("codex-home")),
2911            }),
2912        }
2913        .execute()
2914        .await
2915        .unwrap();
2916        let (install, uninstall) = wrapper_commands(tmp.path());
2917        SessionsCommand {
2918            command: SessionsSubcommands::InstallWrapper(install),
2919        }
2920        .execute()
2921        .await
2922        .unwrap();
2923        SessionsCommand {
2924            command: SessionsSubcommands::UninstallWrapper(uninstall),
2925        }
2926        .execute()
2927        .await
2928        .unwrap();
2929    }
2930
2931    #[tokio::test]
2932    async fn hook_report_is_silent_when_the_daemon_is_down() {
2933        // A valid hook event but no daemon at the socket: the send fails and is
2934        // swallowed (never panics, never errors).
2935        let tmp = tempfile::tempdir_in("/tmp").unwrap();
2936        let sock = tmp.path().join("nope.sock");
2937        let cmd = HookCommand {
2938            socket: Some(sock),
2939            agent: HookAgent::Claude,
2940        };
2941        cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#, None)
2942            .await;
2943        // Unmappable input returns before any socket work.
2944        cmd.report("not json", None).await;
2945        cmd.report(r#"{"hook_event_name":"Stop"}"#, None).await; // no session_id → no op
2946    }
2947
2948    /// Spawns a minimal fake daemon on a short-path Unix socket that answers one
2949    /// request with `reply`. Returns the temp dir (kept alive), the socket path,
2950    /// and the server task.
2951    fn fake_daemon(reply: Value) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
2952        use futures::{SinkExt, StreamExt};
2953        use tokio::net::UnixListener;
2954        use tokio_util::codec::{Framed, LinesCodec};
2955
2956        let dir = tempfile::tempdir_in("/tmp").unwrap();
2957        let sock = dir.path().join("d.sock");
2958        let listener = UnixListener::bind(&sock).unwrap();
2959        let server = tokio::spawn(async move {
2960            let (stream, _) = listener.accept().await.unwrap();
2961            let mut framed = Framed::new(stream, LinesCodec::new());
2962            let _req = framed.next().await.unwrap().unwrap();
2963            framed
2964                .send(serde_json::to_string(&reply).unwrap())
2965                .await
2966                .unwrap();
2967        });
2968        (dir, sock, server)
2969    }
2970
2971    /// Like [`fake_daemon`] but **returns the request envelope it received** so a
2972    /// test can assert the exact op/payload wire shape the client sent.
2973    fn fake_daemon_capture(
2974        reply: Value,
2975    ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<Value>) {
2976        use futures::{SinkExt, StreamExt};
2977        use tokio::net::UnixListener;
2978        use tokio_util::codec::{Framed, LinesCodec};
2979
2980        let dir = tempfile::tempdir_in("/tmp").unwrap();
2981        let sock = dir.path().join("d.sock");
2982        let listener = UnixListener::bind(&sock).unwrap();
2983        let server = tokio::spawn(async move {
2984            let (stream, _) = listener.accept().await.unwrap();
2985            let mut framed = Framed::new(stream, LinesCodec::new());
2986            let req = framed.next().await.unwrap().unwrap();
2987            framed
2988                .send(serde_json::to_string(&reply).unwrap())
2989                .await
2990                .unwrap();
2991            serde_json::from_str::<Value>(&req).unwrap()
2992        });
2993        (dir, sock, server)
2994    }
2995
2996    #[tokio::test]
2997    async fn list_command_execute_renders_from_a_socket() {
2998        let payload = json!({
2999            "ok": true,
3000            "payload": { "sessions": [{
3001                "session_id": "s1", "state": "working",
3002                "source": { "kind": "terminal" }, "repo": "omni-dev",
3003                "cwd": "/home/me/omni-dev", "last_seen": "2000-01-01T00:00:00Z"
3004            }]}
3005        });
3006        // Table output, dispatched through the outer `SessionsCommand` so the
3007        // `List` arm of the dispatch is covered too.
3008        let (_dir, sock, server) = fake_daemon(payload.clone());
3009        SessionsCommand {
3010            command: SessionsSubcommands::List(ListCommand {
3011                socket: Some(sock),
3012                output: TableOrJson::Table,
3013            }),
3014        }
3015        .execute()
3016        .await
3017        .unwrap();
3018        server.await.unwrap();
3019
3020        // JSON output goes through the other branch of the renderer.
3021        let (_dir, sock, server) = fake_daemon(payload);
3022        ListCommand {
3023            socket: Some(sock),
3024            output: TableOrJson::Json,
3025        }
3026        .execute()
3027        .await
3028        .unwrap();
3029        server.await.unwrap();
3030    }
3031
3032    #[test]
3033    fn merge_and_remove_hooks_tolerate_malformed_shapes() {
3034        let cmd = "cmd sessions hook";
3035        // Non-object settings: both are no-ops rather than panics.
3036        assert_eq!(merge_hooks(&mut json!([]), cmd, HOOK_EVENTS), 0);
3037        assert_eq!(remove_hooks(&mut json!([]), cmd), 0);
3038        // `hooks` present but not an object → merge leaves it alone.
3039        assert_eq!(merge_hooks(&mut json!({ "hooks": 5 }), cmd, HOOK_EVENTS), 0);
3040        // No `hooks` key → remove has nothing to do.
3041        assert_eq!(remove_hooks(&mut json!({}), cmd), 0);
3042        // A per-event value that is not an array is skipped, not indexed.
3043        assert_eq!(
3044            merge_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd, HOOK_EVENTS),
3045            HOOK_EVENTS.len() - 1
3046        );
3047        assert_eq!(remove_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd), 0);
3048    }
3049
3050    // --- #1361 typed window feed commands -----------------------------------
3051
3052    #[test]
3053    fn window_subcommands_route_and_require_key() {
3054        assert!(matches!(
3055            parse(&["window", "--key", "w1"]),
3056            SessionsSubcommands::Window(_)
3057        ));
3058        assert!(matches!(
3059            parse(&["window-unregister", "--key", "w1"]),
3060            SessionsSubcommands::WindowUnregister(_)
3061        ));
3062        // `--key` is required for both.
3063        assert!(WindowCommand::try_parse_from(["window"]).is_err());
3064        assert!(WindowUnregisterCommand::try_parse_from(["window-unregister"]).is_err());
3065    }
3066
3067    #[test]
3068    fn window_parses_counts_and_folders() {
3069        let cmd = WindowCommand::try_parse_from([
3070            "window",
3071            "--key",
3072            "w1",
3073            "--folder",
3074            "/a",
3075            "--folder",
3076            "/b",
3077            "--tabs",
3078            "2",
3079            "--terminals",
3080            "3",
3081        ])
3082        .unwrap();
3083        assert_eq!(cmd.key, "w1");
3084        assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
3085        assert_eq!(cmd.tabs, 2);
3086        assert_eq!(cmd.terminals, 3);
3087        // Counts default to zero.
3088        let cmd = WindowCommand::try_parse_from(["window", "--key", "w1"]).unwrap();
3089        assert_eq!(cmd.tabs, 0);
3090        assert_eq!(cmd.terminals, 0);
3091    }
3092
3093    #[tokio::test]
3094    async fn window_and_window_unregister_send_their_ops() {
3095        let (_dir, sock, server) =
3096            fake_daemon_capture(json!({ "ok": true, "payload": { "ok": true } }));
3097        // Routed through the outer `SessionsCommand::execute` so the `Window`
3098        // dispatch arm is covered too.
3099        SessionsCommand {
3100            command: SessionsSubcommands::Window(WindowCommand {
3101                key: "w1".to_string(),
3102                folders: vec![PathBuf::from("/a")],
3103                tabs: 1,
3104                terminals: 0,
3105                socket: Some(sock),
3106            }),
3107        }
3108        .execute()
3109        .await
3110        .unwrap();
3111        // The WindowReport wire shape: op + every field the daemon reads.
3112        let req = server.await.unwrap();
3113        assert_eq!(req["op"], "window");
3114        assert_eq!(req["payload"]["key"], json!("w1"));
3115        assert_eq!(req["payload"]["folders"], json!(["/a"]));
3116        assert_eq!(req["payload"]["tabs"], json!(1));
3117        assert_eq!(req["payload"]["terminals"], json!(0));
3118
3119        // `window-unregister` replies `{removed}` (not `{ok}`); the client reads it.
3120        // Also routed through the wrapper to cover the `WindowUnregister` arm.
3121        let (_dir, sock, server) =
3122            fake_daemon_capture(json!({ "ok": true, "payload": { "removed": true } }));
3123        SessionsCommand {
3124            command: SessionsSubcommands::WindowUnregister(WindowUnregisterCommand {
3125                key: "w1".to_string(),
3126                socket: Some(sock),
3127            }),
3128        }
3129        .execute()
3130        .await
3131        .unwrap();
3132        let req = server.await.unwrap();
3133        assert_eq!(req["op"], "window-unregister");
3134        assert_eq!(req["payload"]["key"], json!("w1"));
3135    }
3136}