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