diff --git a/src/daemon.rs b/src/daemon.rs index 56c06c9..c49bc09 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -540,7 +540,12 @@ pub fn show_status() -> Result<(), String> { if n_running > 0 { for t in tasks.iter().filter(|t| matches!(t.status, TaskStatus::Running)) { - eprintln!(" {} {}", status_symbol(t), t.name); + let elapsed = if !t.elapsed.is_zero() { + format!(" ({})", format_duration_human(t.elapsed.as_millis())) + } else { + String::new() + }; + eprintln!(" {} {}{}", status_symbol(t), t.name, elapsed); } } let mut parts = Vec::new(); @@ -566,9 +571,13 @@ pub fn show_status() -> Result<(), String> { eprintln!(" {}", group_label); for t in &tasks { let sym = status_symbol(t); - let duration = t.result.as_ref() - .map(|r| format_duration_human(r.duration.as_millis())) - .unwrap_or_default(); + let duration = if matches!(t.status, TaskStatus::Running) && !t.elapsed.is_zero() { + format_duration_human(t.elapsed.as_millis()) + } else { + t.result.as_ref() + .map(|r| format_duration_human(r.duration.as_millis())) + .unwrap_or_default() + }; let retry = if t.max_retries > 0 && t.retry_count > 0 { format!(" retry {}/{}", t.retry_count, t.max_retries) @@ -645,26 +654,81 @@ WantedBy=default.target eprintln!("Service enabled and started"); - // Install memory-search hook into Claude settings + // Install poc-daemon service + install_notify_daemon(&unit_dir, &home)?; + + // Install memory-search + poc-hook into Claude settings install_hook()?; Ok(()) } -/// Install the memory-search hook into Claude Code settings.json. +/// Install the poc-daemon (notification/idle) systemd user service. +fn install_notify_daemon(unit_dir: &Path, home: &str) -> Result<(), String> { + let poc_daemon = PathBuf::from(home).join(".cargo/bin/poc-daemon"); + if !poc_daemon.exists() { + eprintln!("Warning: poc-daemon not found at {} — skipping service install", poc_daemon.display()); + eprintln!(" Build with: cargo install --path ."); + return Ok(()); + } + + let unit = format!( +r#"[Unit] +Description=poc-daemon — notification routing and idle management +After=default.target + +[Service] +Type=simple +ExecStart={exe} +Restart=on-failure +RestartSec=10 +Environment=HOME={home} +Environment=PATH={home}/.cargo/bin:{home}/.local/bin:{home}/bin:/usr/local/bin:/usr/bin:/bin + +[Install] +WantedBy=default.target +"#, exe = poc_daemon.display(), home = home); + + let unit_path = unit_dir.join("poc-daemon.service"); + fs::write(&unit_path, &unit) + .map_err(|e| format!("write {}: {}", unit_path.display(), e))?; + eprintln!("Wrote {}", unit_path.display()); + + let status = std::process::Command::new("systemctl") + .args(["--user", "daemon-reload"]) + .status() + .map_err(|e| format!("systemctl daemon-reload: {}", e))?; + if !status.success() { + return Err("systemctl daemon-reload failed".into()); + } + + let status = std::process::Command::new("systemctl") + .args(["--user", "enable", "--now", "poc-daemon"]) + .status() + .map_err(|e| format!("systemctl enable: {}", e))?; + if !status.success() { + return Err("systemctl enable --now poc-daemon failed".into()); + } + + eprintln!("poc-daemon service enabled and started"); + Ok(()) +} + +/// Install memory-search and poc-hook into Claude Code settings.json. /// Public so `poc-memory init` can call it too. +/// +/// Hook layout: +/// UserPromptSubmit: memory-search (10s), poc-hook (5s) +/// PostToolUse: poc-hook (5s) +/// Stop: poc-hook (5s) pub fn install_hook() -> Result<(), String> { let home = std::env::var("HOME").map_err(|e| format!("HOME: {}", e))?; let exe = std::env::current_exe() .map_err(|e| format!("current_exe: {}", e))?; let settings_path = PathBuf::from(&home).join(".claude/settings.json"); - let hook_binary = exe.with_file_name("memory-search"); - if !hook_binary.exists() { - eprintln!("Warning: {} not found — hook not installed", hook_binary.display()); - eprintln!(" Build with: cargo install --path ."); - return Ok(()); - } + let memory_search = exe.with_file_name("memory-search"); + let poc_hook = exe.with_file_name("poc-hook"); let mut settings: serde_json::Value = if settings_path.exists() { let content = fs::read_to_string(&settings_path) @@ -675,61 +739,81 @@ pub fn install_hook() -> Result<(), String> { serde_json::json!({}) }; - let hook_command = hook_binary.to_string_lossy().to_string(); - - // Navigate the nested structure: hooks.UserPromptSubmit[0].hooks[] let obj = settings.as_object_mut().ok_or("settings not an object")?; let hooks_obj = obj.entry("hooks") .or_insert_with(|| serde_json::json!({})) .as_object_mut().ok_or("hooks not an object")?; - let ups_array = hooks_obj.entry("UserPromptSubmit") - .or_insert_with(|| serde_json::json!([{"hooks": []}])) - .as_array_mut().ok_or("UserPromptSubmit not an array")?; - if ups_array.is_empty() { - ups_array.push(serde_json::json!({"hooks": []})); - } - let inner_hooks = ups_array[0] - .as_object_mut().ok_or("first element not an object")? - .entry("hooks") - .or_insert_with(|| serde_json::json!([])) - .as_array_mut().ok_or("inner hooks not an array")?; + let mut changed = false; - // Remove load-memory.sh if present (replaced by memory-search) - let before_len = inner_hooks.len(); - inner_hooks.retain(|h| { - let cmd = h.get("command").and_then(|c| c.as_str()).unwrap_or(""); - !cmd.contains("load-memory") - }); - if inner_hooks.len() < before_len { - eprintln!("Removed load-memory.sh hook (replaced by memory-search)"); - } + // Helper: ensure a hook binary is present in an event's hook list + let ensure_hook = |hooks_obj: &mut serde_json::Map, + event: &str, + binary: &Path, + timeout: u32, + changed: &mut bool| { + if !binary.exists() { + eprintln!("Warning: {} not found — skipping", binary.display()); + return; + } + let cmd = binary.to_string_lossy().to_string(); + let name = binary.file_name().unwrap().to_string_lossy().to_string(); - // Check if memory-search hook already exists - let already_installed = inner_hooks.iter().any(|h| { - h.get("command").and_then(|c| c.as_str()) - .is_some_and(|c| c.contains("memory-search")) - }); + let event_array = hooks_obj.entry(event) + .or_insert_with(|| serde_json::json!([{"hooks": []}])) + .as_array_mut().unwrap(); + if event_array.is_empty() { + event_array.push(serde_json::json!({"hooks": []})); + } + let inner = event_array[0] + .as_object_mut().unwrap() + .entry("hooks") + .or_insert_with(|| serde_json::json!([])) + .as_array_mut().unwrap(); - let mut changed = inner_hooks.len() < before_len; + // Remove legacy load-memory.sh + let before = inner.len(); + inner.retain(|h| { + let c = h.get("command").and_then(|c| c.as_str()).unwrap_or(""); + !c.contains("load-memory") + }); + if inner.len() < before { + eprintln!("Removed load-memory.sh from {event}"); + *changed = true; + } - if already_installed { - eprintln!("Hook already installed in {}", settings_path.display()); - } else { - inner_hooks.push(serde_json::json!({ - "type": "command", - "command": hook_command, - "timeout": 10 - })); - changed = true; - eprintln!("Hook installed: {}", hook_command); - } + let already = inner.iter().any(|h| { + h.get("command").and_then(|c| c.as_str()) + .is_some_and(|c| c.contains(&name)) + }); + + if !already { + inner.push(serde_json::json!({ + "type": "command", + "command": cmd, + "timeout": timeout + })); + *changed = true; + eprintln!("Installed {name} in {event}"); + } + }; + + // UserPromptSubmit: memory-search + poc-hook + ensure_hook(hooks_obj, "UserPromptSubmit", &memory_search, 10, &mut changed); + ensure_hook(hooks_obj, "UserPromptSubmit", &poc_hook, 5, &mut changed); + + // PostToolUse + Stop: poc-hook only + ensure_hook(hooks_obj, "PostToolUse", &poc_hook, 5, &mut changed); + ensure_hook(hooks_obj, "Stop", &poc_hook, 5, &mut changed); if changed { let json = serde_json::to_string_pretty(&settings) .map_err(|e| format!("serialize settings: {}", e))?; fs::write(&settings_path, json) .map_err(|e| format!("write settings: {}", e))?; + eprintln!("Updated {}", settings_path.display()); + } else { + eprintln!("All hooks already installed in {}", settings_path.display()); } Ok(())