Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| -------------------------- | ------------------------------------ |
Expand All @@ -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:

Expand Down
80 changes: 80 additions & 0 deletions crates/agentmesh-watcher/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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::<WatcherRecord>(&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));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No early exit when spawned watcher process dies

Medium Severity

wait_for_background_start only checks process_running(pid) as part of the success condition (alongside STATE_RUNNING). If the spawned child process crashes before writing STATE_RUNNING, the process is dead but the function never detects this — it loops for the full 10-second BACKGROUND_START_TIMEOUT before returning an error. Since this is called during init and start, a watcher that crashes on launch causes those commands to hang for 10 seconds with no feedback.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 90e2bcb. Configure here.


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",
Expand Down Expand Up @@ -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()) {
Expand Down
47 changes: 43 additions & 4 deletions crates/agentmesh/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ fn handle_init(context: &CliContext, command: InitCommand) -> Result<AgentmeshEx
.map_err(map_core_error)?;
if !options.skip_hooks {
install_detected_runtime_hooks(context)?;
start_sync_watcher(context)?;
Comment thread
cursor[bot] marked this conversation as resolved.
}
Ok(print_summary(context, summary.changed, "init"))
}
Expand Down Expand Up @@ -896,6 +897,7 @@ fn print_init_dry_run(context: &CliContext, options: &ParsedInitOptions) -> 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(())
}
Expand Down Expand Up @@ -1363,6 +1365,10 @@ fn handle_start(context: &CliContext, command: StartCommand) -> Result<Agentmesh
" {} Would install AgentMesh-owned hooks for detected runtimes",
context.paint(OutputStyle::Info, "→")
);
println!(
" {} Would start the AgentMesh watcher for immediate file sync",
context.paint(OutputStyle::Info, "→")
);
println!(
" {} Would keep agentmesh.lock, .ai/, and runtime files",
context.paint(OutputStyle::Info, "→")
Expand All @@ -1378,6 +1384,7 @@ fn handle_start(context: &CliContext, command: StartCommand) -> Result<Agentmesh
&[
"refresh machine-local AgentMesh state for this repository",
"install AgentMesh-owned hooks for detected runtimes",
"start the AgentMesh watcher for immediate file sync",
"keep agentmesh.lock, .ai/, and runtime files",
],
)?;
Expand All @@ -1392,6 +1399,7 @@ fn handle_start(context: &CliContext, command: StartCommand) -> Result<Agentmesh
)
.map_err(map_core_error)?;
install_detected_runtime_hooks(context)?;
start_sync_watcher(context)?;

if !context.silent {
println!(
Expand Down Expand Up @@ -2821,12 +2829,18 @@ fn print_codex_trust_prompt(context: &CliContext, hooks: &[agentmesh_protocol::I
"{} Codex requires you to review and trust new command hooks before they run.",
context.paint(OutputStyle::Warning, "⚠")
);
println!(" On your next Codex tool call, Codex will prompt:");
println!(" What to do:");
println!(" 1. Open Codex in this repository.");
println!(
" 2. Run any Codex action that uses a tool, such as a file read or shell command."
);
println!(" 3. When Codex shows the hook trust prompt, approve this command:");
println!();
println!(" \"Trust the new hook '{}'?\"", hook.command);
println!(" {}", hook.command);
println!();
println!(" Approve once; Codex remembers the trust decision for this hook definition.");
println!(" Until then, Codex runs normally but this hook will not fire.");
println!(" This is a one-time Codex security approval. Until approved, AgentMesh still");
println!(" syncs via the watcher, Claude hooks, and manual `agentmesh sync`, but Codex");
println!(" will not run its own hook.");
}
}

Expand Down Expand Up @@ -3129,6 +3143,31 @@ fn ensure_watcher_for_trigger(context: &CliContext, options: &ParsedSyncOptions)
.map_err(map_watcher_error)
}

fn start_sync_watcher(context: &CliContext) -> 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<bool> {
let runtime = match trigger {
SyncTrigger::ClaudeHook => "claude",
Expand Down
52 changes: 37 additions & 15 deletions crates/agentmesh/tests/cli_flows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"),
}
Expand All @@ -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);
}
Expand All @@ -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}"),
}
Expand All @@ -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(),
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions installers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading