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
3 changes: 3 additions & 0 deletions cmux-tui/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

49 changes: 36 additions & 13 deletions cmux-tui/crates/acpmux/src/claude_stdio/hook_callback.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,45 @@
//! Claude Code's `hook_callback` control request: a hook its SDK host registered (the
//! `initialize` request's `hooks`, event -> `[{matcher, hookCallbackIds}]`) has fired, and
//! Claude Code waits for that callback's hook output before it goes on. acpmux registers none
//! today (`"hooks": {}` in `outbound.rs`), so any id is one it does not know: it answers with an
//! empty hook output, which Claude Code reads as a hook with no decision, never with an error.
//! Claude Code 2.1.310 given an error for a registered `Stop` callback never sent that turn's
//! result; given the empty output it ended the turn as usual.

//! Claude's synchronous PreToolUse callback waits for an existing repo sync.
//! Unknown callbacks retain the empty success output Claude expects.
use super::*;

#[cfg(unix)]
pub(super) const SYNC_CALLBACK: &str = "cmux-repo-sync";

impl Translator {
/// Answers `hook_callback` `rid`: success with an empty hook output (no decision, no
/// context, nothing stopped), so the hook Claude Code runs is a no-op and the turn goes on.
pub(super) async fn answer_hook_callback(&self, rid: &str, req: &Value) {
let id = req.get("callback_id").and_then(Value::as_str).unwrap_or("?");
tracing::debug!("claude hook_callback {id}: no hook registered, answered with no decision");
let output = Self::hook_callback_output(req).await;
self.stdin_replies.lock().await.push(json!({
"type": "control_response",
"response": {"subtype": "success", "request_id": rid, "response": {}}
"response": {"subtype": "success", "request_id": rid, "response": output}
}));
}

async fn hook_callback_output(req: &Value) -> Value {
#[cfg(not(unix))]
let output = {
let _ = req;
json!({})
};
#[cfg(unix)]
let mut output = json!({});
#[cfg(unix)]
if req.get("callback_id").and_then(Value::as_str) == Some(SYNC_CALLBACK) {
let input = req.get("input").cloned().unwrap_or(Value::Null);
let result = tokio::task::spawn_blocking(move || {
cmux_git::native_hook::wait(
"claude",
"PreToolUse",
&input,
cmux_git::native_hook::WAIT,
)
})
.await;
output = match result {
Ok(Ok(())) => json!({}),
Ok(Err(error)) => cmux_git::native_hook::denial(&error.to_string()),
Err(error) => cmux_git::native_hook::denial(&error.to_string()),
};
}
output
}
}
32 changes: 32 additions & 0 deletions cmux-tui/crates/acpmux/src/claude_stdio/hook_callback_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,35 @@ async fn other_unsupported_control_requests_keep_the_error() {
assert_eq!(replies[0]["response"]["subtype"], "error");
assert_eq!(replies[0]["response"]["request_id"], "m1");
}

#[cfg(unix)]
#[tokio::test]
async fn registered_pre_tool_callback_waits_and_failed_sync_returns_denial() {
use cmux_git::sync_barrier::Sync;
let root = tempfile::tempdir().unwrap();
let repo = root.path().canonicalize().unwrap().join("repo");
let sync = Sync::begin(&repo).unwrap();
let t = Translator::new("acp-1".into(), "default", "haiku", "default");
let mut call = callback("sync", hook_callback::SYNC_CALLBACK, "PreToolUse");
call["request"]["input"]["cwd"] = json!(repo);
let pending = t.answer_hook_callback("sync", &call["request"]);
tokio::pin!(pending);
assert!(
tokio::time::timeout(std::time::Duration::from_millis(50), &mut pending).await.is_err()
);
sync.complete().unwrap();
pending.await;
assert_eq!(t.take_stdin_replies().await[0]["response"]["response"], json!({}));

drop(Sync::begin(&repo).unwrap());
t.answer_hook_callback("sync", &call["request"]).await;
let replies = t.take_stdin_replies().await;
assert_eq!(
replies[0]["response"]["response"]["hookSpecificOutput"]["permissionDecision"],
"deny"
);
assert_eq!(
outbound::initialize_request(false)["hooks"]["PreToolUse"][0]["hookCallbackIds"][0],
hook_callback::SYNC_CALLBACK
);
}
7 changes: 7 additions & 0 deletions cmux-tui/crates/acpmux/src/claude_stdio/outbound.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,13 @@ pub(super) fn initialize_request(suggestions: bool) -> Value {
"supportedDialogKinds": ["ask_user_question", "exit_plan_mode", "permission"],
"perTaskStopAffordance": true,
});
#[cfg(unix)]
request.set(
"hooks",
json!({"PreToolUse": [{
"matcher": "*", "hookCallbackIds": [super::hook_callback::SYNC_CALLBACK], "timeout": 90
}]}),
);
if suggestions {
request.set("promptSuggestions", json!(true));
}
Expand Down
4 changes: 4 additions & 0 deletions cmux-tui/crates/cmux-git/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ workspace = true
[dependencies]
wait-timeout.workspace = true
sha2.workspace = true
serde_json.workspace = true

[target.'cfg(unix)'.dependencies]
libc.workspace = true

[dev-dependencies]
tempfile = "3"
2 changes: 2 additions & 0 deletions cmux-tui/crates/cmux-git/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ mod repository;
pub mod run;
#[cfg(unix)]
pub mod sync_barrier;
#[cfg(unix)]
pub mod native_hook;
pub mod worktree_root;
pub mod write_run;

Expand Down
46 changes: 46 additions & 0 deletions cmux-tui/crates/cmux-git/src/native_hook.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//! Native pre-tool readiness checks. These wait for an existing sync, but do
//! not hold a lease through a tool running in another process.
use std::{io, path::PathBuf, time::Duration};

use serde_json::{Value, json};

pub const WAIT: Duration = Duration::from_secs(30);

pub fn wait(source: &str, event: &str, native: &Value, timeout: Duration) -> io::Result<()> {
if !matches!(source, "claude" | "codex") || event != "PreToolUse" {
return Ok(());
}
let cwd = native
.get("cwd")
.or_else(|| native.get("working_directory"))
.and_then(Value::as_str)
.map(PathBuf::from)
.map(Ok)
.unwrap_or_else(std::env::current_dir)?;
let cwd = if cwd.is_absolute() { cwd } else { std::env::current_dir()?.join(cwd) };
let mut paths = vec![cwd.clone()];
if let Some(input) = native.get("tool_input") {
for key in ["file_path", "path", "cwd", "workdir", "working_directory", "notebook_path"] {
if let Some(path) = input.get(key).and_then(Value::as_str) {
let path = PathBuf::from(path);
paths.push(if path.is_absolute() { path } else { cwd.join(path) });
}
}
}
let deadline = std::time::Instant::now() + timeout;
for path in paths {
crate::sync_barrier::wait_ready(
&path,
deadline.saturating_duration_since(std::time::Instant::now()),
)?;
}
Ok(())
}

pub fn denial(reason: &str) -> Value {
json!({"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": format!("Repository sync is not ready: {reason}. Retry after synchronization completes.")
}})
}
30 changes: 30 additions & 0 deletions cmux-tui/crates/cmux-git/src/sync_barrier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,36 @@ pub enum Probe {
Incomplete,
}

/// Wait until repository synchronization is complete, or until `timeout`.
///
/// Hook callers use this as a synchronous pre-tool gate. The returned read
/// lease is intentionally released before the caller executes the tool; a
/// caller that must serialize the write itself should retain [`Ready`] from
/// [`probe`] through its mutation instead.
pub fn wait_ready(path: &Path, timeout: Duration) -> io::Result<()> {
let deadline = Instant::now() + timeout;
loop {
match probe(path)? {
Probe::Ready(ready) => {
drop(ready);
return Ok(());
}
Probe::Incomplete => {
return Err(io::Error::other("repository synchronization failed; retry the sync"));
}
Probe::Syncing => {
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"repository synchronization did not finish",
));
}
std::thread::sleep(Duration::from_millis(10));
}
}
}
}

fn marker(folder: &Path) -> io::Result<PathBuf> {
let parent = folder.parent().ok_or_else(|| io::Error::other("sync root has no parent"))?;
let name = folder.file_name().ok_or_else(|| io::Error::other("sync root has no name"))?;
Expand Down
82 changes: 82 additions & 0 deletions cmux-tui/crates/cmux-git/tests/native_hook.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#![cfg(unix)]
use cmux_git::{native_hook, sync_barrier::Sync};
use serde_json::json;
use std::{sync::mpsc, time::Duration};

#[test]
fn native_tools_wait_for_active_sync_at_cwd_or_explicit_file_target() {
for source in ["claude", "codex"] {
for target in [false, true] {
let root = tempfile::tempdir().unwrap();
let parent = root.path().canonicalize().unwrap();
let repo = parent.join("repo");
let sync = Sync::begin(&repo).unwrap();
let native = if target {
json!({"cwd": parent, "tool_input": {"file_path": "repo/file"}})
} else {
json!({"cwd": repo, "tool_input": {"command": "write files"}})
};
let (send, receive) = mpsc::channel();
let waiter = std::thread::spawn(move || {
send.send(native_hook::wait(source, "PreToolUse", &native, Duration::from_secs(2)))
.unwrap();
});
assert!(receive.recv_timeout(Duration::from_millis(50)).is_err());
sync.complete().unwrap();
receive.recv_timeout(Duration::from_secs(1)).unwrap().unwrap();
waiter.join().unwrap();
}
}
}

#[test]
fn active_timeout_and_abandoned_sync_deny_instead_of_allowing_the_tool() {
let root = tempfile::tempdir().unwrap();
let repo = root.path().canonicalize().unwrap().join("repo");
let sync = Sync::begin(&repo).unwrap();
let native = json!({"cwd": repo});
let error = native_hook::wait("codex", "PreToolUse", &native, Duration::ZERO).unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::TimedOut);
assert_eq!(
native_hook::denial(&error.to_string())["hookSpecificOutput"]["permissionDecision"],
"deny"
);
drop(sync);
assert!(native_hook::wait("claude", "PreToolUse", &native, Duration::ZERO).is_err());
native_hook::wait("claude", "PostToolUse", &native, Duration::ZERO).unwrap();
}

#[test]
fn native_pre_tool_retention_measurement() {
let root = tempfile::tempdir().unwrap();
let repo = root.path().canonicalize().unwrap().join("repo");
std::fs::create_dir(&repo).unwrap();
let file = repo.join("file");
let mut retained = [0, 0];
for gated in [false, true] {
for _ in 0..100 {
let sync = Sync::begin(&repo).unwrap();
if !gated {
std::fs::write(&file, "agent edit").unwrap();
}
let ready = if gated {
let native = json!({"cwd": repo});
Some(std::thread::spawn(move || {
native_hook::wait("claude", "PreToolUse", &native, Duration::from_secs(2))
}))
} else {
None
};
std::fs::write(&file, "repo sync").unwrap();
sync.complete().unwrap();
if let Some(ready) = ready {
ready.join().unwrap().unwrap();
std::fs::write(&file, "agent edit").unwrap();
}
retained[usize::from(gated)] +=
usize::from(std::fs::read_to_string(&file).unwrap() == "agent edit");
}
}
println!("native_pre_tool_retention: before={}/100 after={}/100", retained[0], retained[1]);
assert_eq!(retained, [0, 100]);
}
22 changes: 21 additions & 1 deletion cmux-tui/crates/cmux-git/tests/sync_barrier.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#![cfg(unix)]
use cmux_git::sync_barrier::{Probe, Sync, probe};
use cmux_git::sync_barrier::{Probe, Sync, probe, wait_ready};
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};

Expand Down Expand Up @@ -98,6 +98,26 @@ fn a_reader_never_blocks_a_sibling_repository_sync() {
assert!(matches!(probe(&sibling).unwrap(), Probe::Ready(_)));
}

#[test]
fn pre_tool_wait_releases_only_after_sync_completes() {
let scratch = Scratch::new();
let repo = scratch.repo();
let sync = Sync::begin(&repo).unwrap();
let (started_tx, started_rx) = std::sync::mpsc::channel();
let (finished_tx, finished_rx) = std::sync::mpsc::channel();
let target = repo.clone();
let waiter = std::thread::spawn(move || {
started_tx.send(()).unwrap();
wait_ready(&target, std::time::Duration::from_secs(2)).unwrap();
finished_tx.send(()).unwrap();
});
started_rx.recv_timeout(std::time::Duration::from_secs(1)).unwrap();
assert!(finished_rx.recv_timeout(std::time::Duration::from_millis(50)).is_err());
sync.complete().unwrap();
finished_rx.recv_timeout(std::time::Duration::from_secs(1)).unwrap();
waiter.join().unwrap();
}

/// Same deterministic overlap schedule, with and without waiting. This measures
/// edit retention, not end-to-end Cloud latency (which belongs to the probes).
#[test]
Expand Down
Loading
Loading