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::journal::{self, JournalRecord};
44use crate::sessions::{Agent, EventStamp, NotificationKind, ObserveRequest, SessionEvent};
45
46/// The `sessions` service routing key on the daemon control socket.
47const SERVICE: &str = "sessions";
48
49/// How long the fire-and-forget `hook` sink waits for the daemon before giving
50/// up — short, so a slow or wedged daemon never stalls a Claude turn.
51const HOOK_TIMEOUT: Duration = Duration::from_secs(2);
52
53/// Sessions: see the Claude Code sessions running across every terminal and
54/// VS Code window, kept live by the daemon.
55#[derive(Parser)]
56pub struct SessionsCommand {
57    /// The sessions subcommand to execute.
58    #[command(subcommand)]
59    pub command: SessionsSubcommands,
60}
61
62/// Sessions subcommands.
63#[derive(Subcommand)]
64pub enum SessionsSubcommands {
65    /// List the Claude Code, Codex and pi.dev sessions currently running across
66    /// all windows.
67    List(ListCommand),
68    /// Claude Code / Codex hook sink: read a hook event on stdin and report it to
69    /// the daemon (run by the agent, not by hand).
70    Hook(HookCommand),
71    /// Install the Claude Code hooks that feed the sessions tracker into
72    /// `~/.claude/settings.json`, plus the pi.dev extension when pi is installed
73    /// and the Codex hooks when Codex is (idempotent).
74    InstallHooks(InstallHooksCommand),
75    /// Remove the sessions-tracker hooks from `~/.claude/settings.json`, the
76    /// pi.dev extension, and the Codex hooks.
77    UninstallHooks(UninstallHooksCommand),
78    /// Install the `claude-wrap` shim and point VS Code's Claude extension at it
79    /// (idempotent).
80    InstallWrapper(InstallWrapperCommand),
81    /// Remove the `claude-wrap` shim and VS Code's wrapper setting.
82    UninstallWrapper(UninstallWrapperCommand),
83    /// Report a window's Claude tab/terminal counts (companion feed op).
84    Window(WindowCommand),
85    /// Remove a window's embedding report (companion feed op).
86    WindowUnregister(WindowUnregisterCommand),
87}
88
89impl SessionsCommand {
90    /// Executes the sessions command.
91    pub async fn execute(self) -> Result<()> {
92        match self.command {
93            SessionsSubcommands::List(cmd) => cmd.execute().await,
94            SessionsSubcommands::Hook(cmd) => cmd.execute().await,
95            SessionsSubcommands::InstallHooks(cmd) => cmd.execute(),
96            SessionsSubcommands::UninstallHooks(cmd) => cmd.execute(),
97            SessionsSubcommands::InstallWrapper(cmd) => cmd.execute(),
98            SessionsSubcommands::UninstallWrapper(cmd) => cmd.execute(),
99            SessionsSubcommands::Window(cmd) => cmd.execute().await,
100            SessionsSubcommands::WindowUnregister(cmd) => cmd.execute().await,
101        }
102    }
103}
104
105// --- list --------------------------------------------------------------------
106
107/// Lists the live cross-window set of running Claude sessions.
108#[derive(Parser)]
109pub struct ListCommand {
110    /// Control-socket path. Defaults to the per-user runtime location.
111    #[arg(long, value_name = "PATH")]
112    pub socket: Option<PathBuf>,
113    /// Output format.
114    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
115    pub output: TableOrJson,
116}
117
118impl ListCommand {
119    /// Executes the list command.
120    pub async fn execute(self) -> Result<()> {
121        let socket = server::resolve_socket(self.socket)?;
122        let result = call(&socket, "list", Value::Null).await?;
123        match self.output {
124            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
125            TableOrJson::Table => println!("{}", render_sessions(&result)),
126        }
127        Ok(())
128    }
129}
130
131// --- window feed -------------------------------------------------------------
132
133/// Reports a window's Claude embedding counts (the companion `window` feed op).
134///
135/// Exposed as a typed command so scripted/headless reporters and integration
136/// tests can drive the sessions registry the way the VS Code companion does.
137/// Mirrors `WindowReport`.
138#[derive(Parser)]
139pub struct WindowCommand {
140    /// Stable per-window identity (the companion generates a per-activate UUID).
141    #[arg(long, value_name = "KEY")]
142    pub key: String,
143    /// A workspace-folder path (repeatable) — used to join sessions by `cwd`.
144    #[arg(long = "folder", value_name = "PATH")]
145    pub folders: Vec<PathBuf>,
146    /// How many Claude editor tabs the window has.
147    #[arg(long, value_name = "N", default_value_t = 0)]
148    pub tabs: usize,
149    /// How many Claude Code integrated terminals the window has.
150    #[arg(long, value_name = "N", default_value_t = 0)]
151    pub terminals: usize,
152    /// Control-socket path. Defaults to the per-user runtime location.
153    #[arg(long, value_name = "PATH")]
154    pub socket: Option<PathBuf>,
155}
156
157impl WindowCommand {
158    /// Executes the window command.
159    pub async fn execute(self) -> Result<()> {
160        let socket = server::resolve_socket(self.socket)?;
161        let payload = json!({
162            "key": self.key,
163            "folders": self.folders,
164            "tabs": self.tabs,
165            "terminals": self.terminals,
166        });
167        call(&socket, "window", payload).await?;
168        println!("Reported window {}", self.key);
169        Ok(())
170    }
171}
172
173/// Removes a window's embedding report — the companion `window-unregister` feed
174/// op made typed. Prints whether an entry was actually removed.
175#[derive(Parser)]
176pub struct WindowUnregisterCommand {
177    /// The window key to unregister.
178    #[arg(long, value_name = "KEY")]
179    pub key: String,
180    /// Control-socket path. Defaults to the per-user runtime location.
181    #[arg(long, value_name = "PATH")]
182    pub socket: Option<PathBuf>,
183}
184
185impl WindowUnregisterCommand {
186    /// Executes the window-unregister command.
187    pub async fn execute(self) -> Result<()> {
188        let socket = server::resolve_socket(self.socket)?;
189        let reply = call(&socket, "window-unregister", json!({ "key": self.key })).await?;
190        let removed = reply
191            .get("removed")
192            .and_then(Value::as_bool)
193            .unwrap_or(false);
194        println!("removed: {removed}");
195        Ok(())
196    }
197}
198
199// --- hook --------------------------------------------------------------------
200
201/// The Claude Code / Codex hook sink: reads one hook event's JSON on stdin and
202/// reports it to the daemon. Fire-and-forget and infallible-by-design.
203#[derive(Parser)]
204pub struct HookCommand {
205    /// Control-socket path. Defaults to the per-user runtime location.
206    #[arg(long, value_name = "PATH")]
207    pub socket: Option<PathBuf>,
208
209    /// Which agent runs this hook. The payloads share field names, so the agent
210    /// cannot be inferred from them; it selects the event mapping and tags the
211    /// session.
212    #[arg(long, value_enum, default_value_t = HookAgent::Claude)]
213    pub agent: HookAgent,
214}
215
216/// The agents whose hooks run [`HookCommand`]. pi.dev reports through its own
217/// extension, so it has no hook variant.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
219pub enum HookAgent {
220    /// Claude Code (`~/.claude/settings.json`).
221    Claude,
222    /// OpenAI Codex (`$CODEX_HOME/hooks.json`).
223    Codex,
224}
225
226impl HookAgent {
227    /// The registry tag sessions reported by this agent's hooks carry.
228    fn agent(self) -> Agent {
229        match self {
230            Self::Claude => Agent::Claude,
231            Self::Codex => Agent::Codex,
232        }
233    }
234}
235
236impl HookCommand {
237    /// Executes the hook sink. Always returns `Ok(())` (exit 0): a hook must
238    /// never block or fail a Claude turn, so every error — no daemon, bad JSON,
239    /// an unknown event — is swallowed after a best-effort report.
240    pub async fn execute(self) -> Result<()> {
241        // Read before stdin, while the agent that spawned this hook is surely
242        // still alive to be our parent.
243        let pid = agent_pid(std::os::unix::process::parent_id());
244        if let Some(input) = self.read_input(std::io::stdin()) {
245            self.report(&input, pid).await;
246        }
247        Ok(())
248    }
249
250    /// Reads the whole hook payload from `reader`, or `None` — after a debug
251    /// line — when it cannot be read (an I/O error, or bytes that are not
252    /// UTF-8). Split out so tests can feed it a reader that fails.
253    fn read_input(&self, mut reader: impl Read) -> Option<String> {
254        let mut input = String::new();
255        if reader.read_to_string(&mut input).is_err() {
256            tracing::debug!(agent = ?self.agent, outcome = "stdin_read_failed", "session_hook_skipped");
257            return None;
258        }
259        Some(input)
260    }
261
262    /// Parses the hook JSON, maps it to an op, and best-effort sends it. Split
263    /// out so tests can exercise the send path against a fake socket.
264    async fn report(&self, input: &str, pid: Option<u32>) {
265        let Ok(hook) = serde_json::from_str::<HookPayload>(input) else {
266            tracing::debug!(agent = ?self.agent, outcome = "invalid_json", "session_hook_skipped");
267            return;
268        };
269        let Some(hook_op) = hook.to_hook_op(self.agent, pid) else {
270            return;
271        };
272        let Ok(socket) = server::resolve_socket(self.socket.clone()) else {
273            // omni-dev: coverage ignore reason="resolve_socket fails only when the platform has no data directory to put the default socket in (no resolvable home), which a test cannot reproduce on macOS or Linux; the sink is fail-open by design, so this is the same silent return as every other skipped hook"
274            tracing::debug!(agent = ?self.agent, outcome = "socket_resolution_failed", "session_hook_skipped");
275            return;
276            // omni-dev: coverage end
277        };
278        // Stamped once, so the journal record and the POST name the same event and
279        // the daemon can drop whichever copy it sees second (#2108).
280        let stamp = journal::new_stamp();
281        self.journal(&socket, &hook_op, &stamp);
282        let Some((op, payload)) = hook_op.wire(self.agent, Some(&stamp)) else {
283            return; // omni-dev: coverage ignore-line reason="wire returns None only through the serialization failure ignored in wire() itself (a non-UTF-8 cwd, which a hook payload cannot carry), so this arm is unreachable for the same reason"
284        };
285        let env = DaemonEnvelope::service(SERVICE, op, payload);
286        let outcome =
287            match tokio::time::timeout(HOOK_TIMEOUT, DaemonClient::new(&socket).request(env)).await
288            {
289                Err(_) => "timeout",
290                Ok(Err(_)) => "transport_failed",
291                Ok(Ok(reply)) if !reply.ok => "daemon_rejected",
292                Ok(Ok(_)) => "delivered",
293            };
294        tracing::debug!(agent = ?self.agent, session_id = ?hook.session_id, ?pid, op, outcome, "session_hook_report");
295    }
296
297    /// Appends the event to its session's journal beside the socket, so the
298    /// daemon can recover it if the POST is lost (#2108). Fail-open exactly like
299    /// the POST: an error is logged — never with the payload — and swallowed, so
300    /// a journal that cannot be written costs one event's durability, never a
301    /// turn. Written *before* the POST, so a daemon that dies mid-delivery still
302    /// finds the event on disk.
303    fn journal(&self, socket: &Path, op: &HookOp, stamp: &EventStamp) {
304        let dir = paths::sessions_journal_dir_for_socket(socket);
305        match journal::append(&dir, &op.record(stamp)) {
306            Ok(()) => {
307                tracing::debug!(agent = ?self.agent, outcome = "journaled", "session_hook_journal");
308            }
309            Err(error) => {
310                tracing::debug!(agent = ?self.agent, outcome = "journal_failed", %error, "session_hook_journal");
311            }
312        }
313    }
314}
315
316/// The pid of the agent process that ran this hook, from the sink's parent pid.
317///
318/// The agent runs the installed hook command directly (a simple command, so no
319/// per-hook shell sits in between), making the parent the `claude` / `codex`
320/// process itself. The pid tells a resumed session's new process from the old
321/// one it replaced (#1948). Pid 1 means the hook was orphaned — its agent already
322/// exited and init/launchd adopted it — so it identifies nothing.
323fn agent_pid(parent: u32) -> Option<u32> {
324    (parent > 1).then_some(parent)
325}
326
327/// The subset of a Claude Code hook payload the sink reads. Every field is
328/// optional and defaulted, so an unexpected or future payload shape never fails
329/// to parse (the sink then simply produces no op). See the hooks docs.
330#[derive(Debug, Clone, Default, Deserialize)]
331struct HookPayload {
332    /// Subagent hooks share the parent session id.
333    #[serde(default)]
334    agent_id: Option<String>,
335    #[serde(default)]
336    session_id: Option<String>,
337    #[serde(default)]
338    transcript_path: Option<PathBuf>,
339    #[serde(default)]
340    cwd: Option<PathBuf>,
341    #[serde(default)]
342    hook_event_name: Option<String>,
343    /// Present on `Notification` events — the message classified into a
344    /// [`NotificationKind`] when `notification_type` is absent or unrecognised.
345    #[serde(default)]
346    message: Option<String>,
347    /// Present on `Notification` events — Claude Code's own classification
348    /// (`permission_prompt`, `idle_prompt`, …), preferred over `message`.
349    #[serde(default)]
350    notification_type: Option<String>,
351    /// Present on `SessionEnd` — why the session ended.
352    #[serde(default)]
353    reason: Option<String>,
354    /// Present on `SessionStart` — why it fired (`startup`, `resume`, `clear`,
355    /// `compact`). A `compact` start is not a new session.
356    #[serde(default)]
357    source: Option<String>,
358    /// Present on tool events. Codex's clarifying-question UI is the
359    /// `request_user_input` tool, so its `PreToolUse` is a wait for input.
360    #[serde(default)]
361    tool_name: Option<String>,
362    /// Best-effort model id, when a payload carries one.
363    #[serde(default)]
364    model: Option<String>,
365}
366
367/// What the sink reports for one hook event: the typed value the socket POST and
368/// the journal record are both made from, so the two cannot drift (#2108).
369#[derive(Debug, Clone)]
370enum HookOp {
371    /// An `observe` sighting.
372    Observe(ObserveRequest),
373    /// A `SessionEnd`, which the daemon's `end` op takes without an agent; the
374    /// agent rides only into the journal record, to name its directory.
375    End {
376        agent: Agent,
377        session_id: String,
378        reason: Option<String>,
379        pid: Option<u32>,
380    },
381}
382
383impl HookOp {
384    /// The journal record for this event.
385    fn record(&self, stamp: &EventStamp) -> JournalRecord {
386        match self {
387            Self::Observe(req) => JournalRecord::observe(req, stamp),
388            Self::End {
389                agent,
390                session_id,
391                reason,
392                pid,
393            } => JournalRecord::end(*agent, session_id, reason.as_deref(), *pid, stamp),
394        }
395    }
396
397    /// The `(op, payload)` for the daemon, carrying `stamp` as an additive
398    /// top-level `stamp` object when there is one (an older daemon ignores it).
399    fn wire(&self, agent: HookAgent, stamp: Option<&EventStamp>) -> Option<(&'static str, Value)> {
400        let (op, mut payload) = match self {
401            Self::Observe(request) => {
402                let Ok(payload) = serde_json::to_value(request) else {
403                    // omni-dev: coverage ignore reason="to_value on an ObserveRequest fails only for a non-UTF-8 cwd, and the hook payload's cwd is deserialized from a JSON string, so it is always UTF-8; the arm exists so a future non-string field cannot silently drop the report"
404                    tracing::debug!(
405                        ?agent,
406                        outcome = "serialization_failed",
407                        "session_hook_skipped"
408                    );
409                    return None;
410                    // omni-dev: coverage end
411                };
412                ("observe", payload)
413            }
414            Self::End {
415                session_id,
416                reason,
417                pid,
418                ..
419            } => {
420                let mut payload = json!({ "session_id": session_id });
421                if let Some(reason) = reason {
422                    payload["reason"] = Value::String(reason.clone());
423                }
424                if let Some(pid) = pid {
425                    payload["pid"] = Value::from(*pid);
426                }
427                ("end", payload)
428            }
429        };
430        if let Some(stamp) = stamp {
431            payload["stamp"] = json!({ "ts": stamp.ts, "seq": stamp.seq });
432        }
433        Some((op, payload))
434    }
435}
436
437impl HookPayload {
438    /// Maps this hook payload to the [`HookOp`] to report, or `None` when it
439    /// carries no `session_id` or names an event the tracker ignores. `pid` (the
440    /// [`agent_pid`]) rides along on both ops when known — it is also the seed
441    /// for pid-based liveness (#1916), whose identity-token reading is entirely
442    /// the daemon's own [`pid_watcher`](crate::sessions::pid_watcher) work, never
443    /// this sink's.
444    fn to_hook_op(&self, agent: HookAgent, pid: Option<u32>) -> Option<HookOp> {
445        let Some(session_id) = self.session_id.clone().filter(|s| !s.trim().is_empty()) else {
446            tracing::debug!(
447                ?agent,
448                outcome = "missing_session_id",
449                "session_hook_skipped"
450            );
451            return None;
452        };
453        let Some(event_name) = self.hook_event_name.as_deref() else {
454            tracing::debug!(?agent, outcome = "missing_event", "session_hook_skipped");
455            return None;
456        };
457        if event_name == "SessionEnd" {
458            return Some(HookOp::End {
459                agent: agent.agent(),
460                session_id,
461                reason: self.reason.clone().or_else(|| self.message.clone()),
462                pid,
463            });
464        }
465        let mapped = match agent {
466            HookAgent::Claude => session_event_for(
467                event_name,
468                self.source.as_deref(),
469                self.notification_type.as_deref(),
470                self.message.as_deref(),
471            ),
472            HookAgent::Codex => codex_session_event_for(
473                event_name,
474                self.source.as_deref(),
475                self.tool_name.as_deref(),
476            ),
477        };
478        let Some(mut event) = mapped else {
479            tracing::debug!(?agent, outcome = "unmapped_event", "session_hook_skipped");
480            return None;
481        };
482        let agent_id = (agent == HookAgent::Claude)
483            .then(|| self.agent_id.clone())
484            .flatten()
485            .filter(|id| !id.trim().is_empty());
486        if agent_id.is_some() && event_name == "SubagentStop" {
487            event = SessionEvent::PostToolUse;
488        }
489        Some(HookOp::Observe(ObserveRequest {
490            agent_id,
491            agent: agent.agent(),
492            session_id,
493            cwd: self.cwd.clone(),
494            transcript_path: self.transcript_path.clone(),
495            event,
496            repo: None,
497            model: self.model.clone(),
498            pid,
499        }))
500    }
501
502    /// The unstamped `(op, payload)` for the daemon — [`to_hook_op`] and
503    /// [`HookOp::wire`] together, as the sink sent it before the journal.
504    ///
505    /// [`to_hook_op`]: Self::to_hook_op
506    #[cfg(test)]
507    fn to_op(&self, agent: HookAgent, pid: Option<u32>) -> Option<(&'static str, Value)> {
508        self.to_hook_op(agent, pid)?.wire(agent, None)
509    }
510}
511
512/// Maps a Claude Code hook event name to the [`SessionEvent`] it implies, or
513/// `None` for an event the tracker does not act on. `SessionEnd` is handled
514/// separately (it maps to the `end` op, not `observe`).
515///
516/// Everything lands on the **existing** events, so the engine's state machine
517/// handles parent hooks as before (the Codex precedent, ADR-0087). Subagent
518/// identity travels separately so the registry can preserve parent activity
519/// and release only the wait belonging to a completing subagent (#1926):
520///
521/// - `PermissionRequest` is the dedicated "a prompt is about to be shown"
522///   event, so it sets the permission wait without matching `Notification`
523///   text. The sink must never answer it — a `PermissionRequest` hook that
524///   prints a decision approves or denies on the user's behalf — and it cannot:
525///   [`HookCommand`] writes nothing to stdout;
526/// - `Elicitation` (an MCP server asking the user a question) is a wait for
527///   input, released by its `ElicitationResult`;
528/// - `PostToolUseFailure` and `PermissionDenied` (auto mode refused a call and
529///   the turn goes on) are post-tool signals: `working`;
530/// - `StopFailure` ends a turn on an API error, which fires no `Stop`;
531/// - `SubagentStart` means a subagent is being spawned inside a turn: `working`;
532/// - `SubagentStop`, `PreCompact` and `PostCompact` can all fire while the
533///   session is idle — a background subagent finishing after the turn's `Stop`,
534///   a manual `/compact` — and nothing after them would release a `working`, so
535///   they are the state-preserving
536///   [`TranscriptDiscovered`](SessionEvent::TranscriptDiscovered) sighting,
537///   which refreshes liveness alone;
538/// - `SessionStart` with `source: "compact"` follows a compaction, which can run
539///   mid-turn, and is not a new session. Since growth no longer moves a
540///   `starting` session (#1946), mapping it to `SessionStart` would hold a
541///   working session at `starting` until its next tool hook, so it is the same
542///   state-preserving sighting. Every other source (`startup`, `resume`,
543///   `clear`) is a real start.
544///
545/// `PostToolBatch` is deliberately not installed: every tool in a batch has
546/// already reported its own `PostToolUse` / `PostToolUseFailure`, so it would be
547/// a process spawn per batch carrying no new state.
548///
549/// No event fires when the user answers a permission prompt, either way; the
550/// wait is released by whatever hook comes next.
551fn session_event_for(
552    event_name: &str,
553    source: Option<&str>,
554    notification_type: Option<&str>,
555    message: Option<&str>,
556) -> Option<SessionEvent> {
557    Some(match event_name {
558        "SessionStart" if source == Some("compact") => SessionEvent::TranscriptDiscovered,
559        "SessionStart" => SessionEvent::SessionStart,
560        "UserPromptSubmit" => SessionEvent::UserPromptSubmit,
561        "PreToolUse" => SessionEvent::PreToolUse,
562        "PostToolUse" | "PostToolUseFailure" | "PermissionDenied" | "ElicitationResult"
563        | "SubagentStart" => SessionEvent::PostToolUse,
564        "SubagentStop" | "PreCompact" | "PostCompact" => SessionEvent::TranscriptDiscovered,
565        "Stop" | "StopFailure" => SessionEvent::Stop,
566        "PermissionRequest" => SessionEvent::Notification(NotificationKind::PermissionPrompt),
567        "Elicitation" => SessionEvent::Notification(NotificationKind::AgentNeedsInput),
568        "Notification" => {
569            let kind = classify_notification(notification_type, message);
570            let has_notification_type = notification_type.is_some();
571            let has_message = message.is_some();
572            tracing::debug!(
573                ?kind,
574                has_notification_type,
575                has_message,
576                "session_notification_classified"
577            );
578            SessionEvent::Notification(kind)
579        }
580        _ => return None,
581    })
582}
583
584/// Maps a Codex hook event name to the [`SessionEvent`] it implies, or `None` for
585/// an event the tracker does not act on (ADR-0087). Codex's payload uses Claude
586/// Code's field names, but adds events Claude lacks, and all of them land on the
587/// **existing** events so the engine's state machine is unchanged:
588///
589/// - `PermissionRequest` is Codex's dedicated "waiting on an approval" event, so
590///   it is the reliable permission signal (its resolution is inferred from the
591///   next `PostToolUse`, or from the turn ending);
592/// - `PreToolUse` of the `request_user_input` tool is the clarifying-question UI,
593///   a wait for input until its `PostToolUse`;
594/// - `Interrupt` (Esc / Ctrl-C mid-turn, or a declined approval in the TUI) ends
595///   the turn without a `Stop`, so it is one;
596/// - `SubagentStart` carries the parent session's id and is caused by the
597///   parent's spawn tool call, so it can only occur inside a turn: `working`;
598/// - `SubagentStop`, `PreCompact` and `PostCompact` can fire while the session
599///   is idle — `/compact` is captured as `Stop · PreCompact · PostCompact`, and
600///   a subagent that is not joined can finish after the turn's `Stop` — and
601///   nothing after them would release a `working`, so, as for Claude (#1925),
602///   they are the state-preserving
603///   [`TranscriptDiscovered`](SessionEvent::TranscriptDiscovered) sighting.
604///   Liveness needs no heartbeat here: Codex's thread lock keeps an idle
605///   session alive (#1909);
606/// - a `SessionStart` with `source: "compact"` is not a new session, so, as for
607///   Claude, it preserves the state rather than resetting it to `starting`
608///   (#1946).
609///
610/// `SessionEnd` is handled by the caller, as for Claude.
611fn codex_session_event_for(
612    event_name: &str,
613    source: Option<&str>,
614    tool_name: Option<&str>,
615) -> Option<SessionEvent> {
616    Some(match event_name {
617        "SessionStart" if source == Some("compact") => SessionEvent::TranscriptDiscovered,
618        "SessionStart" => SessionEvent::SessionStart,
619        "UserPromptSubmit" => SessionEvent::UserPromptSubmit,
620        "PreToolUse" if tool_name == Some("request_user_input") => {
621            SessionEvent::Notification(NotificationKind::AgentNeedsInput)
622        }
623        "PreToolUse" => SessionEvent::PreToolUse,
624        "PostToolUse" | "SubagentStart" => SessionEvent::PostToolUse,
625        "SubagentStop" | "PreCompact" | "PostCompact" => SessionEvent::TranscriptDiscovered,
626        "PermissionRequest" => SessionEvent::Notification(NotificationKind::PermissionPrompt),
627        "Stop" | "Interrupt" => SessionEvent::Stop,
628        _ => return None,
629    })
630}
631
632/// Classifies a `Notification` into a [`NotificationKind`]. A version that sends
633/// Claude Code's own `notification_type` is classified by it alone — a type we
634/// do not map (`auth_success`, …) is [`NotificationKind::Other`] rather than a
635/// guess at its message, which could mention "allow" or "permission" without
636/// asking for anything. Only an older version that sends no type falls back to
637/// best-effort substring matching on the version-unstable message text, where an
638/// unrecognised message is likewise [`NotificationKind::Other`] (which carries no
639/// state signal and leaves the session's state unchanged).
640fn classify_notification(
641    notification_type: Option<&str>,
642    message: Option<&str>,
643) -> NotificationKind {
644    match notification_type {
645        Some("permission_prompt") => return NotificationKind::PermissionPrompt,
646        Some("idle_prompt") => return NotificationKind::IdlePrompt,
647        Some("elicitation_dialog") => return NotificationKind::AgentNeedsInput,
648        Some(_) => return NotificationKind::Other,
649        None => {}
650    }
651    let Some(message) = message else {
652        return NotificationKind::Other;
653    };
654    let lower = message.to_lowercase();
655    if lower.contains("permission") || lower.contains("approve") || lower.contains("allow") {
656        NotificationKind::PermissionPrompt
657    } else if lower.contains("waiting for your input")
658        || lower.contains("idle")
659        || lower.contains("needs your input")
660    {
661        NotificationKind::IdlePrompt
662    } else {
663        NotificationKind::Other
664    }
665}
666
667// --- install-hooks / uninstall-hooks ----------------------------------------
668
669/// Installs the sessions-tracker hooks into `~/.claude/settings.json`, plus the
670/// pi.dev extension when pi is installed and the Codex hooks when Codex is.
671#[derive(Parser)]
672pub struct InstallHooksCommand {
673    /// Path to the Claude settings file. Defaults to `~/.claude/settings.json`
674    /// (respecting `$CLAUDE_CONFIG_DIR`).
675    #[arg(long, value_name = "PATH")]
676    pub settings: Option<PathBuf>,
677
678    /// pi.dev's agent directory. Defaults to `$PI_CODING_AGENT_DIR`, else
679    /// `~/.pi/agent`. Passing it installs the pi extension even when pi is not
680    /// detected.
681    #[arg(long, value_name = "PATH")]
682    pub pi_agent_dir: Option<PathBuf>,
683
684    /// Codex's home directory, holding `hooks.json`. Defaults to `$CODEX_HOME`,
685    /// else `~/.codex`. Passing it installs the Codex hooks even when Codex is
686    /// not detected.
687    #[arg(long, value_name = "PATH")]
688    pub codex_home: Option<PathBuf>,
689}
690
691impl InstallHooksCommand {
692    /// Executes the install: rewrites any stale sink left by a moved binary,
693    /// merges the hook block idempotently (preserving any other hooks already
694    /// present), then writes the pi extension if pi is present and the Codex
695    /// hooks if Codex is.
696    pub fn execute(self) -> Result<()> {
697        let path = settings_path(self.settings)?;
698        let mut settings = read_settings(&path)?;
699        let command = hook_command();
700        let sync = sync_claude_hooks(&mut settings, &command);
701        if sync.is_noop() {
702            println!(
703                "sessions hooks already installed in {} (no change)",
704                path.display()
705            );
706        } else {
707            write_settings(&path, &settings)?;
708            if sync.replaced > 0 {
709                println!(
710                    "rewrote {} stale `sessions hook` entry(ies) in {} in place",
711                    sync.replaced,
712                    path.display()
713                );
714            }
715            if sync.deduped > 0 {
716                println!(
717                    "removed {} duplicate `sessions hook` entry(ies) from {}",
718                    sync.deduped,
719                    path.display()
720                );
721            }
722            if sync.added > 0 {
723                println!(
724                    "installed {} sessions hook event(s) into {}",
725                    sync.added,
726                    path.display()
727                );
728            }
729            println!("command: {command}");
730        }
731        if sync.customised > 0 {
732            println!("{}", customised_note(sync.customised));
733        }
734        install_pi_extension(self.pi_agent_dir)?;
735        install_codex_hooks(self.codex_home)
736    }
737}
738
739/// Removes the sessions-tracker hooks from `~/.claude/settings.json`, the pi.dev
740/// extension, and the Codex hooks.
741#[derive(Parser)]
742pub struct UninstallHooksCommand {
743    /// Path to the Claude settings file. Defaults to `~/.claude/settings.json`
744    /// (respecting `$CLAUDE_CONFIG_DIR`).
745    #[arg(long, value_name = "PATH")]
746    pub settings: Option<PathBuf>,
747
748    /// pi.dev's agent directory. Defaults to `$PI_CODING_AGENT_DIR`, else
749    /// `~/.pi/agent`.
750    #[arg(long, value_name = "PATH")]
751    pub pi_agent_dir: Option<PathBuf>,
752
753    /// Codex's home directory, holding `hooks.json`. Defaults to `$CODEX_HOME`,
754    /// else `~/.codex`.
755    #[arg(long, value_name = "PATH")]
756    pub codex_home: Option<PathBuf>,
757}
758
759impl UninstallHooksCommand {
760    /// Executes the uninstall: removes any hook entries whose command is ours
761    /// (including a stale sink from another path), leaving every other hook
762    /// untouched, then removes our pi extension and Codex hooks.
763    pub fn execute(self) -> Result<()> {
764        let path = settings_path(self.settings)?;
765        if path.exists() {
766            let mut settings = read_settings(&path)?;
767            let current = hook_command();
768            let removed = remove_hooks(&mut settings, |c| c == current || is_stale_claude_sink(c));
769            write_settings(&path, &settings)?;
770            println!(
771                "removed {removed} sessions hook entry(ies) from {}",
772                path.display()
773            );
774            let customised = count_customised_sinks(&settings, &current);
775            if customised > 0 {
776                println!("{}", customised_note(customised));
777            }
778        } else {
779            println!("no settings file at {} (nothing to remove)", path.display());
780        }
781        uninstall_pi_extension(self.pi_agent_dir)?;
782        uninstall_codex_hooks(self.codex_home)
783    }
784}
785
786// --- pi.dev extension ---------------------------------------------------------
787
788/// Filename of the generated extension inside pi's global extensions directory.
789const PI_EXTENSION_NAME: &str = "omni-dev-sessions.ts";
790
791/// The generated extension's source, with [`PI_SOCKET_PLACEHOLDER`] still in it.
792const PI_EXTENSION_TEMPLATE: &str = include_str!("../templates/pi-sessions-extension.ts");
793
794/// Stands in for the socket path (a JSON string literal) in the template.
795const PI_SOCKET_PLACEHOLDER: &str = "__OMNI_DEV_SOCKET__";
796
797/// The template's first line. A file that does not start with it is not ours, so
798/// install refuses to overwrite it and uninstall leaves it alone.
799fn pi_extension_marker() -> &'static str {
800    PI_EXTENSION_TEMPLATE.lines().next().unwrap_or_default()
801}
802
803/// The extension source with the daemon socket baked in, as `install-hooks`
804/// bakes in the absolute exe path: pi's Node process has no other way to find
805/// the socket, and resolving it there would duplicate `dirs::data_dir()`.
806fn render_pi_extension(socket: &Path) -> String {
807    let literal =
808        serde_json::to_string(&socket.display().to_string()).unwrap_or_else(|_| "\"\"".to_string());
809    PI_EXTENSION_TEMPLATE.replace(PI_SOCKET_PLACEHOLDER, &literal)
810}
811
812/// pi's default agent directory: `$PI_CODING_AGENT_DIR`, else `~/.pi/agent`.
813fn default_pi_agent_dir() -> Result<PathBuf> {
814    if let Some(dir) = std::env::var_os("PI_CODING_AGENT_DIR").filter(|d| !d.is_empty()) {
815        return Ok(PathBuf::from(dir));
816    }
817    let home = dirs::home_dir().context("could not resolve the home directory")?;
818    Ok(home.join(".pi").join("agent"))
819}
820
821/// Whether pi.dev is installed: its agent directory exists (pi has run), or a
822/// `pi` executable is on `path` (installed but never run).
823fn pi_is_present(agent_dir: &Path, path: Option<&std::ffi::OsStr>) -> bool {
824    agent_dir.is_dir()
825        || path.is_some_and(|path| {
826            std::env::split_paths(path).any(|dir| is_executable(&dir.join("pi")))
827        })
828}
829
830/// Whether `path` is a file with an execute bit set.
831fn is_executable(path: &Path) -> bool {
832    use std::os::unix::fs::PermissionsExt;
833
834    std::fs::metadata(path)
835        .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
836}
837
838/// What [`write_pi_extension`] did.
839#[derive(Debug, PartialEq, Eq)]
840enum PiInstall {
841    /// Wrote a new or updated extension.
842    Written,
843    /// The file already had exactly this content.
844    Unchanged,
845}
846
847/// Writes `contents` to `<agent_dir>/extensions/omni-dev-sessions.ts`, creating
848/// the directory. Refuses to replace a file of that name that is not ours.
849fn write_pi_extension(agent_dir: &Path, contents: &str) -> Result<(PathBuf, PiInstall)> {
850    let dir = agent_dir.join("extensions");
851    let file = dir.join(PI_EXTENSION_NAME);
852    if file.exists() {
853        let existing = std::fs::read_to_string(&file)
854            .with_context(|| format!("failed to read {}", file.display()))?;
855        if existing == contents {
856            return Ok((file, PiInstall::Unchanged));
857        }
858        if !existing.starts_with(pi_extension_marker()) {
859            bail!(
860                "{} exists and was not written by omni-dev; refusing to overwrite it",
861                file.display()
862            );
863        }
864    }
865    std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
866    std::fs::write(&file, contents)
867        .with_context(|| format!("failed to write {}", file.display()))?;
868    Ok((file, PiInstall::Written))
869}
870
871/// Removes our extension from `<agent_dir>/extensions/`, returning its path when
872/// it removed it. A file of that name that is not ours is left alone.
873fn remove_pi_extension(agent_dir: &Path) -> Result<Option<PathBuf>> {
874    let file = agent_dir.join("extensions").join(PI_EXTENSION_NAME);
875    let Ok(existing) = std::fs::read_to_string(&file) else {
876        return Ok(None);
877    };
878    if !existing.starts_with(pi_extension_marker()) {
879        return Ok(None);
880    }
881    std::fs::remove_file(&file).with_context(|| format!("failed to remove {}", file.display()))?;
882    Ok(Some(file))
883}
884
885/// The pi half of `install-hooks`. Without `--pi-agent-dir` it installs only when
886/// pi is present, so a machine without pi gets nothing under `~/.pi`.
887fn install_pi_extension(explicit: Option<PathBuf>) -> Result<()> {
888    let agent_dir = if let Some(dir) = explicit {
889        dir
890    } else {
891        let dir = default_pi_agent_dir()?;
892        if !pi_is_present(&dir, std::env::var_os("PATH").as_deref()) {
893            println!(
894                "pi.dev not found (no {} and no `pi` on PATH); skipped its extension",
895                dir.display()
896            );
897            return Ok(());
898        }
899        dir
900    };
901    let socket = server::resolve_socket(None)?;
902    let (file, outcome) = write_pi_extension(&agent_dir, &render_pi_extension(&socket))?;
903    match outcome {
904        PiInstall::Unchanged => println!(
905            "pi.dev extension already installed at {} (no change)",
906            file.display()
907        ),
908        PiInstall::Written => {
909            println!(
910                "installed the pi.dev sessions extension at {}",
911                file.display()
912            );
913            println!("pi sessions started after this report their state");
914        }
915    }
916    Ok(())
917}
918
919/// The pi half of `uninstall-hooks`. Runs whether or not pi is present, so an
920/// uninstalled pi does not strand the file.
921fn uninstall_pi_extension(explicit: Option<PathBuf>) -> Result<()> {
922    let agent_dir = match explicit {
923        Some(dir) => dir,
924        None => default_pi_agent_dir()?,
925    };
926    if let Some(file) = remove_pi_extension(&agent_dir)? {
927        println!("removed {}", file.display());
928    }
929    Ok(())
930}
931
932// --- install-wrapper / uninstall-wrapper -------------------------------------
933
934/// The VS Code setting the Claude Code extension reads to decide what to launch
935/// Claude through. Machine-scoped, so it belongs in the *user* settings file.
936const WRAPPER_SETTING_KEY: &str = "claudeCode.claudeProcessWrapper";
937
938/// Filename of the shim written into omni-dev's runtime directory.
939const SHIM_NAME: &str = "claude-wrap";
940
941/// Installs the `claude-wrap` shim and points VS Code's Claude extension at it.
942#[derive(Parser)]
943pub struct InstallWrapperCommand {
944    /// Path to the VS Code user settings file. Defaults to the platform's
945    /// `Code/User/settings.json`.
946    #[arg(long, value_name = "PATH")]
947    pub settings: Option<PathBuf>,
948
949    /// Path to write the shim to. Defaults to `<data-dir>/omni-dev/claude-wrap`.
950    #[arg(long, value_name = "PATH")]
951    pub shim: Option<PathBuf>,
952}
953
954impl InstallWrapperCommand {
955    /// Executes the install: writes the shim, then idempotently sets the
956    /// extension's wrapper setting to point at it.
957    pub fn execute(self) -> Result<()> {
958        let shim = shim_path(self.shim)?;
959        write_shim(&shim)?;
960        let path = vscode_settings_path(self.settings)?;
961        let target = shim.display().to_string();
962
963        let mut settings =
964            read_settings(&path).map_err(|error| manual_setup_hint(&error, &target))?;
965        let changed = set_wrapper(&mut settings, &target);
966        write_settings(&path, &settings)?;
967
968        println!("wrapper shim: {target}");
969        if changed {
970            println!("set {WRAPPER_SETTING_KEY} in {}", path.display());
971            println!("reload VS Code; Claude tabs opened after that report their exact state");
972        } else {
973            println!(
974                "{WRAPPER_SETTING_KEY} already points there in {} (no change)",
975                path.display()
976            );
977        }
978        Ok(())
979    }
980}
981
982/// Removes the wrapper setting and the shim it points at.
983#[derive(Parser)]
984pub struct UninstallWrapperCommand {
985    /// Path to the VS Code user settings file. Defaults to the platform's
986    /// `Code/User/settings.json`.
987    #[arg(long, value_name = "PATH")]
988    pub settings: Option<PathBuf>,
989
990    /// Path the shim was written to. Defaults to
991    /// `<data-dir>/omni-dev/claude-wrap`.
992    #[arg(long, value_name = "PATH")]
993    pub shim: Option<PathBuf>,
994}
995
996impl UninstallWrapperCommand {
997    /// Executes the uninstall: clears the setting when (and only when) it still
998    /// points at our shim, then removes the shim file.
999    pub fn execute(self) -> Result<()> {
1000        let shim = shim_path(self.shim)?;
1001        let target = shim.display().to_string();
1002        let path = vscode_settings_path(self.settings)?;
1003
1004        if path.exists() {
1005            let mut settings =
1006                read_settings(&path).map_err(|error| manual_setup_hint(&error, &target))?;
1007            if clear_wrapper(&mut settings, &target) {
1008                write_settings(&path, &settings)?;
1009                println!("cleared {WRAPPER_SETTING_KEY} in {}", path.display());
1010            } else {
1011                println!(
1012                    "{WRAPPER_SETTING_KEY} in {} does not point at our shim; left alone",
1013                    path.display()
1014                );
1015            }
1016        } else {
1017            println!("no settings file at {} (nothing to clear)", path.display());
1018        }
1019
1020        if shim.exists() {
1021            std::fs::remove_file(&shim)
1022                .with_context(|| format!("failed to remove {}", shim.display()))?;
1023            println!("removed {target}");
1024        }
1025        Ok(())
1026    }
1027}
1028
1029/// The shim's path: an explicit `--shim`, else `claude-wrap` in omni-dev's
1030/// runtime directory (beside the daemon socket).
1031fn shim_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
1032    match explicit {
1033        Some(path) => Ok(path),
1034        None => Ok(paths::runtime_dir()?.join(SHIM_NAME)),
1035    }
1036}
1037
1038/// The VS Code **user** settings path: an explicit `--settings`, else
1039/// `<config-dir>/Code/User/settings.json` — `~/Library/Application Support/…` on
1040/// macOS and `~/.config/…` on Linux, which is where a machine-scoped setting has
1041/// to live. (The runtime directory is `data_dir()`; this one is deliberately
1042/// `config_dir()`.)
1043fn vscode_settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
1044    if let Some(path) = explicit {
1045        return Ok(path);
1046    }
1047    let base = dirs::config_dir().context("could not determine the user config directory")?;
1048    Ok(base.join("Code").join("User").join("settings.json"))
1049}
1050
1051/// The shim script's contents.
1052///
1053/// The extension spawns the configured wrapper directly — no shell, no argument
1054/// splitting — so the setting has to name a single executable file. This is that
1055/// file: a one-line `exec` into `omni-dev claude-wrap`, using the absolute path
1056/// of the running binary so it does not depend on VS Code's `PATH`.
1057fn shim_contents() -> String {
1058    let exe = std::env::current_exe()
1059        .map_or_else(|_| "omni-dev".to_string(), |exe| exe.display().to_string());
1060    format!("#!/bin/sh\nexec \"{exe}\" claude-wrap -- \"$@\"\n")
1061}
1062
1063/// Writes the shim, creating its directory and marking it owner-executable.
1064fn write_shim(path: &Path) -> Result<()> {
1065    use std::os::unix::fs::PermissionsExt;
1066
1067    if let Some(parent) = path.parent() {
1068        paths::ensure_dir_0700(parent)?;
1069    }
1070    std::fs::write(path, shim_contents())
1071        .with_context(|| format!("failed to write {}", path.display()))?;
1072    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
1073        .with_context(|| format!("failed to make {} executable", path.display()))
1074}
1075
1076/// Points the wrapper setting at `shim`, returning whether anything changed.
1077fn set_wrapper(settings: &mut Value, shim: &str) -> bool {
1078    let Some(root) = settings.as_object_mut() else {
1079        return false;
1080    };
1081    if root.get(WRAPPER_SETTING_KEY).and_then(Value::as_str) == Some(shim) {
1082        return false;
1083    }
1084    root.insert(WRAPPER_SETTING_KEY.to_string(), json!(shim));
1085    true
1086}
1087
1088/// Clears the wrapper setting when it points at `shim`, returning whether it
1089/// did. A setting pointing somewhere else is someone else's and is left alone.
1090fn clear_wrapper(settings: &mut Value, shim: &str) -> bool {
1091    let Some(root) = settings.as_object_mut() else {
1092        return false;
1093    };
1094    if root.get(WRAPPER_SETTING_KEY).and_then(Value::as_str) != Some(shim) {
1095        return false;
1096    }
1097    root.remove(WRAPPER_SETTING_KEY);
1098    true
1099}
1100
1101/// Turns an unparseable-settings error into one that tells the user what to do.
1102///
1103/// VS Code settings files are JSON**C** — comments and trailing commas are
1104/// legal there and common in practice, and [`read_settings`] rightly refuses to
1105/// rewrite what it cannot parse. The shim is already in place by then, so the
1106/// only thing left is one line to paste.
1107fn manual_setup_hint(error: &anyhow::Error, shim: &str) -> anyhow::Error {
1108    anyhow!(
1109        "{error:#}\n\n\
1110         VS Code settings files may contain comments or trailing commas, which cannot be \
1111         rewritten safely. The shim is installed, so add this line by hand instead:\n\n    \
1112         \"{WRAPPER_SETTING_KEY}\": \"{shim}\""
1113    )
1114}
1115
1116/// One hook event the tracker installs.
1117#[derive(Debug, Clone, Copy)]
1118struct HookSpec {
1119    /// The event name, as the settings file keys it.
1120    event: &'static str,
1121    /// Whether the event's group gets the `*` tool `matcher` (the tool events —
1122    /// `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`,
1123    /// `PermissionDenied` — match on tool name; the rest are left unmatched,
1124    /// which matches every occurrence).
1125    matcher: bool,
1126    /// A per-hook `timeout` in seconds, where the agent caps the event below
1127    /// its default.
1128    timeout: Option<u64>,
1129}
1130
1131impl HookSpec {
1132    const fn new(event: &'static str) -> Self {
1133        Self {
1134            event,
1135            matcher: false,
1136            timeout: None,
1137        }
1138    }
1139
1140    const fn matched(event: &'static str) -> Self {
1141        Self {
1142            matcher: true,
1143            ..Self::new(event)
1144        }
1145    }
1146
1147    const fn capped(event: &'static str, secs: u64) -> Self {
1148        Self {
1149            timeout: Some(secs),
1150            ..Self::new(event)
1151        }
1152    }
1153}
1154
1155/// The Claude Code hook events the tracker installs. `SessionEnd` is included —
1156/// it maps to the `end` op in the sink. The events after `SessionEnd` narrow
1157/// the hooks-only state gaps (#1915). Claude Code 2.1.280 drops a hook event it
1158/// does not know with a warning rather than rejecting the settings file; how
1159/// older versions treat one is unverified (docs/sessions-service.md).
1160const HOOK_EVENTS: &[HookSpec] = &[
1161    HookSpec::new("SessionStart"),
1162    HookSpec::new("UserPromptSubmit"),
1163    HookSpec::matched("PreToolUse"),
1164    HookSpec::matched("PostToolUse"),
1165    HookSpec::new("Notification"),
1166    HookSpec::new("Stop"),
1167    HookSpec::new("SessionEnd"),
1168    HookSpec::matched("PermissionRequest"),
1169    HookSpec::matched("PermissionDenied"),
1170    HookSpec::matched("PostToolUseFailure"),
1171    HookSpec::new("StopFailure"),
1172    HookSpec::new("Elicitation"),
1173    HookSpec::new("ElicitationResult"),
1174    HookSpec::new("SubagentStart"),
1175    HookSpec::new("SubagentStop"),
1176    HookSpec::new("PreCompact"),
1177    HookSpec::new("PostCompact"),
1178];
1179
1180/// The Codex hook events the tracker installs into `$CODEX_HOME/hooks.json`:
1181/// Claude's set minus `Notification` (Codex has none), plus Codex's
1182/// `PermissionRequest` and `Interrupt` and the compaction and subagent events
1183/// (ADR-0087). Codex caps `SessionEnd` and `Interrupt` hooks at 3 s.
1184const CODEX_HOOK_EVENTS: &[HookSpec] = &[
1185    HookSpec::new("SessionStart"),
1186    HookSpec::new("UserPromptSubmit"),
1187    HookSpec::matched("PreToolUse"),
1188    HookSpec::matched("PostToolUse"),
1189    HookSpec::new("PermissionRequest"),
1190    HookSpec::new("Stop"),
1191    HookSpec::capped("Interrupt", 3),
1192    HookSpec::capped("SessionEnd", 3),
1193    HookSpec::new("PreCompact"),
1194    HookSpec::new("PostCompact"),
1195    HookSpec::new("SubagentStart"),
1196    HookSpec::new("SubagentStop"),
1197];
1198
1199/// The hook command string written into settings.json: the absolute path of the
1200/// running binary plus `sessions hook`, so Claude Code invokes *this* omni-dev
1201/// regardless of its hook `PATH`. Falls back to the bare `omni-dev sessions hook`
1202/// when the executable path cannot be resolved (documented as the portable form).
1203fn hook_command() -> String {
1204    match std::env::current_exe() {
1205        Ok(exe) => format!("{} sessions hook", exe.display()),
1206        Err(_) => "omni-dev sessions hook".to_string(),
1207    }
1208}
1209
1210/// The Claude settings file path: an explicit `--settings`, else
1211/// `$CLAUDE_CONFIG_DIR/settings.json`, else `~/.claude/settings.json`.
1212fn settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
1213    if let Some(path) = explicit {
1214        return Ok(path);
1215    }
1216    if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
1217        return Ok(PathBuf::from(dir).join("settings.json"));
1218    }
1219    let home = dirs::home_dir().context("could not resolve the home directory")?;
1220    Ok(home.join(".claude").join("settings.json"))
1221}
1222
1223/// Reads and parses `path` into a JSON object, treating a missing file as an
1224/// empty object. Errors (rather than clobbering) when the file exists but is not
1225/// valid JSON, or is valid JSON that is not an object.
1226fn read_settings(path: &Path) -> Result<Value> {
1227    if !path.exists() {
1228        return Ok(json!({}));
1229    }
1230    let text = std::fs::read_to_string(path)
1231        .with_context(|| format!("failed to read {}", path.display()))?;
1232    if text.trim().is_empty() {
1233        return Ok(json!({}));
1234    }
1235    let value: Value = serde_json::from_str(&text).with_context(|| {
1236        format!(
1237            "{} is not valid JSON; refusing to overwrite it",
1238            path.display()
1239        )
1240    })?;
1241    if !value.is_object() {
1242        bail!(
1243            "{} is not a JSON object; refusing to overwrite it",
1244            path.display()
1245        );
1246    }
1247    Ok(value)
1248}
1249
1250/// Serializes `settings` back to `path`, pretty-printed with a trailing newline,
1251/// creating the parent directory if needed.
1252fn write_settings(path: &Path, settings: &Value) -> Result<()> {
1253    if let Some(parent) = path.parent() {
1254        std::fs::create_dir_all(parent)
1255            .with_context(|| format!("failed to create {}", parent.display()))?;
1256    }
1257    let mut text = serde_json::to_string_pretty(settings)?;
1258    text.push('\n');
1259    std::fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))?;
1260    Ok(())
1261}
1262
1263/// Merges the sessions-tracker hook `command` into a settings object under each
1264/// event in `specs`, returning how many events were newly added. Idempotent (an
1265/// event that already has a group running `command` is skipped) and additive (it
1266/// never touches other hooks, and appending moves no existing group). Creates
1267/// `hooks` and any per-event array as needed.
1268fn merge_hooks(settings: &mut Value, command: &str, specs: &[HookSpec]) -> usize {
1269    // `read_settings` guarantees an object, but degrade gracefully rather than
1270    // panic if a caller passes something else.
1271    let Some(root) = settings.as_object_mut() else {
1272        return 0;
1273    };
1274    let hooks = root
1275        .entry("hooks")
1276        .or_insert_with(|| json!({}))
1277        .as_object_mut();
1278    let Some(hooks) = hooks else {
1279        // `hooks` exists but is not an object; leave the file alone rather than
1280        // clobber a user's unexpected shape.
1281        return 0;
1282    };
1283    let mut added = 0;
1284    for spec in specs {
1285        let groups = hooks
1286            .entry(spec.event.to_string())
1287            .or_insert_with(|| json!([]));
1288        let Some(groups) = groups.as_array_mut() else {
1289            continue;
1290        };
1291        if groups.iter().any(|g| group_has_command(g, command)) {
1292            continue; // already installed for this event
1293        }
1294        groups.push(hook_group(command, spec));
1295        added += 1;
1296    }
1297    added
1298}
1299
1300/// Removes every hook entry whose command `is_ours` matches from a settings
1301/// object, pruning any group and per-event array left empty, and returning how
1302/// many hook entries were removed. Leaves all other hooks in place.
1303fn remove_hooks(settings: &mut Value, is_ours: impl Fn(&str) -> bool) -> usize {
1304    let Some(root) = settings.as_object_mut() else {
1305        return 0;
1306    };
1307    let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
1308        return 0;
1309    };
1310    let mut removed = 0;
1311    let mut empty_events = Vec::new();
1312    for (event, groups) in hooks.iter_mut() {
1313        let Some(groups) = groups.as_array_mut() else {
1314            continue;
1315        };
1316        for group in groups.iter_mut() {
1317            if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
1318                let before = inner.len();
1319                inner.retain(|h| {
1320                    !h.get("command")
1321                        .and_then(Value::as_str)
1322                        .is_some_and(&is_ours)
1323                });
1324                removed += before - inner.len();
1325            }
1326        }
1327        // Drop groups whose hook list is now empty, then the event if no groups
1328        // remain, so an uninstall leaves no empty scaffolding behind.
1329        groups.retain(|g| {
1330            g.get("hooks")
1331                .and_then(Value::as_array)
1332                .is_none_or(|h| !h.is_empty())
1333        });
1334        if groups.is_empty() {
1335            empty_events.push(event.clone());
1336        }
1337    }
1338    for event in empty_events {
1339        hooks.remove(&event);
1340    }
1341    removed
1342}
1343
1344/// One hook group as written into an event array: `{ "hooks": [{ "type":
1345/// "command", "command": … }] }`, with a `"matcher": "*"` when the event matches
1346/// on tool name and a `timeout` where the spec caps one.
1347fn hook_group(command: &str, spec: &HookSpec) -> Value {
1348    let mut group = json!({ "hooks": [hook_entry(command, spec)] });
1349    if spec.matcher {
1350        group["matcher"] = Value::String("*".to_string());
1351    }
1352    group
1353}
1354
1355/// One hook entry: `{ "type": "command", "command": … }`, plus a `timeout`
1356/// where the spec caps one.
1357fn hook_entry(command: &str, spec: &HookSpec) -> Value {
1358    let mut hook = json!({ "type": "command", "command": command });
1359    if let Some(secs) = spec.timeout {
1360        hook["timeout"] = json!(secs);
1361    }
1362    hook
1363}
1364
1365/// Whether a hook `group` already contains a hook running `command`.
1366fn group_has_command(group: &Value, command: &str) -> bool {
1367    group
1368        .get("hooks")
1369        .and_then(Value::as_array)
1370        .is_some_and(|hooks| hooks.iter().any(|h| hook_has_command(h, command)))
1371}
1372
1373/// Whether a single hook entry runs `command`.
1374fn hook_has_command(hook: &Value, command: &str) -> bool {
1375    hook.get("command").and_then(Value::as_str) == Some(command)
1376}
1377
1378// --- Claude stale-sink handling (#1927) ---------------------------------------
1379
1380/// What [`sync_claude_hooks`] changed in a Claude settings object.
1381#[derive(Debug, Default, PartialEq, Eq)]
1382struct ClaudeSync {
1383    /// Stale sink entries rewritten to the current command in place.
1384    replaced: usize,
1385    /// Duplicate entries of the current command removed.
1386    deduped: usize,
1387    /// Events that gained a group.
1388    added: usize,
1389    /// Customised sinks (extra arguments) left untouched.
1390    customised: usize,
1391}
1392
1393impl ClaudeSync {
1394    /// Whether the settings object was left unchanged.
1395    fn is_noop(&self) -> bool {
1396        self.replaced == 0 && self.deduped == 0 && self.added == 0
1397    }
1398}
1399
1400/// Whether `command` is a stale **un-customised** Claude sink: an `omni-dev
1401/// sessions hook` from any path whose arguments are empty or only the Claude tag
1402/// (`--agent claude`), the two shapes `install-hooks` has written for Claude.
1403/// Unlike Codex's [`is_sessions_sink`], a sink with any other argument (a
1404/// `--socket`, an `--agent codex`) is a deliberate choice and is **not** stale:
1405/// rewriting it to the canonical command would silently discard that choice.
1406fn is_stale_claude_sink(command: &str) -> bool {
1407    // A launcher with whitespace in it is a wrapper (`FOO=1 …`, `nice -n 10 …`,
1408    // `cd /x && …`), which is as customised as an extra argument.
1409    let Some((launcher, rest)) = split_sink(command) else {
1410        return false;
1411    };
1412    let args: Vec<&str> = rest.split_whitespace().collect();
1413    !launcher.contains(char::is_whitespace)
1414        && matches!(
1415            args.as_slice(),
1416            [] | ["--agent", "claude"] | ["--agent=claude"]
1417        )
1418}
1419
1420/// Whether `command`, which is not `current`, is a sink install and uninstall
1421/// leave alone: an `omni-dev sessions hook` that is not [`is_stale_claude_sink`].
1422fn is_customised_sink(command: &str, current: &str) -> bool {
1423    command != current && is_sessions_sink(command) && !is_stale_claude_sink(command)
1424}
1425
1426/// Counts the `omni-dev sessions hook` entries in `settings` that are neither
1427/// `current` nor stale-and-rewritable: the customised ones install and uninstall
1428/// leave alone.
1429fn count_customised_sinks(settings: &Value, current: &str) -> usize {
1430    settings
1431        .get("hooks")
1432        .and_then(Value::as_object)
1433        .into_iter()
1434        .flat_map(|hooks| hooks.values())
1435        .filter_map(Value::as_array)
1436        .flatten()
1437        .filter_map(|g| g.get("hooks").and_then(Value::as_array))
1438        .flatten()
1439        .filter_map(|h| h.get("command").and_then(Value::as_str))
1440        .filter(|c| is_customised_sink(c, current))
1441        .count()
1442}
1443
1444/// The note printed when customised sinks were left in place.
1445fn customised_note(count: usize) -> String {
1446    format!(
1447        "left {count} customised `sessions hook` entry(ies) (extra arguments such as \
1448         `--socket`) untouched: edit or remove them by hand if they point at a moved binary"
1449    )
1450}
1451
1452/// Removes the second and later occurrences of exactly `command` under each
1453/// event, pruning any group this empties, and returns how many it removed. The
1454/// first copy under each matcher is always kept, so an event never empties. Settings left by the pre-#1927 bug hold the old and new sink side by
1455/// side; once [`replace_stale_sinks`] rewrites the old one they would be two
1456/// identical entries, each spawning a sink per event. Claude identifies a hook by
1457/// content rather than position, so removing one shifts nothing that matters.
1458fn dedupe_command(settings: &mut Value, command: &str) -> usize {
1459    let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else {
1460        return 0;
1461    };
1462    let mut removed = 0;
1463    for groups in hooks.values_mut() {
1464        let Some(groups) = groups.as_array_mut() else {
1465            continue;
1466        };
1467        // Keyed by matcher: a copy under `Bash` does not cover a copy under `*`.
1468        let mut seen: Vec<Option<String>> = Vec::new();
1469        let mut dropped_here = 0;
1470        for group in groups.iter_mut() {
1471            let matcher = group
1472                .get("matcher")
1473                .and_then(Value::as_str)
1474                .map(str::to_string);
1475            let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) else {
1476                continue;
1477            };
1478            let before = inner.len();
1479            inner.retain(|h| {
1480                if !hook_has_command(h, command) {
1481                    return true;
1482                }
1483                if seen.contains(&matcher) {
1484                    return false;
1485                }
1486                seen.push(matcher.clone());
1487                true
1488            });
1489            let dropped = before - inner.len();
1490            if dropped > 0 && inner.is_empty() {
1491                *group = Value::Null; // emptied by us: pruned below
1492            }
1493            dropped_here += dropped;
1494        }
1495        if dropped_here == 0 {
1496            continue; // never touch an event we removed nothing from
1497        }
1498        removed += dropped_here;
1499        groups.retain(|g| !g.is_null());
1500    }
1501    removed
1502}
1503
1504/// The Claude half of `install-hooks` as a pure transform: rewrite stale
1505/// un-customised sinks to `command` in place, drop the duplicates that leaves (or
1506/// that an earlier buggy install left), then add whichever events lack a sink.
1507/// Customised sinks are counted but never touched.
1508fn sync_claude_hooks(settings: &mut Value, command: &str) -> ClaudeSync {
1509    let replaced = replace_stale_sinks(settings, command, HOOK_EVENTS, is_stale_claude_sink);
1510    let deduped = dedupe_command(settings, command);
1511    let added = merge_hooks(settings, command, HOOK_EVENTS);
1512    ClaudeSync {
1513        replaced,
1514        deduped,
1515        added,
1516        customised: count_customised_sinks(settings, command),
1517    }
1518}
1519
1520// --- Codex hooks.json ----------------------------------------------------------
1521
1522/// The one-time trust step every Codex install needs. Codex skips an untrusted
1523/// hook without any message, and trusts a hook by its position *and* content, so
1524/// a changed command string needs trusting again.
1525const CODEX_TRUST_STEP: &str =
1526    "Codex skips untrusted hooks silently: open the Codex CLI, run `/hooks`, \
1527and trust the omni-dev entries (once, and again after the command changes). \
1528One approval covers the VS Code extension and Codex Desktop.";
1529
1530/// The Codex hook command: [`hook_command`] tagged `--agent codex`.
1531fn codex_hook_command() -> String {
1532    format!("{} --agent codex", hook_command())
1533}
1534
1535/// Codex's home directory: `$CODEX_HOME`, else `~/.codex`.
1536fn default_codex_home() -> Result<PathBuf> {
1537    if let Some(dir) = std::env::var_os("CODEX_HOME").filter(|d| !d.is_empty()) {
1538        return Ok(PathBuf::from(dir));
1539    }
1540    let home = dirs::home_dir().context("could not resolve the home directory")?;
1541    Ok(home.join(".codex"))
1542}
1543
1544/// Whether Codex is installed: its home directory exists (Codex has run), or a
1545/// `codex` executable is on `path`.
1546fn codex_is_present(codex_home: &Path, path: Option<&std::ffi::OsStr>) -> bool {
1547    codex_home.is_dir()
1548        || path.is_some_and(|path| {
1549            std::env::split_paths(path).any(|dir| is_executable(&dir.join("codex")))
1550        })
1551}
1552
1553/// Whether `command` runs the `omni-dev sessions hook` sink, from any path to an
1554/// `omni-dev` binary and with any arguments. In Codex's `hooks.json` every such
1555/// entry other than the current [`codex_hook_command`] is stale: an untagged or
1556/// `--agent claude` one (a user who wired Codex by hand) reports Codex sessions
1557/// as Claude's, and a tagged one from another path is left by an upgrade that
1558/// moved the binary.
1559fn is_sessions_sink(command: &str) -> bool {
1560    split_sink(command).is_some()
1561}
1562
1563/// Splits a sink `command` into the text before `sessions hook` (the launcher
1564/// path, plus anything a wrapper put in front of it) and the arguments after it,
1565/// or `None` when it is not an `omni-dev sessions hook` at all. The one place the
1566/// command is tokenised, so [`is_sessions_sink`] and [`is_stale_claude_sink`]
1567/// cannot disagree about what a sink is.
1568fn split_sink(command: &str) -> Option<(&str, &str)> {
1569    let command = command.trim();
1570    let at = command.find(" sessions hook")?;
1571    let rest = &command[at + " sessions hook".len()..];
1572    let launcher = command[..at].trim();
1573    ((rest.is_empty() || rest.starts_with(char::is_whitespace))
1574        && Path::new(launcher)
1575            .file_name()
1576            .is_some_and(|name| name == "omni-dev"))
1577    .then_some((launcher, rest))
1578}
1579
1580/// Rewrites every stale sink entry (one `is_stale` matches, other than `command`
1581/// itself) in `settings` to `command` **where it sits**, returning how many it
1582/// rewrote. Codex passes [`is_sessions_sink`]; Claude the narrower
1583/// [`is_stale_claude_sink`]. (Claude identifies a hook by content, so in-place
1584/// matters there only for keeping a user's ordering; the trust argument below is
1585/// Codex's.) Left beside the
1586/// tagged entry, an untagged one would race it to be a session's first sighting
1587/// and could fix the session's tag as `claude` for good; replacing it in place
1588/// moves no other hook, so nothing else loses its Codex trust (ADR-0087). The
1589/// replaced entry takes the spec's `timeout` for its event. Extra arguments on
1590/// the old entry (a `--socket`, say) are not carried over: the canonical command
1591/// is what makes a second install recognise it.
1592fn replace_stale_sinks(
1593    settings: &mut Value,
1594    command: &str,
1595    specs: &[HookSpec],
1596    is_stale: impl Fn(&str) -> bool,
1597) -> usize {
1598    let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else {
1599        return 0;
1600    };
1601    let mut replaced = 0;
1602    for (event, groups) in hooks.iter_mut() {
1603        let spec = specs.iter().find(|s| s.event == event);
1604        let Some(groups) = groups.as_array_mut() else {
1605            continue;
1606        };
1607        for hook in groups
1608            .iter_mut()
1609            .filter_map(|g| g.get_mut("hooks").and_then(Value::as_array_mut))
1610            .flatten()
1611        {
1612            let stale = hook
1613                .get("command")
1614                .and_then(Value::as_str)
1615                .is_some_and(|c| c != command && is_stale(c));
1616            if !stale {
1617                continue;
1618            }
1619            hook["command"] = Value::String(command.to_string());
1620            if let Some(secs) = spec.and_then(|s| s.timeout) {
1621                hook["timeout"] = json!(secs);
1622            }
1623            replaced += 1;
1624        }
1625    }
1626    replaced
1627}
1628
1629/// What [`remove_hooks_stable`] did.
1630#[derive(Debug, Default, PartialEq, Eq)]
1631struct Removal {
1632    /// How many hook entries were removed.
1633    removed: usize,
1634    /// Whether a surviving hook moved to a lower index **within its group** — the
1635    /// one shift a group placeholder cannot prevent, since a hook list has no
1636    /// empty-entry form. That hook needs trusting again.
1637    shifted: bool,
1638}
1639
1640/// Removes every hook entry `is_ours` matches, **without shifting any surviving
1641/// group** — the position-stable counterpart of [`remove_hooks`] for Codex, which
1642/// trusts a hook by its `<event>:<group>:<hook>` index. A group this empties keeps
1643/// its slot as an inert `{ "hooks": [] }` placeholder unless no group follows it,
1644/// so a later group never moves into its index and silently loses its trust;
1645/// trailing empty groups (and then an empty event) are dropped, since nothing
1646/// can shift into them.
1647fn remove_hooks_stable(settings: &mut Value, is_ours: impl Fn(&str) -> bool) -> Removal {
1648    let mut outcome = Removal::default();
1649    let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else {
1650        return outcome;
1651    };
1652    let mut empty_events = Vec::new();
1653    for (event, groups) in hooks.iter_mut() {
1654        let Some(groups) = groups.as_array_mut() else {
1655            continue;
1656        };
1657        for group in groups.iter_mut() {
1658            let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) else {
1659                continue;
1660            };
1661            let mut removed_before = false;
1662            inner.retain(|h| {
1663                let ours = h
1664                    .get("command")
1665                    .and_then(Value::as_str)
1666                    .is_some_and(&is_ours);
1667                if ours {
1668                    removed_before = true;
1669                    outcome.removed += 1;
1670                } else if removed_before {
1671                    outcome.shifted = true;
1672                }
1673                !ours
1674            });
1675            if removed_before && inner.is_empty() {
1676                *group = json!({ "hooks": [] });
1677            }
1678        }
1679        while groups.last().is_some_and(is_empty_group) {
1680            groups.pop();
1681        }
1682        if groups.is_empty() {
1683            empty_events.push(event.clone());
1684        }
1685    }
1686    for event in empty_events {
1687        hooks.remove(&event);
1688    }
1689    outcome
1690}
1691
1692/// Whether `group` is a group with an empty `hooks` list.
1693fn is_empty_group(group: &Value) -> bool {
1694    group
1695        .get("hooks")
1696        .and_then(Value::as_array)
1697        .is_some_and(Vec::is_empty)
1698}
1699
1700/// The Codex half of `install-hooks`. Without `--codex-home` it installs only
1701/// when Codex is present, so a machine without Codex gets nothing under
1702/// `~/.codex`.
1703fn install_codex_hooks(explicit: Option<PathBuf>) -> Result<()> {
1704    let codex_home = if let Some(dir) = explicit {
1705        dir
1706    } else {
1707        let dir = default_codex_home()?;
1708        if !codex_is_present(&dir, std::env::var_os("PATH").as_deref()) {
1709            println!(
1710                "Codex not found (no {} and no `codex` on PATH); skipped its hooks",
1711                dir.display()
1712            );
1713            return Ok(());
1714        }
1715        dir
1716    };
1717    let path = codex_home.join("hooks.json");
1718    let mut hooks = read_settings(&path)?;
1719    let command = codex_hook_command();
1720    let replaced = replace_stale_sinks(&mut hooks, &command, CODEX_HOOK_EVENTS, is_sessions_sink);
1721    let added = merge_hooks(&mut hooks, &command, CODEX_HOOK_EVENTS);
1722    if replaced == 0 && added == 0 {
1723        println!(
1724            "Codex sessions hooks already installed in {} (no change)",
1725            path.display()
1726        );
1727        return Ok(());
1728    }
1729    write_settings(&path, &hooks)?;
1730    if replaced > 0 {
1731        println!(
1732            "rewrote {replaced} existing `sessions hook` entry(ies) in {} in place",
1733            path.display()
1734        );
1735    }
1736    if added > 0 {
1737        println!(
1738            "installed {added} Codex sessions hook event(s) into {}",
1739            path.display()
1740        );
1741    }
1742    println!("command: {command}\n{CODEX_TRUST_STEP}");
1743    Ok(())
1744}
1745
1746/// The Codex half of `uninstall-hooks`. Runs whether or not Codex is present, so
1747/// an uninstalled Codex does not strand the entries. Removes every sink entry,
1748/// tagged or not and from any path (install would have rewritten it),
1749/// position-stably.
1750fn uninstall_codex_hooks(explicit: Option<PathBuf>) -> Result<()> {
1751    let codex_home = match explicit {
1752        Some(dir) => dir,
1753        None => default_codex_home()?,
1754    };
1755    let path = codex_home.join("hooks.json");
1756    if !path.exists() {
1757        return Ok(());
1758    }
1759    let mut hooks = read_settings(&path)?;
1760    // The exact current command too, in case this binary is not named `omni-dev`.
1761    let current = codex_hook_command();
1762    let Removal { removed, shifted } =
1763        remove_hooks_stable(&mut hooks, |c| c == current || is_sessions_sink(c));
1764    if removed == 0 {
1765        return Ok(());
1766    }
1767    write_settings(&path, &hooks)?;
1768    println!(
1769        "removed {removed} Codex sessions hook entry(ies) from {}",
1770        path.display()
1771    );
1772    if shifted {
1773        println!(
1774            "a hook that shared a group with ours moved up within it, so Codex no \
1775             longer trusts it: run `/hooks` in the Codex CLI to trust it again"
1776        );
1777    } else {
1778        println!("no other Codex hook moved, so their `/hooks` trust stands");
1779    }
1780    Ok(())
1781}
1782
1783// --- shared socket + rendering ----------------------------------------------
1784
1785/// Sends one `sessions` service op over the control socket, returning its
1786/// payload or turning an `ok: false` reply into an error.
1787async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
1788    let reply = DaemonClient::new(socket)
1789        .request(DaemonEnvelope::service(SERVICE, op, payload))
1790        .await?;
1791    reply_payload(reply)
1792}
1793
1794/// Unwraps a daemon reply into its payload, turning an `ok: false` reply into an
1795/// error. Pure (no socket), so both mappings are unit-testable.
1796fn reply_payload(reply: DaemonReply) -> Result<Value> {
1797    if reply.ok {
1798        Ok(reply.payload)
1799    } else {
1800        bail!(
1801            "daemon returned an error: {}",
1802            reply.error.as_deref().unwrap_or("unknown error")
1803        )
1804    }
1805}
1806
1807/// Renders a `list` reply as a human-readable table: a header and one row per
1808/// live session (state, source, repo, working directory, and age). Returns a
1809/// placeholder line when nothing is running.
1810fn render_sessions(result: &Value) -> String {
1811    let sessions = result
1812        .get("sessions")
1813        .and_then(Value::as_array)
1814        .map(Vec::as_slice)
1815        .unwrap_or_default();
1816    if sessions.is_empty() {
1817        return "No active agent sessions.".to_string();
1818    }
1819    // CWD is last so a long path never misaligns the columns after it.
1820    let mut out = format!(
1821        "{:<13} {:<6} {:<8} {:<20} {:>5}  {}",
1822        "STATE", "AGENT", "SOURCE", "REPO", "AGE", "CWD"
1823    );
1824    for session in sessions {
1825        let state = state_display(session.get("state").and_then(Value::as_str).unwrap_or("-"));
1826        let agent = agent_label(session);
1827        let source = source_label(session);
1828        let repo = sanitize(session.get("repo").and_then(Value::as_str).unwrap_or("-"));
1829        let cwd = sanitize(session.get("cwd").and_then(Value::as_str).unwrap_or("-"));
1830        let age = age_secs(session.get("last_seen").and_then(Value::as_str));
1831        out.push_str(&format!(
1832            "\n{state:<13} {agent:<6} {source:<8} {repo:<20} {age:>4}s  {cwd}"
1833        ));
1834    }
1835    out
1836}
1837
1838/// A compact, fixed-width-friendly label for a session state, so the wide
1839/// `waiting_for_permission` does not overflow the STATE column. Falls through to
1840/// the raw (sanitized) string for any unexpected value.
1841fn state_display(state: &str) -> String {
1842    match state {
1843        "waiting_for_permission" => "waiting-perm".to_string(),
1844        "waiting_for_input" => "waiting-input".to_string(),
1845        other => sanitize(other),
1846    }
1847}
1848
1849/// The agent label for a session: `pi` for pi.dev, `codex` for Codex, else
1850/// `claude` (a daemon that predates the `agent` field only ever reports Claude
1851/// Code sessions).
1852fn agent_label(session: &Value) -> &'static str {
1853    match session.get("agent").and_then(Value::as_str) {
1854        Some("pi") => "pi",
1855        Some("codex") => "codex",
1856        _ => "claude",
1857    }
1858}
1859
1860/// The short source label for a session: `vscode` when embedded in a VS Code
1861/// window, else `terminal`.
1862fn source_label(session: &Value) -> &'static str {
1863    match session.pointer("/source/kind").and_then(Value::as_str) {
1864        Some("vs_code") => "vscode",
1865        _ => "terminal",
1866    }
1867}
1868
1869/// Strips control characters from an untrusted registry string so a crafted
1870/// payload cannot inject terminal escape sequences into the rendered table
1871/// (#1137). The `--json` path stays verbatim.
1872fn sanitize(s: &str) -> String {
1873    sanitize_for_terminal(s)
1874}
1875
1876/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
1877fn age_secs(ts: Option<&str>) -> i64 {
1878    ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1879        .map_or(0, |t| {
1880            (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
1881        })
1882}
1883
1884#[cfg(test)]
1885#[allow(clippy::unwrap_used, clippy::expect_used)]
1886mod tests {
1887    #[tokio::test]
1888    async fn hook_diagnostics_identify_gates_without_logging_payload_content() {
1889        let dir = tempfile::tempdir().unwrap();
1890        let command = HookCommand {
1891            socket: Some(dir.path().join("missing.sock")),
1892            agent: HookAgent::Claude,
1893        };
1894        let ((), logs) = crate::test_support::capture_future_at(tracing::Level::DEBUG, async {
1895            for input in ["not json HOOK_CONTENT_SECRET", "{}",
1896                r#"{"session_id":"test"}"#,
1897                r#"{"session_id":"test","hook_event_name":"future-event"}"#,
1898                r#"{"session_id":"test","hook_event_name":"Notification","message":"HOOK_CONTENT_SECRET"}"#] {
1899                command.report(input, None).await;
1900            }
1901        }).await;
1902        for reason in [
1903            "invalid_json",
1904            "missing_session_id",
1905            "missing_event",
1906            "unmapped_event",
1907            "transport_failed",
1908        ] {
1909            assert!(logs.contains(reason), "missing {reason}: {logs}");
1910        }
1911        assert!(logs.contains("session_notification_classified"));
1912        assert!(!logs.contains("HOOK_CONTENT_SECRET"));
1913    }
1914
1915    const JOURNAL_ID: &str = "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4c";
1916
1917    fn journal_hook(event: &str) -> String {
1918        format!(
1919            r#"{{"session_id":"{JOURNAL_ID}","cwd":"/work/repo","hook_event_name":"{event}","prompt":"PROMPT_CONTENT_SECRET","tool_input":{{"command":"TOOL_CONTENT_SECRET"}}}}"#
1920        )
1921    }
1922
1923    fn read_journal(dir: &Path, agent: &str) -> Vec<JournalRecord> {
1924        let path = dir
1925            .join("sessions")
1926            .join(agent)
1927            .join(format!("{JOURNAL_ID}.jsonl"));
1928        let text = std::fs::read_to_string(path).unwrap();
1929        assert!(!text.contains("CONTENT_SECRET"), "{text}");
1930        text.lines()
1931            .map(|line| JournalRecord::parse(line).unwrap())
1932            .collect()
1933    }
1934
1935    #[tokio::test]
1936    async fn a_hook_is_journaled_even_when_the_daemon_is_unreachable() {
1937        let dir = tempfile::tempdir().unwrap();
1938        let command = HookCommand {
1939            socket: Some(dir.path().join("missing.sock")),
1940            agent: HookAgent::Claude,
1941        };
1942        command
1943            .report(&journal_hook("UserPromptSubmit"), Some(77))
1944            .await;
1945        command.report(&journal_hook("Stop"), Some(77)).await;
1946        command.report(&journal_hook("SessionEnd"), Some(77)).await;
1947
1948        let records = read_journal(dir.path(), "claude");
1949        assert_eq!(records.len(), 3);
1950        assert!(records
1951            .iter()
1952            .all(|r| r.agent == Agent::Claude && r.pid() == Some(77)));
1953        assert!(matches!(
1954            &records[0].body,
1955            journal::JournalBody::Observe { event: SessionEvent::UserPromptSubmit, cwd: Some(cwd), .. }
1956                if cwd == Path::new("/work/repo")
1957        ));
1958        assert!(matches!(
1959            &records[1].body,
1960            journal::JournalBody::Observe {
1961                event: SessionEvent::Stop,
1962                ..
1963            }
1964        ));
1965        assert!(matches!(&records[2].body, journal::JournalBody::End { .. }));
1966        // Each event has its own stamp.
1967        let seqs: std::collections::HashSet<_> = records.iter().map(|r| r.seq.clone()).collect();
1968        assert_eq!(seqs.len(), 3);
1969    }
1970
1971    #[tokio::test]
1972    async fn a_codex_hook_is_journaled_under_its_own_agent() {
1973        let dir = tempfile::tempdir().unwrap();
1974        let command = HookCommand {
1975            socket: Some(dir.path().join("missing.sock")),
1976            agent: HookAgent::Codex,
1977        };
1978        command
1979            .report(&journal_hook("PermissionRequest"), None)
1980            .await;
1981        let records = read_journal(dir.path(), "codex");
1982        assert_eq!(records.len(), 1);
1983        assert_eq!(records[0].agent, Agent::Codex);
1984        assert!(!dir.path().join("sessions/claude").exists());
1985    }
1986
1987    #[tokio::test]
1988    async fn events_the_tracker_ignores_and_ids_no_journal_is_named_for_leave_no_journal() {
1989        let dir = tempfile::tempdir().unwrap();
1990        let command = HookCommand {
1991            socket: Some(dir.path().join("missing.sock")),
1992            agent: HookAgent::Claude,
1993        };
1994        command.report(&journal_hook("FutureEvent"), None).await;
1995        command
1996            .report(
1997                r#"{"session_id":"../../escape","hook_event_name":"Stop"}"#,
1998                None,
1999            )
2000            .await;
2001        assert!(!dir.path().join("sessions").exists());
2002        assert!(!dir.path().join("escape").exists());
2003    }
2004
2005    #[tokio::test]
2006    async fn a_journal_write_failure_is_swallowed_and_never_logs_content() {
2007        let dir = tempfile::tempdir().unwrap();
2008        // The journal root is a file, so no journal can be made.
2009        std::fs::write(dir.path().join("sessions"), "").unwrap();
2010        let command = HookCommand {
2011            socket: Some(dir.path().join("missing.sock")),
2012            agent: HookAgent::Claude,
2013        };
2014        let ((), logs) = crate::test_support::capture_future_at(tracing::Level::DEBUG, async {
2015            command.report(&journal_hook("Stop"), None).await;
2016        })
2017        .await;
2018        assert!(logs.contains("journal_failed"), "{logs}");
2019        // It went on to report, which is what the fail-open sink is for.
2020        assert!(logs.contains("transport_failed"), "{logs}");
2021        assert!(!logs.contains("CONTENT_SECRET"), "{logs}");
2022    }
2023
2024    #[test]
2025    fn the_wire_payload_carries_the_stamp_only_when_given_one() {
2026        let stamp = journal::new_stamp();
2027        let hook = |event: &str| serde_json::from_str::<HookPayload>(&journal_hook(event)).unwrap();
2028        for (event, op) in [("Stop", "observe"), ("SessionEnd", "end")] {
2029            let hook_op = hook(event).to_hook_op(HookAgent::Claude, Some(5)).unwrap();
2030            let (name, bare) = hook_op.wire(HookAgent::Claude, None).unwrap();
2031            assert_eq!(name, op);
2032            assert!(bare.get("stamp").is_none(), "{event}");
2033            let (_, stamped) = hook_op.wire(HookAgent::Claude, Some(&stamp)).unwrap();
2034            assert_eq!(stamped["stamp"]["seq"], stamp.seq.as_str());
2035            assert_eq!(
2036                serde_json::from_value::<EventStamp>(stamped["stamp"].clone()).unwrap(),
2037                stamp
2038            );
2039            // The stamp is the only difference from the unstamped payload.
2040            let mut without = stamped.clone();
2041            without.as_object_mut().unwrap().remove("stamp");
2042            assert_eq!(without, bare);
2043            // The journal record is made from the same value.
2044            let record = hook_op.record(&stamp);
2045            assert_eq!(record.stamp(), stamp);
2046            assert_eq!(record.session_id, JOURNAL_ID);
2047        }
2048    }
2049
2050    use super::*;
2051    use crate::sessions::SessionState;
2052
2053    /// Mirrors the `omni-dev sessions` argv surface for parse tests.
2054    #[derive(Parser)]
2055    struct Wrapper {
2056        #[command(subcommand)]
2057        cmd: SessionsSubcommands,
2058    }
2059
2060    fn parse(args: &[&str]) -> SessionsSubcommands {
2061        let mut full = vec!["omni-dev"];
2062        full.extend_from_slice(args);
2063        Wrapper::try_parse_from(full).unwrap().cmd
2064    }
2065
2066    #[test]
2067    fn subcommands_parse() {
2068        assert!(matches!(parse(&["list"]), SessionsSubcommands::List(_)));
2069        assert!(matches!(parse(&["hook"]), SessionsSubcommands::Hook(_)));
2070        assert!(matches!(
2071            parse(&["install-hooks"]),
2072            SessionsSubcommands::InstallHooks(_)
2073        ));
2074        assert!(matches!(
2075            parse(&["uninstall-hooks"]),
2076            SessionsSubcommands::UninstallHooks(_)
2077        ));
2078        assert!(matches!(
2079            parse(&["install-wrapper"]),
2080            SessionsSubcommands::InstallWrapper(_)
2081        ));
2082        assert!(matches!(
2083            parse(&["uninstall-wrapper"]),
2084            SessionsSubcommands::UninstallWrapper(_)
2085        ));
2086    }
2087
2088    #[test]
2089    fn list_parses_flags() {
2090        let cmd =
2091            ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
2092        assert_eq!(cmd.output, TableOrJson::Json);
2093        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
2094    }
2095
2096    // --- hook mapping --------------------------------------------------------
2097
2098    fn hook_op(json_str: &str) -> Option<(&'static str, Value)> {
2099        serde_json::from_str::<HookPayload>(json_str)
2100            .unwrap()
2101            .to_op(HookAgent::Claude, None)
2102    }
2103
2104    #[test]
2105    fn hook_maps_lifecycle_events_to_observe() {
2106        let (op, payload) = hook_op(
2107            r#"{"session_id":"s1","cwd":"/p","transcript_path":"/t.jsonl","hook_event_name":"PreToolUse"}"#,
2108        )
2109        .unwrap();
2110        assert_eq!(op, "observe");
2111        assert_eq!(payload["session_id"], "s1");
2112        assert_eq!(payload["cwd"], "/p");
2113        assert_eq!(payload["event"], "pre_tool_use");
2114    }
2115
2116    #[test]
2117    fn hook_maps_session_start_and_stop() {
2118        assert_eq!(
2119            hook_op(r#"{"session_id":"s1","hook_event_name":"SessionStart"}"#)
2120                .unwrap()
2121                .1["event"],
2122            "session_start"
2123        );
2124        assert_eq!(
2125            hook_op(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
2126                .unwrap()
2127                .1["event"],
2128            "stop"
2129        );
2130    }
2131
2132    #[test]
2133    fn hook_maps_session_end_to_end_op() {
2134        let (op, payload) =
2135            hook_op(r#"{"session_id":"s1","hook_event_name":"SessionEnd","message":"exit"}"#)
2136                .unwrap();
2137        assert_eq!(op, "end");
2138        assert_eq!(payload["session_id"], "s1");
2139        assert_eq!(payload["reason"], "exit");
2140    }
2141
2142    #[test]
2143    fn hook_sends_the_agent_pid_on_both_ops_when_known() {
2144        let hook = |json_str: &str, pid| {
2145            serde_json::from_str::<HookPayload>(json_str)
2146                .unwrap()
2147                .to_op(HookAgent::Claude, pid)
2148                .unwrap()
2149        };
2150        let start = r#"{"session_id":"s1","hook_event_name":"SessionStart"}"#;
2151        let end = r#"{"session_id":"s1","hook_event_name":"SessionEnd"}"#;
2152        assert_eq!(hook(start, Some(42)).1["pid"], 42);
2153        assert_eq!(hook(end, Some(42)).1["pid"], 42);
2154        // Unknown: omitted from the wire, exactly as before #1948.
2155        assert!(hook(start, None).1.get("pid").is_none());
2156        assert!(hook(end, None).1.get("pid").is_none());
2157    }
2158
2159    #[test]
2160    fn agent_pid_treats_an_orphaned_hook_as_unknown() {
2161        assert_eq!(agent_pid(4242), Some(4242));
2162        assert_eq!(agent_pid(1), None);
2163        assert_eq!(agent_pid(0), None);
2164    }
2165
2166    #[test]
2167    fn hook_classifies_notifications() {
2168        let permission = hook_op(
2169            r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}"#,
2170        )
2171        .unwrap();
2172        assert_eq!(permission.1["event"]["notification"], "permission_prompt");
2173
2174        let idle = hook_op(
2175            r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude is waiting for your input"}"#,
2176        )
2177        .unwrap();
2178        assert_eq!(idle.1["event"]["notification"], "idle_prompt");
2179
2180        let other = hook_op(
2181            r#"{"session_id":"s1","hook_event_name":"Notification","message":"something else"}"#,
2182        )
2183        .unwrap();
2184        assert_eq!(other.1["event"]["notification"], "other");
2185    }
2186
2187    #[test]
2188    fn hook_ignores_unknown_events_and_missing_session_id() {
2189        // No session_id → no op.
2190        assert!(hook_op(r#"{"hook_event_name":"Stop"}"#).is_none());
2191        // Blank session_id → no op.
2192        assert!(hook_op(r#"{"session_id":"  ","hook_event_name":"Stop"}"#).is_none());
2193        // Unknown event → no op.
2194        assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"CwdChanged"}"#).is_none());
2195        // Garbage that still parses as an (empty) payload → no op.
2196        assert!(hook_op("{}").is_none());
2197    }
2198
2199    fn codex_op(json_str: &str) -> Option<(&'static str, Value)> {
2200        serde_json::from_str::<HookPayload>(json_str)
2201            .unwrap()
2202            .to_op(HookAgent::Codex, None)
2203    }
2204
2205    fn codex_event(event: &str, tool: Option<&str>) -> Value {
2206        let mut hook = json!({ "session_id": "c1", "hook_event_name": event });
2207        if let Some(tool) = tool {
2208            hook["tool_name"] = json!(tool);
2209        }
2210        codex_op(&hook.to_string()).unwrap().1["event"].clone()
2211    }
2212
2213    #[test]
2214    fn codex_hooks_are_tagged_from_the_first_event() {
2215        let (op, payload) = codex_op(
2216            r#"{"session_id":"c1","cwd":"/p","model":"gpt-5.5","hook_event_name":"SessionStart","source":"startup"}"#,
2217        )
2218        .unwrap();
2219        assert_eq!(op, "observe");
2220        assert_eq!(payload["agent"], "codex");
2221        assert_eq!(payload["model"], "gpt-5.5");
2222        assert_eq!(payload["event"], "session_start");
2223        // The Claude sink stays untagged on the wire.
2224        let claude = hook_op(r#"{"session_id":"s1","hook_event_name":"Stop"}"#).unwrap();
2225        assert!(claude.1.get("agent").is_none());
2226    }
2227
2228    #[test]
2229    fn codex_events_map_onto_the_existing_session_events() {
2230        assert_eq!(codex_event("UserPromptSubmit", None), "user_prompt_submit");
2231        assert_eq!(codex_event("PreToolUse", Some("Bash")), "pre_tool_use");
2232        assert_eq!(codex_event("PostToolUse", Some("Bash")), "post_tool_use");
2233        assert_eq!(codex_event("Stop", None), "stop");
2234        let compact =
2235            json!({ "session_id": "c1", "hook_event_name": "SessionStart", "source": "compact" });
2236        assert_eq!(
2237            codex_op(&compact.to_string()).unwrap().1["event"],
2238            "transcript_discovered",
2239            "a compaction start is not a new session (#1946)"
2240        );
2241        assert_eq!(
2242            codex_event("PermissionRequest", Some("Bash"))["notification"],
2243            "permission_prompt"
2244        );
2245        assert_eq!(
2246            codex_event("PreToolUse", Some("request_user_input"))["notification"],
2247            "agent_needs_input"
2248        );
2249        // Its resolution is an ordinary PostToolUse.
2250        assert_eq!(
2251            codex_event("PostToolUse", Some("request_user_input")),
2252            "post_tool_use"
2253        );
2254        assert_eq!(codex_event("Interrupt", None), "stop");
2255        assert_eq!(codex_event("SubagentStart", None), "post_tool_use");
2256        // Each of these can fire on an idle session, so none may turn it working.
2257        for preserving in ["SubagentStop", "PreCompact", "PostCompact"] {
2258            assert_eq!(
2259                codex_event(preserving, None),
2260                "transcript_discovered",
2261                "{preserving}"
2262            );
2263        }
2264        // Codex has no Notification event; an unknown one is ignored.
2265        assert!(codex_op(r#"{"session_id":"c1","hook_event_name":"Notification"}"#).is_none());
2266    }
2267
2268    #[test]
2269    fn codex_session_end_is_the_end_op_with_its_reason() {
2270        let (op, payload) =
2271            codex_op(r#"{"session_id":"c1","hook_event_name":"SessionEnd","reason":"other"}"#)
2272                .unwrap();
2273        assert_eq!(op, "end");
2274        assert_eq!(payload["reason"], "other");
2275    }
2276
2277    #[test]
2278    fn the_claude_sink_ignores_codex_only_events() {
2279        // Codex's `Interrupt` and its `request_user_input` tool have no Claude
2280        // equivalent, so the untagged sink keeps its own mapping for both.
2281        assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"Interrupt"}"#).is_none());
2282        assert_eq!(
2283            hook_op(r#"{"session_id":"s1","hook_event_name":"PreToolUse","tool_name":"request_user_input"}"#)
2284                .unwrap()
2285                .1["event"],
2286            "pre_tool_use"
2287        );
2288    }
2289
2290    fn claude_event(event: &str) -> Value {
2291        let hook = json!({ "session_id": "s1", "hook_event_name": event });
2292        hook_op(&hook.to_string()).unwrap().1["event"].clone()
2293    }
2294
2295    #[test]
2296    fn claude_newer_events_map_onto_the_existing_session_events() {
2297        assert_eq!(
2298            claude_event("PermissionRequest")["notification"],
2299            "permission_prompt"
2300        );
2301        assert_eq!(
2302            claude_event("Elicitation")["notification"],
2303            "agent_needs_input"
2304        );
2305        for working in [
2306            "PostToolUseFailure",
2307            "PermissionDenied",
2308            "ElicitationResult",
2309            "SubagentStart",
2310        ] {
2311            assert_eq!(claude_event(working), "post_tool_use", "{working}");
2312        }
2313        assert_eq!(claude_event("StopFailure"), "stop");
2314        // Liveness-only sightings: each can fire while the session is idle (a
2315        // background subagent, a manual /compact) and must not turn it working.
2316        for preserving in ["SubagentStop", "PreCompact", "PostCompact"] {
2317            assert_eq!(
2318                claude_event(preserving),
2319                "transcript_discovered",
2320                "{preserving}"
2321            );
2322        }
2323    }
2324
2325    #[test]
2326    fn every_installed_claude_event_is_mapped_by_the_sink() {
2327        // An installed event the sink ignores would spawn a process per event
2328        // for nothing.
2329        for HookSpec { event, .. } in HOOK_EVENTS {
2330            let hook = json!({ "session_id": "s1", "hook_event_name": event });
2331            assert!(hook_op(&hook.to_string()).is_some(), "{event} is unmapped");
2332        }
2333    }
2334
2335    #[test]
2336    fn a_finished_subagent_or_compaction_leaves_an_idle_session_idle() {
2337        for preserving in ["SubagentStop", "PreCompact", "PostCompact"] {
2338            let hook = json!({ "session_id": "s1", "hook_event_name": preserving });
2339            let (_, payload) = hook_op(&hook.to_string()).unwrap();
2340            let event: SessionEvent = serde_json::from_value(payload["event"].clone()).unwrap();
2341            assert_eq!(
2342                SessionState::for_event(&event, Some(SessionState::Idle)),
2343                SessionState::Idle,
2344                "{preserving}"
2345            );
2346            assert_eq!(
2347                SessionState::for_event(&event, Some(SessionState::WaitingForPermission)),
2348                SessionState::WaitingForPermission,
2349                "{preserving}"
2350            );
2351        }
2352    }
2353
2354    fn observe_claude_hook(reg: &crate::sessions::SessionsRegistry, event: &str, id: Option<&str>) {
2355        let hook = json!({"session_id": "s1", "hook_event_name": event, "agent_id": id});
2356        let (_, payload) = hook_op(&hook.to_string()).unwrap();
2357        reg.observe(serde_json::from_value(payload).unwrap());
2358    }
2359
2360    #[test]
2361    fn background_subagent_tools_preserve_parent_state_and_refresh_liveness() {
2362        use crate::sessions::SessionsRegistry;
2363        for (parent, expected) in [
2364            ("Stop", SessionState::Idle),
2365            ("UserPromptSubmit", SessionState::Working),
2366        ] {
2367            let reg = SessionsRegistry::new();
2368            observe_claude_hook(&reg, parent, None);
2369            for event in [
2370                "PreToolUse",
2371                "PostToolUse",
2372                "PostToolUseFailure",
2373                "PermissionDenied",
2374                "SubagentStop",
2375            ] {
2376                let before = reg.list()[0].last_seen;
2377                observe_claude_hook(&reg, event, Some("child"));
2378                assert_eq!(reg.list()[0].state, expected, "{parent}: {event}");
2379                assert!(reg.list()[0].last_seen >= before);
2380            }
2381        }
2382    }
2383
2384    #[test]
2385    fn subagent_completion_releases_only_its_own_wait_to_parent_state() {
2386        use crate::sessions::SessionsRegistry;
2387        for (parent, expected) in [
2388            ("Stop", SessionState::Idle),
2389            ("UserPromptSubmit", SessionState::Working),
2390        ] {
2391            for (wait, waiting, completions) in [
2392                (
2393                    "PermissionRequest",
2394                    SessionState::WaitingForPermission,
2395                    vec![
2396                        "PostToolUse",
2397                        "PostToolUseFailure",
2398                        "PermissionDenied",
2399                        "SubagentStop",
2400                    ],
2401                ),
2402                (
2403                    "Elicitation",
2404                    SessionState::WaitingForInput,
2405                    vec!["ElicitationResult", "SubagentStop"],
2406                ),
2407            ] {
2408                for completion in completions {
2409                    let reg = SessionsRegistry::new();
2410                    observe_claude_hook(&reg, parent, None);
2411                    observe_claude_hook(&reg, wait, Some("a"));
2412                    observe_claude_hook(&reg, "PostToolUse", Some("b"));
2413                    observe_claude_hook(&reg, "PreToolUse", Some("a"));
2414                    observe_claude_hook(&reg, "Notification", Some("a"));
2415                    assert_eq!(reg.list()[0].state, waiting);
2416                    observe_claude_hook(&reg, completion, Some("a"));
2417                    assert_eq!(
2418                        reg.list()[0].state,
2419                        expected,
2420                        "{parent}: {wait}/{completion}"
2421                    );
2422                }
2423            }
2424        }
2425    }
2426
2427    #[test]
2428    fn overlapping_subagent_waits_resolve_independently() {
2429        let reg = crate::sessions::SessionsRegistry::new();
2430        observe_claude_hook(&reg, "Stop", None);
2431        observe_claude_hook(&reg, "PermissionRequest", Some("a"));
2432        observe_claude_hook(&reg, "Elicitation", Some("b"));
2433        assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
2434        observe_claude_hook(&reg, "PostToolUse", Some("a"));
2435        assert_eq!(reg.list()[0].state, SessionState::WaitingForInput);
2436        observe_claude_hook(&reg, "ElicitationResult", Some("b"));
2437        assert_eq!(reg.list()[0].state, SessionState::Idle);
2438    }
2439
2440    #[test]
2441    fn first_subagent_wait_and_parent_wait_release_remain_supported() {
2442        let reg = crate::sessions::SessionsRegistry::new();
2443        observe_claude_hook(&reg, "PermissionRequest", Some("a"));
2444        assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
2445        observe_claude_hook(&reg, "PostToolUse", None);
2446        assert_eq!(reg.list()[0].state, SessionState::Working);
2447        observe_claude_hook(&reg, "Stop", None);
2448        observe_claude_hook(&reg, "PostToolUse", Some("a"));
2449        assert_eq!(reg.list()[0].state, SessionState::Idle);
2450        observe_claude_hook(&reg, "PermissionRequest", None);
2451        observe_claude_hook(&reg, "PostToolUse", Some("b"));
2452        assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
2453    }
2454
2455    #[test]
2456    fn subagent_notifications_survive_passive_evidence_and_end_ignores_late_tools() {
2457        let reg = crate::sessions::SessionsRegistry::new();
2458        observe_claude_hook(&reg, "Stop", None);
2459        let hook = json!({"session_id":"s1", "hook_event_name":"Notification", "agent_id":"a", "notification_type":"permission_prompt"});
2460        let (_, payload) = hook_op(&hook.to_string()).unwrap();
2461        reg.observe(serde_json::from_value(payload).unwrap());
2462        for event in [
2463            SessionEvent::TranscriptDiscovered,
2464            SessionEvent::TranscriptGrew,
2465        ] {
2466            let req: ObserveRequest =
2467                serde_json::from_value(json!({"session_id":"s1", "event":event})).unwrap();
2468            reg.observe(req);
2469            assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
2470        }
2471        observe_claude_hook(&reg, "PostToolUse", Some("a"));
2472        assert_eq!(reg.list()[0].state, SessionState::Idle);
2473        reg.end("s1", None, None);
2474        let before = reg.list()[0].last_seen;
2475        observe_claude_hook(&reg, "PostToolUse", Some("a"));
2476        assert_eq!(reg.list()[0].state, SessionState::Ended);
2477        assert_eq!(reg.list()[0].last_seen, before);
2478    }
2479
2480    #[test]
2481    fn subagent_identity_is_optional_and_claude_only() {
2482        for id in [None, Some(""), Some("  ")] {
2483            let hook = json!({"session_id":"s1", "hook_event_name":"PostToolUse", "agent_id":id});
2484            let payload = hook_op(&hook.to_string()).unwrap().1;
2485            assert!(payload.get("agent_id").is_none());
2486            let request: ObserveRequest = serde_json::from_value(payload).unwrap();
2487            assert!(request.agent_id.is_none());
2488        }
2489        let hook = json!({"session_id":"c1", "hook_event_name":"PostToolUse", "agent_id":"child"});
2490        assert!(codex_op(&hook.to_string())
2491            .unwrap()
2492            .1
2493            .get("agent_id")
2494            .is_none());
2495    }
2496
2497    #[test]
2498    fn a_codex_subagent_stop_or_compaction_leaves_an_idle_session_idle() {
2499        for preserving in ["SubagentStop", "PreCompact", "PostCompact"] {
2500            let hook = json!({ "session_id": "c1", "hook_event_name": preserving });
2501            let (_, payload) = codex_op(&hook.to_string()).unwrap();
2502            let event: SessionEvent = serde_json::from_value(payload["event"].clone()).unwrap();
2503            assert_eq!(
2504                SessionState::for_event(&event, Some(SessionState::Idle)),
2505                SessionState::Idle,
2506                "{preserving}"
2507            );
2508            assert_eq!(
2509                SessionState::for_event(&event, Some(SessionState::WaitingForPermission)),
2510                SessionState::WaitingForPermission,
2511                "{preserving}"
2512            );
2513        }
2514    }
2515
2516    #[test]
2517    fn a_compaction_session_start_preserves_state() {
2518        let event_for = |source: Option<&str>| {
2519            let mut hook = json!({ "session_id": "s1", "hook_event_name": "SessionStart" });
2520            if let Some(source) = source {
2521                hook["source"] = json!(source);
2522            }
2523            let (_, payload) = hook_op(&hook.to_string()).unwrap();
2524            serde_json::from_value::<SessionEvent>(payload["event"].clone()).unwrap()
2525        };
2526        // Claude fires it after a compaction, possibly mid-turn: a working
2527        // session must stay working, an idle one idle (#1946).
2528        let compact = event_for(Some("compact"));
2529        assert_eq!(compact, SessionEvent::TranscriptDiscovered);
2530        for current in [SessionState::Working, SessionState::Idle] {
2531            assert_eq!(SessionState::for_event(&compact, Some(current)), current);
2532        }
2533        for start in [Some("startup"), Some("resume"), Some("clear"), None] {
2534            assert_eq!(event_for(start), SessionEvent::SessionStart, "{start:?}");
2535        }
2536    }
2537
2538    #[test]
2539    fn hook_classifies_notifications_by_type_before_message() {
2540        let typed = |ty: &str, msg: &str| {
2541            let hook = json!({
2542                "session_id": "s1",
2543                "hook_event_name": "Notification",
2544                "notification_type": ty,
2545                "message": msg,
2546            });
2547            hook_op(&hook.to_string()).unwrap().1["event"]["notification"].clone()
2548        };
2549        // The type wins over a message the substring match would misread.
2550        assert_eq!(
2551            typed("permission_prompt", "something else"),
2552            "permission_prompt"
2553        );
2554        assert_eq!(typed("idle_prompt", "Please approve"), "idle_prompt");
2555        assert_eq!(typed("elicitation_dialog", "x"), "agent_needs_input");
2556        // A type we do not map is `other`, never a guess at its message.
2557        assert_eq!(typed("auth_success", "You can now allow access"), "other");
2558        // With no type at all (an older version), the message is classified.
2559        let untyped = hook_op(
2560            r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude needs your permission"}"#,
2561        )
2562        .unwrap();
2563        assert_eq!(untyped.1["event"]["notification"], "permission_prompt");
2564    }
2565
2566    #[test]
2567    fn hook_parses_the_agent_flag() {
2568        match parse(&["hook", "--agent", "codex"]) {
2569            SessionsSubcommands::Hook(cmd) => assert_eq!(cmd.agent, HookAgent::Codex),
2570            _ => panic!("expected hook"),
2571        }
2572        match parse(&["hook"]) {
2573            SessionsSubcommands::Hook(cmd) => assert_eq!(cmd.agent, HookAgent::Claude),
2574            _ => panic!("expected hook"),
2575        }
2576        match parse(&["install-hooks", "--codex-home", "/x/codex"]) {
2577            SessionsSubcommands::InstallHooks(cmd) => {
2578                assert_eq!(cmd.codex_home, Some(PathBuf::from("/x/codex")));
2579            }
2580            _ => panic!("expected install-hooks"),
2581        }
2582    }
2583
2584    // --- Codex hooks.json ---------------------------------------------------
2585
2586    const TAGGED: &str = "/bin/omni-dev sessions hook --agent codex";
2587
2588    /// The `(group index, commands)` layout of one event, for position checks.
2589    fn layout(hooks: &Value, event: &str) -> Vec<Vec<String>> {
2590        hooks["hooks"][event]
2591            .as_array()
2592            .map(|groups| {
2593                groups
2594                    .iter()
2595                    .map(|g| {
2596                        g["hooks"]
2597                            .as_array()
2598                            .unwrap()
2599                            .iter()
2600                            .map(|h| h["command"].as_str().unwrap().to_string())
2601                            .collect()
2602                    })
2603                    .collect()
2604            })
2605            .unwrap_or_default()
2606    }
2607
2608    #[test]
2609    fn is_sessions_sink_matches_any_omni_dev_sink_and_nothing_else() {
2610        assert!(is_sessions_sink(
2611            "/Users/me/.cargo/bin/omni-dev sessions hook"
2612        ));
2613        assert!(is_sessions_sink("omni-dev sessions hook"));
2614        assert!(is_sessions_sink(TAGGED));
2615        assert!(is_sessions_sink("omni-dev sessions hook --socket /x"));
2616        assert!(is_sessions_sink(
2617            "/old/omni-dev sessions hook --agent codex"
2618        ));
2619        assert!(!is_sessions_sink("/bin/other sessions hook"));
2620        assert!(!is_sessions_sink("omni-dev sessions list"));
2621        assert!(!is_sessions_sink("omni-dev sessions hooks"));
2622        assert!(!is_sessions_sink("my-omni-dev sessions hook"));
2623    }
2624
2625    #[test]
2626    fn codex_install_rewrites_a_moved_binarys_tagged_entry_in_place() {
2627        let mut hooks = json!({ "hooks": { "Stop": [
2628            { "hooks": [{ "type": "command", "command": "/old/omni-dev sessions hook --agent codex" }] },
2629            { "hooks": [{ "type": "command", "command": "omni-dev sessions hook --socket /x" }] },
2630            { "hooks": [{ "type": "command", "command": "mine" }] }
2631        ] } });
2632        assert_eq!(
2633            replace_stale_sinks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS, is_sessions_sink),
2634            2
2635        );
2636        assert_eq!(
2637            layout(&hooks, "Stop"),
2638            vec![vec![TAGGED], vec![TAGGED], vec!["mine"]]
2639        );
2640        // Already tagged by the current binary: nothing added under Stop.
2641        merge_hooks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS);
2642        assert_eq!(layout(&hooks, "Stop").len(), 3);
2643    }
2644
2645    #[test]
2646    fn codex_install_replaces_untagged_sinks_in_place_and_never_reorders() {
2647        let mut hooks = json!({ "hooks": {
2648            "PreToolUse": [
2649                { "matcher": "Bash", "hooks": [{ "type": "command", "command": "guard" }] },
2650                { "matcher": "*", "hooks": [{ "type": "command", "command": "/old/omni-dev sessions hook" }] },
2651                { "hooks": [{ "type": "command", "command": "audit" }] }
2652            ],
2653            "SessionEnd": [
2654                { "hooks": [{ "type": "command", "command": "omni-dev sessions hook" }] }
2655            ]
2656        }});
2657        let replaced = replace_stale_sinks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS, is_sessions_sink);
2658        assert_eq!(replaced, 2);
2659        let added = merge_hooks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS);
2660        // Only the events without a (now tagged) sink got a new group.
2661        assert_eq!(added, CODEX_HOOK_EVENTS.len() - 2);
2662        // The untagged sink became the tagged one where it sat; nothing moved.
2663        assert_eq!(
2664            layout(&hooks, "PreToolUse"),
2665            vec![vec!["guard"], vec![TAGGED], vec!["audit"]]
2666        );
2667        assert_eq!(hooks["hooks"]["PreToolUse"][1]["matcher"], "*");
2668        // The capped event picked up its timeout in place.
2669        assert_eq!(hooks["hooks"]["SessionEnd"][0]["hooks"][0]["timeout"], 3);
2670        // Appended groups carry the matcher and timeout their event needs.
2671        assert_eq!(hooks["hooks"]["PostToolUse"][0]["matcher"], "*");
2672        assert_eq!(hooks["hooks"]["Interrupt"][0]["hooks"][0]["timeout"], 3);
2673        assert!(hooks["hooks"]["Stop"][0]["hooks"][0]
2674            .get("timeout")
2675            .is_none());
2676        assert!(hooks["hooks"].get("Notification").is_none());
2677        // Idempotent.
2678        assert_eq!(
2679            replace_stale_sinks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS, is_sessions_sink),
2680            0
2681        );
2682        assert_eq!(merge_hooks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS), 0);
2683    }
2684
2685    #[test]
2686    fn codex_uninstall_never_shifts_a_surviving_group() {
2687        let mut hooks = json!({ "hooks": {
2688            "Stop": [
2689                { "hooks": [{ "type": "command", "command": TAGGED }] },
2690                { "hooks": [{ "type": "command", "command": "user-a" }] },
2691                { "hooks": [{ "type": "command", "command": "omni-dev sessions hook" }] },
2692                { "hooks": [{ "type": "command", "command": "user-b" }] },
2693                { "hooks": [{ "type": "command", "command": TAGGED }] }
2694            ],
2695            "PreToolUse": [
2696                { "hooks": [
2697                    { "type": "command", "command": TAGGED },
2698                    { "type": "command", "command": "shared" }
2699                ] }
2700            ],
2701            "Interrupt": [
2702                { "hooks": [] },
2703                { "hooks": [{ "type": "command", "command": TAGGED }] }
2704            ],
2705            "PostToolUse": [
2706                { "hooks": [{ "type": "command", "command": "keep" }] }
2707            ]
2708        }});
2709        let removal = remove_hooks_stable(&mut hooks, is_sessions_sink);
2710        assert_eq!(removal.removed, 5);
2711        // `shared` moved from hook index 1 to 0 within its group.
2712        assert!(removal.shifted);
2713        // Emptied non-trailing groups stay as placeholders, so user-a and user-b
2714        // keep their indices (1 and 3); the trailing emptied group is dropped.
2715        let stop = hooks["hooks"]["Stop"].as_array().unwrap();
2716        assert_eq!(stop.len(), 4);
2717        assert_eq!(stop[0], json!({ "hooks": [] }));
2718        assert_eq!(layout(&hooks, "Stop")[1], vec!["user-a"]);
2719        assert_eq!(stop[2], json!({ "hooks": [] }));
2720        assert_eq!(layout(&hooks, "Stop")[3], vec!["user-b"]);
2721        // A group with other hooks keeps them.
2722        assert_eq!(layout(&hooks, "PreToolUse"), vec![vec!["shared"]]);
2723        // Trailing placeholders, then the empty event, are dropped.
2724        assert!(hooks["hooks"].get("Interrupt").is_none());
2725        assert_eq!(layout(&hooks, "PostToolUse"), vec![vec!["keep"]]);
2726        // A second pass removes nothing.
2727        assert_eq!(
2728            remove_hooks_stable(&mut hooks, is_sessions_sink),
2729            Removal::default()
2730        );
2731    }
2732
2733    #[test]
2734    fn remove_hooks_stable_does_not_report_a_shift_for_a_trailing_hook() {
2735        let mut hooks = json!({ "hooks": { "Stop": [ { "hooks": [
2736            { "type": "command", "command": "shared" },
2737            { "type": "command", "command": TAGGED }
2738        ] } ] } });
2739        let removal = remove_hooks_stable(&mut hooks, is_sessions_sink);
2740        assert_eq!(
2741            removal,
2742            Removal {
2743                removed: 1,
2744                shifted: false
2745            }
2746        );
2747        assert_eq!(layout(&hooks, "Stop"), vec![vec!["shared"]]);
2748    }
2749
2750    #[test]
2751    fn remove_hooks_stable_tolerates_malformed_shapes() {
2752        let none = Removal::default();
2753        assert_eq!(remove_hooks_stable(&mut json!([]), |_| true), none);
2754        assert_eq!(remove_hooks_stable(&mut json!({}), |_| true), none);
2755        let mut odd = json!({ "hooks": { "Stop": 5, "Start": [ { "matcher": "x" } ] } });
2756        assert_eq!(remove_hooks_stable(&mut odd, |_| true), none);
2757        assert_eq!(odd["hooks"]["Stop"], 5);
2758        assert_eq!(
2759            replace_stale_sinks(&mut json!({}), TAGGED, CODEX_HOOK_EVENTS, is_sessions_sink),
2760            0
2761        );
2762        assert_eq!(
2763            replace_stale_sinks(
2764                &mut json!({ "hooks": { "Stop": 5 } }),
2765                TAGGED,
2766                CODEX_HOOK_EVENTS,
2767                is_sessions_sink
2768            ),
2769            0
2770        );
2771    }
2772
2773    #[test]
2774    fn codex_is_present_via_its_home_or_a_codex_executable_on_path() {
2775        use std::os::unix::fs::PermissionsExt;
2776
2777        let tmp = tempfile::tempdir().unwrap();
2778        let home = tmp.path().join("codex-home");
2779        let bin = tmp.path().join("bin");
2780        std::fs::create_dir_all(&bin).unwrap();
2781        let path = std::env::join_paths([&bin]).unwrap();
2782        assert!(!codex_is_present(&home, Some(&path)));
2783        assert!(!codex_is_present(&home, None));
2784        let exe = bin.join("codex");
2785        std::fs::write(&exe, "#!/bin/sh\n").unwrap();
2786        std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
2787        assert!(codex_is_present(&home, Some(&path)));
2788        std::fs::create_dir_all(&home).unwrap();
2789        assert!(codex_is_present(&home, None));
2790    }
2791
2792    #[test]
2793    fn codex_hook_command_is_the_sink_tagged_codex() {
2794        assert_eq!(
2795            codex_hook_command(),
2796            format!("{} --agent codex", hook_command())
2797        );
2798        assert!(codex_hook_command().ends_with(" sessions hook --agent codex"));
2799    }
2800
2801    #[test]
2802    fn codex_install_and_uninstall_round_trip_through_the_file() {
2803        let tmp = tempfile::tempdir().unwrap();
2804        let home = tmp.path().join("codex-home");
2805        std::fs::create_dir_all(&home).unwrap();
2806        let file = home.join("hooks.json");
2807        std::fs::write(
2808            &file,
2809            json!({ "hooks": { "Stop": [
2810                { "hooks": [{ "type": "command", "command": "omni-dev sessions hook" }] },
2811                { "hooks": [{ "type": "command", "command": "mine" }] }
2812            ] } })
2813            .to_string(),
2814        )
2815        .unwrap();
2816        install_codex_hooks(Some(home.clone())).unwrap();
2817        let tagged = codex_hook_command();
2818        let installed = read_settings(&file).unwrap();
2819        assert_eq!(
2820            layout(&installed, "Stop"),
2821            vec![vec![tagged.clone()], vec!["mine".to_string()]]
2822        );
2823        assert_eq!(layout(&installed, "Interrupt"), vec![vec![tagged]]);
2824        // A second install is the no-change branch and leaves the file as is.
2825        install_codex_hooks(Some(home.clone())).unwrap();
2826        assert_eq!(read_settings(&file).unwrap(), installed);
2827
2828        uninstall_codex_hooks(Some(home.clone())).unwrap();
2829        let removed = read_settings(&file).unwrap();
2830        assert_eq!(
2831            removed,
2832            json!({ "hooks": { "Stop": [ { "hooks": [] }, { "hooks": [{ "type": "command", "command": "mine" }] } ] } })
2833        );
2834        // Nothing left to remove, and a missing home is a no-op.
2835        uninstall_codex_hooks(Some(home)).unwrap();
2836        uninstall_codex_hooks(Some(tmp.path().join("absent"))).unwrap();
2837    }
2838
2839    #[test]
2840    fn install_codex_hooks_via_the_default_home_skips_absent_and_writes_present() {
2841        let _guard = PI_ENV_LOCK
2842            .lock()
2843            .unwrap_or_else(std::sync::PoisonError::into_inner);
2844        let saved = ["CODEX_HOME", "PATH", "HOME"].map(|k| (k, std::env::var_os(k)));
2845
2846        let tmp = tempfile::tempdir().unwrap();
2847        let home = tmp.path().join("codex-home");
2848        let empty_bin = tmp.path().join("bin");
2849        std::fs::create_dir_all(&empty_bin).unwrap();
2850        std::env::set_var("CODEX_HOME", &home);
2851        std::env::set_var("PATH", std::env::join_paths([&empty_bin]).unwrap());
2852
2853        assert_eq!(default_codex_home().unwrap(), home);
2854        install_codex_hooks(None).unwrap();
2855        assert!(
2856            !home.exists(),
2857            "install must not create an absent Codex home"
2858        );
2859        uninstall_codex_hooks(None).unwrap();
2860
2861        std::fs::create_dir_all(&home).unwrap();
2862        install_codex_hooks(None).unwrap();
2863        assert!(home.join("hooks.json").exists());
2864        uninstall_codex_hooks(None).unwrap();
2865        assert_eq!(
2866            read_settings(&home.join("hooks.json")).unwrap(),
2867            json!({ "hooks": {} })
2868        );
2869
2870        // An empty CODEX_HOME falls back to `$HOME/.codex`.
2871        std::env::set_var("CODEX_HOME", "");
2872        std::env::set_var("HOME", "/home/tester");
2873        assert_eq!(
2874            default_codex_home().unwrap(),
2875            PathBuf::from("/home/tester/.codex")
2876        );
2877
2878        for (k, v) in saved {
2879            match v {
2880                Some(val) => std::env::set_var(k, val),
2881                None => std::env::remove_var(k),
2882            }
2883        }
2884    }
2885
2886    #[test]
2887    fn classify_notification_covers_cases() {
2888        assert_eq!(
2889            classify_notification(None, Some("Please approve this")),
2890            NotificationKind::PermissionPrompt
2891        );
2892        assert_eq!(
2893            classify_notification(None, Some("Claude is idle")),
2894            NotificationKind::IdlePrompt
2895        );
2896        assert_eq!(classify_notification(None, None), NotificationKind::Other);
2897        assert_eq!(
2898            classify_notification(Some("idle_prompt"), None),
2899            NotificationKind::IdlePrompt
2900        );
2901    }
2902
2903    // --- install / uninstall hooks ------------------------------------------
2904
2905    #[test]
2906    fn merge_hooks_is_idempotent_and_additive() {
2907        // A pre-existing unrelated hook must survive the merge.
2908        let mut settings = json!({
2909            "hooks": {
2910                "PreToolUse": [
2911                    { "matcher": "Bash", "hooks": [{ "type": "command", "command": "other-tool" }] }
2912                ]
2913            },
2914            "model": "sonnet"
2915        });
2916        let cmd = "/usr/bin/omni-dev sessions hook";
2917        let added = merge_hooks(&mut settings, cmd, HOOK_EVENTS);
2918        assert_eq!(added, HOOK_EVENTS.len());
2919
2920        // Our command landed under every event, and the unrelated hook stands.
2921        for HookSpec { event, .. } in HOOK_EVENTS {
2922            let groups = settings["hooks"][event].as_array().unwrap();
2923            assert!(
2924                groups.iter().any(|g| group_has_command(g, cmd)),
2925                "missing under {event}"
2926            );
2927        }
2928        let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
2929        assert!(pre.iter().any(|g| group_has_command(g, "other-tool")));
2930        assert_eq!(settings["model"], "sonnet");
2931
2932        // A second merge is a no-op.
2933        assert_eq!(merge_hooks(&mut settings, cmd, HOOK_EVENTS), 0);
2934    }
2935
2936    #[test]
2937    fn reinstalling_over_the_original_seven_events_adds_only_the_new_ones() {
2938        // An install from before #1915 carries only the first seven events.
2939        let cmd = "/usr/bin/omni-dev sessions hook";
2940        let original = &HOOK_EVENTS[..7];
2941        assert_eq!(original.last().unwrap().event, "SessionEnd");
2942        let mut settings = json!({});
2943        merge_hooks(&mut settings, cmd, original);
2944        let before = settings.clone();
2945
2946        let added = merge_hooks(&mut settings, cmd, HOOK_EVENTS);
2947        assert_eq!(added, HOOK_EVENTS.len() - 7);
2948        // The existing groups are untouched: one group each, as before.
2949        for HookSpec { event, .. } in original {
2950            assert_eq!(settings["hooks"][event], before["hooks"][event], "{event}");
2951        }
2952        // Tool events get the `*` matcher; the others none.
2953        assert_eq!(settings["hooks"]["PermissionRequest"][0]["matcher"], "*");
2954        assert!(settings["hooks"]["StopFailure"][0].get("matcher").is_none());
2955
2956        // Uninstall removes every event, old and new.
2957        assert_eq!(remove_hooks(&mut settings, |c| c == cmd), HOOK_EVENTS.len());
2958        assert!(settings["hooks"].as_object().unwrap().is_empty());
2959    }
2960
2961    #[test]
2962    fn merge_then_remove_round_trips_and_preserves_others() {
2963        let mut settings = json!({
2964            "hooks": {
2965                "PreToolUse": [
2966                    { "matcher": "Bash", "hooks": [{ "type": "command", "command": "keep-me" }] }
2967                ]
2968            }
2969        });
2970        let cmd = "/usr/bin/omni-dev sessions hook";
2971        merge_hooks(&mut settings, cmd, HOOK_EVENTS);
2972        let removed = remove_hooks(&mut settings, |c| c == cmd);
2973        assert_eq!(removed, HOOK_EVENTS.len());
2974
2975        // Every one of our entries is gone...
2976        for HookSpec { event, .. } in HOOK_EVENTS {
2977            let empty = settings["hooks"]
2978                .get(event)
2979                .and_then(Value::as_array)
2980                .is_none_or(|g| g.iter().all(|g| !group_has_command(g, cmd)));
2981            assert!(empty, "our hook survived under {event}");
2982        }
2983        // ...but the unrelated PreToolUse hook remains.
2984        let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
2985        assert!(pre.iter().any(|g| group_has_command(g, "keep-me")));
2986    }
2987
2988    // --- stale Claude sinks (#1927) -----------------------------------------
2989
2990    const OLD: &str = "/old/bin/omni-dev sessions hook";
2991    const NEW: &str = "/new/bin/omni-dev sessions hook";
2992
2993    fn sinks_under(settings: &Value, event: &str) -> Vec<String> {
2994        settings["hooks"][event]
2995            .as_array()
2996            .into_iter()
2997            .flatten()
2998            .filter_map(|g| g["hooks"].as_array())
2999            .flatten()
3000            .filter_map(|h| h["command"].as_str().map(str::to_string))
3001            .collect()
3002    }
3003
3004    #[test]
3005    fn is_stale_claude_sink_matches_only_uncustomised_sinks() {
3006        assert!(is_stale_claude_sink(OLD));
3007        assert!(is_stale_claude_sink("omni-dev sessions hook"));
3008        assert!(is_stale_claude_sink(
3009            "/x/omni-dev sessions hook --agent claude"
3010        ));
3011        assert!(is_stale_claude_sink(
3012            "/x/omni-dev sessions hook --agent=claude"
3013        ));
3014        // Customised: a deliberate choice, never rewritten.
3015        assert!(!is_stale_claude_sink(
3016            "/x/omni-dev sessions hook --socket /s"
3017        ));
3018        assert!(!is_stale_claude_sink(
3019            "/x/omni-dev sessions hook --agent codex"
3020        ));
3021        assert!(!is_stale_claude_sink(
3022            "/x/omni-dev sessions hook --agent claude --socket /s"
3023        ));
3024        // Not a sink at all.
3025        assert!(!is_stale_claude_sink("/x/other sessions hook"));
3026        assert!(!is_stale_claude_sink("/x/omni-dev sessions list"));
3027    }
3028
3029    #[test]
3030    fn install_after_the_binary_moves_rewrites_the_old_sink_in_place() {
3031        let mut settings = json!({ "model": "sonnet" });
3032        merge_hooks(&mut settings, OLD, HOOK_EVENTS);
3033        // An unrelated hook sharing an event must survive, in its slot.
3034        settings["hooks"]["Stop"].as_array_mut().unwrap().insert(
3035            0,
3036            json!({ "hooks": [{ "type": "command", "command": "other" }] }),
3037        );
3038
3039        let sync = sync_claude_hooks(&mut settings, NEW);
3040        assert_eq!(
3041            sync,
3042            ClaudeSync {
3043                replaced: HOOK_EVENTS.len(),
3044                ..ClaudeSync::default()
3045            }
3046        );
3047        for HookSpec { event, .. } in HOOK_EVENTS {
3048            assert!(
3049                sinks_under(&settings, event).contains(&NEW.to_string()),
3050                "{event}"
3051            );
3052            assert!(
3053                !sinks_under(&settings, event).contains(&OLD.to_string()),
3054                "{event}"
3055            );
3056        }
3057        assert_eq!(sinks_under(&settings, "Stop"), ["other", NEW]);
3058        assert_eq!(settings["model"], "sonnet");
3059
3060        // A second install is a no-op.
3061        assert!(sync_claude_hooks(&mut settings, NEW).is_noop());
3062    }
3063
3064    #[test]
3065    fn install_heals_settings_that_already_hold_both_sinks() {
3066        // What the pre-#1927 bug left behind: old and new side by side.
3067        let mut settings = json!({});
3068        merge_hooks(&mut settings, OLD, HOOK_EVENTS);
3069        merge_hooks(&mut settings, NEW, HOOK_EVENTS);
3070
3071        let sync = sync_claude_hooks(&mut settings, NEW);
3072        assert_eq!(sync.replaced, HOOK_EVENTS.len());
3073        assert_eq!(sync.deduped, HOOK_EVENTS.len());
3074        assert_eq!(sync.added, 0);
3075        for HookSpec { event, .. } in HOOK_EVENTS {
3076            assert_eq!(sinks_under(&settings, event), [NEW], "{event}");
3077        }
3078        assert!(sync_claude_hooks(&mut settings, NEW).is_noop());
3079    }
3080
3081    #[test]
3082    fn install_adds_new_events_while_rewriting_an_old_seven_event_install() {
3083        let mut settings = json!({});
3084        merge_hooks(&mut settings, OLD, &HOOK_EVENTS[..7]);
3085        let sync = sync_claude_hooks(&mut settings, NEW);
3086        assert_eq!(sync.replaced, 7);
3087        assert_eq!(sync.added, HOOK_EVENTS.len() - 7);
3088        for HookSpec { event, .. } in HOOK_EVENTS {
3089            assert_eq!(sinks_under(&settings, event), [NEW], "{event}");
3090        }
3091    }
3092
3093    #[test]
3094    fn install_and_uninstall_preserve_a_customised_sink() {
3095        let custom = "/old/bin/omni-dev sessions hook --socket /my/d.sock";
3096        let mut settings = json!({
3097            "hooks": { "Stop": [{ "hooks": [{ "type": "command", "command": custom }] }] }
3098        });
3099        let sync = sync_claude_hooks(&mut settings, NEW);
3100        assert_eq!(sync.replaced, 0);
3101        assert_eq!(sync.customised, 1);
3102        assert_eq!(sinks_under(&settings, "Stop"), [custom, NEW]);
3103
3104        let removed = remove_hooks(&mut settings, |c| c == NEW || is_stale_claude_sink(c));
3105        assert_eq!(removed, HOOK_EVENTS.len());
3106        assert_eq!(sinks_under(&settings, "Stop"), [custom]);
3107        assert_eq!(count_customised_sinks(&settings, NEW), 1);
3108        assert!(customised_note(1).contains("customised"));
3109    }
3110
3111    #[test]
3112    fn uninstall_from_a_new_path_removes_the_old_path_sink() {
3113        let mut settings = json!({});
3114        merge_hooks(&mut settings, OLD, HOOK_EVENTS);
3115        merge_hooks(&mut settings, NEW, HOOK_EVENTS);
3116        let removed = remove_hooks(&mut settings, |c| c == NEW || is_stale_claude_sink(c));
3117        assert_eq!(removed, 2 * HOOK_EVENTS.len());
3118        assert!(settings["hooks"].as_object().unwrap().is_empty());
3119    }
3120
3121    #[test]
3122    fn is_stale_claude_sink_rejects_wrapped_launchers() {
3123        // Only a wrapper whose last word is a path to `omni-dev` is recognised as a
3124        // sink at all; the rest (`nice -n 10 omni-dev …`) never match, so are
3125        // never touched either.
3126        for wrapped in [
3127            "FOO=1 /x/omni-dev sessions hook",
3128            "sudo -u me /usr/bin/omni-dev sessions hook",
3129            "cd /repo && /x/omni-dev sessions hook",
3130        ] {
3131            assert!(is_sessions_sink(wrapped), "{wrapped}");
3132            assert!(!is_stale_claude_sink(wrapped), "{wrapped}");
3133            assert!(is_customised_sink(wrapped, NEW), "{wrapped}");
3134        }
3135        assert!(!is_sessions_sink("nice -n 10 omni-dev sessions hook"));
3136        assert!(!is_customised_sink(NEW, NEW));
3137        assert!(!is_customised_sink(OLD, NEW));
3138    }
3139
3140    #[test]
3141    fn dedupe_command_keys_on_the_matcher_and_leaves_untouched_events_alone() {
3142        let mut settings = json!({
3143            "hooks": {
3144                "PreToolUse": [
3145                    { "matcher": "Bash", "hooks": [{ "type": "command", "command": NEW }] },
3146                    { "matcher": "*", "hooks": [{ "type": "command", "command": NEW }] },
3147                    { "matcher": "*", "hooks": [{ "type": "command", "command": NEW }] }
3148                ],
3149                "Stop": [],
3150                "Notification": [{ "hooks": [] }]
3151            }
3152        });
3153        // Only the second `*` copy is redundant: `Bash` does not cover `*`.
3154        assert_eq!(dedupe_command(&mut settings, NEW), 1);
3155        assert_eq!(sinks_under(&settings, "PreToolUse"), [NEW, NEW]);
3156        assert_eq!(settings["hooks"]["PreToolUse"][1]["matcher"], "*");
3157        // Pre-existing empty scaffolding in events it removed nothing from stays.
3158        assert_eq!(settings["hooks"]["Stop"], json!([]));
3159        assert_eq!(settings["hooks"]["Notification"], json!([{ "hooks": [] }]));
3160    }
3161
3162    #[test]
3163    fn install_command_execute_rewrites_an_old_sink_and_a_rerun_changes_nothing() {
3164        let tmp = tempfile::tempdir().unwrap();
3165        let path = tmp.path().join("settings.json");
3166        let mut old = json!({});
3167        merge_hooks(&mut old, OLD, HOOK_EVENTS);
3168        merge_hooks(
3169            &mut old,
3170            "/x/omni-dev sessions hook --socket /s",
3171            &HOOK_EVENTS[..1],
3172        );
3173        write_settings(&path, &old).unwrap();
3174        let install = || {
3175            InstallHooksCommand {
3176                settings: Some(path.clone()),
3177                pi_agent_dir: Some(tmp.path().join("pi-agent")),
3178                codex_home: Some(tmp.path().join("codex-home")),
3179            }
3180            .execute()
3181            .unwrap();
3182        };
3183
3184        install();
3185        let current = hook_command();
3186        let settings = read_settings(&path).unwrap();
3187        for HookSpec { event, .. } in HOOK_EVENTS {
3188            let sinks = sinks_under(&settings, event);
3189            assert!(sinks.contains(&current), "{event}: {sinks:?}");
3190            assert!(!sinks.contains(&OLD.to_string()), "{event}: {sinks:?}");
3191        }
3192        // The customised sink survives beside the canonical one.
3193        assert_eq!(count_customised_sinks(&settings, &current), 1);
3194
3195        let after_first = std::fs::read_to_string(&path).unwrap();
3196        install();
3197        assert_eq!(std::fs::read_to_string(&path).unwrap(), after_first);
3198
3199        // Uninstalling from "a new path" also clears anything old left behind.
3200        let mut stale = read_settings(&path).unwrap();
3201        merge_hooks(&mut stale, OLD, HOOK_EVENTS);
3202        write_settings(&path, &stale).unwrap();
3203        UninstallHooksCommand {
3204            settings: Some(path.clone()),
3205            pi_agent_dir: Some(tmp.path().join("pi-agent")),
3206            codex_home: Some(tmp.path().join("codex-home")),
3207        }
3208        .execute()
3209        .unwrap();
3210        let settings = read_settings(&path).unwrap();
3211        assert_eq!(
3212            sinks_under(&settings, HOOK_EVENTS[0].event),
3213            ["/x/omni-dev sessions hook --socket /s"]
3214        );
3215        assert_eq!(count_customised_sinks(&settings, &current), 1);
3216    }
3217
3218    #[test]
3219    fn install_command_execute_reports_and_removes_a_duplicate_sink() {
3220        let tmp = tempfile::tempdir().unwrap();
3221        let path = tmp.path().join("settings.json");
3222        // What the pre-#1927 bug left behind: the old and current sinks side by side.
3223        let mut both = json!({});
3224        merge_hooks(&mut both, OLD, HOOK_EVENTS);
3225        merge_hooks(&mut both, &hook_command(), HOOK_EVENTS);
3226        write_settings(&path, &both).unwrap();
3227
3228        InstallHooksCommand {
3229            settings: Some(path.clone()),
3230            pi_agent_dir: Some(tmp.path().join("pi-agent")),
3231            codex_home: Some(tmp.path().join("codex-home")),
3232        }
3233        .execute()
3234        .unwrap();
3235
3236        let settings = read_settings(&path).unwrap();
3237        for HookSpec { event, .. } in HOOK_EVENTS {
3238            assert_eq!(sinks_under(&settings, event), [hook_command()], "{event}");
3239        }
3240    }
3241
3242    #[test]
3243    fn dedupe_command_keeps_the_first_and_tolerates_odd_shapes() {
3244        let mut settings = json!({
3245            "hooks": {
3246                "Stop": [
3247                    { "hooks": [{ "type": "command", "command": NEW }] },
3248                    { "matcher": "Bash" },
3249                    { "hooks": [{ "type": "command", "command": "other" },
3250                                { "type": "command", "command": NEW }] }
3251                ],
3252                "Notification": 5
3253            }
3254        });
3255        assert_eq!(dedupe_command(&mut settings, NEW), 1);
3256        assert_eq!(sinks_under(&settings, "Stop"), [NEW, "other"]);
3257        // A group with no `hooks` array is left as it was.
3258        assert_eq!(settings["hooks"]["Stop"][1], json!({ "matcher": "Bash" }));
3259        assert_eq!(settings["hooks"]["Notification"], 5);
3260        assert_eq!(dedupe_command(&mut json!({}), NEW), 0);
3261    }
3262
3263    #[test]
3264    fn remove_hooks_prunes_empty_events_entirely() {
3265        let mut settings = json!({});
3266        let cmd = "cmd sessions hook";
3267        merge_hooks(&mut settings, cmd, HOOK_EVENTS);
3268        remove_hooks(&mut settings, |c| c == cmd);
3269        // With no other hooks, every event array empties and is pruned.
3270        let hooks = settings["hooks"].as_object().unwrap();
3271        assert!(hooks.is_empty(), "expected all events pruned: {hooks:?}");
3272    }
3273
3274    #[test]
3275    fn install_uninstall_via_files_round_trips() {
3276        let tmp = tempfile::tempdir().unwrap();
3277        let path = tmp.path().join("settings.json");
3278        // Install into a missing file, then uninstall.
3279        let mut settings = read_settings(&path).unwrap();
3280        merge_hooks(&mut settings, "cmd sessions hook", HOOK_EVENTS);
3281        write_settings(&path, &settings).unwrap();
3282        assert!(path.exists());
3283
3284        let reloaded = read_settings(&path).unwrap();
3285        assert!(reloaded["hooks"]["Stop"].is_array());
3286
3287        let mut settings = read_settings(&path).unwrap();
3288        remove_hooks(&mut settings, |c| c == "cmd sessions hook");
3289        write_settings(&path, &settings).unwrap();
3290        let reloaded = read_settings(&path).unwrap();
3291        assert!(reloaded["hooks"].as_object().unwrap().is_empty());
3292    }
3293
3294    #[test]
3295    fn read_settings_rejects_non_json() {
3296        let tmp = tempfile::tempdir().unwrap();
3297        let path = tmp.path().join("settings.json");
3298        std::fs::write(&path, "not json {").unwrap();
3299        let err = read_settings(&path).unwrap_err();
3300        assert!(err.to_string().contains("not valid JSON"), "{err}");
3301    }
3302
3303    #[test]
3304    fn read_settings_handles_empty_and_non_object() {
3305        let tmp = tempfile::tempdir().unwrap();
3306        let path = tmp.path().join("settings.json");
3307        // An empty (or whitespace-only) file reads as an empty object.
3308        std::fs::write(&path, "   \n").unwrap();
3309        assert_eq!(read_settings(&path).unwrap(), json!({}));
3310        // Valid JSON that is not an object is refused rather than clobbered.
3311        std::fs::write(&path, "[1, 2, 3]").unwrap();
3312        let err = read_settings(&path).unwrap_err();
3313        assert!(err.to_string().contains("not a JSON object"), "{err}");
3314    }
3315
3316    #[test]
3317    fn hook_command_targets_sessions_hook() {
3318        assert!(hook_command().ends_with("sessions hook"));
3319    }
3320
3321    // --- rendering -----------------------------------------------------------
3322
3323    #[test]
3324    fn render_sessions_handles_empty() {
3325        assert_eq!(
3326            render_sessions(&json!({ "sessions": [] })),
3327            "No active agent sessions."
3328        );
3329        assert_eq!(render_sessions(&json!({})), "No active agent sessions.");
3330    }
3331
3332    #[test]
3333    fn render_sessions_renders_rows_and_source() {
3334        let result = json!({ "sessions": [{
3335            "session_id": "s1",
3336            "state": "working",
3337            "source": { "kind": "vs_code", "window_key": "w1" },
3338            "repo": "omni-dev",
3339            "cwd": "/home/me/omni-dev",
3340            "last_seen": "2000-01-01T00:00:00Z",
3341        }]});
3342        let table = render_sessions(&result);
3343        assert!(table.contains("working"), "{table}");
3344        assert!(table.contains("vscode"), "{table}");
3345        assert!(table.contains("omni-dev"), "{table}");
3346        assert!(table.contains("claude"), "{table}");
3347        // Header plus one data row.
3348        assert_eq!(table.lines().count(), 2, "{table}");
3349    }
3350
3351    #[test]
3352    fn render_sessions_strips_control_bytes() {
3353        let result = json!({ "sessions": [{
3354            "session_id": "s1",
3355            "state": "wor\x1b[31mking",
3356            "source": { "kind": "terminal" },
3357            "repo": "ev\x07il",
3358            "cwd": "/tmp/a\rb",
3359            "last_seen": "2000-01-01T00:00:00Z",
3360        }]});
3361        let table = render_sessions(&result);
3362        assert!(
3363            !table.contains(|c: char| c.is_control() && c != '\n'),
3364            "{table:?}"
3365        );
3366        // Embedded CR cannot forge a row: header plus one data row.
3367        assert_eq!(table.lines().count(), 2, "{table:?}");
3368    }
3369
3370    #[test]
3371    fn source_label_maps_kinds() {
3372        assert_eq!(
3373            source_label(&json!({ "source": { "kind": "vs_code", "window_key": "w" } })),
3374            "vscode"
3375        );
3376        assert_eq!(
3377            source_label(&json!({ "source": { "kind": "terminal" } })),
3378            "terminal"
3379        );
3380        assert_eq!(source_label(&json!({})), "terminal");
3381    }
3382
3383    #[test]
3384    fn reply_payload_unwraps_ok_and_maps_errors() {
3385        assert_eq!(
3386            reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
3387            json!({ "a": 1 })
3388        );
3389        let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
3390        assert!(err.to_string().contains("boom"), "{err}");
3391    }
3392
3393    // --- pi.dev extension ----------------------------------------------------
3394
3395    #[test]
3396    fn render_pi_extension_bakes_in_the_socket_as_a_json_string() {
3397        let rendered = render_pi_extension(Path::new("/tmp/we \"ird\"/daemon.sock"));
3398        assert!(
3399            !rendered.contains(PI_SOCKET_PLACEHOLDER),
3400            "placeholder left behind"
3401        );
3402        assert!(
3403            rendered.contains(r#"const SOCKET: string = "/tmp/we \"ird\"/daemon.sock";"#),
3404            "socket not escaped as a JSON string"
3405        );
3406        assert!(rendered.starts_with(pi_extension_marker()));
3407    }
3408
3409    #[test]
3410    fn the_template_reports_only_state_and_tags_the_agent() {
3411        // Each state the mapping can produce, the agent tag, and the `end` op.
3412        for needle in [
3413            r#""working""#,
3414            r#""idle""#,
3415            r#""waiting_for_input""#,
3416            r#"agent: "pi""#,
3417            "stream_state: next",
3418            r#"send("end""#,
3419            r#""agent_settled""#,
3420            r#""ui_prompt_start""#,
3421            r#""session_shutdown""#,
3422        ] {
3423            assert!(
3424                PI_EXTENSION_TEMPLATE.contains(needle),
3425                "template lacks {needle}"
3426            );
3427        }
3428        // pi has no permission prompt, so the template must never claim one.
3429        assert!(!PI_EXTENSION_TEMPLATE.contains("waiting_for_permission"));
3430        // pi's chat input is already live at `session_start`, so the template
3431        // must report `idle` there, never the hook-only `starting` state, which
3432        // means "launched, chat surface not up yet" (reporting it once left every
3433        // pi session showing as busy from launch until the first prompt, #1905).
3434        assert!(!PI_EXTENSION_TEMPLATE.contains("\"starting\""));
3435        assert_eq!(
3436            PI_EXTENSION_TEMPLATE.matches(PI_SOCKET_PLACEHOLDER).count(),
3437            1
3438        );
3439    }
3440
3441    #[test]
3442    fn pi_is_present_via_its_agent_dir_or_a_pi_executable_on_path() {
3443        use std::os::unix::fs::PermissionsExt;
3444
3445        let tmp = tempfile::tempdir().unwrap();
3446        let agent = tmp.path().join("agent");
3447        let bin = tmp.path().join("bin");
3448        std::fs::create_dir_all(&bin).unwrap();
3449        let path = std::env::join_paths([bin.clone()]).unwrap();
3450
3451        // Neither: absent.
3452        assert!(!pi_is_present(&agent, Some(&path)));
3453        assert!(!pi_is_present(&agent, None));
3454
3455        // A non-executable `pi` does not count.
3456        let pi = bin.join("pi");
3457        std::fs::write(&pi, "#!/bin/sh\n").unwrap();
3458        std::fs::set_permissions(&pi, std::fs::Permissions::from_mode(0o600)).unwrap();
3459        assert!(!pi_is_present(&agent, Some(&path)));
3460
3461        // An executable one does.
3462        std::fs::set_permissions(&pi, std::fs::Permissions::from_mode(0o755)).unwrap();
3463        assert!(pi_is_present(&agent, Some(&path)));
3464
3465        // So does the agent directory alone.
3466        std::fs::create_dir_all(&agent).unwrap();
3467        assert!(pi_is_present(&agent, None));
3468    }
3469
3470    #[test]
3471    fn write_pi_extension_is_idempotent_and_updates_its_own_file() {
3472        let tmp = tempfile::tempdir().unwrap();
3473        let v1 = render_pi_extension(Path::new("/a.sock"));
3474        let (file, outcome) = write_pi_extension(tmp.path(), &v1).unwrap();
3475        assert_eq!(outcome, PiInstall::Written);
3476        assert_eq!(file, tmp.path().join("extensions").join(PI_EXTENSION_NAME));
3477        assert_eq!(std::fs::read_to_string(&file).unwrap(), v1);
3478
3479        assert_eq!(
3480            write_pi_extension(tmp.path(), &v1).unwrap().1,
3481            PiInstall::Unchanged
3482        );
3483
3484        // A new socket path (or a newer template) replaces our own file.
3485        let v2 = render_pi_extension(Path::new("/b.sock"));
3486        assert_eq!(
3487            write_pi_extension(tmp.path(), &v2).unwrap().1,
3488            PiInstall::Written
3489        );
3490        assert_eq!(std::fs::read_to_string(&file).unwrap(), v2);
3491    }
3492
3493    #[test]
3494    fn pi_extension_install_and_remove_leave_foreign_files_alone() {
3495        let tmp = tempfile::tempdir().unwrap();
3496        let dir = tmp.path().join("extensions");
3497        std::fs::create_dir_all(&dir).unwrap();
3498        let other = dir.join("someone-elses.ts");
3499        std::fs::write(&other, "export default () => {};\n").unwrap();
3500
3501        // A same-named file that is not ours is neither overwritten nor removed.
3502        let ours = dir.join(PI_EXTENSION_NAME);
3503        std::fs::write(&ours, "// hand-written\n").unwrap();
3504        let err = write_pi_extension(tmp.path(), &render_pi_extension(Path::new("/s")))
3505            .unwrap_err()
3506            .to_string();
3507        assert!(err.contains("not written by omni-dev"), "{err}");
3508        assert_eq!(remove_pi_extension(tmp.path()).unwrap(), None);
3509        assert_eq!(std::fs::read_to_string(&ours).unwrap(), "// hand-written\n");
3510
3511        // Ours is removed; the neighbour survives.
3512        std::fs::remove_file(&ours).unwrap();
3513        write_pi_extension(tmp.path(), &render_pi_extension(Path::new("/s"))).unwrap();
3514        assert_eq!(remove_pi_extension(tmp.path()).unwrap(), Some(ours.clone()));
3515        assert!(!ours.exists());
3516        assert!(other.exists());
3517
3518        // Removing again, or from a directory that never existed, is a no-op.
3519        assert_eq!(remove_pi_extension(tmp.path()).unwrap(), None);
3520        assert_eq!(remove_pi_extension(&tmp.path().join("nope")).unwrap(), None);
3521    }
3522
3523    #[test]
3524    fn hook_commands_parse_the_pi_agent_dir_flag() {
3525        match parse(&["install-hooks", "--pi-agent-dir", "/x/agent"]) {
3526            SessionsSubcommands::InstallHooks(cmd) => {
3527                assert_eq!(cmd.pi_agent_dir, Some(PathBuf::from("/x/agent")));
3528            }
3529            _ => 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"
3530        }
3531        match parse(&["uninstall-hooks"]) {
3532            SessionsSubcommands::UninstallHooks(cmd) => assert_eq!(cmd.pi_agent_dir, None),
3533            _ => 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"
3534        }
3535    }
3536
3537    /// Env-isolation lock for tests that mutate `PI_CODING_AGENT_DIR`, `PATH`,
3538    /// or `HOME` to exercise [`default_pi_agent_dir`]'s and
3539    /// [`install_pi_extension`]'s env-driven branches. Aliases the crate-wide
3540    /// [`crate::test_support::HOME_ENV_MUTEX`] per its own doc comment
3541    /// (issue #1465), rather than a module-local `Mutex<()>`.
3542    static PI_ENV_LOCK: &std::sync::Mutex<()> = &crate::test_support::HOME_ENV_MUTEX;
3543
3544    fn snapshot_pi_env() -> [(&'static str, Option<std::ffi::OsString>); 3] {
3545        ["PI_CODING_AGENT_DIR", "PATH", "HOME"].map(|k| (k, std::env::var_os(k)))
3546    }
3547
3548    fn restore_pi_env(snap: [(&'static str, Option<std::ffi::OsString>); 3]) {
3549        for (k, v) in snap {
3550            match v {
3551                Some(val) => std::env::set_var(k, val),
3552                None => std::env::remove_var(k),
3553            }
3554        }
3555    }
3556
3557    #[test]
3558    fn default_pi_agent_dir_honors_the_env_var_and_falls_back_to_home() {
3559        let _guard = PI_ENV_LOCK
3560            .lock()
3561            .unwrap_or_else(std::sync::PoisonError::into_inner);
3562        let snap = snapshot_pi_env();
3563
3564        // An explicit, non-empty env var wins.
3565        std::env::set_var("PI_CODING_AGENT_DIR", "/explicit/agent");
3566        assert_eq!(
3567            default_pi_agent_dir().unwrap(),
3568            PathBuf::from("/explicit/agent")
3569        );
3570
3571        // An empty env var is treated as unset, falling back to `$HOME/.pi/agent`.
3572        std::env::set_var("PI_CODING_AGENT_DIR", "");
3573        std::env::set_var("HOME", "/home/tester");
3574        assert_eq!(
3575            default_pi_agent_dir().unwrap(),
3576            PathBuf::from("/home/tester/.pi/agent")
3577        );
3578
3579        // A wholly unset env var falls back the same way.
3580        std::env::remove_var("PI_CODING_AGENT_DIR");
3581        assert_eq!(
3582            default_pi_agent_dir().unwrap(),
3583            PathBuf::from("/home/tester/.pi/agent")
3584        );
3585
3586        restore_pi_env(snap);
3587    }
3588
3589    #[test]
3590    fn install_pi_extension_via_the_default_dir_skips_absent_and_writes_present() {
3591        let _guard = PI_ENV_LOCK
3592            .lock()
3593            .unwrap_or_else(std::sync::PoisonError::into_inner);
3594        let snap = snapshot_pi_env();
3595
3596        let tmp = tempfile::tempdir().unwrap();
3597        let agent_dir = tmp.path().join("nonexistent-agent");
3598        let empty_bin = tmp.path().join("bin");
3599        std::fs::create_dir_all(&empty_bin).unwrap();
3600
3601        std::env::set_var("PI_CODING_AGENT_DIR", &agent_dir);
3602        std::env::set_var("PATH", std::env::join_paths([&empty_bin]).unwrap());
3603
3604        // Neither the agent dir exists nor is `pi` on PATH: install skips,
3605        // via default_pi_agent_dir()'s env-var branch.
3606        install_pi_extension(None).unwrap();
3607        assert!(
3608            !agent_dir.exists(),
3609            "install must not create the agent dir when pi is absent"
3610        );
3611
3612        // Uninstall with no explicit dir also resolves via default_pi_agent_dir()
3613        // and is a no-op since nothing was ever installed there.
3614        uninstall_pi_extension(None).unwrap();
3615
3616        // Once the agent dir exists, pi counts as present: install falls
3617        // through to actually write the extension, still via the env-resolved
3618        // default dir (no explicit `--pi-agent-dir`).
3619        std::fs::create_dir_all(&agent_dir).unwrap();
3620        install_pi_extension(None).unwrap();
3621        let extension = agent_dir.join("extensions").join(PI_EXTENSION_NAME);
3622        assert!(extension.exists());
3623
3624        // Uninstall with no explicit dir removes what install just wrote.
3625        uninstall_pi_extension(None).unwrap();
3626        assert!(!extension.exists());
3627
3628        restore_pi_env(snap);
3629    }
3630
3631    #[test]
3632    fn agent_label_defaults_to_claude() {
3633        assert_eq!(agent_label(&json!({ "agent": "pi" })), "pi");
3634        assert_eq!(agent_label(&json!({ "agent": "codex" })), "codex");
3635        assert_eq!(agent_label(&json!({ "agent": "claude" })), "claude");
3636        assert_eq!(agent_label(&json!({})), "claude");
3637    }
3638
3639    // --- command execute() paths -------------------------------------------
3640
3641    #[test]
3642    fn install_then_uninstall_command_execute_round_trips() {
3643        let tmp = tempfile::tempdir().unwrap();
3644        let path = tmp.path().join("settings.json");
3645        // Install into a missing file: the hook block lands.
3646        InstallHooksCommand {
3647            settings: Some(path.clone()),
3648            pi_agent_dir: Some(tmp.path().join("pi-agent")),
3649            codex_home: Some(tmp.path().join("codex-home")),
3650        }
3651        .execute()
3652        .unwrap();
3653        assert!(read_settings(&path).unwrap()["hooks"]["Stop"].is_array());
3654        let extension = tmp
3655            .path()
3656            .join("pi-agent/extensions")
3657            .join(PI_EXTENSION_NAME);
3658        assert!(extension.exists());
3659        // A second install is the idempotent "no change" branch.
3660        InstallHooksCommand {
3661            settings: Some(path.clone()),
3662            pi_agent_dir: Some(tmp.path().join("pi-agent")),
3663            codex_home: Some(tmp.path().join("codex-home")),
3664        }
3665        .execute()
3666        .unwrap();
3667        // Uninstall removes our block, leaving an empty hooks object.
3668        UninstallHooksCommand {
3669            settings: Some(path.clone()),
3670            pi_agent_dir: Some(tmp.path().join("pi-agent")),
3671            codex_home: Some(tmp.path().join("codex-home")),
3672        }
3673        .execute()
3674        .unwrap();
3675        assert!(read_settings(&path).unwrap()["hooks"]
3676            .as_object()
3677            .unwrap()
3678            .is_empty());
3679        assert!(!extension.exists());
3680    }
3681
3682    // --- install-wrapper / uninstall-wrapper --------------------------------
3683
3684    /// An install/uninstall pair pointed entirely inside `tmp`, so nothing
3685    /// touches the developer's real data or config directories.
3686    fn wrapper_commands(tmp: &Path) -> (InstallWrapperCommand, UninstallWrapperCommand) {
3687        let settings = tmp.join("settings.json");
3688        let shim = tmp.join("bin").join("claude-wrap");
3689        (
3690            InstallWrapperCommand {
3691                settings: Some(settings.clone()),
3692                shim: Some(shim.clone()),
3693            },
3694            UninstallWrapperCommand {
3695                settings: Some(settings),
3696                shim: Some(shim),
3697            },
3698        )
3699    }
3700
3701    #[test]
3702    fn install_wrapper_writes_an_executable_shim_and_sets_the_setting() {
3703        use std::os::unix::fs::PermissionsExt;
3704
3705        let tmp = tempfile::tempdir().unwrap();
3706        let (install, _) = wrapper_commands(tmp.path());
3707        let shim = install.shim.clone().unwrap();
3708        let settings = install.settings.clone().unwrap();
3709        install.execute().unwrap();
3710
3711        // The shim is a one-line exec into `claude-wrap`, owner-executable, and
3712        // names an absolute binary so it does not depend on VS Code's PATH.
3713        let script = std::fs::read_to_string(&shim).unwrap();
3714        assert!(script.starts_with("#!/bin/sh\nexec \"/"), "{script}");
3715        assert!(script.contains(" claude-wrap -- \"$@\""), "{script}");
3716        let mode = std::fs::metadata(&shim).unwrap().permissions().mode();
3717        assert_eq!(mode & 0o777, 0o700);
3718
3719        assert_eq!(
3720            read_settings(&settings).unwrap()[WRAPPER_SETTING_KEY],
3721            json!(shim.display().to_string())
3722        );
3723    }
3724
3725    #[test]
3726    fn install_wrapper_is_idempotent_and_preserves_other_settings() {
3727        let tmp = tempfile::tempdir().unwrap();
3728        let (install, _) = wrapper_commands(tmp.path());
3729        let settings_path = install.settings.clone().unwrap();
3730        write_settings(&settings_path, &json!({ "editor.fontSize": 13 })).unwrap();
3731
3732        install.execute().unwrap();
3733        let (again, _) = wrapper_commands(tmp.path());
3734        again.execute().unwrap();
3735
3736        let settings = read_settings(&settings_path).unwrap();
3737        assert_eq!(settings["editor.fontSize"], json!(13));
3738        assert!(settings[WRAPPER_SETTING_KEY].is_string());
3739    }
3740
3741    #[test]
3742    fn uninstall_wrapper_clears_only_our_own_setting() {
3743        let tmp = tempfile::tempdir().unwrap();
3744        let (install, uninstall) = wrapper_commands(tmp.path());
3745        let settings_path = install.settings.clone().unwrap();
3746        let shim = install.shim.clone().unwrap();
3747        install.execute().unwrap();
3748        uninstall.execute().unwrap();
3749
3750        assert!(read_settings(&settings_path).unwrap()[WRAPPER_SETTING_KEY].is_null());
3751        assert!(!shim.exists());
3752
3753        // A setting someone else owns survives, and so does their file.
3754        write_settings(
3755            &settings_path,
3756            &json!({ WRAPPER_SETTING_KEY: "/opt/someone-elses-wrapper" }),
3757        )
3758        .unwrap();
3759        let (_, uninstall) = wrapper_commands(tmp.path());
3760        uninstall.execute().unwrap();
3761        assert_eq!(
3762            read_settings(&settings_path).unwrap()[WRAPPER_SETTING_KEY],
3763            json!("/opt/someone-elses-wrapper")
3764        );
3765    }
3766
3767    #[test]
3768    fn uninstall_wrapper_on_a_missing_settings_file_is_a_noop() {
3769        let tmp = tempfile::tempdir().unwrap();
3770        let (_, uninstall) = wrapper_commands(tmp.path());
3771        let settings = uninstall.settings.clone().unwrap();
3772        uninstall.execute().unwrap();
3773        assert!(!settings.exists());
3774    }
3775
3776    #[test]
3777    fn install_wrapper_on_a_jsonc_settings_file_explains_the_manual_step() {
3778        let tmp = tempfile::tempdir().unwrap();
3779        let (install, _) = wrapper_commands(tmp.path());
3780        let settings = install.settings.clone().unwrap();
3781        let shim = install.shim.clone().unwrap();
3782        // Comments are legal in VS Code settings and cannot be rewritten safely.
3783        std::fs::write(
3784            &settings,
3785            "{\n  // a comment\n  \"editor.fontSize\": 13\n}\n",
3786        )
3787        .unwrap();
3788
3789        let error = format!("{:#}", install.execute().unwrap_err());
3790        assert!(error.contains("not valid JSON"), "{error}");
3791        assert!(error.contains(WRAPPER_SETTING_KEY), "{error}");
3792        // The shim is written first, so the printed line is ready to paste.
3793        assert!(error.contains(&shim.display().to_string()), "{error}");
3794        assert!(shim.exists());
3795    }
3796
3797    #[test]
3798    fn install_wrapper_fails_when_the_shim_directory_cannot_be_made() {
3799        let tmp = tempfile::tempdir().unwrap();
3800        // A regular file where the shim's parent directory would have to go, so
3801        // creating it cannot succeed — a mistyped `--shim` in practice.
3802        let blocker = tmp.path().join("not-a-dir");
3803        std::fs::write(&blocker, b"").unwrap();
3804        let error = InstallWrapperCommand {
3805            settings: Some(tmp.path().join("settings.json")),
3806            shim: Some(blocker.join("claude-wrap")),
3807        }
3808        .execute()
3809        .unwrap_err();
3810        assert!(format!("{error:#}").contains("not-a-dir"), "{error:#}");
3811        // The settings file is left untouched when the shim cannot be written.
3812        assert!(!tmp.path().join("settings.json").exists());
3813    }
3814
3815    #[test]
3816    fn set_and_clear_wrapper_tolerate_a_non_object_settings_value() {
3817        // `read_settings` guarantees an object, but these degrade rather than
3818        // panic if a caller hands them anything else (the `merge_hooks` rule).
3819        let mut scalar = json!("not an object");
3820        assert!(!set_wrapper(&mut scalar, "/shim"));
3821        assert!(!clear_wrapper(&mut scalar, "/shim"));
3822        assert_eq!(scalar, json!("not an object"));
3823    }
3824
3825    #[test]
3826    fn the_wrapper_paths_default_outside_the_claude_config_dir() {
3827        // The shim lives beside the daemon socket; the setting lives in VS Code's
3828        // *config* directory, which is a different base on every platform.
3829        let shim = shim_path(None).unwrap();
3830        assert_eq!(shim.file_name().unwrap(), SHIM_NAME);
3831        assert_eq!(shim.parent().unwrap(), paths::runtime_dir().unwrap());
3832        let settings = vscode_settings_path(None).unwrap();
3833        assert!(
3834            settings.ends_with("Code/User/settings.json"),
3835            "{settings:?}"
3836        );
3837    }
3838
3839    #[test]
3840    fn uninstall_command_on_a_missing_file_is_a_noop() {
3841        let tmp = tempfile::tempdir().unwrap();
3842        let path = tmp.path().join("does-not-exist.json");
3843        // The no-file branch: nothing to remove, still Ok, and no file created.
3844        UninstallHooksCommand {
3845            settings: Some(path.clone()),
3846            pi_agent_dir: Some(tmp.path().join("pi-agent")),
3847            codex_home: Some(tmp.path().join("codex-home")),
3848        }
3849        .execute()
3850        .unwrap();
3851        assert!(!path.exists());
3852    }
3853
3854    #[tokio::test]
3855    async fn sessions_command_dispatches_to_subcommands() {
3856        // Cover the outer dispatch for the two file-backed arms (no socket/stdin).
3857        let tmp = tempfile::tempdir().unwrap();
3858        let path = tmp.path().join("settings.json");
3859        SessionsCommand {
3860            command: SessionsSubcommands::InstallHooks(InstallHooksCommand {
3861                settings: Some(path.clone()),
3862                pi_agent_dir: Some(tmp.path().join("pi-agent")),
3863                codex_home: Some(tmp.path().join("codex-home")),
3864            }),
3865        }
3866        .execute()
3867        .await
3868        .unwrap();
3869        SessionsCommand {
3870            command: SessionsSubcommands::UninstallHooks(UninstallHooksCommand {
3871                settings: Some(path.clone()),
3872                pi_agent_dir: Some(tmp.path().join("pi-agent")),
3873                codex_home: Some(tmp.path().join("codex-home")),
3874            }),
3875        }
3876        .execute()
3877        .await
3878        .unwrap();
3879        let (install, uninstall) = wrapper_commands(tmp.path());
3880        SessionsCommand {
3881            command: SessionsSubcommands::InstallWrapper(install),
3882        }
3883        .execute()
3884        .await
3885        .unwrap();
3886        SessionsCommand {
3887            command: SessionsSubcommands::UninstallWrapper(uninstall),
3888        }
3889        .execute()
3890        .await
3891        .unwrap();
3892    }
3893
3894    #[tokio::test]
3895    async fn hook_report_is_silent_when_the_daemon_is_down() {
3896        // A valid hook event but no daemon at the socket: the send fails and is
3897        // swallowed (never panics, never errors).
3898        let tmp = tempfile::tempdir_in("/tmp").unwrap();
3899        let sock = tmp.path().join("nope.sock");
3900        let cmd = HookCommand {
3901            socket: Some(sock),
3902            agent: HookAgent::Claude,
3903        };
3904        cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#, None)
3905            .await;
3906        // Unmappable input returns before any socket work.
3907        cmd.report("not json", None).await;
3908        cmd.report(r#"{"hook_event_name":"Stop"}"#, None).await; // no session_id → no op
3909    }
3910
3911    #[test]
3912    fn hook_input_that_cannot_be_read_is_skipped_with_a_debug_line() {
3913        /// A reader whose every read fails, like a stdin closed under the sink.
3914        struct Broken;
3915        impl Read for Broken {
3916            fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
3917                Err(std::io::Error::other("stdin went away"))
3918            }
3919        }
3920        let cmd = HookCommand {
3921            socket: None,
3922            agent: HookAgent::Claude,
3923        };
3924        let logs = crate::test_support::capture_at(tracing::Level::DEBUG, || {
3925            // Bytes that are not UTF-8 fail `read_to_string`, as does an I/O error.
3926            assert_eq!(cmd.read_input(&b"\xff\xfe"[..]), None);
3927            assert_eq!(cmd.read_input(Broken), None);
3928        });
3929        assert_eq!(logs.matches("stdin_read_failed").count(), 2, "{logs}");
3930        assert_eq!(
3931            cmd.read_input(&b"{\"session_id\":\"s1\"}"[..]).as_deref(),
3932            Some("{\"session_id\":\"s1\"}")
3933        );
3934    }
3935
3936    #[tokio::test]
3937    async fn hook_report_logs_a_daemon_rejection_without_its_content() {
3938        let (_dir, sock, server) = fake_daemon(json!({"ok": false, "error": "HOOK_REPLY_SECRET"}));
3939        let cmd = HookCommand {
3940            socket: Some(sock),
3941            agent: HookAgent::Claude,
3942        };
3943        let ((), logs) = crate::test_support::capture_future_at(
3944            tracing::Level::DEBUG,
3945            cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#, None),
3946        )
3947        .await;
3948        server.await.unwrap();
3949        assert!(logs.contains("daemon_rejected"), "{logs}");
3950        assert!(!logs.contains("HOOK_REPLY_SECRET"), "{logs}");
3951    }
3952
3953    #[tokio::test(start_paused = true)]
3954    async fn hook_report_gives_up_on_a_wedged_daemon() {
3955        let dir = tempfile::tempdir_in("/tmp").unwrap();
3956        let sock = dir.path().join("d.sock");
3957        // Bound but never accepted: the connect lands in the backlog and the
3958        // request in the socket buffer, so only the hook timeout can end the
3959        // exchange. With the clock paused, that two-second wait costs no wall time.
3960        let _wedged = tokio::net::UnixListener::bind(&sock).unwrap();
3961        let cmd = HookCommand {
3962            socket: Some(sock),
3963            agent: HookAgent::Claude,
3964        };
3965        let ((), logs) = crate::test_support::capture_future_at(
3966            tracing::Level::DEBUG,
3967            cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#, None),
3968        )
3969        .await;
3970        assert!(logs.contains("timeout"), "{logs}");
3971    }
3972
3973    #[tokio::test]
3974    async fn hook_report_logs_a_delivered_event() {
3975        let (_dir, sock, server) = fake_daemon(json!({"ok": true, "payload": {}}));
3976        let cmd = HookCommand {
3977            socket: Some(sock),
3978            agent: HookAgent::Claude,
3979        };
3980        let ((), logs) = crate::test_support::capture_future_at(
3981            tracing::Level::DEBUG,
3982            cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#, None),
3983        )
3984        .await;
3985        server.await.unwrap();
3986        assert!(logs.contains("delivered"), "{logs}");
3987    }
3988
3989    /// Spawns a minimal fake daemon on a short-path Unix socket that answers one
3990    /// request with `reply`. Returns the temp dir (kept alive), the socket path,
3991    /// and the server task.
3992    fn fake_daemon(reply: Value) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
3993        use futures::{SinkExt, StreamExt};
3994        use tokio::net::UnixListener;
3995        use tokio_util::codec::{Framed, LinesCodec};
3996
3997        let dir = tempfile::tempdir_in("/tmp").unwrap();
3998        let sock = dir.path().join("d.sock");
3999        let listener = UnixListener::bind(&sock).unwrap();
4000        let server = tokio::spawn(async move {
4001            let (stream, _) = listener.accept().await.unwrap();
4002            let mut framed = Framed::new(stream, LinesCodec::new());
4003            let _req = framed.next().await.unwrap().unwrap();
4004            framed
4005                .send(serde_json::to_string(&reply).unwrap())
4006                .await
4007                .unwrap();
4008        });
4009        (dir, sock, server)
4010    }
4011
4012    /// Like [`fake_daemon`] but **returns the request envelope it received** so a
4013    /// test can assert the exact op/payload wire shape the client sent.
4014    fn fake_daemon_capture(
4015        reply: Value,
4016    ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<Value>) {
4017        use futures::{SinkExt, StreamExt};
4018        use tokio::net::UnixListener;
4019        use tokio_util::codec::{Framed, LinesCodec};
4020
4021        let dir = tempfile::tempdir_in("/tmp").unwrap();
4022        let sock = dir.path().join("d.sock");
4023        let listener = UnixListener::bind(&sock).unwrap();
4024        let server = tokio::spawn(async move {
4025            let (stream, _) = listener.accept().await.unwrap();
4026            let mut framed = Framed::new(stream, LinesCodec::new());
4027            let req = framed.next().await.unwrap().unwrap();
4028            framed
4029                .send(serde_json::to_string(&reply).unwrap())
4030                .await
4031                .unwrap();
4032            serde_json::from_str::<Value>(&req).unwrap()
4033        });
4034        (dir, sock, server)
4035    }
4036
4037    #[tokio::test]
4038    async fn list_command_execute_renders_from_a_socket() {
4039        let payload = json!({
4040            "ok": true,
4041            "payload": { "sessions": [{
4042                "session_id": "s1", "state": "working",
4043                "source": { "kind": "terminal" }, "repo": "omni-dev",
4044                "cwd": "/home/me/omni-dev", "last_seen": "2000-01-01T00:00:00Z"
4045            }]}
4046        });
4047        // Table output, dispatched through the outer `SessionsCommand` so the
4048        // `List` arm of the dispatch is covered too.
4049        let (_dir, sock, server) = fake_daemon(payload.clone());
4050        SessionsCommand {
4051            command: SessionsSubcommands::List(ListCommand {
4052                socket: Some(sock),
4053                output: TableOrJson::Table,
4054            }),
4055        }
4056        .execute()
4057        .await
4058        .unwrap();
4059        server.await.unwrap();
4060
4061        // JSON output goes through the other branch of the renderer.
4062        let (_dir, sock, server) = fake_daemon(payload);
4063        ListCommand {
4064            socket: Some(sock),
4065            output: TableOrJson::Json,
4066        }
4067        .execute()
4068        .await
4069        .unwrap();
4070        server.await.unwrap();
4071    }
4072
4073    #[test]
4074    fn merge_and_remove_hooks_tolerate_malformed_shapes() {
4075        let cmd = "cmd sessions hook";
4076        // Non-object settings: both are no-ops rather than panics.
4077        assert_eq!(merge_hooks(&mut json!([]), cmd, HOOK_EVENTS), 0);
4078        assert_eq!(remove_hooks(&mut json!([]), |c| c == cmd), 0);
4079        // `hooks` present but not an object → merge leaves it alone.
4080        assert_eq!(merge_hooks(&mut json!({ "hooks": 5 }), cmd, HOOK_EVENTS), 0);
4081        // No `hooks` key → remove has nothing to do.
4082        assert_eq!(remove_hooks(&mut json!({}), |c| c == cmd), 0);
4083        // A per-event value that is not an array is skipped, not indexed.
4084        assert_eq!(
4085            merge_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd, HOOK_EVENTS),
4086            HOOK_EVENTS.len() - 1
4087        );
4088        assert_eq!(
4089            remove_hooks(&mut json!({ "hooks": { "Stop": 5 } }), |c| c == cmd),
4090            0
4091        );
4092    }
4093
4094    // --- #1361 typed window feed commands -----------------------------------
4095
4096    #[test]
4097    fn window_subcommands_route_and_require_key() {
4098        assert!(matches!(
4099            parse(&["window", "--key", "w1"]),
4100            SessionsSubcommands::Window(_)
4101        ));
4102        assert!(matches!(
4103            parse(&["window-unregister", "--key", "w1"]),
4104            SessionsSubcommands::WindowUnregister(_)
4105        ));
4106        // `--key` is required for both.
4107        assert!(WindowCommand::try_parse_from(["window"]).is_err());
4108        assert!(WindowUnregisterCommand::try_parse_from(["window-unregister"]).is_err());
4109    }
4110
4111    #[test]
4112    fn window_parses_counts_and_folders() {
4113        let cmd = WindowCommand::try_parse_from([
4114            "window",
4115            "--key",
4116            "w1",
4117            "--folder",
4118            "/a",
4119            "--folder",
4120            "/b",
4121            "--tabs",
4122            "2",
4123            "--terminals",
4124            "3",
4125        ])
4126        .unwrap();
4127        assert_eq!(cmd.key, "w1");
4128        assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
4129        assert_eq!(cmd.tabs, 2);
4130        assert_eq!(cmd.terminals, 3);
4131        // Counts default to zero.
4132        let cmd = WindowCommand::try_parse_from(["window", "--key", "w1"]).unwrap();
4133        assert_eq!(cmd.tabs, 0);
4134        assert_eq!(cmd.terminals, 0);
4135    }
4136
4137    #[tokio::test]
4138    async fn window_and_window_unregister_send_their_ops() {
4139        let (_dir, sock, server) =
4140            fake_daemon_capture(json!({ "ok": true, "payload": { "ok": true } }));
4141        // Routed through the outer `SessionsCommand::execute` so the `Window`
4142        // dispatch arm is covered too.
4143        SessionsCommand {
4144            command: SessionsSubcommands::Window(WindowCommand {
4145                key: "w1".to_string(),
4146                folders: vec![PathBuf::from("/a")],
4147                tabs: 1,
4148                terminals: 0,
4149                socket: Some(sock),
4150            }),
4151        }
4152        .execute()
4153        .await
4154        .unwrap();
4155        // The WindowReport wire shape: op + every field the daemon reads.
4156        let req = server.await.unwrap();
4157        assert_eq!(req["op"], "window");
4158        assert_eq!(req["payload"]["key"], json!("w1"));
4159        assert_eq!(req["payload"]["folders"], json!(["/a"]));
4160        assert_eq!(req["payload"]["tabs"], json!(1));
4161        assert_eq!(req["payload"]["terminals"], json!(0));
4162
4163        // `window-unregister` replies `{removed}` (not `{ok}`); the client reads it.
4164        // Also routed through the wrapper to cover the `WindowUnregister` arm.
4165        let (_dir, sock, server) =
4166            fake_daemon_capture(json!({ "ok": true, "payload": { "removed": true } }));
4167        SessionsCommand {
4168            command: SessionsSubcommands::WindowUnregister(WindowUnregisterCommand {
4169                key: "w1".to_string(),
4170                socket: Some(sock),
4171            }),
4172        }
4173        .execute()
4174        .await
4175        .unwrap();
4176        let req = server.await.unwrap();
4177        assert_eq!(req["op"], "window-unregister");
4178        assert_eq!(req["payload"]["key"], json!("w1"));
4179    }
4180}