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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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하세요.

**설치**

Expand Down
24 changes: 20 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,17 +415,25 @@ 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
.session_key
.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);
}
}
Expand All @@ -434,6 +442,14 @@ fn run_cmux_hook_codex(args: &[String]) -> ExitCode {
ExitCode::SUCCESS
}

fn clamped_codex_hook_apply_mode(hook_started: Instant) -> Option<cmux::ApplyMode> {
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<u64> {
let hard_timeout = Duration::from_secs(codex_hooks::HOOK_TIMEOUT_SECS);
let reserve = Duration::from_millis(CODEX_HOOK_STDOUT_RESERVE_MS);
Expand Down
281 changes: 281 additions & 0 deletions tests/oneline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 세그먼트와 함께 한 줄에 나타난다.
Expand All @@ -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(());

/// 프로세스 내에서 절대 충돌하지 않는 고유 토큰(`<pid>-<nanos>-<counter>`)을 만든다.
fn unique_token() -> String {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -655,13 +726,221 @@ 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)]
#[test]
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");
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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}"));
Expand Down
Loading