nexus_memory_hooks/agents/
droid.rs1use async_trait::async_trait;
6use std::path::{Path, PathBuf};
7use tokio::fs;
8
9use crate::base::{AgentHook, BaseHook, LifecycleCapabilities, SessionEndCallback};
10use crate::error::{HookError, Result};
11use crate::monitor::ProcessMonitor;
12use crate::session::SessionContext;
13use crate::types::{AgentType, SessionActivity, SupportTier};
14
15const SESSION_START_EVENT: &str = "SessionStart";
16const SESSION_END_EVENT: &str = "SessionEnd";
17const CHECKPOINT_EVENT: &str = "PostToolUse";
18const COMPACT_EVENT: &str = "PreCompact";
19const ERROR_EVENT: &str = "Stop";
20
21pub struct DroidHook {
23 base: BaseHook,
24 settings_hook_installed: bool,
25 process_monitor: ProcessMonitor,
26 readonly: bool,
27 settings_path_override: Option<PathBuf>,
28}
29
30impl DroidHook {
31 pub const CONFIG_DIR: &'static str = ".factory";
32
33 pub fn new() -> Self {
34 Self::new_with_mode(false)
35 }
36
37 pub fn new_readonly() -> Self {
38 Self::new_with_mode(true)
39 }
40
41 fn new_with_mode(readonly: bool) -> Self {
42 let settings_hook_installed = Self::default_settings_path()
43 .ok()
44 .and_then(|path| Self::has_settings_hooks_at_path(&path).ok())
45 .unwrap_or(false);
46 Self {
47 base: BaseHook::new("droid"),
48 settings_hook_installed,
49 process_monitor: ProcessMonitor::new(),
50 readonly,
51 settings_path_override: None,
52 }
53 }
54
55 #[cfg(test)]
56 fn with_settings_path(settings_path: PathBuf, readonly: bool) -> Self {
57 Self {
58 base: BaseHook::new("droid"),
59 settings_hook_installed: false,
60 process_monitor: ProcessMonitor::new(),
61 readonly,
62 settings_path_override: Some(settings_path),
63 }
64 }
65
66 fn default_settings_path() -> Result<PathBuf> {
67 let home = dirs::home_dir().ok_or_else(|| {
68 HookError::InstallationFailed(format!(
69 "Home directory unavailable; cannot resolve {}/settings.json",
70 Self::CONFIG_DIR
71 ))
72 })?;
73 Ok(home.join(Self::CONFIG_DIR).join("settings.json"))
74 }
75
76 fn settings_path(&self) -> Result<PathBuf> {
77 if let Some(path) = &self.settings_path_override {
78 return Ok(path.clone());
79 }
80 Self::default_settings_path()
81 }
82
83 fn find_nexus_binary() -> String {
84 if let Ok(bin) = std::env::var("NEXUS_HOOK_BINARY") {
85 if !bin.trim().is_empty() {
86 return bin;
87 }
88 }
89
90 if let Ok(current_exe) = std::env::current_exe() {
91 let name = current_exe.file_name().and_then(|n| n.to_str());
92 #[cfg(not(windows))]
93 let valid = name.is_some_and(|n| matches!(n, "nexus" | "nexus-bin"));
94 #[cfg(windows)]
95 let valid = name.is_some_and(|n| {
96 matches!(n, "nexus" | "nexus-bin" | "nexus.exe" | "nexus-bin.exe")
97 });
98 if valid {
99 return current_exe.to_string_lossy().to_string();
100 }
101 }
102
103 #[cfg_attr(not(windows), allow(unused_mut))]
104 let mut candidates: Vec<PathBuf> = [
105 dirs::home_dir().map(|h| h.join(".cargo").join("bin").join("nexus")),
106 dirs::home_dir().map(|h| h.join(".cargo").join("bin").join("nexus-bin")),
107 dirs::home_dir().map(|h| h.join(".local").join("bin").join("nexus")),
108 dirs::home_dir().map(|h| h.join(".local").join("bin").join("nexus-bin")),
109 Some(PathBuf::from("/usr/local/bin/nexus")),
110 Some(PathBuf::from("/usr/local/bin/nexus-bin")),
111 ]
112 .into_iter()
113 .flatten()
114 .collect();
115
116 #[cfg(windows)]
117 {
118 if let Some(home) = dirs::home_dir() {
119 candidates.extend([
120 home.join(".cargo").join("bin").join("nexus.exe"),
121 home.join(".cargo").join("bin").join("nexus-bin.exe"),
122 home.join(".local").join("bin").join("nexus.exe"),
123 home.join(".local").join("bin").join("nexus-bin.exe"),
124 ]);
125 }
126 }
127
128 for candidate in candidates {
129 if candidate.exists() {
130 return candidate.to_string_lossy().to_string();
131 }
132 }
133
134 "nexus".to_string()
135 }
136
137 fn desired_commands() -> [(String, String); 5] {
138 let nexus_bin = Self::find_nexus_binary()
139 .replace('\\', "\\\\")
140 .replace('"', "\\\"");
141
142 let scoped = |base: &str| -> String {
143 #[cfg(not(windows))]
144 {
145 format!(
146 "bash -c \"{base} --session-key \\\"${{FACTORY_SESSION_ID:-${{SESSION_ID:-}}}}\\\" --cwd \\\"${{FACTORY_CWD:-${{PWD:-}}}}\\\"\""
147 )
148 }
149 #[cfg(windows)]
150 {
151 format!("{base}")
155 }
156 };
157
158 [
159 (
160 SESSION_START_EVENT.to_string(),
161 scoped(&format!(
162 "\"{nexus_bin}\" session start --agent droid --mode session"
163 )),
164 ),
165 (
166 SESSION_END_EVENT.to_string(),
167 scoped(&format!(
168 "\"{nexus_bin}\" session end --agent droid --reason session-end"
169 )),
170 ),
171 (
172 CHECKPOINT_EVENT.to_string(),
173 scoped(&format!(
174 "\"{nexus_bin}\" session event --agent droid --kind checkpoint"
175 )),
176 ),
177 (
178 COMPACT_EVENT.to_string(),
179 scoped(&format!(
180 "\"{nexus_bin}\" session event --agent droid --kind compact"
181 )),
182 ),
183 (
184 ERROR_EVENT.to_string(),
185 scoped(&format!(
186 "\"{nexus_bin}\" session event --agent droid --kind error"
187 )),
188 ),
189 ]
190 }
191
192 fn has_settings_hooks_at_path(settings_path: &Path) -> Result<bool> {
193 let content = match std::fs::read_to_string(settings_path) {
194 Ok(content) => content,
195 Err(_) => return Ok(false),
196 };
197 let settings = match serde_json::from_str::<serde_json::Value>(&content) {
198 Ok(settings) => settings,
199 Err(_) => return Ok(false),
200 };
201
202 let commands = Self::desired_commands();
203 Ok(commands
204 .iter()
205 .all(|(event, command)| Self::settings_has_command(&settings, event, command)))
206 }
207
208 fn has_settings_hooks(&self) -> Result<bool> {
209 let settings_path = self.settings_path()?;
210 Self::has_settings_hooks_at_path(&settings_path)
211 }
212
213 fn settings_has_command(settings: &serde_json::Value, event: &str, command: &str) -> bool {
214 settings
215 .get("hooks")
216 .and_then(|hooks| hooks.get(event))
217 .and_then(|event_entries| event_entries.as_array())
218 .is_some_and(|entries| {
219 entries
220 .iter()
221 .any(|entry| Self::entry_contains_exact_command(entry, command))
222 })
223 }
224
225 fn entry_contains_exact_command(entry: &serde_json::Value, desired_command: &str) -> bool {
226 entry
227 .get("command")
228 .and_then(|command| command.as_str())
229 .map(|command| command == desired_command)
230 .unwrap_or(false)
231 || entry
232 .get("hooks")
233 .and_then(|hooks| hooks.as_array())
234 .is_some_and(|hooks| {
235 hooks.iter().any(|hook| {
236 hook.get("command")
237 .and_then(|command| command.as_str())
238 .map(|command| command == desired_command)
239 .unwrap_or(false)
240 })
241 })
242 }
243
244 async fn install_settings_hooks(&mut self) -> Result<()> {
245 self.ensure_mutable()?;
246
247 if self.settings_hook_installed && self.has_settings_hooks().unwrap_or(false) {
248 return Ok(());
249 }
250
251 let settings_path = self.settings_path()?;
252 let mut settings = if fs::try_exists(&settings_path).await.map_err(|e| {
253 HookError::InstallationFailed(format!("Failed to check settings.json: {}", e))
254 })? {
255 let content = fs::read_to_string(&settings_path).await.map_err(|e| {
256 HookError::InstallationFailed(format!("Failed to read settings.json: {}", e))
257 })?;
258 serde_json::from_str::<serde_json::Value>(&content).map_err(|e| {
259 HookError::InstallationFailed(format!("Failed to parse settings.json: {}", e))
260 })?
261 } else {
262 serde_json::json!({})
263 };
264
265 for (event, command) in Self::desired_commands() {
266 Self::upsert_event_hook(&mut settings, &event, &command)?;
267 }
268
269 if let Some(parent) = settings_path.parent() {
270 fs::create_dir_all(parent).await.map_err(|e| {
271 HookError::InstallationFailed(format!("Failed to create settings dir: {}", e))
272 })?;
273 }
274
275 let serialized = serde_json::to_string_pretty(&settings).map_err(|e| {
276 HookError::InstallationFailed(format!("Failed to serialize settings: {}", e))
277 })?;
278 let tmp_path = settings_path.with_extension("json.tmp");
279 fs::write(&tmp_path, serialized).await.map_err(|e| {
280 HookError::InstallationFailed(format!("Failed to write temporary settings: {}", e))
281 })?;
282 #[cfg(windows)]
283 if fs::try_exists(&settings_path).await.map_err(|e| {
284 HookError::InstallationFailed(format!("Failed to check settings.json: {}", e))
285 })? {
286 fs::remove_file(&settings_path).await.map_err(|e| {
287 HookError::InstallationFailed(format!(
288 "Failed to remove existing settings.json before replace: {}",
289 e
290 ))
291 })?;
292 }
293 fs::rename(&tmp_path, &settings_path).await.map_err(|e| {
294 HookError::InstallationFailed(format!("Failed to replace settings.json: {}", e))
295 })?;
296
297 self.settings_hook_installed = true;
298 Ok(())
299 }
300
301 fn upsert_event_hook(
302 settings: &mut serde_json::Value,
303 event_name: &str,
304 desired_command: &str,
305 ) -> Result<()> {
306 let settings_obj = settings.as_object_mut().ok_or_else(|| {
307 HookError::InstallationFailed(
308 "settings.json must contain a top-level JSON object".to_string(),
309 )
310 })?;
311
312 let hooks = settings_obj
313 .entry("hooks")
314 .or_insert_with(|| serde_json::json!({}));
315 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
316 HookError::InstallationFailed("'hooks' must be a JSON object".to_string())
317 })?;
318
319 let event_entries = hooks_obj
320 .entry(event_name)
321 .or_insert_with(|| serde_json::json!([]));
322 let entries = event_entries.as_array_mut().ok_or_else(|| {
323 HookError::InstallationFailed(format!("'hooks.{event_name}' must be an array"))
324 })?;
325
326 if Self::replace_existing_event_hook(entries, desired_command) {
327 return Ok(());
328 }
329
330 entries.push(serde_json::json!({
331 "matcher": "",
332 "hooks": [{
333 "type": "command",
334 "command": desired_command,
335 }]
336 }));
337 Ok(())
338 }
339
340 fn replace_existing_event_hook(
341 entries: &mut Vec<serde_json::Value>,
342 desired_command: &str,
343 ) -> bool {
344 let mut replaced = false;
345 let mut canonical_seen = false;
346 let mut rewritten = Vec::with_capacity(entries.len());
347
348 for mut entry in entries.drain(..) {
349 let mut managed_in_entry = false;
350
351 if let Some(command) = entry.get("command").and_then(|value| value.as_str()) {
352 if Self::is_nexus_managed_command(command) {
353 managed_in_entry = true;
354 replaced = true;
355 if canonical_seen {
356 continue;
357 }
358 if let Some(obj) = entry.as_object_mut() {
359 obj.insert(
360 "command".to_string(),
361 serde_json::Value::String(desired_command.to_string()),
362 );
363 obj.insert(
364 "type".to_string(),
365 serde_json::Value::String("command".into()),
366 );
367 }
368 canonical_seen = true;
369 }
370 }
371
372 if let Some(hooks) = entry
373 .get_mut("hooks")
374 .and_then(|value| value.as_array_mut())
375 {
376 let mut filtered_hooks = Vec::with_capacity(hooks.len());
377 for mut hook in hooks.drain(..) {
378 let managed = hook
379 .get("command")
380 .and_then(|value| value.as_str())
381 .is_some_and(Self::is_nexus_managed_command);
382 if managed {
383 managed_in_entry = true;
384 replaced = true;
385 if canonical_seen {
386 continue;
387 }
388 if let Some(obj) = hook.as_object_mut() {
389 obj.insert(
390 "command".to_string(),
391 serde_json::Value::String(desired_command.to_string()),
392 );
393 obj.insert(
394 "type".to_string(),
395 serde_json::Value::String("command".into()),
396 );
397 }
398 canonical_seen = true;
399 }
400 filtered_hooks.push(hook);
401 }
402 *hooks = filtered_hooks;
403 }
404
405 if !managed_in_entry || canonical_seen {
406 rewritten.push(entry);
407 }
408 }
409
410 *entries = rewritten;
411 replaced
412 }
413
414 fn is_nexus_managed_command(command: &str) -> bool {
415 let command = command.to_ascii_lowercase();
416 command.contains("nexus")
417 && command.contains("--agent droid")
418 && (command.contains(" session ") || command.contains("ingest-hook-event"))
419 }
420
421 fn ensure_mutable(&self) -> Result<()> {
422 if self.readonly {
423 return Err(HookError::NotSupported(
424 "DroidHook readonly mode does not allow hook installation".to_string(),
425 ));
426 }
427 Ok(())
428 }
429}
430
431impl Default for DroidHook {
432 fn default() -> Self {
433 Self::new()
434 }
435}
436
437#[async_trait]
438impl AgentHook for DroidHook {
439 fn agent_type(&self) -> &str {
440 &self.base.agent_type
441 }
442
443 async fn install_session_start_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
444 self.ensure_mutable()?;
445 self.base.add_callback(callback);
446 self.install_settings_hooks().await
447 }
448
449 async fn install_session_end_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
450 self.ensure_mutable()?;
451 self.base.add_callback(callback);
452 self.base.installed = true;
453 self.install_settings_hooks().await
454 }
455
456 async fn install_checkpoint_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
457 self.ensure_mutable()?;
458 self.base.add_callback(callback);
459 self.install_settings_hooks().await
460 }
461
462 async fn install_compact_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
463 self.ensure_mutable()?;
464 self.base.add_callback(callback);
465 self.install_settings_hooks().await
466 }
467
468 async fn install_error_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
469 self.ensure_mutable()?;
470 self.base.add_callback(callback);
471 self.install_settings_hooks().await
472 }
473
474 async fn detect_session_activity(&self) -> Result<SessionActivity> {
475 let mut monitor = self.process_monitor.clone();
476 let processes = monitor.find_agent_processes(AgentType::Droid);
477
478 let mut activity = SessionActivity::new(AgentType::Droid);
479 if !processes.is_empty() {
480 activity.is_active = true;
481 activity.processes = processes;
482 }
483
484 Ok(activity)
485 }
486
487 async fn extract_session_context(&self) -> Result<SessionContext> {
488 let mut context = SessionContext::new("droid")
489 .with_source("native")
490 .with_reliability(if self.settings_hook_installed {
491 0.98
492 } else {
493 0.9
494 });
495 context.complete();
496 Ok(context)
497 }
498
499 fn is_hook_installed(&self) -> bool {
500 self.settings_hook_installed
501 }
502
503 fn reliability_score(&self) -> f32 {
504 if self.settings_hook_installed {
505 0.98
506 } else {
507 0.9
508 }
509 }
510
511 fn lifecycle_capabilities(&self) -> LifecycleCapabilities {
512 LifecycleCapabilities {
513 session_start: true,
514 session_end: true,
515 checkpoint: true,
516 error_hook: true,
517 compact: true,
518 }
519 }
520
521 fn support_tier(&self) -> SupportTier {
522 SupportTier::NativeLifecycle
523 }
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 #[test]
531 fn test_droid_hook_new() {
532 let hook = DroidHook::new();
533 assert_eq!(hook.agent_type(), "droid");
534 }
535
536 #[test]
537 fn test_find_nexus_binary_supports_nexus_bin() {
538 let bin = DroidHook::find_nexus_binary();
539 assert!(!bin.is_empty());
540 assert!(bin.contains("nexus"));
541 }
542
543 #[test]
544 fn test_droid_lifecycle_capabilities() {
545 let hook = DroidHook::new();
546 let caps = hook.lifecycle_capabilities();
547 assert!(caps.session_start);
548 assert!(caps.session_end);
549 assert!(caps.checkpoint);
550 assert!(caps.error_hook);
551 assert!(caps.compact);
552 }
553
554 #[tokio::test]
555 async fn test_install_session_end_hook_is_supported() {
556 let home = tempfile::tempdir().unwrap();
557 let settings_path = home.path().join(".factory").join("settings.json");
558 let mut hook = DroidHook::with_settings_path(settings_path, false);
559 let callback = std::sync::Arc::new(|_ctx| {});
560 let result = hook.install_session_end_hook(callback).await;
561 assert!(
562 result.is_ok(),
563 "install_session_end_hook should succeed in a temp dir: {result:?}"
564 );
565 }
566
567 #[tokio::test]
568 async fn test_readonly_droid_hook_rejects_install() {
569 let home = tempfile::tempdir().unwrap();
570 let settings_path = home.path().join(".factory").join("settings.json");
571 let mut hook = DroidHook::with_settings_path(settings_path, true);
572 let callback = std::sync::Arc::new(|_ctx| {});
573 let result = hook.install_session_start_hook(callback).await;
574 assert!(matches!(result, Err(HookError::NotSupported(_))));
575 }
576}