diff --git a/README.md b/README.md index 7eddc3d..a08c233 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,11 @@ The hook target is `understatus cmux hook-codex`. It is intentionally quiet and - it updates cmux with `cmux set-status` / `clear-status` only; - it never uses `cmux set-progress` because cmux progress is workspace-global and can clobber between panes; +- CPU/memory pills are hook-event snapshots, not live polling; +- `Stop` clears understatus Codex pills so the sidebar can be empty between turns or while idle; +- installed hooks point at the concrete `understatus` binary path available at install time, so + rebuild/reinstall the hooks and re-trust them in Codex `/hooks` after changing or upgrading + understatus; - in hook mode, it only applies when a caller/environment target such as `CMUX_WORKSPACE_ID` is present; it will not fall back to a focused workspace. @@ -378,7 +383,7 @@ macOS용 AI 코딩 CLI statusline 애드온입니다. CPU%, 메모리, 배터리 - **세션 캐시 격리** — 체인 출력·펄스 상태·네트워크 델타 캐시는 `session_id`별로 분리되어 여러 터미널을 동시에 열어도 값이 섞이지 않습니다. 배터리는 머신 전역. - **Claude rate-limit 표시** — Claude Code stdin에 `rate_limits`가 들어오면 5h/weekly 사용률과 리셋 카운트다운을 네트워크 호출 없이 표시합니다. - **lterm/cmux·Codex 경로** — `--source lterm --surface-format cmux-status`는 cmux pill JSON을 출력합니다. Codex를 cmux 안에서 쓰는 경우 `understatus cmux install-codex-hooks`로 Codex hook을 설치하면 왼쪽 sidebar status pill이 자동 갱신됩니다. 독립 Codex/tmux 경로는 `--source codex --oneline`으로 `~/.codex`를 best-effort 보강합니다. -- **hook 안전성** — Codex hook은 `{}`만 출력하고, 5초 timeout으로 fail-soft 동작하며, cmux에는 `set-status`/`clear-status`만 사용합니다. pane 간 전역 progress 충돌을 막기 위해 `set-progress`는 쓰지 않습니다. +- **hook 안전성** — Codex hook은 `{}`만 출력하고, 5초 timeout으로 fail-soft 동작하며, cmux에는 `set-status`/`clear-status`만 사용합니다. CPU/mem은 hook 시점 스냅샷이고, `Stop`은 idle sidebar가 비도록 Codex pill을 지웁니다. pane 간 전역 progress 충돌을 막기 위해 `set-progress`는 쓰지 않습니다. hook은 설치 시점의 구체적 바이너리 경로를 쓰므로 understatus 변경/업그레이드 후 재설치하고 Codex `/hooks`에서 다시 trust하세요. **설치** diff --git a/src/main.rs b/src/main.rs index 47a7a9d..de37def 100644 --- a/src/main.rs +++ b/src/main.rs @@ -415,6 +415,17 @@ fn run_cmux_hook_codex(args: &[String]) -> ExitCode { window: parsed.window, }; let target = cmux::resolve_target(explicit_target, true); + if input.hook_event_name == "Stop" { + if target.has_caller_target() { + if let Some(mode) = clamped_codex_hook_apply_mode(hook_started) { + let _ = cmux::clear_all_codex(target, mode); + } + } + print!("{}", codex_hooks::neutral_hook_stdout()); + let _ = std::io::stdout().flush(); + return ExitCode::SUCCESS; + } + let surface = std::env::var("CMUX_SURFACE_ID").ok(); let payload = codex_hooks::render_payload(&input, &target, surface.as_deref()); let owner = parsed @@ -422,10 +433,7 @@ fn run_cmux_hook_codex(args: &[String]) -> ExitCode { .unwrap_or_else(|| codex_hooks::owner_key(&input, &target, surface.as_deref())); if target.has_caller_target() { let pills = render_cmux_output_for_raw(Source::Codex, &payload); - if let Some(remaining_ms) = remaining_codex_hook_apply_budget_ms(hook_started) { - let mut mode = cmux::ApplyMode::hook(); - mode.timeout_ms = mode.timeout_ms.min(remaining_ms); - mode.total_timeout_ms = Some(remaining_ms); + if let Some(mode) = clamped_codex_hook_apply_mode(hook_started) { let _ = cmux::apply_pills(&pills, &owner, target, mode); } } @@ -434,6 +442,14 @@ fn run_cmux_hook_codex(args: &[String]) -> ExitCode { ExitCode::SUCCESS } +fn clamped_codex_hook_apply_mode(hook_started: Instant) -> Option { + let remaining_ms = remaining_codex_hook_apply_budget_ms(hook_started)?; + let mut mode = cmux::ApplyMode::hook(); + mode.timeout_ms = mode.timeout_ms.min(remaining_ms); + mode.total_timeout_ms = Some(remaining_ms); + Some(mode) +} + fn remaining_codex_hook_apply_budget_ms(hook_started: Instant) -> Option { let hard_timeout = Duration::from_secs(codex_hooks::HOOK_TIMEOUT_SECS); let reserve = Duration::from_millis(CODEX_HOOK_STDOUT_RESERVE_MS); diff --git a/tests/oneline.rs b/tests/oneline.rs index 20003d6..2e79864 100644 --- a/tests/oneline.rs +++ b/tests/oneline.rs @@ -9,6 +9,7 @@ use std::io::Write; use std::process::{Command, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; /// chain 실행 여부를 stdout으로 직접 관측하기 위한 센티널. chain_command가 도는 경우에만 /// 자식 stdout에 합성되어 self 세그먼트와 함께 한 줄에 나타난다. @@ -25,6 +26,7 @@ const CONFIG_LIMIT_BYTES: usize = 256 * 1024; /// 읽기와 경합하며 torn read(부분 판독)를 유발한다(E2E flaky 근본 원인). 매 호출마다 증가하는 /// 전역 카운터를 경로에 섞어 충돌을 원천 차단한다. static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0); +static CMUX_FAKE_TEST_LOCK: Mutex<()> = Mutex::new(()); /// 프로세스 내에서 절대 충돌하지 않는 고유 토큰(`--`)을 만든다. fn unique_token() -> String { @@ -161,6 +163,75 @@ fn make_isolated_home(tag: &str) -> String { path.to_string_lossy().into_owned() } +#[cfg(unix)] +fn install_fake_cmux( + tag: &str, + script: &str, +) -> (std::path::PathBuf, std::path::PathBuf, std::ffi::OsString) { + use std::os::unix::fs::PermissionsExt; + + let token = unique_token(); + let bin_dir = std::env::temp_dir().join(format!("understatus-fake-cmux-{tag}-{token}")); + let record_path = + std::env::temp_dir().join(format!("understatus-fake-cmux-{tag}-record-{token}.txt")); + std::fs::create_dir_all(&bin_dir).expect("fake cmux bin dir 생성 실패"); + + let cmux_path = bin_dir.join("cmux"); + std::fs::write(&cmux_path, script).expect("fake cmux 작성 실패"); + let mut perms = std::fs::metadata(&cmux_path) + .expect("fake cmux metadata") + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&cmux_path, perms).expect("fake cmux chmod 실패"); + + let old_path = std::env::var_os("PATH").unwrap_or_default(); + let mut paths = vec![bin_dir.clone()]; + paths.extend(std::env::split_paths(&old_path)); + let path = std::env::join_paths(paths).expect("PATH join 실패"); + + (bin_dir, record_path, path) +} + +#[cfg(unix)] +fn run_cmux_hook_codex_with_fake_cmux( + args: &[&str], + stdin: &[u8], + home: &str, + path: &std::ffi::OsStr, + record_path: &std::path::Path, + config_path: Option<&str>, +) -> std::process::Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_understatus")); + command + .args(["cmux", "hook-codex"]) + .args(args) + .env("NO_COLOR", "1") + .env("HOME", home) + .env("PATH", path) + .env("CMUX_RECORD", record_path) + .env_remove("CMUX_WORKSPACE_ID") + .env_remove("CMUX_WINDOW_ID") + .env_remove("CMUX_WINDOW") + .env_remove("CMUX_SURFACE_ID") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(config_path) = config_path { + command.env("UNDERSTATUS_CONFIG", config_path); + } + + let mut child = command + .spawn() + .expect("understatus cmux hook-codex 실행 실패"); + child + .stdin + .take() + .expect("stdin 핸들 없음") + .write_all(stdin) + .expect("stdin 쓰기 실패"); + child.wait_with_output().expect("자식 종료 대기 실패") +} + /// --oneline은 정확히 1행을 후행 개행 없이 출력해야 한다(spec §6.3). #[test] fn oneline_emits_single_line_without_trailing_newline() { @@ -655,6 +726,213 @@ fn cmux_hook_codex_without_target_noops_and_prints_neutral_json() { let _ = std::fs::remove_dir_all(&empty_path); } +/// Codex + CMUX bridge: Stop hook은 렌더링/샘플링 없이 현재 target의 관측된 Codex pill만 지운다. +#[cfg(unix)] +#[test] +fn cmux_hook_codex_stop_clears_observed_understatus_keys_only() { + let _cmux_lock = CMUX_FAKE_TEST_LOCK.lock().expect("fake cmux test lock"); + let home = make_isolated_home("cmux-hook-stop-clear"); + let (bin_dir, record_path, path) = install_fake_cmux( + "hook-stop-clear", + r##"#!/bin/sh +cmd="$1" +{ + printf '%s' "$cmd" + shift + for arg in "$@"; do + printf '\t%s' "$arg" + done + printf '\n' +} >> "$CMUX_RECORD" +if [ "$cmd" = "sidebar-state" ]; then + cat <<'STATE' +status_count=3 + understatus.codex.session_a.cpu=cpu 31% color=#6D8296 priority=100 + understatus.codex.session_a.mem=mem 68% color=#A0A0A0 priority=90 + other.tool=ok color=#00ff00 priority=1 +STATE +fi +exit 0 +"##, + ); + + let output = run_cmux_hook_codex_with_fake_cmux( + &["--workspace", "workspace:test", "--window", "window:test"], + br#"{"hook_event_name":"Stop","session_id":"sess-stop","cwd":"/tmp/proj"}"#, + &home, + &path, + &record_path, + None, + ); + assert!( + output.status.success(), + "Stop hook은 fake sidebar clear 실패 없이 성공해야 함: {:?}, stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8(output.stdout).expect("stdout UTF-8"), + "{}" + ); + + let record = std::fs::read_to_string(&record_path).expect("fake cmux 호출 기록"); + assert!( + record.contains("sidebar-state\t--workspace\tworkspace:test\t--window\twindow:test"), + "현재 CMUX status를 조회해야 함: {record:?}" + ); + assert!( + record.contains("clear-status\tunderstatus.codex.session_a.cpu"), + "관측된 cpu key clear 필요: {record:?}" + ); + assert!( + record.contains("clear-status\tunderstatus.codex.session_a.mem"), + "관측된 mem key clear 필요: {record:?}" + ); + assert!( + !record.contains("clear-status\tother.tool"), + "다른 도구 key는 건드리면 안 됨: {record:?}" + ); + assert!( + !record.contains("set-status"), + "Stop hook은 set-status를 호출하면 안 됨: {record:?}" + ); + + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&bin_dir); + let _ = std::fs::remove_file(&record_path); +} + +/// Codex + CMUX bridge: Stop hook cleanup은 sidebar-state 조회 실패에도 fail-soft여야 한다. +#[cfg(unix)] +#[test] +fn cmux_hook_codex_stop_sidebar_state_failure_still_succeeds() { + let _cmux_lock = CMUX_FAKE_TEST_LOCK.lock().expect("fake cmux test lock"); + let home = make_isolated_home("cmux-hook-stop-sidebar-fails"); + let (bin_dir, record_path, path) = install_fake_cmux( + "hook-stop-fail", + r#"#!/bin/sh +cmd="$1" +{ + printf '%s' "$cmd" + shift + for arg in "$@"; do + printf '\t%s' "$arg" + done + printf '\n' +} >> "$CMUX_RECORD" +if [ "$cmd" = "sidebar-state" ]; then + exit 7 +fi +exit 0 +"#, + ); + + let output = run_cmux_hook_codex_with_fake_cmux( + &["--workspace", "workspace:test", "--window", "window:test"], + br#"{"hook_event_name":"Stop","session_id":"sess-stop","cwd":"/tmp/proj"}"#, + &home, + &path, + &record_path, + None, + ); + assert!( + output.status.success(), + "Stop hook은 sidebar-state 실패에도 성공해야 함: {:?}, stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8(output.stdout).expect("stdout UTF-8"), + "{}" + ); + + let record = std::fs::read_to_string(&record_path).expect("fake cmux 호출 기록"); + assert!( + record.contains("sidebar-state\t--workspace\tworkspace:test\t--window\twindow:test"), + "sidebar-state 시도는 필요: {record:?}" + ); + assert!( + !record.contains("set-status"), + "Stop hook은 set-status를 호출하면 안 됨: {record:?}" + ); + + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&bin_dir); + let _ = std::fs::remove_file(&record_path); +} + +/// Codex + CMUX bridge: Stop이 아닌 hook-codex는 기존처럼 status pill을 적용한다. +#[cfg(unix)] +#[test] +fn cmux_hook_codex_non_stop_applies_fake_cmux_set_status() { + let _cmux_lock = CMUX_FAKE_TEST_LOCK.lock().expect("fake cmux test lock"); + let home = make_isolated_home("cmux-hook-non-stop"); + let config = write_deterministic_core_config("cmux-hook-non-stop"); + let (bin_dir, record_path, path) = install_fake_cmux( + "hook-set", + r#"#!/bin/sh +{ + printf '%s' "$1" + shift + for arg in "$@"; do + printf '\t%s' "$arg" + done + printf '\n' +} >> "$CMUX_RECORD" +exit 0 +"#, + ); + + let output = run_cmux_hook_codex_with_fake_cmux( + &[ + "--workspace", + "workspace:test", + "--window", + "window:test", + "--session-key", + "it_owner", + ], + br#"{"hook_event_name":"UserPromptSubmit","session_id":"sess-live","cwd":"/tmp/proj"}"#, + &home, + &path, + &record_path, + Some(config.as_str()), + ); + assert!( + output.status.success(), + "non-Stop hook은 fake cmux set-status 성공 시 성공해야 함: {:?}, stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8(output.stdout).expect("stdout UTF-8"), + "{}" + ); + + let record = std::fs::read_to_string(&record_path).expect("fake cmux 호출 기록"); + assert!( + record.contains("set-status\tunderstatus.codex.it_owner.model"), + "model pill set-status 호출 필요: {record:?}" + ); + assert!( + record.contains("set-status\tunderstatus.codex.it_owner.cpu"), + "cpu pill set-status 호출 필요: {record:?}" + ); + assert!( + record.contains("set-status\tunderstatus.codex.it_owner.mem"), + "mem pill set-status 호출 필요: {record:?}" + ); + assert!( + !record.contains("set-progress"), + "CMUX 전역 progress 명령은 호출하면 안 됨: {record:?}" + ); + + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_file(&config); + let _ = std::fs::remove_dir_all(&bin_dir); + let _ = std::fs::remove_file(&record_path); +} + /// Codex + CMUX bridge: fake `cmux` executable을 PATH에 주입해 실제 CLI 공개 경로가 /// `set-status`만 호출하고 `set-progress`는 절대 호출하지 않으며 workspace/window target을 전달함을 검증한다. #[cfg(unix)] @@ -662,6 +940,7 @@ fn cmux_hook_codex_without_target_noops_and_prints_neutral_json() { fn cmux_apply_codex_invokes_fake_cmux_set_status_only() { use std::os::unix::fs::PermissionsExt; + let _cmux_lock = CMUX_FAKE_TEST_LOCK.lock().expect("fake cmux test lock"); let token = unique_token(); let home = make_isolated_home("cmux-apply-fake"); let config = write_deterministic_core_config("cmux-apply-fake"); @@ -775,6 +1054,7 @@ exit 0 fn cmux_apply_codex_clears_observed_legacy_session_keys() { use std::os::unix::fs::PermissionsExt; + let _cmux_lock = CMUX_FAKE_TEST_LOCK.lock().expect("fake cmux test lock"); let token = unique_token(); let home = make_isolated_home("cmux-apply-clears-observed"); let config = write_deterministic_core_config("cmux-apply-clears-observed"); @@ -893,6 +1173,7 @@ exit 0 fn cmux_clear_codex_without_session_key_clears_observed_understatus_keys() { use std::os::unix::fs::PermissionsExt; + let _cmux_lock = CMUX_FAKE_TEST_LOCK.lock().expect("fake cmux test lock"); let token = unique_token(); let home = make_isolated_home("cmux-clear-observed"); let bin_dir = std::env::temp_dir().join(format!("understatus-fake-cmux-clear-all-{token}"));