diff --git a/README.md b/README.md index 7c78901..6466ec6 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,10 @@ git commit -m "chore: initialize AgentMesh sync" Do not commit machine-local hook files (`.claude/settings.local.json`, `.codex/hooks.json`). Each teammate runs `agentmesh init` on their machine. Add `.codex/hooks.json` to `.gitignore`. -Codex requires a one-time trust approval for the command hook before it runs. Sync still works via -the watcher daemon and manual `agentmesh sync` until then. +Codex requires a one-time trust approval before it runs the AgentMesh command hook. After setup, +open Codex in the repository and run any tool-backed action; when Codex asks whether to trust the +AgentMesh hook command, approve it once. Sync still works via the watcher daemon, Claude hooks, and +manual `agentmesh sync` until then. | Situation | Command | | -------------------------- | ------------------------------------ | @@ -96,7 +98,9 @@ To start AgentMesh again for an initialized repository: agentmesh start -y ``` -This refreshes machine-local AgentMesh state and installs AgentMesh-owned hooks for detected runtimes. It keeps `agentmesh.lock`, `.ai/`, and runtime files such as `AGENTS.md` intact. +This refreshes machine-local AgentMesh state, installs AgentMesh-owned hooks for detected runtimes, +and starts the watcher so direct edits to `AGENTS.md`, `CLAUDE.md`, and `.ai/` files sync +immediately. It keeps `agentmesh.lock`, `.ai/`, and runtime files such as `AGENTS.md` intact. To stop AgentMesh for the current repository while keeping all repository state and AgentMesh installed on this computer: diff --git a/crates/agentmesh-watcher/src/lib.rs b/crates/agentmesh-watcher/src/lib.rs index 8fd7d99..697030f 100644 --- a/crates/agentmesh-watcher/src/lib.rs +++ b/crates/agentmesh-watcher/src/lib.rs @@ -19,6 +19,7 @@ use thiserror::Error; const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(500); const DEFAULT_VCS_THROTTLE: Duration = Duration::from_secs(2); const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(30 * 60); +const BACKGROUND_START_TIMEOUT: Duration = Duration::from_secs(10); const LONG_POLL_TIMEOUT: Duration = Duration::from_secs(60); const MAX_LOG_BYTES: u64 = 10 * 1024 * 1024; const MAX_ROTATED_LOGS: u8 = 3; @@ -263,6 +264,12 @@ fn start_with_cache_root( if !opts.register_as_service { if let Some(record) = read_active_record(&layout)? { if is_running_state(&record.state) { + if opts.foreground + && record.pid == std::process::id() + && record.state == STATE_BACKGROUND_SPAWNED + { + return run_foreground(repo_root, opts, &layout); + } append_log( &layout.log_file, "start-idempotent", @@ -322,6 +329,11 @@ fn spawn_background( .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } if opts.persistent { command.arg("--persistent"); } @@ -351,10 +363,41 @@ fn spawn_background( "state": record.state, }), )?; + wait_for_background_start(layout, record.pid)?; Ok(handle(repo_root, layout)) } +fn wait_for_background_start(layout: &WatcherLayout, pid: u32) -> Result<()> { + let deadline = Instant::now() + BACKGROUND_START_TIMEOUT; + loop { + if let Ok(record) = read_json::(&layout.state_file) { + if record.pid == pid && record.state == STATE_RUNNING && process_running(pid) { + return Ok(()); + } + } + if Instant::now() >= deadline { + append_log( + &layout.log_file, + "start-timeout", + json!({ + "pid": pid, + "timeout_ms": duration_ms(BACKGROUND_START_TIMEOUT), + }), + )?; + return Err(WatcherError::Io { + action: "wait for watcher process to start", + path: layout.state_file.clone(), + source: std::io::Error::new( + std::io::ErrorKind::TimedOut, + "watcher process did not report running", + ), + }); + } + std::thread::sleep(Duration::from_millis(50)); + } +} + fn register_service(repo_root: &Path, opts: &WatchOptions, layout: &WatcherLayout) -> Result<()> { let executable = env::current_exe().map_err(|source| WatcherError::Io { action: "resolve current executable", @@ -1591,6 +1634,43 @@ mod tests { assert!(status.idle_since.is_none()); } + #[test] + fn foreground_child_promotes_background_spawn_record() { + let temp = tempdir(); + let repo = temp.path().join("repo"); + create_dir(&repo); + let cache = temp.path().join("cache"); + let layout = layout(&repo, &cache); + let spawned = WatcherRecord::new( + std::process::id(), + &repo, + &WatchOptions::default(), + false, + STATE_BACKGROUND_SPAWNED, + DRAIN_IDLE, + ); + if let Err(error) = write_record(&layout, &spawned) { + panic!("background-spawned record should write: {error}"); + } + let options = WatchOptions { + foreground: true, + idle_timeout: Some(Duration::from_millis(5)), + debounce: Duration::from_millis(1), + vcs_throttle: Duration::from_millis(1), + ..WatchOptions::default() + }; + + if let Err(error) = start_with_cache_root(&repo, options, &cache) { + panic!("foreground child should enter the watcher loop: {error}"); + } + let log = match fs::read_to_string(layout.log_file) { + Ok(log) => log, + Err(error) => panic!("watcher log should be readable: {error}"), + }; + + assert!(log.contains("start-foreground")); + } + #[test] fn default_idle_timeout_is_thirty_minutes_and_persistent_disables_it() { let default_timeout = match idle_timeout(&WatchOptions::default()) { diff --git a/crates/agentmesh/src/main.rs b/crates/agentmesh/src/main.rs index 86e3c05..3d58704 100644 --- a/crates/agentmesh/src/main.rs +++ b/crates/agentmesh/src/main.rs @@ -731,6 +731,7 @@ fn handle_init(context: &CliContext, command: InitCommand) -> Result Resu ); println!(" Accept prompts: {}", options.yes); println!(" Install hooks: {}", !options.skip_hooks); + println!(" Start watcher: {}", !options.skip_hooks); println!(" No repository or machine-local files were changed."); Ok(()) } @@ -1363,6 +1365,10 @@ fn handle_start(context: &CliContext, command: StartCommand) -> Result Result Result Result<()> { + if std::env::var_os("AGENTMESH_DISABLE_WATCHER_AUTOSTART").is_some() { + return Ok(()); + } + + let handle = agentmesh_watcher::start( + &context.repo_root, + agentmesh_watcher::WatchOptions { + persistent: true, + foreground: false, + register_as_service: false, + ..agentmesh_watcher::WatchOptions::default() + }, + ) + .map_err(map_watcher_error)?; + + if !context.silent { + println!(" watcher: running"); + println!(" watcher state: {}", handle.state_file.display()); + println!(" watcher log: {}", handle.log_file.display()); + } + + Ok(()) +} + fn hook_ownership_exists_for_trigger(context: &CliContext, trigger: &SyncTrigger) -> Result { let runtime = match trigger { SyncTrigger::ClaudeHook => "claude", diff --git a/crates/agentmesh/tests/cli_flows.rs b/crates/agentmesh/tests/cli_flows.rs index 4eb65ff..3268f51 100644 --- a/crates/agentmesh/tests/cli_flows.rs +++ b/crates/agentmesh/tests/cli_flows.rs @@ -22,27 +22,27 @@ fn run_agentmesh(repo: &Path, cache: &Path, args: &[&str]) -> Output { } fn run_agentmesh_binary(binary: &Path, repo: &Path, cache: &Path, args: &[&str]) -> Output { - match Command::new(binary) + let mut command = Command::new(binary); + command .arg("--cwd") .arg(repo) - .env("AGENTMESH_CACHE_DIR", cache) - .args(args) - .output() - { + .env("AGENTMESH_CACHE_DIR", cache); + configure_windows_test_command(&mut command); + match command.args(args).output() { Ok(output) => output, Err(error) => panic!("agentmesh command should run: {error}"), } } fn run_agentmesh_with_home(repo: &Path, cache: &Path, home: &Path, args: &[&str]) -> Output { - match Command::new(agentmesh_bin()) + let mut command = Command::new(agentmesh_bin()); + command .arg("--cwd") .arg(repo) .env("AGENTMESH_CACHE_DIR", cache) - .env("HOME", home) - .args(args) - .output() - { + .env("HOME", home); + configure_windows_test_command(&mut command); + match command.args(args).output() { Ok(output) => output, Err(error) => panic!("agentmesh command should run: {error}"), } @@ -59,6 +59,7 @@ fn run_agentmesh_with_env( .arg("--cwd") .arg(repo) .env("AGENTMESH_CACHE_DIR", cache); + configure_windows_test_command(&mut command); for (key, value) in envs { command.env(key, value); } @@ -69,14 +70,14 @@ fn run_agentmesh_with_env( } fn run_agentmesh_without_no_color(repo: &Path, cache: &Path, args: &[&str]) -> Output { - match Command::new(agentmesh_bin()) + let mut command = Command::new(agentmesh_bin()); + command .arg("--cwd") .arg(repo) .env("AGENTMESH_CACHE_DIR", cache) - .env_remove("NO_COLOR") - .args(args) - .output() - { + .env_remove("NO_COLOR"); + configure_windows_test_command(&mut command); + match command.args(args).output() { Ok(output) => output, Err(error) => panic!("agentmesh command should run: {error}"), } @@ -96,6 +97,12 @@ fn spawn_agentmesh_with_home(repo: &Path, cache: &Path, home: &Path, args: &[&st } } +fn configure_windows_test_command(command: &mut Command) { + if cfg!(windows) { + command.env("AGENTMESH_DISABLE_WATCHER_AUTOSTART", "1"); + } +} + fn assert_success(output: &Output) { assert!( output.status.success(), @@ -585,6 +592,8 @@ fn init_projects_all_entities_and_installs_detected_runtime_hooks() { assert!(read(repo.join(".codex/hooks.json")).contains("codex-hook")); assert!(find_named_file(&cache, "integrity.json").is_some()); assert!(find_named_file(&cache, "hook-ownership.json").is_some()); + + assert_success(&run_agentmesh(&repo, &cache, &["--silent", "stop", "-y"])); } #[test] @@ -875,6 +884,17 @@ fn install_stop_and_start_are_machine_local_and_surgical() { assert!(codex_overlay.contains("echo user")); assert!(codex_overlay.contains("codex-hook")); assert!(repo_cache_dir.exists()); + + if !cfg!(windows) { + let watcher_running = wait_until(Duration::from_secs(5), || { + String::from_utf8_lossy(&run_agentmesh(&repo, &cache, &["status"]).stdout) + .contains("watcher: running") + }); + assert!(watcher_running, "start should launch the watcher"); + } + + let stop = run_agentmesh(&repo, &cache, &["--silent", "stop", "-y"]); + assert_success(&stop); } #[test] @@ -1404,6 +1424,8 @@ fn upgrade_rewrites_recorded_runtime_hooks_to_current_binary() { assert!(!contents.contains(&escaped_stale_binary)); assert!(contents.contains(&escaped_binary)); } + + assert_success(&run_agentmesh(&repo, &cache, &["--silent", "stop", "-y"])); } #[test] diff --git a/crates/agentmesh/tests/snapshots/cli_flows__representative_command_outputs.snap b/crates/agentmesh/tests/snapshots/cli_flows__representative_command_outputs.snap index ed09213..72b6217 100644 --- a/crates/agentmesh/tests/snapshots/cli_flows__representative_command_outputs.snap +++ b/crates/agentmesh/tests/snapshots/cli_flows__representative_command_outputs.snap @@ -62,6 +62,7 @@ stdout: Canonical instructions: default Accept prompts: true Install hooks: false + Start watcher: false No repository or machine-local files were changed. stderr: @@ -201,6 +202,7 @@ exit: Some(0) stdout: → Would refresh machine-local AgentMesh state for this repository → Would install AgentMesh-owned hooks for detected runtimes + → Would start the AgentMesh watcher for immediate file sync → Would keep agentmesh.lock, .ai/, and runtime files No files were changed; AgentMesh sync was not started. diff --git a/installers/README.md b/installers/README.md index b1f2e07..db518ec 100644 --- a/installers/README.md +++ b/installers/README.md @@ -73,6 +73,8 @@ To start AgentMesh again for an initialized repository: agentmesh start -y ``` +This refreshes machine-local hooks and starts the watcher for immediate direct-file sync. + To stop AgentMesh for the current repository while keeping all repository state and AgentMesh installed on this computer: ```bash