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::{Agent, NotificationKind, ObserveRequest, SessionEvent};
44
45const SERVICE: &str = "sessions";
47
48const HOOK_TIMEOUT: Duration = Duration::from_secs(2);
51
52#[derive(Parser)]
55pub struct SessionsCommand {
56 #[command(subcommand)]
58 pub command: SessionsSubcommands,
59}
60
61#[derive(Subcommand)]
63pub enum SessionsSubcommands {
64 List(ListCommand),
67 Hook(HookCommand),
70 InstallHooks(InstallHooksCommand),
74 UninstallHooks(UninstallHooksCommand),
77 InstallWrapper(InstallWrapperCommand),
80 UninstallWrapper(UninstallWrapperCommand),
82 Window(WindowCommand),
84 WindowUnregister(WindowUnregisterCommand),
86}
87
88impl SessionsCommand {
89 pub async fn execute(self) -> Result<()> {
91 match self.command {
92 SessionsSubcommands::List(cmd) => cmd.execute().await,
93 SessionsSubcommands::Hook(cmd) => cmd.execute().await,
94 SessionsSubcommands::InstallHooks(cmd) => cmd.execute(),
95 SessionsSubcommands::UninstallHooks(cmd) => cmd.execute(),
96 SessionsSubcommands::InstallWrapper(cmd) => cmd.execute(),
97 SessionsSubcommands::UninstallWrapper(cmd) => cmd.execute(),
98 SessionsSubcommands::Window(cmd) => cmd.execute().await,
99 SessionsSubcommands::WindowUnregister(cmd) => cmd.execute().await,
100 }
101 }
102}
103
104#[derive(Parser)]
108pub struct ListCommand {
109 #[arg(long, value_name = "PATH")]
111 pub socket: Option<PathBuf>,
112 #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
114 pub output: TableOrJson,
115}
116
117impl ListCommand {
118 pub async fn execute(self) -> Result<()> {
120 let socket = server::resolve_socket(self.socket)?;
121 let result = call(&socket, "list", Value::Null).await?;
122 match self.output {
123 TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
124 TableOrJson::Table => println!("{}", render_sessions(&result)),
125 }
126 Ok(())
127 }
128}
129
130#[derive(Parser)]
138pub struct WindowCommand {
139 #[arg(long, value_name = "KEY")]
141 pub key: String,
142 #[arg(long = "folder", value_name = "PATH")]
144 pub folders: Vec<PathBuf>,
145 #[arg(long, value_name = "N", default_value_t = 0)]
147 pub tabs: usize,
148 #[arg(long, value_name = "N", default_value_t = 0)]
150 pub terminals: usize,
151 #[arg(long, value_name = "PATH")]
153 pub socket: Option<PathBuf>,
154}
155
156impl WindowCommand {
157 pub async fn execute(self) -> Result<()> {
159 let socket = server::resolve_socket(self.socket)?;
160 let payload = json!({
161 "key": self.key,
162 "folders": self.folders,
163 "tabs": self.tabs,
164 "terminals": self.terminals,
165 });
166 call(&socket, "window", payload).await?;
167 println!("Reported window {}", self.key);
168 Ok(())
169 }
170}
171
172#[derive(Parser)]
175pub struct WindowUnregisterCommand {
176 #[arg(long, value_name = "KEY")]
178 pub key: String,
179 #[arg(long, value_name = "PATH")]
181 pub socket: Option<PathBuf>,
182}
183
184impl WindowUnregisterCommand {
185 pub async fn execute(self) -> Result<()> {
187 let socket = server::resolve_socket(self.socket)?;
188 let reply = call(&socket, "window-unregister", json!({ "key": self.key })).await?;
189 let removed = reply
190 .get("removed")
191 .and_then(Value::as_bool)
192 .unwrap_or(false);
193 println!("removed: {removed}");
194 Ok(())
195 }
196}
197
198#[derive(Parser)]
203pub struct HookCommand {
204 #[arg(long, value_name = "PATH")]
206 pub socket: Option<PathBuf>,
207
208 #[arg(long, value_enum, default_value_t = HookAgent::Claude)]
212 pub agent: HookAgent,
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
218pub enum HookAgent {
219 Claude,
221 Codex,
223}
224
225impl HookAgent {
226 fn agent(self) -> Agent {
228 match self {
229 Self::Claude => Agent::Claude,
230 Self::Codex => Agent::Codex,
231 }
232 }
233}
234
235impl HookCommand {
236 pub async fn execute(self) -> Result<()> {
240 let pid = agent_pid(std::os::unix::process::parent_id());
243 let mut input = String::new();
244 if std::io::stdin().read_to_string(&mut input).is_err() {
245 return Ok(());
246 }
247 self.report(&input, pid).await;
248 Ok(())
249 }
250
251 async fn report(&self, input: &str, pid: Option<u32>) {
254 let Ok(hook) = serde_json::from_str::<HookPayload>(input) else {
255 return;
256 };
257 let Some((op, payload)) = hook.to_op(self.agent, pid) else {
258 return;
259 };
260 let Ok(socket) = server::resolve_socket(self.socket.clone()) else {
261 return;
262 };
263 let env = DaemonEnvelope::service(SERVICE, op, payload);
266 let _ = tokio::time::timeout(HOOK_TIMEOUT, DaemonClient::new(&socket).request(env)).await;
267 }
268}
269
270fn agent_pid(parent: u32) -> Option<u32> {
278 (parent > 1).then_some(parent)
279}
280
281#[derive(Debug, Clone, Default, Deserialize)]
285struct HookPayload {
286 #[serde(default)]
287 session_id: Option<String>,
288 #[serde(default)]
289 transcript_path: Option<PathBuf>,
290 #[serde(default)]
291 cwd: Option<PathBuf>,
292 #[serde(default)]
293 hook_event_name: Option<String>,
294 #[serde(default)]
297 message: Option<String>,
298 #[serde(default)]
301 notification_type: Option<String>,
302 #[serde(default)]
304 reason: Option<String>,
305 #[serde(default)]
308 source: Option<String>,
309 #[serde(default)]
312 tool_name: Option<String>,
313 #[serde(default)]
315 model: Option<String>,
316}
317
318impl HookPayload {
319 fn to_op(&self, agent: HookAgent, pid: Option<u32>) -> Option<(&'static str, Value)> {
326 let session_id = self.session_id.clone().filter(|s| !s.trim().is_empty())?;
327 let event_name = self.hook_event_name.as_deref()?;
328 if event_name == "SessionEnd" {
329 let mut payload = json!({ "session_id": session_id });
330 if let Some(reason) = self.reason.as_ref().or(self.message.as_ref()) {
331 payload["reason"] = Value::String(reason.clone());
332 }
333 if let Some(pid) = pid {
334 payload["pid"] = Value::from(pid);
335 }
336 return Some(("end", payload));
337 }
338 let event = match agent {
339 HookAgent::Claude => session_event_for(
340 event_name,
341 self.source.as_deref(),
342 self.notification_type.as_deref(),
343 self.message.as_deref(),
344 )?,
345 HookAgent::Codex => codex_session_event_for(
346 event_name,
347 self.source.as_deref(),
348 self.tool_name.as_deref(),
349 )?,
350 };
351 let request = ObserveRequest {
352 agent: agent.agent(),
353 session_id,
354 cwd: self.cwd.clone(),
355 transcript_path: self.transcript_path.clone(),
356 event,
357 repo: None,
358 model: self.model.clone(),
359 pid,
360 };
361 Some(("observe", serde_json::to_value(request).ok()?))
362 }
363}
364
365fn session_event_for(
403 event_name: &str,
404 source: Option<&str>,
405 notification_type: Option<&str>,
406 message: Option<&str>,
407) -> Option<SessionEvent> {
408 Some(match event_name {
409 "SessionStart" if source == Some("compact") => SessionEvent::TranscriptDiscovered,
410 "SessionStart" => SessionEvent::SessionStart,
411 "UserPromptSubmit" => SessionEvent::UserPromptSubmit,
412 "PreToolUse" => SessionEvent::PreToolUse,
413 "PostToolUse" | "PostToolUseFailure" | "PermissionDenied" | "ElicitationResult"
414 | "SubagentStart" => SessionEvent::PostToolUse,
415 "SubagentStop" | "PreCompact" | "PostCompact" => SessionEvent::TranscriptDiscovered,
416 "Stop" | "StopFailure" => SessionEvent::Stop,
417 "PermissionRequest" => SessionEvent::Notification(NotificationKind::PermissionPrompt),
418 "Elicitation" => SessionEvent::Notification(NotificationKind::AgentNeedsInput),
419 "Notification" => {
420 SessionEvent::Notification(classify_notification(notification_type, message))
421 }
422 _ => return None,
423 })
424}
425
426fn codex_session_event_for(
446 event_name: &str,
447 source: Option<&str>,
448 tool_name: Option<&str>,
449) -> Option<SessionEvent> {
450 Some(match event_name {
451 "SessionStart" if source == Some("compact") => SessionEvent::TranscriptDiscovered,
452 "SessionStart" => SessionEvent::SessionStart,
453 "UserPromptSubmit" => SessionEvent::UserPromptSubmit,
454 "PreToolUse" if tool_name == Some("request_user_input") => {
455 SessionEvent::Notification(NotificationKind::AgentNeedsInput)
456 }
457 "PreToolUse" => SessionEvent::PreToolUse,
458 "PostToolUse" | "SubagentStart" | "SubagentStop" | "PreCompact" | "PostCompact" => {
459 SessionEvent::PostToolUse
460 }
461 "PermissionRequest" => SessionEvent::Notification(NotificationKind::PermissionPrompt),
462 "Stop" | "Interrupt" => SessionEvent::Stop,
463 _ => return None,
464 })
465}
466
467fn classify_notification(
476 notification_type: Option<&str>,
477 message: Option<&str>,
478) -> NotificationKind {
479 match notification_type {
480 Some("permission_prompt") => return NotificationKind::PermissionPrompt,
481 Some("idle_prompt") => return NotificationKind::IdlePrompt,
482 Some("elicitation_dialog") => return NotificationKind::AgentNeedsInput,
483 Some(_) => return NotificationKind::Other,
484 None => {}
485 }
486 let Some(message) = message else {
487 return NotificationKind::Other;
488 };
489 let lower = message.to_lowercase();
490 if lower.contains("permission") || lower.contains("approve") || lower.contains("allow") {
491 NotificationKind::PermissionPrompt
492 } else if lower.contains("waiting for your input")
493 || lower.contains("idle")
494 || lower.contains("needs your input")
495 {
496 NotificationKind::IdlePrompt
497 } else {
498 NotificationKind::Other
499 }
500}
501
502#[derive(Parser)]
507pub struct InstallHooksCommand {
508 #[arg(long, value_name = "PATH")]
511 pub settings: Option<PathBuf>,
512
513 #[arg(long, value_name = "PATH")]
517 pub pi_agent_dir: Option<PathBuf>,
518
519 #[arg(long, value_name = "PATH")]
523 pub codex_home: Option<PathBuf>,
524}
525
526impl InstallHooksCommand {
527 pub fn execute(self) -> Result<()> {
531 let path = settings_path(self.settings)?;
532 let mut settings = read_settings(&path)?;
533 let command = hook_command();
534 let added = merge_hooks(&mut settings, &command, HOOK_EVENTS);
535 write_settings(&path, &settings)?;
536 if added == 0 {
537 println!(
538 "sessions hooks already installed in {} (no change)",
539 path.display()
540 );
541 } else {
542 println!(
543 "installed {added} sessions hook event(s) into {}\ncommand: {command}",
544 path.display()
545 );
546 }
547 install_pi_extension(self.pi_agent_dir)?;
548 install_codex_hooks(self.codex_home)
549 }
550}
551
552#[derive(Parser)]
555pub struct UninstallHooksCommand {
556 #[arg(long, value_name = "PATH")]
559 pub settings: Option<PathBuf>,
560
561 #[arg(long, value_name = "PATH")]
564 pub pi_agent_dir: Option<PathBuf>,
565
566 #[arg(long, value_name = "PATH")]
569 pub codex_home: Option<PathBuf>,
570}
571
572impl UninstallHooksCommand {
573 pub fn execute(self) -> Result<()> {
577 let path = settings_path(self.settings)?;
578 if path.exists() {
579 let mut settings = read_settings(&path)?;
580 let removed = remove_hooks(&mut settings, &hook_command());
581 write_settings(&path, &settings)?;
582 println!(
583 "removed {removed} sessions hook entry(ies) from {}",
584 path.display()
585 );
586 } else {
587 println!("no settings file at {} (nothing to remove)", path.display());
588 }
589 uninstall_pi_extension(self.pi_agent_dir)?;
590 uninstall_codex_hooks(self.codex_home)
591 }
592}
593
594const PI_EXTENSION_NAME: &str = "omni-dev-sessions.ts";
598
599const PI_EXTENSION_TEMPLATE: &str = include_str!("../templates/pi-sessions-extension.ts");
601
602const PI_SOCKET_PLACEHOLDER: &str = "__OMNI_DEV_SOCKET__";
604
605fn pi_extension_marker() -> &'static str {
608 PI_EXTENSION_TEMPLATE.lines().next().unwrap_or_default()
609}
610
611fn render_pi_extension(socket: &Path) -> String {
615 let literal =
616 serde_json::to_string(&socket.display().to_string()).unwrap_or_else(|_| "\"\"".to_string());
617 PI_EXTENSION_TEMPLATE.replace(PI_SOCKET_PLACEHOLDER, &literal)
618}
619
620fn default_pi_agent_dir() -> Result<PathBuf> {
622 if let Some(dir) = std::env::var_os("PI_CODING_AGENT_DIR").filter(|d| !d.is_empty()) {
623 return Ok(PathBuf::from(dir));
624 }
625 let home = dirs::home_dir().context("could not resolve the home directory")?;
626 Ok(home.join(".pi").join("agent"))
627}
628
629fn pi_is_present(agent_dir: &Path, path: Option<&std::ffi::OsStr>) -> bool {
632 agent_dir.is_dir()
633 || path.is_some_and(|path| {
634 std::env::split_paths(path).any(|dir| is_executable(&dir.join("pi")))
635 })
636}
637
638fn is_executable(path: &Path) -> bool {
640 use std::os::unix::fs::PermissionsExt;
641
642 std::fs::metadata(path)
643 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
644}
645
646#[derive(Debug, PartialEq, Eq)]
648enum PiInstall {
649 Written,
651 Unchanged,
653}
654
655fn write_pi_extension(agent_dir: &Path, contents: &str) -> Result<(PathBuf, PiInstall)> {
658 let dir = agent_dir.join("extensions");
659 let file = dir.join(PI_EXTENSION_NAME);
660 if file.exists() {
661 let existing = std::fs::read_to_string(&file)
662 .with_context(|| format!("failed to read {}", file.display()))?;
663 if existing == contents {
664 return Ok((file, PiInstall::Unchanged));
665 }
666 if !existing.starts_with(pi_extension_marker()) {
667 bail!(
668 "{} exists and was not written by omni-dev; refusing to overwrite it",
669 file.display()
670 );
671 }
672 }
673 std::fs::create_dir_all(&dir).with_context(|| format!("failed to create {}", dir.display()))?;
674 std::fs::write(&file, contents)
675 .with_context(|| format!("failed to write {}", file.display()))?;
676 Ok((file, PiInstall::Written))
677}
678
679fn remove_pi_extension(agent_dir: &Path) -> Result<Option<PathBuf>> {
682 let file = agent_dir.join("extensions").join(PI_EXTENSION_NAME);
683 let Ok(existing) = std::fs::read_to_string(&file) else {
684 return Ok(None);
685 };
686 if !existing.starts_with(pi_extension_marker()) {
687 return Ok(None);
688 }
689 std::fs::remove_file(&file).with_context(|| format!("failed to remove {}", file.display()))?;
690 Ok(Some(file))
691}
692
693fn install_pi_extension(explicit: Option<PathBuf>) -> Result<()> {
696 let agent_dir = if let Some(dir) = explicit {
697 dir
698 } else {
699 let dir = default_pi_agent_dir()?;
700 if !pi_is_present(&dir, std::env::var_os("PATH").as_deref()) {
701 println!(
702 "pi.dev not found (no {} and no `pi` on PATH); skipped its extension",
703 dir.display()
704 );
705 return Ok(());
706 }
707 dir
708 };
709 let socket = server::resolve_socket(None)?;
710 let (file, outcome) = write_pi_extension(&agent_dir, &render_pi_extension(&socket))?;
711 match outcome {
712 PiInstall::Unchanged => println!(
713 "pi.dev extension already installed at {} (no change)",
714 file.display()
715 ),
716 PiInstall::Written => {
717 println!(
718 "installed the pi.dev sessions extension at {}",
719 file.display()
720 );
721 println!("pi sessions started after this report their state");
722 }
723 }
724 Ok(())
725}
726
727fn uninstall_pi_extension(explicit: Option<PathBuf>) -> Result<()> {
730 let agent_dir = match explicit {
731 Some(dir) => dir,
732 None => default_pi_agent_dir()?,
733 };
734 if let Some(file) = remove_pi_extension(&agent_dir)? {
735 println!("removed {}", file.display());
736 }
737 Ok(())
738}
739
740const WRAPPER_SETTING_KEY: &str = "claudeCode.claudeProcessWrapper";
745
746const SHIM_NAME: &str = "claude-wrap";
748
749#[derive(Parser)]
751pub struct InstallWrapperCommand {
752 #[arg(long, value_name = "PATH")]
755 pub settings: Option<PathBuf>,
756
757 #[arg(long, value_name = "PATH")]
759 pub shim: Option<PathBuf>,
760}
761
762impl InstallWrapperCommand {
763 pub fn execute(self) -> Result<()> {
766 let shim = shim_path(self.shim)?;
767 write_shim(&shim)?;
768 let path = vscode_settings_path(self.settings)?;
769 let target = shim.display().to_string();
770
771 let mut settings =
772 read_settings(&path).map_err(|error| manual_setup_hint(&error, &target))?;
773 let changed = set_wrapper(&mut settings, &target);
774 write_settings(&path, &settings)?;
775
776 println!("wrapper shim: {target}");
777 if changed {
778 println!("set {WRAPPER_SETTING_KEY} in {}", path.display());
779 println!("reload VS Code; Claude tabs opened after that report their exact state");
780 } else {
781 println!(
782 "{WRAPPER_SETTING_KEY} already points there in {} (no change)",
783 path.display()
784 );
785 }
786 Ok(())
787 }
788}
789
790#[derive(Parser)]
792pub struct UninstallWrapperCommand {
793 #[arg(long, value_name = "PATH")]
796 pub settings: Option<PathBuf>,
797
798 #[arg(long, value_name = "PATH")]
801 pub shim: Option<PathBuf>,
802}
803
804impl UninstallWrapperCommand {
805 pub fn execute(self) -> Result<()> {
808 let shim = shim_path(self.shim)?;
809 let target = shim.display().to_string();
810 let path = vscode_settings_path(self.settings)?;
811
812 if path.exists() {
813 let mut settings =
814 read_settings(&path).map_err(|error| manual_setup_hint(&error, &target))?;
815 if clear_wrapper(&mut settings, &target) {
816 write_settings(&path, &settings)?;
817 println!("cleared {WRAPPER_SETTING_KEY} in {}", path.display());
818 } else {
819 println!(
820 "{WRAPPER_SETTING_KEY} in {} does not point at our shim; left alone",
821 path.display()
822 );
823 }
824 } else {
825 println!("no settings file at {} (nothing to clear)", path.display());
826 }
827
828 if shim.exists() {
829 std::fs::remove_file(&shim)
830 .with_context(|| format!("failed to remove {}", shim.display()))?;
831 println!("removed {target}");
832 }
833 Ok(())
834 }
835}
836
837fn shim_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
840 match explicit {
841 Some(path) => Ok(path),
842 None => Ok(paths::runtime_dir()?.join(SHIM_NAME)),
843 }
844}
845
846fn vscode_settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
852 if let Some(path) = explicit {
853 return Ok(path);
854 }
855 let base = dirs::config_dir().context("could not determine the user config directory")?;
856 Ok(base.join("Code").join("User").join("settings.json"))
857}
858
859fn shim_contents() -> String {
866 let exe = std::env::current_exe()
867 .map_or_else(|_| "omni-dev".to_string(), |exe| exe.display().to_string());
868 format!("#!/bin/sh\nexec \"{exe}\" claude-wrap -- \"$@\"\n")
869}
870
871fn write_shim(path: &Path) -> Result<()> {
873 use std::os::unix::fs::PermissionsExt;
874
875 if let Some(parent) = path.parent() {
876 paths::ensure_dir_0700(parent)?;
877 }
878 std::fs::write(path, shim_contents())
879 .with_context(|| format!("failed to write {}", path.display()))?;
880 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))
881 .with_context(|| format!("failed to make {} executable", path.display()))
882}
883
884fn set_wrapper(settings: &mut Value, shim: &str) -> bool {
886 let Some(root) = settings.as_object_mut() else {
887 return false;
888 };
889 if root.get(WRAPPER_SETTING_KEY).and_then(Value::as_str) == Some(shim) {
890 return false;
891 }
892 root.insert(WRAPPER_SETTING_KEY.to_string(), json!(shim));
893 true
894}
895
896fn clear_wrapper(settings: &mut Value, shim: &str) -> bool {
899 let Some(root) = settings.as_object_mut() else {
900 return false;
901 };
902 if root.get(WRAPPER_SETTING_KEY).and_then(Value::as_str) != Some(shim) {
903 return false;
904 }
905 root.remove(WRAPPER_SETTING_KEY);
906 true
907}
908
909fn manual_setup_hint(error: &anyhow::Error, shim: &str) -> anyhow::Error {
916 anyhow!(
917 "{error:#}\n\n\
918 VS Code settings files may contain comments or trailing commas, which cannot be \
919 rewritten safely. The shim is installed, so add this line by hand instead:\n\n \
920 \"{WRAPPER_SETTING_KEY}\": \"{shim}\""
921 )
922}
923
924#[derive(Debug, Clone, Copy)]
926struct HookSpec {
927 event: &'static str,
929 matcher: bool,
934 timeout: Option<u64>,
937}
938
939impl HookSpec {
940 const fn new(event: &'static str) -> Self {
941 Self {
942 event,
943 matcher: false,
944 timeout: None,
945 }
946 }
947
948 const fn matched(event: &'static str) -> Self {
949 Self {
950 matcher: true,
951 ..Self::new(event)
952 }
953 }
954
955 const fn capped(event: &'static str, secs: u64) -> Self {
956 Self {
957 timeout: Some(secs),
958 ..Self::new(event)
959 }
960 }
961}
962
963const HOOK_EVENTS: &[HookSpec] = &[
969 HookSpec::new("SessionStart"),
970 HookSpec::new("UserPromptSubmit"),
971 HookSpec::matched("PreToolUse"),
972 HookSpec::matched("PostToolUse"),
973 HookSpec::new("Notification"),
974 HookSpec::new("Stop"),
975 HookSpec::new("SessionEnd"),
976 HookSpec::matched("PermissionRequest"),
977 HookSpec::matched("PermissionDenied"),
978 HookSpec::matched("PostToolUseFailure"),
979 HookSpec::new("StopFailure"),
980 HookSpec::new("Elicitation"),
981 HookSpec::new("ElicitationResult"),
982 HookSpec::new("SubagentStart"),
983 HookSpec::new("SubagentStop"),
984 HookSpec::new("PreCompact"),
985 HookSpec::new("PostCompact"),
986];
987
988const CODEX_HOOK_EVENTS: &[HookSpec] = &[
993 HookSpec::new("SessionStart"),
994 HookSpec::new("UserPromptSubmit"),
995 HookSpec::matched("PreToolUse"),
996 HookSpec::matched("PostToolUse"),
997 HookSpec::new("PermissionRequest"),
998 HookSpec::new("Stop"),
999 HookSpec::capped("Interrupt", 3),
1000 HookSpec::capped("SessionEnd", 3),
1001 HookSpec::new("PreCompact"),
1002 HookSpec::new("PostCompact"),
1003 HookSpec::new("SubagentStart"),
1004 HookSpec::new("SubagentStop"),
1005];
1006
1007fn hook_command() -> String {
1012 match std::env::current_exe() {
1013 Ok(exe) => format!("{} sessions hook", exe.display()),
1014 Err(_) => "omni-dev sessions hook".to_string(),
1015 }
1016}
1017
1018fn settings_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
1021 if let Some(path) = explicit {
1022 return Ok(path);
1023 }
1024 if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR") {
1025 return Ok(PathBuf::from(dir).join("settings.json"));
1026 }
1027 let home = dirs::home_dir().context("could not resolve the home directory")?;
1028 Ok(home.join(".claude").join("settings.json"))
1029}
1030
1031fn read_settings(path: &Path) -> Result<Value> {
1035 if !path.exists() {
1036 return Ok(json!({}));
1037 }
1038 let text = std::fs::read_to_string(path)
1039 .with_context(|| format!("failed to read {}", path.display()))?;
1040 if text.trim().is_empty() {
1041 return Ok(json!({}));
1042 }
1043 let value: Value = serde_json::from_str(&text).with_context(|| {
1044 format!(
1045 "{} is not valid JSON; refusing to overwrite it",
1046 path.display()
1047 )
1048 })?;
1049 if !value.is_object() {
1050 bail!(
1051 "{} is not a JSON object; refusing to overwrite it",
1052 path.display()
1053 );
1054 }
1055 Ok(value)
1056}
1057
1058fn write_settings(path: &Path, settings: &Value) -> Result<()> {
1061 if let Some(parent) = path.parent() {
1062 std::fs::create_dir_all(parent)
1063 .with_context(|| format!("failed to create {}", parent.display()))?;
1064 }
1065 let mut text = serde_json::to_string_pretty(settings)?;
1066 text.push('\n');
1067 std::fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))?;
1068 Ok(())
1069}
1070
1071fn merge_hooks(settings: &mut Value, command: &str, specs: &[HookSpec]) -> usize {
1077 let Some(root) = settings.as_object_mut() else {
1080 return 0;
1081 };
1082 let hooks = root
1083 .entry("hooks")
1084 .or_insert_with(|| json!({}))
1085 .as_object_mut();
1086 let Some(hooks) = hooks else {
1087 return 0;
1090 };
1091 let mut added = 0;
1092 for spec in specs {
1093 let groups = hooks
1094 .entry(spec.event.to_string())
1095 .or_insert_with(|| json!([]));
1096 let Some(groups) = groups.as_array_mut() else {
1097 continue;
1098 };
1099 if groups.iter().any(|g| group_has_command(g, command)) {
1100 continue; }
1102 groups.push(hook_group(command, spec));
1103 added += 1;
1104 }
1105 added
1106}
1107
1108fn remove_hooks(settings: &mut Value, command: &str) -> usize {
1112 let Some(root) = settings.as_object_mut() else {
1113 return 0;
1114 };
1115 let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else {
1116 return 0;
1117 };
1118 let mut removed = 0;
1119 let mut empty_events = Vec::new();
1120 for (event, groups) in hooks.iter_mut() {
1121 let Some(groups) = groups.as_array_mut() else {
1122 continue;
1123 };
1124 for group in groups.iter_mut() {
1125 if let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) {
1126 let before = inner.len();
1127 inner.retain(|h| !hook_has_command(h, command));
1128 removed += before - inner.len();
1129 }
1130 }
1131 groups.retain(|g| {
1134 g.get("hooks")
1135 .and_then(Value::as_array)
1136 .is_none_or(|h| !h.is_empty())
1137 });
1138 if groups.is_empty() {
1139 empty_events.push(event.clone());
1140 }
1141 }
1142 for event in empty_events {
1143 hooks.remove(&event);
1144 }
1145 removed
1146}
1147
1148fn hook_group(command: &str, spec: &HookSpec) -> Value {
1152 let mut group = json!({ "hooks": [hook_entry(command, spec)] });
1153 if spec.matcher {
1154 group["matcher"] = Value::String("*".to_string());
1155 }
1156 group
1157}
1158
1159fn hook_entry(command: &str, spec: &HookSpec) -> Value {
1162 let mut hook = json!({ "type": "command", "command": command });
1163 if let Some(secs) = spec.timeout {
1164 hook["timeout"] = json!(secs);
1165 }
1166 hook
1167}
1168
1169fn group_has_command(group: &Value, command: &str) -> bool {
1171 group
1172 .get("hooks")
1173 .and_then(Value::as_array)
1174 .is_some_and(|hooks| hooks.iter().any(|h| hook_has_command(h, command)))
1175}
1176
1177fn hook_has_command(hook: &Value, command: &str) -> bool {
1179 hook.get("command").and_then(Value::as_str) == Some(command)
1180}
1181
1182const CODEX_TRUST_STEP: &str =
1188 "Codex skips untrusted hooks silently: open the Codex CLI, run `/hooks`, \
1189and trust the omni-dev entries (once, and again after the command changes). \
1190One approval covers the VS Code extension and Codex Desktop.";
1191
1192fn codex_hook_command() -> String {
1194 format!("{} --agent codex", hook_command())
1195}
1196
1197fn default_codex_home() -> Result<PathBuf> {
1199 if let Some(dir) = std::env::var_os("CODEX_HOME").filter(|d| !d.is_empty()) {
1200 return Ok(PathBuf::from(dir));
1201 }
1202 let home = dirs::home_dir().context("could not resolve the home directory")?;
1203 Ok(home.join(".codex"))
1204}
1205
1206fn codex_is_present(codex_home: &Path, path: Option<&std::ffi::OsStr>) -> bool {
1209 codex_home.is_dir()
1210 || path.is_some_and(|path| {
1211 std::env::split_paths(path).any(|dir| is_executable(&dir.join("codex")))
1212 })
1213}
1214
1215fn is_sessions_sink(command: &str) -> bool {
1222 let command = command.trim();
1223 let Some(at) = command.find(" sessions hook") else {
1224 return false;
1225 };
1226 let rest = &command[at + " sessions hook".len()..];
1227 (rest.is_empty() || rest.starts_with(char::is_whitespace))
1228 && Path::new(command[..at].trim())
1229 .file_name()
1230 .is_some_and(|name| name == "omni-dev")
1231}
1232
1233fn replace_stale_sinks(settings: &mut Value, command: &str, specs: &[HookSpec]) -> usize {
1242 let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else {
1243 return 0;
1244 };
1245 let mut replaced = 0;
1246 for (event, groups) in hooks.iter_mut() {
1247 let spec = specs.iter().find(|s| s.event == event);
1248 let Some(groups) = groups.as_array_mut() else {
1249 continue;
1250 };
1251 for hook in groups
1252 .iter_mut()
1253 .filter_map(|g| g.get_mut("hooks").and_then(Value::as_array_mut))
1254 .flatten()
1255 {
1256 let stale = hook
1257 .get("command")
1258 .and_then(Value::as_str)
1259 .is_some_and(|c| c != command && is_sessions_sink(c));
1260 if !stale {
1261 continue;
1262 }
1263 hook["command"] = Value::String(command.to_string());
1264 if let Some(secs) = spec.and_then(|s| s.timeout) {
1265 hook["timeout"] = json!(secs);
1266 }
1267 replaced += 1;
1268 }
1269 }
1270 replaced
1271}
1272
1273#[derive(Debug, Default, PartialEq, Eq)]
1275struct Removal {
1276 removed: usize,
1278 shifted: bool,
1282}
1283
1284fn remove_hooks_stable(settings: &mut Value, is_ours: impl Fn(&str) -> bool) -> Removal {
1292 let mut outcome = Removal::default();
1293 let Some(hooks) = settings.get_mut("hooks").and_then(Value::as_object_mut) else {
1294 return outcome;
1295 };
1296 let mut empty_events = Vec::new();
1297 for (event, groups) in hooks.iter_mut() {
1298 let Some(groups) = groups.as_array_mut() else {
1299 continue;
1300 };
1301 for group in groups.iter_mut() {
1302 let Some(inner) = group.get_mut("hooks").and_then(Value::as_array_mut) else {
1303 continue;
1304 };
1305 let mut removed_before = false;
1306 inner.retain(|h| {
1307 let ours = h
1308 .get("command")
1309 .and_then(Value::as_str)
1310 .is_some_and(&is_ours);
1311 if ours {
1312 removed_before = true;
1313 outcome.removed += 1;
1314 } else if removed_before {
1315 outcome.shifted = true;
1316 }
1317 !ours
1318 });
1319 if removed_before && inner.is_empty() {
1320 *group = json!({ "hooks": [] });
1321 }
1322 }
1323 while groups.last().is_some_and(is_empty_group) {
1324 groups.pop();
1325 }
1326 if groups.is_empty() {
1327 empty_events.push(event.clone());
1328 }
1329 }
1330 for event in empty_events {
1331 hooks.remove(&event);
1332 }
1333 outcome
1334}
1335
1336fn is_empty_group(group: &Value) -> bool {
1338 group
1339 .get("hooks")
1340 .and_then(Value::as_array)
1341 .is_some_and(Vec::is_empty)
1342}
1343
1344fn install_codex_hooks(explicit: Option<PathBuf>) -> Result<()> {
1348 let codex_home = if let Some(dir) = explicit {
1349 dir
1350 } else {
1351 let dir = default_codex_home()?;
1352 if !codex_is_present(&dir, std::env::var_os("PATH").as_deref()) {
1353 println!(
1354 "Codex not found (no {} and no `codex` on PATH); skipped its hooks",
1355 dir.display()
1356 );
1357 return Ok(());
1358 }
1359 dir
1360 };
1361 let path = codex_home.join("hooks.json");
1362 let mut hooks = read_settings(&path)?;
1363 let command = codex_hook_command();
1364 let replaced = replace_stale_sinks(&mut hooks, &command, CODEX_HOOK_EVENTS);
1365 let added = merge_hooks(&mut hooks, &command, CODEX_HOOK_EVENTS);
1366 if replaced == 0 && added == 0 {
1367 println!(
1368 "Codex sessions hooks already installed in {} (no change)",
1369 path.display()
1370 );
1371 return Ok(());
1372 }
1373 write_settings(&path, &hooks)?;
1374 if replaced > 0 {
1375 println!(
1376 "rewrote {replaced} existing `sessions hook` entry(ies) in {} in place",
1377 path.display()
1378 );
1379 }
1380 if added > 0 {
1381 println!(
1382 "installed {added} Codex sessions hook event(s) into {}",
1383 path.display()
1384 );
1385 }
1386 println!("command: {command}\n{CODEX_TRUST_STEP}");
1387 Ok(())
1388}
1389
1390fn uninstall_codex_hooks(explicit: Option<PathBuf>) -> Result<()> {
1395 let codex_home = match explicit {
1396 Some(dir) => dir,
1397 None => default_codex_home()?,
1398 };
1399 let path = codex_home.join("hooks.json");
1400 if !path.exists() {
1401 return Ok(());
1402 }
1403 let mut hooks = read_settings(&path)?;
1404 let current = codex_hook_command();
1406 let Removal { removed, shifted } =
1407 remove_hooks_stable(&mut hooks, |c| c == current || is_sessions_sink(c));
1408 if removed == 0 {
1409 return Ok(());
1410 }
1411 write_settings(&path, &hooks)?;
1412 println!(
1413 "removed {removed} Codex sessions hook entry(ies) from {}",
1414 path.display()
1415 );
1416 if shifted {
1417 println!(
1418 "a hook that shared a group with ours moved up within it, so Codex no \
1419 longer trusts it: run `/hooks` in the Codex CLI to trust it again"
1420 );
1421 } else {
1422 println!("no other Codex hook moved, so their `/hooks` trust stands");
1423 }
1424 Ok(())
1425}
1426
1427async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
1432 let reply = DaemonClient::new(socket)
1433 .request(DaemonEnvelope::service(SERVICE, op, payload))
1434 .await?;
1435 reply_payload(reply)
1436}
1437
1438fn reply_payload(reply: DaemonReply) -> Result<Value> {
1441 if reply.ok {
1442 Ok(reply.payload)
1443 } else {
1444 bail!(
1445 "daemon returned an error: {}",
1446 reply.error.as_deref().unwrap_or("unknown error")
1447 )
1448 }
1449}
1450
1451fn render_sessions(result: &Value) -> String {
1455 let sessions = result
1456 .get("sessions")
1457 .and_then(Value::as_array)
1458 .map(Vec::as_slice)
1459 .unwrap_or_default();
1460 if sessions.is_empty() {
1461 return "No active agent sessions.".to_string();
1462 }
1463 let mut out = format!(
1465 "{:<13} {:<6} {:<8} {:<20} {:>5} {}",
1466 "STATE", "AGENT", "SOURCE", "REPO", "AGE", "CWD"
1467 );
1468 for session in sessions {
1469 let state = state_display(session.get("state").and_then(Value::as_str).unwrap_or("-"));
1470 let agent = agent_label(session);
1471 let source = source_label(session);
1472 let repo = sanitize(session.get("repo").and_then(Value::as_str).unwrap_or("-"));
1473 let cwd = sanitize(session.get("cwd").and_then(Value::as_str).unwrap_or("-"));
1474 let age = age_secs(session.get("last_seen").and_then(Value::as_str));
1475 out.push_str(&format!(
1476 "\n{state:<13} {agent:<6} {source:<8} {repo:<20} {age:>4}s {cwd}"
1477 ));
1478 }
1479 out
1480}
1481
1482fn state_display(state: &str) -> String {
1486 match state {
1487 "waiting_for_permission" => "waiting-perm".to_string(),
1488 "waiting_for_input" => "waiting-input".to_string(),
1489 other => sanitize(other),
1490 }
1491}
1492
1493fn agent_label(session: &Value) -> &'static str {
1497 match session.get("agent").and_then(Value::as_str) {
1498 Some("pi") => "pi",
1499 Some("codex") => "codex",
1500 _ => "claude",
1501 }
1502}
1503
1504fn source_label(session: &Value) -> &'static str {
1507 match session.pointer("/source/kind").and_then(Value::as_str) {
1508 Some("vs_code") => "vscode",
1509 _ => "terminal",
1510 }
1511}
1512
1513fn sanitize(s: &str) -> String {
1517 sanitize_for_terminal(s)
1518}
1519
1520fn age_secs(ts: Option<&str>) -> i64 {
1522 ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1523 .map_or(0, |t| {
1524 (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
1525 })
1526}
1527
1528#[cfg(test)]
1529#[allow(clippy::unwrap_used, clippy::expect_used)]
1530mod tests {
1531 use super::*;
1532 use crate::sessions::SessionState;
1533
1534 #[derive(Parser)]
1536 struct Wrapper {
1537 #[command(subcommand)]
1538 cmd: SessionsSubcommands,
1539 }
1540
1541 fn parse(args: &[&str]) -> SessionsSubcommands {
1542 let mut full = vec!["omni-dev"];
1543 full.extend_from_slice(args);
1544 Wrapper::try_parse_from(full).unwrap().cmd
1545 }
1546
1547 #[test]
1548 fn subcommands_parse() {
1549 assert!(matches!(parse(&["list"]), SessionsSubcommands::List(_)));
1550 assert!(matches!(parse(&["hook"]), SessionsSubcommands::Hook(_)));
1551 assert!(matches!(
1552 parse(&["install-hooks"]),
1553 SessionsSubcommands::InstallHooks(_)
1554 ));
1555 assert!(matches!(
1556 parse(&["uninstall-hooks"]),
1557 SessionsSubcommands::UninstallHooks(_)
1558 ));
1559 assert!(matches!(
1560 parse(&["install-wrapper"]),
1561 SessionsSubcommands::InstallWrapper(_)
1562 ));
1563 assert!(matches!(
1564 parse(&["uninstall-wrapper"]),
1565 SessionsSubcommands::UninstallWrapper(_)
1566 ));
1567 }
1568
1569 #[test]
1570 fn list_parses_flags() {
1571 let cmd =
1572 ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
1573 assert_eq!(cmd.output, TableOrJson::Json);
1574 assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
1575 }
1576
1577 fn hook_op(json_str: &str) -> Option<(&'static str, Value)> {
1580 serde_json::from_str::<HookPayload>(json_str)
1581 .unwrap()
1582 .to_op(HookAgent::Claude, None)
1583 }
1584
1585 #[test]
1586 fn hook_maps_lifecycle_events_to_observe() {
1587 let (op, payload) = hook_op(
1588 r#"{"session_id":"s1","cwd":"/p","transcript_path":"/t.jsonl","hook_event_name":"PreToolUse"}"#,
1589 )
1590 .unwrap();
1591 assert_eq!(op, "observe");
1592 assert_eq!(payload["session_id"], "s1");
1593 assert_eq!(payload["cwd"], "/p");
1594 assert_eq!(payload["event"], "pre_tool_use");
1595 }
1596
1597 #[test]
1598 fn hook_maps_session_start_and_stop() {
1599 assert_eq!(
1600 hook_op(r#"{"session_id":"s1","hook_event_name":"SessionStart"}"#)
1601 .unwrap()
1602 .1["event"],
1603 "session_start"
1604 );
1605 assert_eq!(
1606 hook_op(r#"{"session_id":"s1","hook_event_name":"Stop"}"#)
1607 .unwrap()
1608 .1["event"],
1609 "stop"
1610 );
1611 }
1612
1613 #[test]
1614 fn hook_maps_session_end_to_end_op() {
1615 let (op, payload) =
1616 hook_op(r#"{"session_id":"s1","hook_event_name":"SessionEnd","message":"exit"}"#)
1617 .unwrap();
1618 assert_eq!(op, "end");
1619 assert_eq!(payload["session_id"], "s1");
1620 assert_eq!(payload["reason"], "exit");
1621 }
1622
1623 #[test]
1624 fn hook_sends_the_agent_pid_on_both_ops_when_known() {
1625 let hook = |json_str: &str, pid| {
1626 serde_json::from_str::<HookPayload>(json_str)
1627 .unwrap()
1628 .to_op(HookAgent::Claude, pid)
1629 .unwrap()
1630 };
1631 let start = r#"{"session_id":"s1","hook_event_name":"SessionStart"}"#;
1632 let end = r#"{"session_id":"s1","hook_event_name":"SessionEnd"}"#;
1633 assert_eq!(hook(start, Some(42)).1["pid"], 42);
1634 assert_eq!(hook(end, Some(42)).1["pid"], 42);
1635 assert!(hook(start, None).1.get("pid").is_none());
1637 assert!(hook(end, None).1.get("pid").is_none());
1638 }
1639
1640 #[test]
1641 fn agent_pid_treats_an_orphaned_hook_as_unknown() {
1642 assert_eq!(agent_pid(4242), Some(4242));
1643 assert_eq!(agent_pid(1), None);
1644 assert_eq!(agent_pid(0), None);
1645 }
1646
1647 #[test]
1648 fn hook_classifies_notifications() {
1649 let permission = hook_op(
1650 r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}"#,
1651 )
1652 .unwrap();
1653 assert_eq!(permission.1["event"]["notification"], "permission_prompt");
1654
1655 let idle = hook_op(
1656 r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude is waiting for your input"}"#,
1657 )
1658 .unwrap();
1659 assert_eq!(idle.1["event"]["notification"], "idle_prompt");
1660
1661 let other = hook_op(
1662 r#"{"session_id":"s1","hook_event_name":"Notification","message":"something else"}"#,
1663 )
1664 .unwrap();
1665 assert_eq!(other.1["event"]["notification"], "other");
1666 }
1667
1668 #[test]
1669 fn hook_ignores_unknown_events_and_missing_session_id() {
1670 assert!(hook_op(r#"{"hook_event_name":"Stop"}"#).is_none());
1672 assert!(hook_op(r#"{"session_id":" ","hook_event_name":"Stop"}"#).is_none());
1674 assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"CwdChanged"}"#).is_none());
1676 assert!(hook_op("{}").is_none());
1678 }
1679
1680 fn codex_op(json_str: &str) -> Option<(&'static str, Value)> {
1681 serde_json::from_str::<HookPayload>(json_str)
1682 .unwrap()
1683 .to_op(HookAgent::Codex, None)
1684 }
1685
1686 fn codex_event(event: &str, tool: Option<&str>) -> Value {
1687 let mut hook = json!({ "session_id": "c1", "hook_event_name": event });
1688 if let Some(tool) = tool {
1689 hook["tool_name"] = json!(tool);
1690 }
1691 codex_op(&hook.to_string()).unwrap().1["event"].clone()
1692 }
1693
1694 #[test]
1695 fn codex_hooks_are_tagged_from_the_first_event() {
1696 let (op, payload) = codex_op(
1697 r#"{"session_id":"c1","cwd":"/p","model":"gpt-5.5","hook_event_name":"SessionStart","source":"startup"}"#,
1698 )
1699 .unwrap();
1700 assert_eq!(op, "observe");
1701 assert_eq!(payload["agent"], "codex");
1702 assert_eq!(payload["model"], "gpt-5.5");
1703 assert_eq!(payload["event"], "session_start");
1704 let claude = hook_op(r#"{"session_id":"s1","hook_event_name":"Stop"}"#).unwrap();
1706 assert!(claude.1.get("agent").is_none());
1707 }
1708
1709 #[test]
1710 fn codex_events_map_onto_the_existing_session_events() {
1711 assert_eq!(codex_event("UserPromptSubmit", None), "user_prompt_submit");
1712 assert_eq!(codex_event("PreToolUse", Some("Bash")), "pre_tool_use");
1713 assert_eq!(codex_event("PostToolUse", Some("Bash")), "post_tool_use");
1714 assert_eq!(codex_event("Stop", None), "stop");
1715 let compact =
1716 json!({ "session_id": "c1", "hook_event_name": "SessionStart", "source": "compact" });
1717 assert_eq!(
1718 codex_op(&compact.to_string()).unwrap().1["event"],
1719 "transcript_discovered",
1720 "a compaction start is not a new session (#1946)"
1721 );
1722 assert_eq!(
1723 codex_event("PermissionRequest", Some("Bash"))["notification"],
1724 "permission_prompt"
1725 );
1726 assert_eq!(
1727 codex_event("PreToolUse", Some("request_user_input"))["notification"],
1728 "agent_needs_input"
1729 );
1730 assert_eq!(
1732 codex_event("PostToolUse", Some("request_user_input")),
1733 "post_tool_use"
1734 );
1735 assert_eq!(codex_event("Interrupt", None), "stop");
1736 for heartbeat in ["SubagentStart", "SubagentStop", "PreCompact", "PostCompact"] {
1737 assert_eq!(codex_event(heartbeat, None), "post_tool_use", "{heartbeat}");
1738 }
1739 assert!(codex_op(r#"{"session_id":"c1","hook_event_name":"Notification"}"#).is_none());
1741 }
1742
1743 #[test]
1744 fn codex_session_end_is_the_end_op_with_its_reason() {
1745 let (op, payload) =
1746 codex_op(r#"{"session_id":"c1","hook_event_name":"SessionEnd","reason":"other"}"#)
1747 .unwrap();
1748 assert_eq!(op, "end");
1749 assert_eq!(payload["reason"], "other");
1750 }
1751
1752 #[test]
1753 fn the_claude_sink_ignores_codex_only_events() {
1754 assert!(hook_op(r#"{"session_id":"s1","hook_event_name":"Interrupt"}"#).is_none());
1757 assert_eq!(
1758 hook_op(r#"{"session_id":"s1","hook_event_name":"PreToolUse","tool_name":"request_user_input"}"#)
1759 .unwrap()
1760 .1["event"],
1761 "pre_tool_use"
1762 );
1763 }
1764
1765 fn claude_event(event: &str) -> Value {
1766 let hook = json!({ "session_id": "s1", "hook_event_name": event });
1767 hook_op(&hook.to_string()).unwrap().1["event"].clone()
1768 }
1769
1770 #[test]
1771 fn claude_newer_events_map_onto_the_existing_session_events() {
1772 assert_eq!(
1773 claude_event("PermissionRequest")["notification"],
1774 "permission_prompt"
1775 );
1776 assert_eq!(
1777 claude_event("Elicitation")["notification"],
1778 "agent_needs_input"
1779 );
1780 for working in [
1781 "PostToolUseFailure",
1782 "PermissionDenied",
1783 "ElicitationResult",
1784 "SubagentStart",
1785 ] {
1786 assert_eq!(claude_event(working), "post_tool_use", "{working}");
1787 }
1788 assert_eq!(claude_event("StopFailure"), "stop");
1789 for preserving in ["SubagentStop", "PreCompact", "PostCompact"] {
1792 assert_eq!(
1793 claude_event(preserving),
1794 "transcript_discovered",
1795 "{preserving}"
1796 );
1797 }
1798 }
1799
1800 #[test]
1801 fn every_installed_claude_event_is_mapped_by_the_sink() {
1802 for HookSpec { event, .. } in HOOK_EVENTS {
1805 let hook = json!({ "session_id": "s1", "hook_event_name": event });
1806 assert!(hook_op(&hook.to_string()).is_some(), "{event} is unmapped");
1807 }
1808 }
1809
1810 #[test]
1811 fn a_finished_subagent_or_compaction_leaves_an_idle_session_idle() {
1812 for preserving in ["SubagentStop", "PreCompact", "PostCompact"] {
1813 let hook = json!({ "session_id": "s1", "hook_event_name": preserving });
1814 let (_, payload) = hook_op(&hook.to_string()).unwrap();
1815 let event: SessionEvent = serde_json::from_value(payload["event"].clone()).unwrap();
1816 assert_eq!(
1817 SessionState::for_event(&event, Some(SessionState::Idle)),
1818 SessionState::Idle,
1819 "{preserving}"
1820 );
1821 assert_eq!(
1822 SessionState::for_event(&event, Some(SessionState::WaitingForPermission)),
1823 SessionState::WaitingForPermission,
1824 "{preserving}"
1825 );
1826 }
1827 }
1828
1829 #[test]
1830 fn a_compaction_session_start_preserves_state() {
1831 let event_for = |source: Option<&str>| {
1832 let mut hook = json!({ "session_id": "s1", "hook_event_name": "SessionStart" });
1833 if let Some(source) = source {
1834 hook["source"] = json!(source);
1835 }
1836 let (_, payload) = hook_op(&hook.to_string()).unwrap();
1837 serde_json::from_value::<SessionEvent>(payload["event"].clone()).unwrap()
1838 };
1839 let compact = event_for(Some("compact"));
1842 assert_eq!(compact, SessionEvent::TranscriptDiscovered);
1843 for current in [SessionState::Working, SessionState::Idle] {
1844 assert_eq!(SessionState::for_event(&compact, Some(current)), current);
1845 }
1846 for start in [Some("startup"), Some("resume"), Some("clear"), None] {
1847 assert_eq!(event_for(start), SessionEvent::SessionStart, "{start:?}");
1848 }
1849 }
1850
1851 #[test]
1852 fn hook_classifies_notifications_by_type_before_message() {
1853 let typed = |ty: &str, msg: &str| {
1854 let hook = json!({
1855 "session_id": "s1",
1856 "hook_event_name": "Notification",
1857 "notification_type": ty,
1858 "message": msg,
1859 });
1860 hook_op(&hook.to_string()).unwrap().1["event"]["notification"].clone()
1861 };
1862 assert_eq!(
1864 typed("permission_prompt", "something else"),
1865 "permission_prompt"
1866 );
1867 assert_eq!(typed("idle_prompt", "Please approve"), "idle_prompt");
1868 assert_eq!(typed("elicitation_dialog", "x"), "agent_needs_input");
1869 assert_eq!(typed("auth_success", "You can now allow access"), "other");
1871 let untyped = hook_op(
1873 r#"{"session_id":"s1","hook_event_name":"Notification","message":"Claude needs your permission"}"#,
1874 )
1875 .unwrap();
1876 assert_eq!(untyped.1["event"]["notification"], "permission_prompt");
1877 }
1878
1879 #[test]
1880 fn hook_parses_the_agent_flag() {
1881 match parse(&["hook", "--agent", "codex"]) {
1882 SessionsSubcommands::Hook(cmd) => assert_eq!(cmd.agent, HookAgent::Codex),
1883 _ => panic!("expected hook"),
1884 }
1885 match parse(&["hook"]) {
1886 SessionsSubcommands::Hook(cmd) => assert_eq!(cmd.agent, HookAgent::Claude),
1887 _ => panic!("expected hook"),
1888 }
1889 match parse(&["install-hooks", "--codex-home", "/x/codex"]) {
1890 SessionsSubcommands::InstallHooks(cmd) => {
1891 assert_eq!(cmd.codex_home, Some(PathBuf::from("/x/codex")));
1892 }
1893 _ => panic!("expected install-hooks"),
1894 }
1895 }
1896
1897 const TAGGED: &str = "/bin/omni-dev sessions hook --agent codex";
1900
1901 fn layout(hooks: &Value, event: &str) -> Vec<Vec<String>> {
1903 hooks["hooks"][event]
1904 .as_array()
1905 .map(|groups| {
1906 groups
1907 .iter()
1908 .map(|g| {
1909 g["hooks"]
1910 .as_array()
1911 .unwrap()
1912 .iter()
1913 .map(|h| h["command"].as_str().unwrap().to_string())
1914 .collect()
1915 })
1916 .collect()
1917 })
1918 .unwrap_or_default()
1919 }
1920
1921 #[test]
1922 fn is_sessions_sink_matches_any_omni_dev_sink_and_nothing_else() {
1923 assert!(is_sessions_sink(
1924 "/Users/me/.cargo/bin/omni-dev sessions hook"
1925 ));
1926 assert!(is_sessions_sink("omni-dev sessions hook"));
1927 assert!(is_sessions_sink(TAGGED));
1928 assert!(is_sessions_sink("omni-dev sessions hook --socket /x"));
1929 assert!(is_sessions_sink(
1930 "/old/omni-dev sessions hook --agent codex"
1931 ));
1932 assert!(!is_sessions_sink("/bin/other sessions hook"));
1933 assert!(!is_sessions_sink("omni-dev sessions list"));
1934 assert!(!is_sessions_sink("omni-dev sessions hooks"));
1935 assert!(!is_sessions_sink("my-omni-dev sessions hook"));
1936 }
1937
1938 #[test]
1939 fn codex_install_rewrites_a_moved_binarys_tagged_entry_in_place() {
1940 let mut hooks = json!({ "hooks": { "Stop": [
1941 { "hooks": [{ "type": "command", "command": "/old/omni-dev sessions hook --agent codex" }] },
1942 { "hooks": [{ "type": "command", "command": "omni-dev sessions hook --socket /x" }] },
1943 { "hooks": [{ "type": "command", "command": "mine" }] }
1944 ] } });
1945 assert_eq!(
1946 replace_stale_sinks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS),
1947 2
1948 );
1949 assert_eq!(
1950 layout(&hooks, "Stop"),
1951 vec![vec![TAGGED], vec![TAGGED], vec!["mine"]]
1952 );
1953 merge_hooks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS);
1955 assert_eq!(layout(&hooks, "Stop").len(), 3);
1956 }
1957
1958 #[test]
1959 fn codex_install_replaces_untagged_sinks_in_place_and_never_reorders() {
1960 let mut hooks = json!({ "hooks": {
1961 "PreToolUse": [
1962 { "matcher": "Bash", "hooks": [{ "type": "command", "command": "guard" }] },
1963 { "matcher": "*", "hooks": [{ "type": "command", "command": "/old/omni-dev sessions hook" }] },
1964 { "hooks": [{ "type": "command", "command": "audit" }] }
1965 ],
1966 "SessionEnd": [
1967 { "hooks": [{ "type": "command", "command": "omni-dev sessions hook" }] }
1968 ]
1969 }});
1970 let replaced = replace_stale_sinks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS);
1971 assert_eq!(replaced, 2);
1972 let added = merge_hooks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS);
1973 assert_eq!(added, CODEX_HOOK_EVENTS.len() - 2);
1975 assert_eq!(
1977 layout(&hooks, "PreToolUse"),
1978 vec![vec!["guard"], vec![TAGGED], vec!["audit"]]
1979 );
1980 assert_eq!(hooks["hooks"]["PreToolUse"][1]["matcher"], "*");
1981 assert_eq!(hooks["hooks"]["SessionEnd"][0]["hooks"][0]["timeout"], 3);
1983 assert_eq!(hooks["hooks"]["PostToolUse"][0]["matcher"], "*");
1985 assert_eq!(hooks["hooks"]["Interrupt"][0]["hooks"][0]["timeout"], 3);
1986 assert!(hooks["hooks"]["Stop"][0]["hooks"][0]
1987 .get("timeout")
1988 .is_none());
1989 assert!(hooks["hooks"].get("Notification").is_none());
1990 assert_eq!(
1992 replace_stale_sinks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS),
1993 0
1994 );
1995 assert_eq!(merge_hooks(&mut hooks, TAGGED, CODEX_HOOK_EVENTS), 0);
1996 }
1997
1998 #[test]
1999 fn codex_uninstall_never_shifts_a_surviving_group() {
2000 let mut hooks = json!({ "hooks": {
2001 "Stop": [
2002 { "hooks": [{ "type": "command", "command": TAGGED }] },
2003 { "hooks": [{ "type": "command", "command": "user-a" }] },
2004 { "hooks": [{ "type": "command", "command": "omni-dev sessions hook" }] },
2005 { "hooks": [{ "type": "command", "command": "user-b" }] },
2006 { "hooks": [{ "type": "command", "command": TAGGED }] }
2007 ],
2008 "PreToolUse": [
2009 { "hooks": [
2010 { "type": "command", "command": TAGGED },
2011 { "type": "command", "command": "shared" }
2012 ] }
2013 ],
2014 "Interrupt": [
2015 { "hooks": [] },
2016 { "hooks": [{ "type": "command", "command": TAGGED }] }
2017 ],
2018 "PostToolUse": [
2019 { "hooks": [{ "type": "command", "command": "keep" }] }
2020 ]
2021 }});
2022 let removal = remove_hooks_stable(&mut hooks, is_sessions_sink);
2023 assert_eq!(removal.removed, 5);
2024 assert!(removal.shifted);
2026 let stop = hooks["hooks"]["Stop"].as_array().unwrap();
2029 assert_eq!(stop.len(), 4);
2030 assert_eq!(stop[0], json!({ "hooks": [] }));
2031 assert_eq!(layout(&hooks, "Stop")[1], vec!["user-a"]);
2032 assert_eq!(stop[2], json!({ "hooks": [] }));
2033 assert_eq!(layout(&hooks, "Stop")[3], vec!["user-b"]);
2034 assert_eq!(layout(&hooks, "PreToolUse"), vec![vec!["shared"]]);
2036 assert!(hooks["hooks"].get("Interrupt").is_none());
2038 assert_eq!(layout(&hooks, "PostToolUse"), vec![vec!["keep"]]);
2039 assert_eq!(
2041 remove_hooks_stable(&mut hooks, is_sessions_sink),
2042 Removal::default()
2043 );
2044 }
2045
2046 #[test]
2047 fn remove_hooks_stable_does_not_report_a_shift_for_a_trailing_hook() {
2048 let mut hooks = json!({ "hooks": { "Stop": [ { "hooks": [
2049 { "type": "command", "command": "shared" },
2050 { "type": "command", "command": TAGGED }
2051 ] } ] } });
2052 let removal = remove_hooks_stable(&mut hooks, is_sessions_sink);
2053 assert_eq!(
2054 removal,
2055 Removal {
2056 removed: 1,
2057 shifted: false
2058 }
2059 );
2060 assert_eq!(layout(&hooks, "Stop"), vec![vec!["shared"]]);
2061 }
2062
2063 #[test]
2064 fn remove_hooks_stable_tolerates_malformed_shapes() {
2065 let none = Removal::default();
2066 assert_eq!(remove_hooks_stable(&mut json!([]), |_| true), none);
2067 assert_eq!(remove_hooks_stable(&mut json!({}), |_| true), none);
2068 let mut odd = json!({ "hooks": { "Stop": 5, "Start": [ { "matcher": "x" } ] } });
2069 assert_eq!(remove_hooks_stable(&mut odd, |_| true), none);
2070 assert_eq!(odd["hooks"]["Stop"], 5);
2071 assert_eq!(
2072 replace_stale_sinks(&mut json!({}), TAGGED, CODEX_HOOK_EVENTS),
2073 0
2074 );
2075 assert_eq!(
2076 replace_stale_sinks(
2077 &mut json!({ "hooks": { "Stop": 5 } }),
2078 TAGGED,
2079 CODEX_HOOK_EVENTS
2080 ),
2081 0
2082 );
2083 }
2084
2085 #[test]
2086 fn codex_is_present_via_its_home_or_a_codex_executable_on_path() {
2087 use std::os::unix::fs::PermissionsExt;
2088
2089 let tmp = tempfile::tempdir().unwrap();
2090 let home = tmp.path().join("codex-home");
2091 let bin = tmp.path().join("bin");
2092 std::fs::create_dir_all(&bin).unwrap();
2093 let path = std::env::join_paths([&bin]).unwrap();
2094 assert!(!codex_is_present(&home, Some(&path)));
2095 assert!(!codex_is_present(&home, None));
2096 let exe = bin.join("codex");
2097 std::fs::write(&exe, "#!/bin/sh\n").unwrap();
2098 std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
2099 assert!(codex_is_present(&home, Some(&path)));
2100 std::fs::create_dir_all(&home).unwrap();
2101 assert!(codex_is_present(&home, None));
2102 }
2103
2104 #[test]
2105 fn codex_hook_command_is_the_sink_tagged_codex() {
2106 assert_eq!(
2107 codex_hook_command(),
2108 format!("{} --agent codex", hook_command())
2109 );
2110 assert!(codex_hook_command().ends_with(" sessions hook --agent codex"));
2111 }
2112
2113 #[test]
2114 fn codex_install_and_uninstall_round_trip_through_the_file() {
2115 let tmp = tempfile::tempdir().unwrap();
2116 let home = tmp.path().join("codex-home");
2117 std::fs::create_dir_all(&home).unwrap();
2118 let file = home.join("hooks.json");
2119 std::fs::write(
2120 &file,
2121 json!({ "hooks": { "Stop": [
2122 { "hooks": [{ "type": "command", "command": "omni-dev sessions hook" }] },
2123 { "hooks": [{ "type": "command", "command": "mine" }] }
2124 ] } })
2125 .to_string(),
2126 )
2127 .unwrap();
2128 install_codex_hooks(Some(home.clone())).unwrap();
2129 let tagged = codex_hook_command();
2130 let installed = read_settings(&file).unwrap();
2131 assert_eq!(
2132 layout(&installed, "Stop"),
2133 vec![vec![tagged.clone()], vec!["mine".to_string()]]
2134 );
2135 assert_eq!(layout(&installed, "Interrupt"), vec![vec![tagged]]);
2136 install_codex_hooks(Some(home.clone())).unwrap();
2138 assert_eq!(read_settings(&file).unwrap(), installed);
2139
2140 uninstall_codex_hooks(Some(home.clone())).unwrap();
2141 let removed = read_settings(&file).unwrap();
2142 assert_eq!(
2143 removed,
2144 json!({ "hooks": { "Stop": [ { "hooks": [] }, { "hooks": [{ "type": "command", "command": "mine" }] } ] } })
2145 );
2146 uninstall_codex_hooks(Some(home)).unwrap();
2148 uninstall_codex_hooks(Some(tmp.path().join("absent"))).unwrap();
2149 }
2150
2151 #[test]
2152 fn install_codex_hooks_via_the_default_home_skips_absent_and_writes_present() {
2153 let _guard = PI_ENV_LOCK
2154 .lock()
2155 .unwrap_or_else(std::sync::PoisonError::into_inner);
2156 let saved = ["CODEX_HOME", "PATH", "HOME"].map(|k| (k, std::env::var_os(k)));
2157
2158 let tmp = tempfile::tempdir().unwrap();
2159 let home = tmp.path().join("codex-home");
2160 let empty_bin = tmp.path().join("bin");
2161 std::fs::create_dir_all(&empty_bin).unwrap();
2162 std::env::set_var("CODEX_HOME", &home);
2163 std::env::set_var("PATH", std::env::join_paths([&empty_bin]).unwrap());
2164
2165 assert_eq!(default_codex_home().unwrap(), home);
2166 install_codex_hooks(None).unwrap();
2167 assert!(
2168 !home.exists(),
2169 "install must not create an absent Codex home"
2170 );
2171 uninstall_codex_hooks(None).unwrap();
2172
2173 std::fs::create_dir_all(&home).unwrap();
2174 install_codex_hooks(None).unwrap();
2175 assert!(home.join("hooks.json").exists());
2176 uninstall_codex_hooks(None).unwrap();
2177 assert_eq!(
2178 read_settings(&home.join("hooks.json")).unwrap(),
2179 json!({ "hooks": {} })
2180 );
2181
2182 std::env::set_var("CODEX_HOME", "");
2184 std::env::set_var("HOME", "/home/tester");
2185 assert_eq!(
2186 default_codex_home().unwrap(),
2187 PathBuf::from("/home/tester/.codex")
2188 );
2189
2190 for (k, v) in saved {
2191 match v {
2192 Some(val) => std::env::set_var(k, val),
2193 None => std::env::remove_var(k),
2194 }
2195 }
2196 }
2197
2198 #[test]
2199 fn classify_notification_covers_cases() {
2200 assert_eq!(
2201 classify_notification(None, Some("Please approve this")),
2202 NotificationKind::PermissionPrompt
2203 );
2204 assert_eq!(
2205 classify_notification(None, Some("Claude is idle")),
2206 NotificationKind::IdlePrompt
2207 );
2208 assert_eq!(classify_notification(None, None), NotificationKind::Other);
2209 assert_eq!(
2210 classify_notification(Some("idle_prompt"), None),
2211 NotificationKind::IdlePrompt
2212 );
2213 }
2214
2215 #[test]
2218 fn merge_hooks_is_idempotent_and_additive() {
2219 let mut settings = json!({
2221 "hooks": {
2222 "PreToolUse": [
2223 { "matcher": "Bash", "hooks": [{ "type": "command", "command": "other-tool" }] }
2224 ]
2225 },
2226 "model": "sonnet"
2227 });
2228 let cmd = "/usr/bin/omni-dev sessions hook";
2229 let added = merge_hooks(&mut settings, cmd, HOOK_EVENTS);
2230 assert_eq!(added, HOOK_EVENTS.len());
2231
2232 for HookSpec { event, .. } in HOOK_EVENTS {
2234 let groups = settings["hooks"][event].as_array().unwrap();
2235 assert!(
2236 groups.iter().any(|g| group_has_command(g, cmd)),
2237 "missing under {event}"
2238 );
2239 }
2240 let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
2241 assert!(pre.iter().any(|g| group_has_command(g, "other-tool")));
2242 assert_eq!(settings["model"], "sonnet");
2243
2244 assert_eq!(merge_hooks(&mut settings, cmd, HOOK_EVENTS), 0);
2246 }
2247
2248 #[test]
2249 fn reinstalling_over_the_original_seven_events_adds_only_the_new_ones() {
2250 let cmd = "/usr/bin/omni-dev sessions hook";
2252 let original = &HOOK_EVENTS[..7];
2253 assert_eq!(original.last().unwrap().event, "SessionEnd");
2254 let mut settings = json!({});
2255 merge_hooks(&mut settings, cmd, original);
2256 let before = settings.clone();
2257
2258 let added = merge_hooks(&mut settings, cmd, HOOK_EVENTS);
2259 assert_eq!(added, HOOK_EVENTS.len() - 7);
2260 for HookSpec { event, .. } in original {
2262 assert_eq!(settings["hooks"][event], before["hooks"][event], "{event}");
2263 }
2264 assert_eq!(settings["hooks"]["PermissionRequest"][0]["matcher"], "*");
2266 assert!(settings["hooks"]["StopFailure"][0].get("matcher").is_none());
2267
2268 assert_eq!(remove_hooks(&mut settings, cmd), HOOK_EVENTS.len());
2270 assert!(settings["hooks"].as_object().unwrap().is_empty());
2271 }
2272
2273 #[test]
2274 fn merge_then_remove_round_trips_and_preserves_others() {
2275 let mut settings = json!({
2276 "hooks": {
2277 "PreToolUse": [
2278 { "matcher": "Bash", "hooks": [{ "type": "command", "command": "keep-me" }] }
2279 ]
2280 }
2281 });
2282 let cmd = "/usr/bin/omni-dev sessions hook";
2283 merge_hooks(&mut settings, cmd, HOOK_EVENTS);
2284 let removed = remove_hooks(&mut settings, cmd);
2285 assert_eq!(removed, HOOK_EVENTS.len());
2286
2287 for HookSpec { event, .. } in HOOK_EVENTS {
2289 let empty = settings["hooks"]
2290 .get(event)
2291 .and_then(Value::as_array)
2292 .is_none_or(|g| g.iter().all(|g| !group_has_command(g, cmd)));
2293 assert!(empty, "our hook survived under {event}");
2294 }
2295 let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
2297 assert!(pre.iter().any(|g| group_has_command(g, "keep-me")));
2298 }
2299
2300 #[test]
2301 fn remove_hooks_prunes_empty_events_entirely() {
2302 let mut settings = json!({});
2303 let cmd = "cmd sessions hook";
2304 merge_hooks(&mut settings, cmd, HOOK_EVENTS);
2305 remove_hooks(&mut settings, cmd);
2306 let hooks = settings["hooks"].as_object().unwrap();
2308 assert!(hooks.is_empty(), "expected all events pruned: {hooks:?}");
2309 }
2310
2311 #[test]
2312 fn install_uninstall_via_files_round_trips() {
2313 let tmp = tempfile::tempdir().unwrap();
2314 let path = tmp.path().join("settings.json");
2315 let mut settings = read_settings(&path).unwrap();
2317 merge_hooks(&mut settings, "cmd sessions hook", HOOK_EVENTS);
2318 write_settings(&path, &settings).unwrap();
2319 assert!(path.exists());
2320
2321 let reloaded = read_settings(&path).unwrap();
2322 assert!(reloaded["hooks"]["Stop"].is_array());
2323
2324 let mut settings = read_settings(&path).unwrap();
2325 remove_hooks(&mut settings, "cmd sessions hook");
2326 write_settings(&path, &settings).unwrap();
2327 let reloaded = read_settings(&path).unwrap();
2328 assert!(reloaded["hooks"].as_object().unwrap().is_empty());
2329 }
2330
2331 #[test]
2332 fn read_settings_rejects_non_json() {
2333 let tmp = tempfile::tempdir().unwrap();
2334 let path = tmp.path().join("settings.json");
2335 std::fs::write(&path, "not json {").unwrap();
2336 let err = read_settings(&path).unwrap_err();
2337 assert!(err.to_string().contains("not valid JSON"), "{err}");
2338 }
2339
2340 #[test]
2341 fn read_settings_handles_empty_and_non_object() {
2342 let tmp = tempfile::tempdir().unwrap();
2343 let path = tmp.path().join("settings.json");
2344 std::fs::write(&path, " \n").unwrap();
2346 assert_eq!(read_settings(&path).unwrap(), json!({}));
2347 std::fs::write(&path, "[1, 2, 3]").unwrap();
2349 let err = read_settings(&path).unwrap_err();
2350 assert!(err.to_string().contains("not a JSON object"), "{err}");
2351 }
2352
2353 #[test]
2354 fn hook_command_targets_sessions_hook() {
2355 assert!(hook_command().ends_with("sessions hook"));
2356 }
2357
2358 #[test]
2361 fn render_sessions_handles_empty() {
2362 assert_eq!(
2363 render_sessions(&json!({ "sessions": [] })),
2364 "No active agent sessions."
2365 );
2366 assert_eq!(render_sessions(&json!({})), "No active agent sessions.");
2367 }
2368
2369 #[test]
2370 fn render_sessions_renders_rows_and_source() {
2371 let result = json!({ "sessions": [{
2372 "session_id": "s1",
2373 "state": "working",
2374 "source": { "kind": "vs_code", "window_key": "w1" },
2375 "repo": "omni-dev",
2376 "cwd": "/home/me/omni-dev",
2377 "last_seen": "2000-01-01T00:00:00Z",
2378 }]});
2379 let table = render_sessions(&result);
2380 assert!(table.contains("working"), "{table}");
2381 assert!(table.contains("vscode"), "{table}");
2382 assert!(table.contains("omni-dev"), "{table}");
2383 assert!(table.contains("claude"), "{table}");
2384 assert_eq!(table.lines().count(), 2, "{table}");
2386 }
2387
2388 #[test]
2389 fn render_sessions_strips_control_bytes() {
2390 let result = json!({ "sessions": [{
2391 "session_id": "s1",
2392 "state": "wor\x1b[31mking",
2393 "source": { "kind": "terminal" },
2394 "repo": "ev\x07il",
2395 "cwd": "/tmp/a\rb",
2396 "last_seen": "2000-01-01T00:00:00Z",
2397 }]});
2398 let table = render_sessions(&result);
2399 assert!(
2400 !table.contains(|c: char| c.is_control() && c != '\n'),
2401 "{table:?}"
2402 );
2403 assert_eq!(table.lines().count(), 2, "{table:?}");
2405 }
2406
2407 #[test]
2408 fn source_label_maps_kinds() {
2409 assert_eq!(
2410 source_label(&json!({ "source": { "kind": "vs_code", "window_key": "w" } })),
2411 "vscode"
2412 );
2413 assert_eq!(
2414 source_label(&json!({ "source": { "kind": "terminal" } })),
2415 "terminal"
2416 );
2417 assert_eq!(source_label(&json!({})), "terminal");
2418 }
2419
2420 #[test]
2421 fn reply_payload_unwraps_ok_and_maps_errors() {
2422 assert_eq!(
2423 reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
2424 json!({ "a": 1 })
2425 );
2426 let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
2427 assert!(err.to_string().contains("boom"), "{err}");
2428 }
2429
2430 #[test]
2433 fn render_pi_extension_bakes_in_the_socket_as_a_json_string() {
2434 let rendered = render_pi_extension(Path::new("/tmp/we \"ird\"/daemon.sock"));
2435 assert!(
2436 !rendered.contains(PI_SOCKET_PLACEHOLDER),
2437 "placeholder left behind"
2438 );
2439 assert!(
2440 rendered.contains(r#"const SOCKET: string = "/tmp/we \"ird\"/daemon.sock";"#),
2441 "socket not escaped as a JSON string"
2442 );
2443 assert!(rendered.starts_with(pi_extension_marker()));
2444 }
2445
2446 #[test]
2447 fn the_template_reports_only_state_and_tags_the_agent() {
2448 for needle in [
2450 r#""working""#,
2451 r#""idle""#,
2452 r#""waiting_for_input""#,
2453 r#"agent: "pi""#,
2454 "stream_state: next",
2455 r#"send("end""#,
2456 r#""agent_settled""#,
2457 r#""ui_prompt_start""#,
2458 r#""session_shutdown""#,
2459 ] {
2460 assert!(
2461 PI_EXTENSION_TEMPLATE.contains(needle),
2462 "template lacks {needle}"
2463 );
2464 }
2465 assert!(!PI_EXTENSION_TEMPLATE.contains("waiting_for_permission"));
2467 assert!(!PI_EXTENSION_TEMPLATE.contains("\"starting\""));
2472 assert_eq!(
2473 PI_EXTENSION_TEMPLATE.matches(PI_SOCKET_PLACEHOLDER).count(),
2474 1
2475 );
2476 }
2477
2478 #[test]
2479 fn pi_is_present_via_its_agent_dir_or_a_pi_executable_on_path() {
2480 use std::os::unix::fs::PermissionsExt;
2481
2482 let tmp = tempfile::tempdir().unwrap();
2483 let agent = tmp.path().join("agent");
2484 let bin = tmp.path().join("bin");
2485 std::fs::create_dir_all(&bin).unwrap();
2486 let path = std::env::join_paths([bin.clone()]).unwrap();
2487
2488 assert!(!pi_is_present(&agent, Some(&path)));
2490 assert!(!pi_is_present(&agent, None));
2491
2492 let pi = bin.join("pi");
2494 std::fs::write(&pi, "#!/bin/sh\n").unwrap();
2495 std::fs::set_permissions(&pi, std::fs::Permissions::from_mode(0o600)).unwrap();
2496 assert!(!pi_is_present(&agent, Some(&path)));
2497
2498 std::fs::set_permissions(&pi, std::fs::Permissions::from_mode(0o755)).unwrap();
2500 assert!(pi_is_present(&agent, Some(&path)));
2501
2502 std::fs::create_dir_all(&agent).unwrap();
2504 assert!(pi_is_present(&agent, None));
2505 }
2506
2507 #[test]
2508 fn write_pi_extension_is_idempotent_and_updates_its_own_file() {
2509 let tmp = tempfile::tempdir().unwrap();
2510 let v1 = render_pi_extension(Path::new("/a.sock"));
2511 let (file, outcome) = write_pi_extension(tmp.path(), &v1).unwrap();
2512 assert_eq!(outcome, PiInstall::Written);
2513 assert_eq!(file, tmp.path().join("extensions").join(PI_EXTENSION_NAME));
2514 assert_eq!(std::fs::read_to_string(&file).unwrap(), v1);
2515
2516 assert_eq!(
2517 write_pi_extension(tmp.path(), &v1).unwrap().1,
2518 PiInstall::Unchanged
2519 );
2520
2521 let v2 = render_pi_extension(Path::new("/b.sock"));
2523 assert_eq!(
2524 write_pi_extension(tmp.path(), &v2).unwrap().1,
2525 PiInstall::Written
2526 );
2527 assert_eq!(std::fs::read_to_string(&file).unwrap(), v2);
2528 }
2529
2530 #[test]
2531 fn pi_extension_install_and_remove_leave_foreign_files_alone() {
2532 let tmp = tempfile::tempdir().unwrap();
2533 let dir = tmp.path().join("extensions");
2534 std::fs::create_dir_all(&dir).unwrap();
2535 let other = dir.join("someone-elses.ts");
2536 std::fs::write(&other, "export default () => {};\n").unwrap();
2537
2538 let ours = dir.join(PI_EXTENSION_NAME);
2540 std::fs::write(&ours, "// hand-written\n").unwrap();
2541 let err = write_pi_extension(tmp.path(), &render_pi_extension(Path::new("/s")))
2542 .unwrap_err()
2543 .to_string();
2544 assert!(err.contains("not written by omni-dev"), "{err}");
2545 assert_eq!(remove_pi_extension(tmp.path()).unwrap(), None);
2546 assert_eq!(std::fs::read_to_string(&ours).unwrap(), "// hand-written\n");
2547
2548 std::fs::remove_file(&ours).unwrap();
2550 write_pi_extension(tmp.path(), &render_pi_extension(Path::new("/s"))).unwrap();
2551 assert_eq!(remove_pi_extension(tmp.path()).unwrap(), Some(ours.clone()));
2552 assert!(!ours.exists());
2553 assert!(other.exists());
2554
2555 assert_eq!(remove_pi_extension(tmp.path()).unwrap(), None);
2557 assert_eq!(remove_pi_extension(&tmp.path().join("nope")).unwrap(), None);
2558 }
2559
2560 #[test]
2561 fn hook_commands_parse_the_pi_agent_dir_flag() {
2562 match parse(&["install-hooks", "--pi-agent-dir", "/x/agent"]) {
2563 SessionsSubcommands::InstallHooks(cmd) => {
2564 assert_eq!(cmd.pi_agent_dir, Some(PathBuf::from("/x/agent")));
2565 }
2566 _ => panic!("expected install-hooks"), }
2568 match parse(&["uninstall-hooks"]) {
2569 SessionsSubcommands::UninstallHooks(cmd) => assert_eq!(cmd.pi_agent_dir, None),
2570 _ => panic!("expected uninstall-hooks"), }
2572 }
2573
2574 static PI_ENV_LOCK: &std::sync::Mutex<()> = &crate::test_support::HOME_ENV_MUTEX;
2580
2581 fn snapshot_pi_env() -> [(&'static str, Option<std::ffi::OsString>); 3] {
2582 ["PI_CODING_AGENT_DIR", "PATH", "HOME"].map(|k| (k, std::env::var_os(k)))
2583 }
2584
2585 fn restore_pi_env(snap: [(&'static str, Option<std::ffi::OsString>); 3]) {
2586 for (k, v) in snap {
2587 match v {
2588 Some(val) => std::env::set_var(k, val),
2589 None => std::env::remove_var(k),
2590 }
2591 }
2592 }
2593
2594 #[test]
2595 fn default_pi_agent_dir_honors_the_env_var_and_falls_back_to_home() {
2596 let _guard = PI_ENV_LOCK
2597 .lock()
2598 .unwrap_or_else(std::sync::PoisonError::into_inner);
2599 let snap = snapshot_pi_env();
2600
2601 std::env::set_var("PI_CODING_AGENT_DIR", "/explicit/agent");
2603 assert_eq!(
2604 default_pi_agent_dir().unwrap(),
2605 PathBuf::from("/explicit/agent")
2606 );
2607
2608 std::env::set_var("PI_CODING_AGENT_DIR", "");
2610 std::env::set_var("HOME", "/home/tester");
2611 assert_eq!(
2612 default_pi_agent_dir().unwrap(),
2613 PathBuf::from("/home/tester/.pi/agent")
2614 );
2615
2616 std::env::remove_var("PI_CODING_AGENT_DIR");
2618 assert_eq!(
2619 default_pi_agent_dir().unwrap(),
2620 PathBuf::from("/home/tester/.pi/agent")
2621 );
2622
2623 restore_pi_env(snap);
2624 }
2625
2626 #[test]
2627 fn install_pi_extension_via_the_default_dir_skips_absent_and_writes_present() {
2628 let _guard = PI_ENV_LOCK
2629 .lock()
2630 .unwrap_or_else(std::sync::PoisonError::into_inner);
2631 let snap = snapshot_pi_env();
2632
2633 let tmp = tempfile::tempdir().unwrap();
2634 let agent_dir = tmp.path().join("nonexistent-agent");
2635 let empty_bin = tmp.path().join("bin");
2636 std::fs::create_dir_all(&empty_bin).unwrap();
2637
2638 std::env::set_var("PI_CODING_AGENT_DIR", &agent_dir);
2639 std::env::set_var("PATH", std::env::join_paths([&empty_bin]).unwrap());
2640
2641 install_pi_extension(None).unwrap();
2644 assert!(
2645 !agent_dir.exists(),
2646 "install must not create the agent dir when pi is absent"
2647 );
2648
2649 uninstall_pi_extension(None).unwrap();
2652
2653 std::fs::create_dir_all(&agent_dir).unwrap();
2657 install_pi_extension(None).unwrap();
2658 let extension = agent_dir.join("extensions").join(PI_EXTENSION_NAME);
2659 assert!(extension.exists());
2660
2661 uninstall_pi_extension(None).unwrap();
2663 assert!(!extension.exists());
2664
2665 restore_pi_env(snap);
2666 }
2667
2668 #[test]
2669 fn agent_label_defaults_to_claude() {
2670 assert_eq!(agent_label(&json!({ "agent": "pi" })), "pi");
2671 assert_eq!(agent_label(&json!({ "agent": "codex" })), "codex");
2672 assert_eq!(agent_label(&json!({ "agent": "claude" })), "claude");
2673 assert_eq!(agent_label(&json!({})), "claude");
2674 }
2675
2676 #[test]
2679 fn install_then_uninstall_command_execute_round_trips() {
2680 let tmp = tempfile::tempdir().unwrap();
2681 let path = tmp.path().join("settings.json");
2682 InstallHooksCommand {
2684 settings: Some(path.clone()),
2685 pi_agent_dir: Some(tmp.path().join("pi-agent")),
2686 codex_home: Some(tmp.path().join("codex-home")),
2687 }
2688 .execute()
2689 .unwrap();
2690 assert!(read_settings(&path).unwrap()["hooks"]["Stop"].is_array());
2691 let extension = tmp
2692 .path()
2693 .join("pi-agent/extensions")
2694 .join(PI_EXTENSION_NAME);
2695 assert!(extension.exists());
2696 InstallHooksCommand {
2698 settings: Some(path.clone()),
2699 pi_agent_dir: Some(tmp.path().join("pi-agent")),
2700 codex_home: Some(tmp.path().join("codex-home")),
2701 }
2702 .execute()
2703 .unwrap();
2704 UninstallHooksCommand {
2706 settings: Some(path.clone()),
2707 pi_agent_dir: Some(tmp.path().join("pi-agent")),
2708 codex_home: Some(tmp.path().join("codex-home")),
2709 }
2710 .execute()
2711 .unwrap();
2712 assert!(read_settings(&path).unwrap()["hooks"]
2713 .as_object()
2714 .unwrap()
2715 .is_empty());
2716 assert!(!extension.exists());
2717 }
2718
2719 fn wrapper_commands(tmp: &Path) -> (InstallWrapperCommand, UninstallWrapperCommand) {
2724 let settings = tmp.join("settings.json");
2725 let shim = tmp.join("bin").join("claude-wrap");
2726 (
2727 InstallWrapperCommand {
2728 settings: Some(settings.clone()),
2729 shim: Some(shim.clone()),
2730 },
2731 UninstallWrapperCommand {
2732 settings: Some(settings),
2733 shim: Some(shim),
2734 },
2735 )
2736 }
2737
2738 #[test]
2739 fn install_wrapper_writes_an_executable_shim_and_sets_the_setting() {
2740 use std::os::unix::fs::PermissionsExt;
2741
2742 let tmp = tempfile::tempdir().unwrap();
2743 let (install, _) = wrapper_commands(tmp.path());
2744 let shim = install.shim.clone().unwrap();
2745 let settings = install.settings.clone().unwrap();
2746 install.execute().unwrap();
2747
2748 let script = std::fs::read_to_string(&shim).unwrap();
2751 assert!(script.starts_with("#!/bin/sh\nexec \"/"), "{script}");
2752 assert!(script.contains(" claude-wrap -- \"$@\""), "{script}");
2753 let mode = std::fs::metadata(&shim).unwrap().permissions().mode();
2754 assert_eq!(mode & 0o777, 0o700);
2755
2756 assert_eq!(
2757 read_settings(&settings).unwrap()[WRAPPER_SETTING_KEY],
2758 json!(shim.display().to_string())
2759 );
2760 }
2761
2762 #[test]
2763 fn install_wrapper_is_idempotent_and_preserves_other_settings() {
2764 let tmp = tempfile::tempdir().unwrap();
2765 let (install, _) = wrapper_commands(tmp.path());
2766 let settings_path = install.settings.clone().unwrap();
2767 write_settings(&settings_path, &json!({ "editor.fontSize": 13 })).unwrap();
2768
2769 install.execute().unwrap();
2770 let (again, _) = wrapper_commands(tmp.path());
2771 again.execute().unwrap();
2772
2773 let settings = read_settings(&settings_path).unwrap();
2774 assert_eq!(settings["editor.fontSize"], json!(13));
2775 assert!(settings[WRAPPER_SETTING_KEY].is_string());
2776 }
2777
2778 #[test]
2779 fn uninstall_wrapper_clears_only_our_own_setting() {
2780 let tmp = tempfile::tempdir().unwrap();
2781 let (install, uninstall) = wrapper_commands(tmp.path());
2782 let settings_path = install.settings.clone().unwrap();
2783 let shim = install.shim.clone().unwrap();
2784 install.execute().unwrap();
2785 uninstall.execute().unwrap();
2786
2787 assert!(read_settings(&settings_path).unwrap()[WRAPPER_SETTING_KEY].is_null());
2788 assert!(!shim.exists());
2789
2790 write_settings(
2792 &settings_path,
2793 &json!({ WRAPPER_SETTING_KEY: "/opt/someone-elses-wrapper" }),
2794 )
2795 .unwrap();
2796 let (_, uninstall) = wrapper_commands(tmp.path());
2797 uninstall.execute().unwrap();
2798 assert_eq!(
2799 read_settings(&settings_path).unwrap()[WRAPPER_SETTING_KEY],
2800 json!("/opt/someone-elses-wrapper")
2801 );
2802 }
2803
2804 #[test]
2805 fn uninstall_wrapper_on_a_missing_settings_file_is_a_noop() {
2806 let tmp = tempfile::tempdir().unwrap();
2807 let (_, uninstall) = wrapper_commands(tmp.path());
2808 let settings = uninstall.settings.clone().unwrap();
2809 uninstall.execute().unwrap();
2810 assert!(!settings.exists());
2811 }
2812
2813 #[test]
2814 fn install_wrapper_on_a_jsonc_settings_file_explains_the_manual_step() {
2815 let tmp = tempfile::tempdir().unwrap();
2816 let (install, _) = wrapper_commands(tmp.path());
2817 let settings = install.settings.clone().unwrap();
2818 let shim = install.shim.clone().unwrap();
2819 std::fs::write(
2821 &settings,
2822 "{\n // a comment\n \"editor.fontSize\": 13\n}\n",
2823 )
2824 .unwrap();
2825
2826 let error = format!("{:#}", install.execute().unwrap_err());
2827 assert!(error.contains("not valid JSON"), "{error}");
2828 assert!(error.contains(WRAPPER_SETTING_KEY), "{error}");
2829 assert!(error.contains(&shim.display().to_string()), "{error}");
2831 assert!(shim.exists());
2832 }
2833
2834 #[test]
2835 fn install_wrapper_fails_when_the_shim_directory_cannot_be_made() {
2836 let tmp = tempfile::tempdir().unwrap();
2837 let blocker = tmp.path().join("not-a-dir");
2840 std::fs::write(&blocker, b"").unwrap();
2841 let error = InstallWrapperCommand {
2842 settings: Some(tmp.path().join("settings.json")),
2843 shim: Some(blocker.join("claude-wrap")),
2844 }
2845 .execute()
2846 .unwrap_err();
2847 assert!(format!("{error:#}").contains("not-a-dir"), "{error:#}");
2848 assert!(!tmp.path().join("settings.json").exists());
2850 }
2851
2852 #[test]
2853 fn set_and_clear_wrapper_tolerate_a_non_object_settings_value() {
2854 let mut scalar = json!("not an object");
2857 assert!(!set_wrapper(&mut scalar, "/shim"));
2858 assert!(!clear_wrapper(&mut scalar, "/shim"));
2859 assert_eq!(scalar, json!("not an object"));
2860 }
2861
2862 #[test]
2863 fn the_wrapper_paths_default_outside_the_claude_config_dir() {
2864 let shim = shim_path(None).unwrap();
2867 assert_eq!(shim.file_name().unwrap(), SHIM_NAME);
2868 assert_eq!(shim.parent().unwrap(), paths::runtime_dir().unwrap());
2869 let settings = vscode_settings_path(None).unwrap();
2870 assert!(
2871 settings.ends_with("Code/User/settings.json"),
2872 "{settings:?}"
2873 );
2874 }
2875
2876 #[test]
2877 fn uninstall_command_on_a_missing_file_is_a_noop() {
2878 let tmp = tempfile::tempdir().unwrap();
2879 let path = tmp.path().join("does-not-exist.json");
2880 UninstallHooksCommand {
2882 settings: Some(path.clone()),
2883 pi_agent_dir: Some(tmp.path().join("pi-agent")),
2884 codex_home: Some(tmp.path().join("codex-home")),
2885 }
2886 .execute()
2887 .unwrap();
2888 assert!(!path.exists());
2889 }
2890
2891 #[tokio::test]
2892 async fn sessions_command_dispatches_to_subcommands() {
2893 let tmp = tempfile::tempdir().unwrap();
2895 let path = tmp.path().join("settings.json");
2896 SessionsCommand {
2897 command: SessionsSubcommands::InstallHooks(InstallHooksCommand {
2898 settings: Some(path.clone()),
2899 pi_agent_dir: Some(tmp.path().join("pi-agent")),
2900 codex_home: Some(tmp.path().join("codex-home")),
2901 }),
2902 }
2903 .execute()
2904 .await
2905 .unwrap();
2906 SessionsCommand {
2907 command: SessionsSubcommands::UninstallHooks(UninstallHooksCommand {
2908 settings: Some(path.clone()),
2909 pi_agent_dir: Some(tmp.path().join("pi-agent")),
2910 codex_home: Some(tmp.path().join("codex-home")),
2911 }),
2912 }
2913 .execute()
2914 .await
2915 .unwrap();
2916 let (install, uninstall) = wrapper_commands(tmp.path());
2917 SessionsCommand {
2918 command: SessionsSubcommands::InstallWrapper(install),
2919 }
2920 .execute()
2921 .await
2922 .unwrap();
2923 SessionsCommand {
2924 command: SessionsSubcommands::UninstallWrapper(uninstall),
2925 }
2926 .execute()
2927 .await
2928 .unwrap();
2929 }
2930
2931 #[tokio::test]
2932 async fn hook_report_is_silent_when_the_daemon_is_down() {
2933 let tmp = tempfile::tempdir_in("/tmp").unwrap();
2936 let sock = tmp.path().join("nope.sock");
2937 let cmd = HookCommand {
2938 socket: Some(sock),
2939 agent: HookAgent::Claude,
2940 };
2941 cmd.report(r#"{"session_id":"s1","hook_event_name":"Stop"}"#, None)
2942 .await;
2943 cmd.report("not json", None).await;
2945 cmd.report(r#"{"hook_event_name":"Stop"}"#, None).await; }
2947
2948 fn fake_daemon(reply: Value) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
2952 use futures::{SinkExt, StreamExt};
2953 use tokio::net::UnixListener;
2954 use tokio_util::codec::{Framed, LinesCodec};
2955
2956 let dir = tempfile::tempdir_in("/tmp").unwrap();
2957 let sock = dir.path().join("d.sock");
2958 let listener = UnixListener::bind(&sock).unwrap();
2959 let server = tokio::spawn(async move {
2960 let (stream, _) = listener.accept().await.unwrap();
2961 let mut framed = Framed::new(stream, LinesCodec::new());
2962 let _req = framed.next().await.unwrap().unwrap();
2963 framed
2964 .send(serde_json::to_string(&reply).unwrap())
2965 .await
2966 .unwrap();
2967 });
2968 (dir, sock, server)
2969 }
2970
2971 fn fake_daemon_capture(
2974 reply: Value,
2975 ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<Value>) {
2976 use futures::{SinkExt, StreamExt};
2977 use tokio::net::UnixListener;
2978 use tokio_util::codec::{Framed, LinesCodec};
2979
2980 let dir = tempfile::tempdir_in("/tmp").unwrap();
2981 let sock = dir.path().join("d.sock");
2982 let listener = UnixListener::bind(&sock).unwrap();
2983 let server = tokio::spawn(async move {
2984 let (stream, _) = listener.accept().await.unwrap();
2985 let mut framed = Framed::new(stream, LinesCodec::new());
2986 let req = framed.next().await.unwrap().unwrap();
2987 framed
2988 .send(serde_json::to_string(&reply).unwrap())
2989 .await
2990 .unwrap();
2991 serde_json::from_str::<Value>(&req).unwrap()
2992 });
2993 (dir, sock, server)
2994 }
2995
2996 #[tokio::test]
2997 async fn list_command_execute_renders_from_a_socket() {
2998 let payload = json!({
2999 "ok": true,
3000 "payload": { "sessions": [{
3001 "session_id": "s1", "state": "working",
3002 "source": { "kind": "terminal" }, "repo": "omni-dev",
3003 "cwd": "/home/me/omni-dev", "last_seen": "2000-01-01T00:00:00Z"
3004 }]}
3005 });
3006 let (_dir, sock, server) = fake_daemon(payload.clone());
3009 SessionsCommand {
3010 command: SessionsSubcommands::List(ListCommand {
3011 socket: Some(sock),
3012 output: TableOrJson::Table,
3013 }),
3014 }
3015 .execute()
3016 .await
3017 .unwrap();
3018 server.await.unwrap();
3019
3020 let (_dir, sock, server) = fake_daemon(payload);
3022 ListCommand {
3023 socket: Some(sock),
3024 output: TableOrJson::Json,
3025 }
3026 .execute()
3027 .await
3028 .unwrap();
3029 server.await.unwrap();
3030 }
3031
3032 #[test]
3033 fn merge_and_remove_hooks_tolerate_malformed_shapes() {
3034 let cmd = "cmd sessions hook";
3035 assert_eq!(merge_hooks(&mut json!([]), cmd, HOOK_EVENTS), 0);
3037 assert_eq!(remove_hooks(&mut json!([]), cmd), 0);
3038 assert_eq!(merge_hooks(&mut json!({ "hooks": 5 }), cmd, HOOK_EVENTS), 0);
3040 assert_eq!(remove_hooks(&mut json!({}), cmd), 0);
3042 assert_eq!(
3044 merge_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd, HOOK_EVENTS),
3045 HOOK_EVENTS.len() - 1
3046 );
3047 assert_eq!(remove_hooks(&mut json!({ "hooks": { "Stop": 5 } }), cmd), 0);
3048 }
3049
3050 #[test]
3053 fn window_subcommands_route_and_require_key() {
3054 assert!(matches!(
3055 parse(&["window", "--key", "w1"]),
3056 SessionsSubcommands::Window(_)
3057 ));
3058 assert!(matches!(
3059 parse(&["window-unregister", "--key", "w1"]),
3060 SessionsSubcommands::WindowUnregister(_)
3061 ));
3062 assert!(WindowCommand::try_parse_from(["window"]).is_err());
3064 assert!(WindowUnregisterCommand::try_parse_from(["window-unregister"]).is_err());
3065 }
3066
3067 #[test]
3068 fn window_parses_counts_and_folders() {
3069 let cmd = WindowCommand::try_parse_from([
3070 "window",
3071 "--key",
3072 "w1",
3073 "--folder",
3074 "/a",
3075 "--folder",
3076 "/b",
3077 "--tabs",
3078 "2",
3079 "--terminals",
3080 "3",
3081 ])
3082 .unwrap();
3083 assert_eq!(cmd.key, "w1");
3084 assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
3085 assert_eq!(cmd.tabs, 2);
3086 assert_eq!(cmd.terminals, 3);
3087 let cmd = WindowCommand::try_parse_from(["window", "--key", "w1"]).unwrap();
3089 assert_eq!(cmd.tabs, 0);
3090 assert_eq!(cmd.terminals, 0);
3091 }
3092
3093 #[tokio::test]
3094 async fn window_and_window_unregister_send_their_ops() {
3095 let (_dir, sock, server) =
3096 fake_daemon_capture(json!({ "ok": true, "payload": { "ok": true } }));
3097 SessionsCommand {
3100 command: SessionsSubcommands::Window(WindowCommand {
3101 key: "w1".to_string(),
3102 folders: vec![PathBuf::from("/a")],
3103 tabs: 1,
3104 terminals: 0,
3105 socket: Some(sock),
3106 }),
3107 }
3108 .execute()
3109 .await
3110 .unwrap();
3111 let req = server.await.unwrap();
3113 assert_eq!(req["op"], "window");
3114 assert_eq!(req["payload"]["key"], json!("w1"));
3115 assert_eq!(req["payload"]["folders"], json!(["/a"]));
3116 assert_eq!(req["payload"]["tabs"], json!(1));
3117 assert_eq!(req["payload"]["terminals"], json!(0));
3118
3119 let (_dir, sock, server) =
3122 fake_daemon_capture(json!({ "ok": true, "payload": { "removed": true } }));
3123 SessionsCommand {
3124 command: SessionsSubcommands::WindowUnregister(WindowUnregisterCommand {
3125 key: "w1".to_string(),
3126 socket: Some(sock),
3127 }),
3128 }
3129 .execute()
3130 .await
3131 .unwrap();
3132 let req = server.await.unwrap();
3133 assert_eq!(req["op"], "window-unregister");
3134 assert_eq!(req["payload"]["key"], json!("w1"));
3135 }
3136}