1use 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
46const SERVICE: &str = "sessions";
48
49const HOOK_TIMEOUT: Duration = Duration::from_secs(2);
52
53#[derive(Parser)]
56pub struct SessionsCommand {
57 #[command(subcommand)]
59 pub command: SessionsSubcommands,
60}
61
62#[derive(Subcommand)]
64pub enum SessionsSubcommands {
65 List(ListCommand),
68 Hook(HookCommand),
71 InstallHooks(InstallHooksCommand),
75 UninstallHooks(UninstallHooksCommand),
78 InstallWrapper(InstallWrapperCommand),
81 UninstallWrapper(UninstallWrapperCommand),
83 Window(WindowCommand),
85 WindowUnregister(WindowUnregisterCommand),
87}
88
89impl SessionsCommand {
90 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#[derive(Parser)]
109pub struct ListCommand {
110 #[arg(long, value_name = "PATH")]
112 pub socket: Option<PathBuf>,
113 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
115 pub output: TableOrJson,
116}
117
118impl ListCommand {
119 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#[derive(Parser)]
139pub struct WindowCommand {
140 #[arg(long, value_name = "KEY")]
142 pub key: String,
143 #[arg(long = "folder", value_name = "PATH")]
145 pub folders: Vec<PathBuf>,
146 #[arg(long, value_name = "N", default_value_t = 0)]
148 pub tabs: usize,
149 #[arg(long, value_name = "N", default_value_t = 0)]
151 pub terminals: usize,
152 #[arg(long, value_name = "PATH")]
154 pub socket: Option<PathBuf>,
155}
156
157impl WindowCommand {
158 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#[derive(Parser)]
176pub struct WindowUnregisterCommand {
177 #[arg(long, value_name = "KEY")]
179 pub key: String,
180 #[arg(long, value_name = "PATH")]
182 pub socket: Option<PathBuf>,
183}
184
185impl WindowUnregisterCommand {
186 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#[derive(Parser)]
204pub struct HookCommand {
205 #[arg(long, value_name = "PATH")]
207 pub socket: Option<PathBuf>,
208
209 #[arg(long, value_enum, default_value_t = HookAgent::Claude)]
213 pub agent: HookAgent,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
219pub enum HookAgent {
220 Claude,
222 Codex,
224}
225
226impl HookAgent {
227 fn agent(self) -> Agent {
229 match self {
230 Self::Claude => Agent::Claude,
231 Self::Codex => Agent::Codex,
232 }
233 }
234}
235
236impl HookCommand {
237 pub async fn execute(self) -> Result<()> {
241 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 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 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 tracing::debug!(agent = ?self.agent, outcome = "socket_resolution_failed", "session_hook_skipped");
275 return;
276 };
278 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; };
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 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
316fn agent_pid(parent: u32) -> Option<u32> {
324 (parent > 1).then_some(parent)
325}
326
327#[derive(Debug, Clone, Default, Deserialize)]
331struct HookPayload {
332 #[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 #[serde(default)]
346 message: Option<String>,
347 #[serde(default)]
350 notification_type: Option<String>,
351 #[serde(default)]
353 reason: Option<String>,
354 #[serde(default)]
357 source: Option<String>,
358 #[serde(default)]
361 tool_name: Option<String>,
362 #[serde(default)]
364 model: Option<String>,
365}
366
367#[derive(Debug, Clone)]
370enum HookOp {
371 Observe(ObserveRequest),
373 End {
376 agent: Agent,
377 session_id: String,
378 reason: Option<String>,
379 pid: Option<u32>,
380 },
381}
382
383impl HookOp {
384 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 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 tracing::debug!(
405 ?agent,
406 outcome = "serialization_failed",
407 "session_hook_skipped"
408 );
409 return None;
410 };
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 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 #[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
512fn 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
584fn 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
632fn 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#[derive(Parser)]
672pub struct InstallHooksCommand {
673 #[arg(long, value_name = "PATH")]
676 pub settings: Option<PathBuf>,
677
678 #[arg(long, value_name = "PATH")]
682 pub pi_agent_dir: Option<PathBuf>,
683
684 #[arg(long, value_name = "PATH")]
688 pub codex_home: Option<PathBuf>,
689}
690
691impl InstallHooksCommand {
692 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#[derive(Parser)]
742pub struct UninstallHooksCommand {
743 #[arg(long, value_name = "PATH")]
746 pub settings: Option<PathBuf>,
747
748 #[arg(long, value_name = "PATH")]
751 pub pi_agent_dir: Option<PathBuf>,
752
753 #[arg(long, value_name = "PATH")]
756 pub codex_home: Option<PathBuf>,
757}
758
759impl UninstallHooksCommand {
760 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, ¤t);
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
786const PI_EXTENSION_NAME: &str = "omni-dev-sessions.ts";
790
791const PI_EXTENSION_TEMPLATE: &str = include_str!("../templates/pi-sessions-extension.ts");
793
794const PI_SOCKET_PLACEHOLDER: &str = "__OMNI_DEV_SOCKET__";
796
797fn pi_extension_marker() -> &'static str {
800 PI_EXTENSION_TEMPLATE.lines().next().unwrap_or_default()
801}
802
803fn 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
812fn 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
821fn 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
830fn 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#[derive(Debug, PartialEq, Eq)]
840enum PiInstall {
841 Written,
843 Unchanged,
845}
846
847fn 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
871fn 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
885fn 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
919fn 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
932const WRAPPER_SETTING_KEY: &str = "claudeCode.claudeProcessWrapper";
937
938const SHIM_NAME: &str = "claude-wrap";
940
941#[derive(Parser)]
943pub struct InstallWrapperCommand {
944 #[arg(long, value_name = "PATH")]
947 pub settings: Option<PathBuf>,
948
949 #[arg(long, value_name = "PATH")]
951 pub shim: Option<PathBuf>,
952}
953
954impl InstallWrapperCommand {
955 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#[derive(Parser)]
984pub struct UninstallWrapperCommand {
985 #[arg(long, value_name = "PATH")]
988 pub settings: Option<PathBuf>,
989
990 #[arg(long, value_name = "PATH")]
993 pub shim: Option<PathBuf>,
994}
995
996impl UninstallWrapperCommand {
997 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
1029fn 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
1038fn 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
1051fn 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
1063fn 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
1076fn 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
1088fn 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
1101fn 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#[derive(Debug, Clone, Copy)]
1118struct HookSpec {
1119 event: &'static str,
1121 matcher: bool,
1126 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
1155const 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
1180const 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
1199fn 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
1210fn 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
1223fn 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
1250fn 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
1263fn merge_hooks(settings: &mut Value, command: &str, specs: &[HookSpec]) -> usize {
1269 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 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; }
1294 groups.push(hook_group(command, spec));
1295 added += 1;
1296 }
1297 added
1298}
1299
1300fn 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 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
1344fn 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
1355fn 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
1365fn 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
1373fn hook_has_command(hook: &Value, command: &str) -> bool {
1375 hook.get("command").and_then(Value::as_str) == Some(command)
1376}
1377
1378#[derive(Debug, Default, PartialEq, Eq)]
1382struct ClaudeSync {
1383 replaced: usize,
1385 deduped: usize,
1387 added: usize,
1389 customised: usize,
1391}
1392
1393impl ClaudeSync {
1394 fn is_noop(&self) -> bool {
1396 self.replaced == 0 && self.deduped == 0 && self.added == 0
1397 }
1398}
1399
1400fn is_stale_claude_sink(command: &str) -> bool {
1407 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
1420fn is_customised_sink(command: &str, current: &str) -> bool {
1423 command != current && is_sessions_sink(command) && !is_stale_claude_sink(command)
1424}
1425
1426fn 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
1444fn 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
1452fn 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 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; }
1493 dropped_here += dropped;
1494 }
1495 if dropped_here == 0 {
1496 continue; }
1498 removed += dropped_here;
1499 groups.retain(|g| !g.is_null());
1500 }
1501 removed
1502}
1503
1504fn 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
1520const 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
1530fn codex_hook_command() -> String {
1532 format!("{} --agent codex", hook_command())
1533}
1534
1535fn 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
1544fn 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
1553fn is_sessions_sink(command: &str) -> bool {
1560 split_sink(command).is_some()
1561}
1562
1563fn 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
1580fn 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#[derive(Debug, Default, PartialEq, Eq)]
1631struct Removal {
1632 removed: usize,
1634 shifted: bool,
1638}
1639
1640fn 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
1692fn 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
1700fn 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
1746fn 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 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
1783async 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
1794fn 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
1807fn 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 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
1838fn 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
1849fn 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
1860fn 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
1869fn sanitize(s: &str) -> String {
1873 sanitize_for_terminal(s)
1874}
1875
1876fn 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 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 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 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 let mut without = stamped.clone();
2041 without.as_object_mut().unwrap().remove("stamp");
2042 assert_eq!(without, bare);
2043 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 #[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 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 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 assert!(hook_op(r#"{"hook_event_name":"Stop"}"#).is_none());
2191 assert!(hook_op(r#"{"session_id":" ","hook_event_name":"Stop"}"#).is_none());
2193 assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"CwdChanged"}"#).is_none());
2195 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 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 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 for preserving in ["SubagentStop", "PreCompact", "PostCompact"] {
2258 assert_eq!(
2259 codex_event(preserving, None),
2260 "transcript_discovered",
2261 "{preserving}"
2262 );
2263 }
2264 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 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 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 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(®, 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(®, 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(®, parent, None);
2411 observe_claude_hook(®, wait, Some("a"));
2412 observe_claude_hook(®, "PostToolUse", Some("b"));
2413 observe_claude_hook(®, "PreToolUse", Some("a"));
2414 observe_claude_hook(®, "Notification", Some("a"));
2415 assert_eq!(reg.list()[0].state, waiting);
2416 observe_claude_hook(®, 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(®, "Stop", None);
2431 observe_claude_hook(®, "PermissionRequest", Some("a"));
2432 observe_claude_hook(®, "Elicitation", Some("b"));
2433 assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
2434 observe_claude_hook(®, "PostToolUse", Some("a"));
2435 assert_eq!(reg.list()[0].state, SessionState::WaitingForInput);
2436 observe_claude_hook(®, "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(®, "PermissionRequest", Some("a"));
2444 assert_eq!(reg.list()[0].state, SessionState::WaitingForPermission);
2445 observe_claude_hook(®, "PostToolUse", None);
2446 assert_eq!(reg.list()[0].state, SessionState::Working);
2447 observe_claude_hook(®, "Stop", None);
2448 observe_claude_hook(®, "PostToolUse", Some("a"));
2449 assert_eq!(reg.list()[0].state, SessionState::Idle);
2450 observe_claude_hook(®, "PermissionRequest", None);
2451 observe_claude_hook(®, "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(®, "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(®, "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(®, "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 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 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 assert_eq!(typed("auth_success", "You can now allow access"), "other");
2558 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 const TAGGED: &str = "/bin/omni-dev sessions hook --agent codex";
2587
2588 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 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 assert_eq!(added, CODEX_HOOK_EVENTS.len() - 2);
2662 assert_eq!(
2664 layout(&hooks, "PreToolUse"),
2665 vec![vec!["guard"], vec![TAGGED], vec!["audit"]]
2666 );
2667 assert_eq!(hooks["hooks"]["PreToolUse"][1]["matcher"], "*");
2668 assert_eq!(hooks["hooks"]["SessionEnd"][0]["hooks"][0]["timeout"], 3);
2670 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 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 assert!(removal.shifted);
2713 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 assert_eq!(layout(&hooks, "PreToolUse"), vec![vec!["shared"]]);
2723 assert!(hooks["hooks"].get("Interrupt").is_none());
2725 assert_eq!(layout(&hooks, "PostToolUse"), vec![vec!["keep"]]);
2726 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 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 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 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 #[test]
2906 fn merge_hooks_is_idempotent_and_additive() {
2907 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 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 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 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 for HookSpec { event, .. } in original {
2950 assert_eq!(settings["hooks"][event], before["hooks"][event], "{event}");
2951 }
2952 assert_eq!(settings["hooks"]["PermissionRequest"][0]["matcher"], "*");
2954 assert!(settings["hooks"]["StopFailure"][0].get("matcher").is_none());
2955
2956 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 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 let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
2985 assert!(pre.iter().any(|g| group_has_command(g, "keep-me")));
2986 }
2987
2988 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 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 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 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 assert!(sync_claude_hooks(&mut settings, NEW).is_noop());
3062 }
3063
3064 #[test]
3065 fn install_heals_settings_that_already_hold_both_sinks() {
3066 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 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 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 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(¤t), "{event}: {sinks:?}");
3190 assert!(!sinks.contains(&OLD.to_string()), "{event}: {sinks:?}");
3191 }
3192 assert_eq!(count_customised_sinks(&settings, ¤t), 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 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, ¤t), 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 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 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 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 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 std::fs::write(&path, " \n").unwrap();
3309 assert_eq!(read_settings(&path).unwrap(), json!({}));
3310 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 #[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 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 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 #[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 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 assert!(!PI_EXTENSION_TEMPLATE.contains("waiting_for_permission"));
3430 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 assert!(!pi_is_present(&agent, Some(&path)));
3453 assert!(!pi_is_present(&agent, None));
3454
3455 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 std::fs::set_permissions(&pi, std::fs::Permissions::from_mode(0o755)).unwrap();
3463 assert!(pi_is_present(&agent, Some(&path)));
3464
3465 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 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 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 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 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"), }
3531 match parse(&["uninstall-hooks"]) {
3532 SessionsSubcommands::UninstallHooks(cmd) => assert_eq!(cmd.pi_agent_dir, None),
3533 _ => panic!("expected uninstall-hooks"), }
3535 }
3536
3537 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 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 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 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 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_pi_extension(None).unwrap();
3615
3616 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_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 #[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 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 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 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 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 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 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 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 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 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 assert!(!tmp.path().join("settings.json").exists());
3813 }
3814
3815 #[test]
3816 fn set_and_clear_wrapper_tolerate_a_non_object_settings_value() {
3817 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 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 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 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 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 cmd.report("not json", None).await;
3908 cmd.report(r#"{"hook_event_name":"Stop"}"#, None).await; }
3910
3911 #[test]
3912 fn hook_input_that_cannot_be_read_is_skipped_with_a_debug_line() {
3913 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 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 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 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 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 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 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 assert_eq!(merge_hooks(&mut json!([]), cmd, HOOK_EVENTS), 0);
4078 assert_eq!(remove_hooks(&mut json!([]), |c| c == cmd), 0);
4079 assert_eq!(merge_hooks(&mut json!({ "hooks": 5 }), cmd, HOOK_EVENTS), 0);
4081 assert_eq!(remove_hooks(&mut json!({}), |c| c == cmd), 0);
4083 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 #[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 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 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 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 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 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}