installer: add poc-daemon systemd service, multi-event hook setup

- Install poc-daemon.service alongside poc-memory.service
- Rewrite install_hook() to install hooks across multiple events:
  UserPromptSubmit: memory-search (10s) + poc-hook (5s)
  PostToolUse: poc-hook (5s)
  Stop: poc-hook (5s)
- Show elapsed time for running tasks in status display
- Deduplicate hook installation (idempotent ensure_hook helper)
- Still removes legacy load-memory.sh hooks

Co-Authored-By: ProofOfConcept <poc@bcachefs.org>
This commit is contained in:
ProofOfConcept 2026-03-05 21:16:28 -05:00 committed by Kent Overstreet
parent d919bc3e51
commit 8662759d53

View file

@ -540,7 +540,12 @@ pub fn show_status() -> Result<(), String> {
if n_running > 0 { if n_running > 0 {
for t in tasks.iter().filter(|t| matches!(t.status, TaskStatus::Running)) { 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(); let mut parts = Vec::new();
@ -566,9 +571,13 @@ pub fn show_status() -> Result<(), String> {
eprintln!(" {}", group_label); eprintln!(" {}", group_label);
for t in &tasks { for t in &tasks {
let sym = status_symbol(t); let sym = status_symbol(t);
let duration = t.result.as_ref() 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())) .map(|r| format_duration_human(r.duration.as_millis()))
.unwrap_or_default(); .unwrap_or_default()
};
let retry = if t.max_retries > 0 && t.retry_count > 0 { let retry = if t.max_retries > 0 && t.retry_count > 0 {
format!(" retry {}/{}", t.retry_count, t.max_retries) format!(" retry {}/{}", t.retry_count, t.max_retries)
@ -645,26 +654,81 @@ WantedBy=default.target
eprintln!("Service enabled and started"); 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()?; install_hook()?;
Ok(()) 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. /// 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> { pub fn install_hook() -> Result<(), String> {
let home = std::env::var("HOME").map_err(|e| format!("HOME: {}", e))?; let home = std::env::var("HOME").map_err(|e| format!("HOME: {}", e))?;
let exe = std::env::current_exe() let exe = std::env::current_exe()
.map_err(|e| format!("current_exe: {}", e))?; .map_err(|e| format!("current_exe: {}", e))?;
let settings_path = PathBuf::from(&home).join(".claude/settings.json"); let settings_path = PathBuf::from(&home).join(".claude/settings.json");
let hook_binary = exe.with_file_name("memory-search");
if !hook_binary.exists() { let memory_search = exe.with_file_name("memory-search");
eprintln!("Warning: {} not found — hook not installed", hook_binary.display()); let poc_hook = exe.with_file_name("poc-hook");
eprintln!(" Build with: cargo install --path .");
return Ok(());
}
let mut settings: serde_json::Value = if settings_path.exists() { let mut settings: serde_json::Value = if settings_path.exists() {
let content = fs::read_to_string(&settings_path) let content = fs::read_to_string(&settings_path)
@ -675,61 +739,81 @@ pub fn install_hook() -> Result<(), String> {
serde_json::json!({}) 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 obj = settings.as_object_mut().ok_or("settings not an object")?;
let hooks_obj = obj.entry("hooks") let hooks_obj = obj.entry("hooks")
.or_insert_with(|| serde_json::json!({})) .or_insert_with(|| serde_json::json!({}))
.as_object_mut().ok_or("hooks not an object")?; .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() { let mut changed = false;
ups_array.push(serde_json::json!({"hooks": []}));
// Helper: ensure a hook binary is present in an event's hook list
let ensure_hook = |hooks_obj: &mut serde_json::Map<String, serde_json::Value>,
event: &str,
binary: &Path,
timeout: u32,
changed: &mut bool| {
if !binary.exists() {
eprintln!("Warning: {} not found — skipping", binary.display());
return;
} }
let inner_hooks = ups_array[0] let cmd = binary.to_string_lossy().to_string();
.as_object_mut().ok_or("first element not an object")? let name = binary.file_name().unwrap().to_string_lossy().to_string();
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") .entry("hooks")
.or_insert_with(|| serde_json::json!([])) .or_insert_with(|| serde_json::json!([]))
.as_array_mut().ok_or("inner hooks not an array")?; .as_array_mut().unwrap();
// Remove load-memory.sh if present (replaced by memory-search) // Remove legacy load-memory.sh
let before_len = inner_hooks.len(); let before = inner.len();
inner_hooks.retain(|h| { inner.retain(|h| {
let cmd = h.get("command").and_then(|c| c.as_str()).unwrap_or(""); let c = h.get("command").and_then(|c| c.as_str()).unwrap_or("");
!cmd.contains("load-memory") !c.contains("load-memory")
}); });
if inner_hooks.len() < before_len { if inner.len() < before {
eprintln!("Removed load-memory.sh hook (replaced by memory-search)"); eprintln!("Removed load-memory.sh from {event}");
*changed = true;
} }
// Check if memory-search hook already exists let already = inner.iter().any(|h| {
let already_installed = inner_hooks.iter().any(|h| {
h.get("command").and_then(|c| c.as_str()) h.get("command").and_then(|c| c.as_str())
.is_some_and(|c| c.contains("memory-search")) .is_some_and(|c| c.contains(&name))
}); });
let mut changed = inner_hooks.len() < before_len; if !already {
inner.push(serde_json::json!({
if already_installed {
eprintln!("Hook already installed in {}", settings_path.display());
} else {
inner_hooks.push(serde_json::json!({
"type": "command", "type": "command",
"command": hook_command, "command": cmd,
"timeout": 10 "timeout": timeout
})); }));
changed = true; *changed = true;
eprintln!("Hook installed: {}", hook_command); 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 { if changed {
let json = serde_json::to_string_pretty(&settings) let json = serde_json::to_string_pretty(&settings)
.map_err(|e| format!("serialize settings: {}", e))?; .map_err(|e| format!("serialize settings: {}", e))?;
fs::write(&settings_path, json) fs::write(&settings_path, json)
.map_err(|e| format!("write settings: {}", e))?; .map_err(|e| format!("write settings: {}", e))?;
eprintln!("Updated {}", settings_path.display());
} else {
eprintln!("All hooks already installed in {}", settings_path.display());
} }
Ok(()) Ok(())