diff --git a/Cargo.lock b/Cargo.lock index 75378ab..335e6a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,7 +172,7 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "understatus" -version = "0.7.1" +version = "0.7.2" dependencies = [ "anyhow", "libc", diff --git a/Cargo.toml b/Cargo.toml index 8f13178..12d64e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "understatus" -version = "0.7.1" +version = "0.7.2" edition = "2021" description = "A calm, unobtrusive macOS statusline addon for AI coding CLIs (Claude Code): CPU/memory/disk/network + session info with a quiet glyph theme." # macOS 전용 크레이트 (host_processor_info / sysctl / IOKit FFI 의존) diff --git a/README.md b/README.md index 1f01a28..7eddc3d 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,8 @@ understatus binary (new process per call — no daemon, no state files, no lock |-----|--------|-------| | **Claude Code** | ✅ Full support | Custom `statusLine.command`, stdin JSON, `refreshInterval`, chain preservation, and Claude `rate_limits` rendering are supported. | | **lterm / cmux** | ✅ Native pill surface | `understatus render --source lterm --surface-format cmux-status` emits one-line JSON for cmux status pills. The plain lterm path can also use `--source lterm --oneline`. | -| **Codex CLI (standalone)** | ⚠️ Manual/tmux path | Codex has a built-in `[tui].status_line` item list, not a Claude-style command hook. For tmux or shell status integrations, feed a small JSON payload to `understatus render --source codex --oneline`; understatus then enriches from `~/.codex` on a best-effort basis. | +| **Codex CLI in cmux** | ✅ Sidebar pills via hooks | Codex still has a built-in `[tui].status_line` item list rather than a Claude-style command hook, so understatus pushes status to cmux's sidebar instead: `understatus cmux install-codex-hooks`, then trust the hook from Codex `/hooks`. | +| **Codex CLI (standalone)** | ⚠️ Manual/tmux path | Outside cmux, Codex remains manual/tmux: feed a small JSON payload to `understatus render --source codex --oneline`; understatus enriches from `~/.codex` on a best-effort basis. | | **Gemini CLI** | ⏳ Forward-looking | `/footer` and `/statusline` expose built-in items only; custom command-backed statuslines are not supported by understatus today. | Examples: @@ -312,10 +313,40 @@ printf '{"agent":"codex","cwd":"%s"}' "$PWD" | understatus render --source codex # lterm cmux native status pills (JSON, no ANSI) printf '{"source":"lterm","session":"codex","pane":"%%3","cwd":"%s","agent":"codex"}' "$PWD" \ | understatus render --source lterm --surface-format cmux-status + +# Codex inside cmux: install sidebar status hooks. +# After installing, open /hooks in Codex and trust the new understatus hook definitions. +understatus cmux install-codex-hooks + +# Manual/debug cmux apply without installing hooks. +printf '{"agent":"codex","cwd":"%s"}' "$PWD" | understatus cmux apply-codex --workspace "$CMUX_WORKSPACE_ID" ``` For tmux status bars, prefer `color.mode = "none"` and let tmux apply its own `#[fg=...]` styling if raw ANSI SGR is not desired. +### Codex + cmux sidebar hooks + +`understatus cmux install-codex-hooks` non-destructively merges understatus command hooks into +`~/.codex/hooks.json`; existing hooks such as OMX are preserved. Codex treats user hooks as +non-managed hooks, so new or changed hook definitions must be reviewed/trusted from Codex `/hooks` +before they run. + +The hook target is `understatus cmux hook-codex`. It is intentionally quiet and fail-soft: + +- it emits neutral JSON (`{}`) so Codex hook stdout does not add prompt context or break `Stop`; +- every installed hook handler has an explicit short timeout (`5` seconds); +- 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; +- 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. + +Uninstall with: + +```bash +understatus cmux uninstall-codex-hooks +``` + --- ## Platform @@ -346,7 +377,8 @@ macOS용 AI 코딩 CLI statusline 애드온입니다. CPU%, 메모리, 배터리 - **비파괴 설치** — 기존 `statusLine.command`를 체이닝으로 보존하고 정확히 복원합니다. - **세션 캐시 격리** — 체인 출력·펄스 상태·네트워크 델타 캐시는 `session_id`별로 분리되어 여러 터미널을 동시에 열어도 값이 섞이지 않습니다. 배터리는 머신 전역. - **Claude rate-limit 표시** — Claude Code stdin에 `rate_limits`가 들어오면 5h/weekly 사용률과 리셋 카운트다운을 네트워크 호출 없이 표시합니다. -- **lterm/cmux·Codex 경로** — `--source lterm --surface-format cmux-status`는 cmux pill JSON을 출력하고, `--source codex --oneline`은 tmux나 셸 status 연동 환경에서 `~/.codex`를 best-effort로 보강합니다. +- **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`는 쓰지 않습니다. **설치** diff --git a/homebrew/README.md b/homebrew/README.md index 5aec52b..fb12e38 100644 --- a/homebrew/README.md +++ b/homebrew/README.md @@ -1,111 +1,93 @@ # understatus Homebrew Formula -This directory contains the Homebrew formula for understatus, staged here in the main repository. The actual tap that users install from is a **separate repository**: `ictechgy/homebrew-understatus`. - ---- - -## Directory layout +This directory stages the Homebrew formula for understatus. Users install from +the separate tap repository: -``` -homebrew/ - understatus.rb <- the formula (staged here; must be copied to the tap repo) - README.md <- this file +```text +ictechgy/homebrew-understatus ``` -The tap repo expects the formula at: +The tap expects the formula at: -``` +```text Formula/understatus.rb ``` --- -## Step-by-step: publishing a release - -### 1. Push the version tag - -Ensure `Cargo.toml` has `version = "0.1.0"`, then push the tag that triggers the release workflow: - -```sh -git tag v0.1.0 -git push origin v0.1.0 -``` - -The `.github/workflows/release.yml` workflow builds two release assets on GitHub Actions: - -| Asset | Runner | -|---|---| -| `understatus-0.1.0-aarch64-apple-darwin.tar.gz` | `macos-14` (Apple Silicon) | -| `understatus-0.1.0-x86_64-apple-darwin.tar.gz` | `macos-13` (Intel) | +## Current formula shape -Each tarball contains a single executable named `understatus` at its root. +`homebrew/understatus.rb` installs **prebuilt macOS binaries** from GitHub +Releases: -### 2. Obtain the SHA-256 of the source tarball +- Apple Silicon: `aarch64-apple-darwin` +- Intel: `x86_64-apple-darwin` -After the tag is pushed (and GitHub has processed it), download the auto-generated source tarball and hash it: +The formula does not build from source and does not require a Rust toolchain at +install time. It is macOS-only because understatus uses macOS system APIs for +CPU, memory, battery, disk, and network metrics. -```sh -curl -L https://github.com/ictechgy/understatus/archive/refs/tags/v0.1.0.tar.gz \ - | shasum -a 256 -``` +--- -This prints a line like: +## Publishing a release -``` -a3f2...dead - -``` +1. Ensure all versioned files agree: -Copy the hex digest (the part before the two spaces). + - `Cargo.toml` + - `Cargo.lock` if dependency metadata changed + - `npm/package.json` + - `homebrew/understatus.rb` -### 3. Fill in the sha256 placeholder +2. Tag and push the release: -Open `homebrew/understatus.rb` and replace: + ```sh + git tag vX.Y.Z + git push origin vX.Y.Z + ``` -```ruby -sha256 "REPLACE_WITH_SOURCE_TARBALL_SHA256" -``` +3. Wait for `.github/workflows/release.yml` to publish both tarballs: -with the actual digest: + ```text + understatus-X.Y.Z-aarch64-apple-darwin.tar.gz + understatus-X.Y.Z-x86_64-apple-darwin.tar.gz + ``` -```ruby -sha256 "a3f2...dead" -``` +4. Compute SHA-256 checksums: -### 4. Copy the formula to the tap repository + ```sh + curl -L https://github.com/ictechgy/understatus/releases/download/vX.Y.Z/understatus-X.Y.Z-aarch64-apple-darwin.tar.gz \ + | shasum -a 256 -The tap repository must exist at `ictechgy/homebrew-understatus` on GitHub (create it if it does not yet exist). Copy the updated formula into it: + curl -L https://github.com/ictechgy/understatus/releases/download/vX.Y.Z/understatus-X.Y.Z-x86_64-apple-darwin.tar.gz \ + | shasum -a 256 + ``` -```sh -# From the understatus repo root -cp homebrew/understatus.rb /path/to/homebrew-understatus/Formula/understatus.rb +5. Update `homebrew/understatus.rb` with the new URLs and checksums. -cd /path/to/homebrew-understatus -git add Formula/understatus.rb -git commit -m "feat: add understatus formula v0.1.0" -git push origin main -``` - -### 5. Verify the tap works +6. Copy the formula into the tap repository: -```sh -brew tap ictechgy/understatus -brew install ictechgy/understatus/understatus -understatus --version -``` - ---- + ```sh + cp homebrew/understatus.rb /path/to/homebrew-understatus/Formula/understatus.rb + cd /path/to/homebrew-understatus + git add Formula/understatus.rb + git commit -m "Update understatus to vX.Y.Z" + git push origin main + ``` -## For subsequent releases (e.g. v0.2.0) +7. Verify the tap: -1. Bump `version` in `Cargo.toml` to `0.2.0`. -2. Update the `url` and `sha256` lines in `homebrew/understatus.rb` (new tag URL + new sha256). -3. Repeat steps 1-5 above with the new version number. + ```sh + brew tap ictechgy/understatus + brew reinstall ictechgy/understatus/understatus + understatus --version + ``` --- ## Formula design notes -- **Builds from source** using `cargo install` via Homebrew's `std_cargo_args`. No prebuilt binary is bundled in this formula. -- **macOS only**: the formula declares `depends_on :macos` because understatus uses macOS-exclusive APIs (`host_processor_info`, `sysctl`, IOKit FFI). -- **Rust** is listed as a build-time dependency (`depends_on "rust" => :build`) and is not required at runtime. -- The smoke test (`brew test understatus`) runs `understatus --version` and asserts the string `understatus` appears in the output. +- The release tarball contains a single executable named `understatus`. +- `bin.install "understatus"` installs that executable directly. +- `depends_on :macos` documents the macOS-only runtime boundary. +- `brew test understatus` checks that the installed binary reports the expected + crate name. diff --git a/npm/README.md b/npm/README.md index f7a0ff3..3bc58d1 100644 --- a/npm/README.md +++ b/npm/README.md @@ -1,8 +1,8 @@ # understatus -Claude Code용 macOS statusline 애드온. +Claude Code와 Codex/cmux를 위한 macOS statusline 애드온입니다. -CPU, 메모리, 세션 정보를 calm glyph 테마(○▁▄▆◆)로 표시합니다. +CPU, 메모리, git/세션 정보를 calm glyph 테마(○▁▄▆◆)와 cmux sidebar pill로 조용하게 표시합니다. ## 설치 @@ -23,12 +23,24 @@ understatus install # Claude Code settings.json에서 제거 understatus uninstall + +# Codex를 cmux 안에서 쓸 때 왼쪽 sidebar pill hook 설치 +understatus cmux install-codex-hooks + +# Codex hook 제거 +understatus cmux uninstall-codex-hooks ``` +Codex hook은 `~/.codex/hooks.json`에 비파괴적으로 병합됩니다. Codex가 non-managed hook +승인을 요구하면 `/hooks`에서 understatus hook을 review/trust 해야 자동 갱신됩니다. +hook stdout은 항상 neutral JSON(`{}`)이며, cmux에는 `set-status`/`clear-status`만 사용합니다. +전역 progress 충돌을 피하기 위해 `set-progress`는 사용하지 않습니다. + ## 요구 사항 - macOS (arm64 / x64) - Node.js >= 16 +- Codex/cmux sidebar 연동을 쓰려면 로컬 `cmux` CLI와 Codex hooks 지원이 필요합니다. ## 링크 diff --git a/npm/install.js b/npm/install.js index a92ce19..5711c97 100644 --- a/npm/install.js +++ b/npm/install.js @@ -22,7 +22,7 @@ const os = require('os'); // 네이티브 바이너리를 받아오는 GitHub 릴리스 태그. // release/publish guard가 Cargo.toml, npm/package.json, 이 값, git tag를 lockstep으로 // 검증합니다. 새 네이티브 릴리스를 낼 때 네 버전을 함께 올리세요. -const VERSION = '0.7.1'; +const VERSION = '0.7.2'; // CPU 아키텍처를 Rust 타겟 트리플로 매핑 const archMap = { diff --git a/npm/package.json b/npm/package.json index 06656ec..96f59e4 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "understatus", - "version": "0.7.1", + "version": "0.7.2", "description": "Claude Code statusline addon for macOS — CPU, memory, session info with a calm glyph theme", "bin": { "understatus": "bin/understatus.js" diff --git a/src/cmux.rs b/src/cmux.rs new file mode 100644 index 0000000..40f7f59 --- /dev/null +++ b/src/cmux.rs @@ -0,0 +1,572 @@ +//! CMUX sidebar status-pill applier for Codex/understatus. +//! +//! This module intentionally stays one layer below rendering: `render.rs` still owns the +//! understatus → pill mapping, while this module owns CMUX target resolution, namespaced keys, +//! stale-key diffing, and `cmux set-status` / `clear-status` command execution. + +use std::collections::BTreeSet; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::chain; +use crate::render::{CmuxStatusOutput, Pill}; + +/// Session cache file containing the last keys applied by the Codex/CMUX bridge. +const APPLIED_KEYS_CACHE: &str = "cmux_codex_applied_keys"; + +/// Default hard timeout for a single manual `cmux` CLI invocation. +pub(crate) const DEFAULT_CMUX_TIMEOUT_MS: u64 = 5_000; +/// Hook-mode per-command timeout. Installed Codex hooks use a 5s total timeout, so one +/// slow `cmux` call must not consume the whole hook budget. +const HOOK_CMUX_COMMAND_TIMEOUT_MS: u64 = 750; +/// Hook-mode total CMUX execution budget. Leaves time for render/JSON/stdout before Codex's +/// installed 5s hook timeout kills the process. +const HOOK_CMUX_TOTAL_TIMEOUT_MS: u64 = 3_500; +/// Manual focused-target probe should not hang before the apply timeout can run. +const IDENTIFY_FOCUSED_TIMEOUT_MS: u64 = 1_000; + +/// CMUX workspace/window target. Empty target is allowed in manual mode (cmux CLI default), +/// but hook mode requires a caller/env target and will no-op if both fields are absent. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct CmuxTarget { + pub(crate) workspace: Option, + pub(crate) window: Option, +} + +impl CmuxTarget { + pub(crate) fn from_env() -> Self { + Self { + workspace: env_nonempty("CMUX_WORKSPACE_ID"), + // CMUX docs expose --window and recent CLI support; keep multiple env aliases harmlessly. + window: env_nonempty("CMUX_WINDOW_ID").or_else(|| env_nonempty("CMUX_WINDOW")), + } + } + + pub(crate) fn has_caller_target(&self) -> bool { + self.workspace.is_some() || self.window.is_some() + } + + fn append_target_args(&self, args: &mut Vec) { + if let Some(workspace) = &self.workspace { + args.push("--workspace".to_string()); + args.push(workspace.clone()); + } + if let Some(window) = &self.window { + args.push("--window".to_string()); + args.push(window.clone()); + } + } +} + +/// Runtime mode for applying CMUX commands. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ApplyMode { + /// Hook mode is fail-soft and must not apply to focused fallback targets. + pub(crate) hook: bool, + /// Suppress cmux stderr/stdout when true. + pub(crate) quiet: bool, + /// Per-command timeout in milliseconds. + pub(crate) timeout_ms: u64, + /// Optional total command-execution budget in milliseconds. + pub(crate) total_timeout_ms: Option, +} + +impl ApplyMode { + pub(crate) fn hook() -> Self { + Self { + hook: true, + quiet: true, + timeout_ms: HOOK_CMUX_COMMAND_TIMEOUT_MS, + total_timeout_ms: Some(HOOK_CMUX_TOTAL_TIMEOUT_MS), + } + } + + pub(crate) fn manual(quiet: bool) -> Self { + Self { + hook: false, + quiet, + timeout_ms: DEFAULT_CMUX_TIMEOUT_MS, + total_timeout_ms: None, + } + } +} + +/// Planned `cmux` command. Kept explicit so tests can assert no `set-progress` is ever planned. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CmuxCommand { + pub(crate) args: Vec, +} + +impl CmuxCommand { + fn new(args: Vec) -> Self { + Self { args } + } +} + +/// Outcome of an apply attempt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ApplyReport { + pub(crate) target_resolved: bool, + pub(crate) set_count: usize, + pub(crate) clear_count: usize, + pub(crate) command_success: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +struct AppliedState { + target: CmuxTarget, + keys: Vec, +} + +fn env_nonempty(name: &str) -> Option { + std::env::var(name).ok().filter(|value| !value.is_empty()) +} + +/// Derive a stable namespaced CMUX key for one understatus pill. +pub(crate) fn namespaced_key(owner: &str, pill_key: &str) -> String { + format!( + "understatus.codex.{}.{}", + chain::sanitize_session_key(owner), + chain::sanitize_session_key(pill_key) + ) +} + +/// Resolve target for hook/manual mode. In hook mode this never falls back to a focused workspace. +pub(crate) fn resolve_target(explicit: CmuxTarget, hook_mode: bool) -> CmuxTarget { + let env_target = CmuxTarget::from_env(); + let target = CmuxTarget { + workspace: explicit.workspace.or(env_target.workspace), + window: explicit.window.or(env_target.window), + }; + if hook_mode || target.has_caller_target() { + return target; + } + identify_focused_target().unwrap_or_default() +} + +/// Best-effort focused target for manual/debug mode only. +fn identify_focused_target() -> Option { + let output = command_output_with_timeout( + Command::new("cmux") + .args(["identify", "--json"]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .stdout(Stdio::piped()), + IDENTIFY_FOCUSED_TIMEOUT_MS, + )?; + if !output.status.success() { + return None; + } + let value: Value = serde_json::from_slice(&output.stdout).ok()?; + Some(CmuxTarget { + workspace: find_json_string(&value, &["workspace_id", "workspaceId", "workspace"]), + window: find_json_string(&value, &["window_id", "windowId", "window"]), + }) + .filter(CmuxTarget::has_caller_target) +} + +fn command_output_with_timeout( + command: &mut Command, + timeout_ms: u64, +) -> Option { + let mut child = command.spawn().ok()?; + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + loop { + match child.try_wait() { + Ok(Some(_)) => return child.wait_with_output().ok(), + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(_) => return None, + } + } +} + +fn find_json_string(value: &Value, names: &[&str]) -> Option { + match value { + Value::Object(map) => { + for name in names { + if let Some(found) = map.get(*name).and_then(Value::as_str) { + if !found.is_empty() { + return Some(found.to_string()); + } + } + } + map.values() + .find_map(|child| find_json_string(child, names)) + } + Value::Array(items) => items + .iter() + .find_map(|child| find_json_string(child, names)), + _ => None, + } +} + +/// Apply current pill output to CMUX, clearing stale keys from the previous successful apply. +pub(crate) fn apply_pills( + output: &CmuxStatusOutput, + owner_raw: &str, + target: CmuxTarget, + mode: ApplyMode, +) -> ApplyReport { + let owner = chain::sanitize_session_key(owner_raw); + if mode.hook && !target.has_caller_target() { + return ApplyReport { + target_resolved: false, + set_count: 0, + clear_count: 0, + command_success: true, + }; + } + + let previous = read_applied_state(&owner); + let current_keys: Vec = output + .pills + .iter() + .map(|pill| namespaced_key(&owner, &pill.key)) + .collect(); + let (clear_commands, set_commands) = + plan_apply_commands(output, &owner, &target, previous.as_ref()); + let clear_count = clear_commands.len(); + let set_count = set_commands.len(); + let mut all_commands = clear_commands; + all_commands.extend(set_commands); + + let command_success = execute_commands(&all_commands, mode); + if command_success { + write_applied_state( + &owner, + &AppliedState { + target: target.clone(), + keys: current_keys, + }, + ); + } + + ApplyReport { + target_resolved: true, + set_count, + clear_count, + command_success, + } +} + +/// Clear all keys previously applied for this owner. +pub(crate) fn clear_owner( + owner_raw: &str, + target_override: CmuxTarget, + mode: ApplyMode, +) -> ApplyReport { + let owner = chain::sanitize_session_key(owner_raw); + let previous = read_applied_state(&owner); + let target = if target_override.has_caller_target() { + target_override + } else { + previous + .as_ref() + .map(|state| state.target.clone()) + .unwrap_or_default() + }; + if mode.hook && !target.has_caller_target() { + return ApplyReport { + target_resolved: false, + set_count: 0, + clear_count: 0, + command_success: true, + }; + } + let keys = previous.map(|state| state.keys).unwrap_or_default(); + let commands: Vec = keys + .iter() + .map(|key| plan_clear_status(key, &target)) + .collect(); + let clear_count = commands.len(); + let command_success = execute_commands(&commands, mode); + if command_success { + write_applied_state( + &owner, + &AppliedState { + target, + keys: Vec::new(), + }, + ); + } + ApplyReport { + target_resolved: true, + set_count: 0, + clear_count, + command_success, + } +} + +fn plan_apply_commands( + output: &CmuxStatusOutput, + owner: &str, + target: &CmuxTarget, + previous: Option<&AppliedState>, +) -> (Vec, Vec) { + let current: BTreeSet = output + .pills + .iter() + .map(|pill| namespaced_key(owner, &pill.key)) + .collect(); + let mut clear = Vec::new(); + if let Some(previous) = previous { + let previous_keys: BTreeSet = previous.keys.iter().cloned().collect(); + let stale: Vec = if previous.target == *target { + previous_keys.difference(¤t).cloned().collect() + } else { + previous_keys.into_iter().collect() + }; + for key in stale { + clear.push(plan_clear_status(&key, &previous.target)); + } + } + let set = output + .pills + .iter() + .map(|pill| plan_set_status(owner, pill, target)) + .collect(); + (clear, set) +} + +fn plan_set_status(owner: &str, pill: &Pill, target: &CmuxTarget) -> CmuxCommand { + let mut args = vec![ + "set-status".to_string(), + namespaced_key(owner, &pill.key), + pill.value.clone(), + ]; + target.append_target_args(&mut args); + if let Some(icon) = &pill.icon { + args.push("--icon".to_string()); + args.push(icon.clone()); + } + if let Some(color) = &pill.color { + args.push("--color".to_string()); + args.push(color.clone()); + } + args.push("--priority".to_string()); + args.push(pill.priority.to_string()); + CmuxCommand::new(args) +} + +fn plan_clear_status(key: &str, target: &CmuxTarget) -> CmuxCommand { + let mut args = vec!["clear-status".to_string(), key.to_string()]; + target.append_target_args(&mut args); + CmuxCommand::new(args) +} + +fn execute_commands(commands: &[CmuxCommand], mode: ApplyMode) -> bool { + let total_deadline = mode + .total_timeout_ms + .map(|timeout| Instant::now() + Duration::from_millis(timeout)); + for command in commands { + if command.args.iter().any(|arg| arg == "set-progress") { + return false; + } + let timeout_ms = match remaining_timeout_ms(total_deadline, mode.timeout_ms) { + Some(timeout) => timeout, + None => return false, + }; + if !execute_one(command, mode, timeout_ms) { + return false; + } + } + true +} + +fn remaining_timeout_ms(total_deadline: Option, per_command_ms: u64) -> Option { + let Some(deadline) = total_deadline else { + return Some(per_command_ms); + }; + let remaining = deadline.checked_duration_since(Instant::now())?; + let remaining_ms = remaining.as_millis().try_into().unwrap_or(u64::MAX); + Some(per_command_ms.min(remaining_ms).max(1)) +} + +fn execute_one(command: &CmuxCommand, mode: ApplyMode, timeout_ms: u64) -> bool { + let mut process = Command::new("cmux"); + process.args(&command.args).stdin(Stdio::null()); + if mode.quiet { + process.stdout(Stdio::null()).stderr(Stdio::null()); + } + let mut child = match process.spawn() { + Ok(child) => child, + Err(_) => return false, + }; + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + loop { + match child.try_wait() { + Ok(Some(status)) => return status.success(), + Ok(None) => { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return false; + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(_) => return false, + } + } +} + +fn read_applied_state(owner: &str) -> Option { + chain::read_session_named_cache(owner, APPLIED_KEYS_CACHE) + .and_then(|(_, payload)| serde_json::from_str(&payload).ok()) +} + +fn write_applied_state(owner: &str, state: &AppliedState) { + if let Ok(payload) = serde_json::to_string(state) { + chain::write_session_named_cache( + owner, + APPLIED_KEYS_CACHE, + chain::cache_now_millis(), + &payload, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pill(key: &str, value: &str) -> Pill { + Pill { + key: key.to_string(), + label: None, + value: value.to_string(), + color: Some("#AABBCC".to_string()), + icon: Some("sparkles".to_string()), + priority: 42, + } + } + + fn output(keys: &[(&str, &str)]) -> CmuxStatusOutput { + CmuxStatusOutput { + schema: "cmux-status", + version: 1, + pills: keys.iter().map(|(key, value)| pill(key, value)).collect(), + } + } + + #[test] + fn namespaced_key_sanitizes_owner_and_pill() { + let key = namespaced_key("../bad owner", "ctx/%"); + assert!(key.starts_with("understatus.codex.")); + assert!(!key.contains('/')); + assert!(!key.contains(' ')); + } + + #[test] + fn plan_set_status_uses_supported_status_command_only() { + let target = CmuxTarget { + workspace: Some("workspace:1".to_string()), + window: Some("window:2".to_string()), + }; + let (_, set) = + plan_apply_commands(&output(&[("model", "gpt-5.5")]), "owner", &target, None); + assert_eq!(set.len(), 1); + assert_eq!(set[0].args[0], "set-status"); + assert!(set[0].args.contains(&"--workspace".to_string())); + assert!(set[0].args.contains(&"--window".to_string())); + assert!(!set[0].args.iter().any(|arg| arg == "set-progress")); + } + + #[test] + fn hook_mode_uses_short_per_command_and_total_budget() { + let mode = ApplyMode::hook(); + assert!(mode.timeout_ms < DEFAULT_CMUX_TIMEOUT_MS); + assert_eq!(mode.total_timeout_ms, Some(HOOK_CMUX_TOTAL_TIMEOUT_MS)); + assert!(mode.total_timeout_ms.expect("hook total timeout") < 5_000); + } + + #[test] + fn remaining_timeout_respects_total_deadline() { + let deadline = Some(Instant::now() + Duration::from_millis(25)); + let timeout = remaining_timeout_ms(deadline, 750).expect("remaining budget"); + assert!(timeout <= 25); + assert!(timeout >= 1); + assert_eq!(remaining_timeout_ms(None, 750), Some(750)); + } + + #[test] + fn plan_clears_missing_previous_pills() { + let target = CmuxTarget { + workspace: Some("workspace:1".to_string()), + window: None, + }; + let previous = AppliedState { + target: target.clone(), + keys: vec![ + namespaced_key("owner", "model"), + namespaced_key("owner", "ctx"), + ], + }; + let (clear, set) = plan_apply_commands( + &output(&[("model", "gpt-5.5")]), + "owner", + &target, + Some(&previous), + ); + assert_eq!(set.len(), 1); + assert_eq!(clear.len(), 1); + assert_eq!(clear[0].args[0], "clear-status"); + assert!(clear[0].args[1].ends_with(".ctx")); + } + + #[test] + fn hook_mode_without_target_noops() { + let report = apply_pills( + &output(&[("model", "gpt-5.5")]), + "owner", + CmuxTarget::default(), + ApplyMode::hook(), + ); + assert!(!report.target_resolved); + assert_eq!(report.set_count, 0); + assert!(report.command_success); + } + + #[test] + fn parse_nested_identify_target() { + let value: Value = serde_json::json!({ + "result": { + "workspace": {"workspace_id": "workspace:2"}, + "window": {"windowId": "window:3"} + } + }); + assert_eq!( + find_json_string(&value, &["workspace_id", "workspaceId"]), + Some("workspace:2".to_string()) + ); + assert_eq!( + find_json_string(&value, &["window_id", "windowId"]), + Some("window:3".to_string()) + ); + } + + #[test] + fn command_output_with_timeout_returns_none_for_slow_command() { + let started = Instant::now(); + let result = command_output_with_timeout( + Command::new("sh") + .arg("-c") + .arg("sleep 1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()), + 20, + ); + assert!(result.is_none()); + assert!(started.elapsed() < Duration::from_millis(500)); + } +} diff --git a/src/codex_hooks.rs b/src/codex_hooks.rs new file mode 100644 index 0000000..7fe58aa --- /dev/null +++ b/src/codex_hooks.rs @@ -0,0 +1,649 @@ +//! Codex hook normalization and non-destructive `~/.codex/hooks.json` merge helpers. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; +use serde_json::{json, Map, Value}; + +use crate::cmux::CmuxTarget; + +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + +/// Stable substring used to identify understatus-managed Codex hooks. +pub(crate) const HOOK_COMMAND_SENTINEL: &str = "cmux hook-codex"; +/// Every Codex hook entry must set an explicit short timeout. +pub(crate) const HOOK_TIMEOUT_SECS: u64 = 5; + +const EVENTS: &[&str] = &["SessionStart", "UserPromptSubmit", "Stop", "PostCompact"]; + +/// Minimal normalized view of Codex hook stdin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HookInput { + pub(crate) hook_event_name: String, + pub(crate) session_id: Option, + pub(crate) turn_id: Option, + pub(crate) cwd: String, +} + +/// Parse Codex hook stdin. Invalid/partial JSON degrades to cwd-only input. +pub(crate) fn parse_hook_input(raw: &str, fallback_cwd: &str) -> HookInput { + let value: Option = serde_json::from_str(raw).ok(); + let get = |name: &str| -> Option { + value + .as_ref() + .and_then(|root| root.get(name)) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(str::to_string) + }; + HookInput { + hook_event_name: get("hook_event_name").unwrap_or_else(|| "Unknown".to_string()), + session_id: get("session_id"), + turn_id: get("turn_id"), + cwd: get("cwd").unwrap_or_else(|| fallback_cwd.to_string()), + } +} + +/// Convert Codex hook input into the lterm/Codex-compatible render payload consumed by +/// `--source codex`. +pub(crate) fn render_payload( + input: &HookInput, + target: &CmuxTarget, + surface_id: Option<&str>, +) -> String { + let mut root = Map::new(); + root.insert("agent".to_string(), json!("codex")); + root.insert("cwd".to_string(), json!(input.cwd)); + let owner = owner_key(input, target, surface_id); + root.insert("session".to_string(), json!(owner)); + if let Some(session_id) = &input.session_id { + root.insert("session_id".to_string(), json!(session_id)); + } + if let Some(surface) = surface_id.filter(|surface| !surface.is_empty()) { + root.insert("surface".to_string(), json!(surface)); + } + if let Some(workspace) = &target.workspace { + root.insert("workspace".to_string(), json!(workspace)); + } + serde_json::to_string(&Value::Object(root)) + .unwrap_or_else(|_| "{\"agent\":\"codex\"}".to_string()) +} + +/// Stable owner key for CMUX status namespaces and understatus cache state. +pub(crate) fn owner_key( + input: &HookInput, + target: &CmuxTarget, + surface_id: Option<&str>, +) -> String { + if let Some(session_id) = &input.session_id { + return session_id.clone(); + } + let surface = surface_id.filter(|surface| !surface.is_empty()); + if let Some(surface) = surface { + if target.workspace.is_some() || target.window.is_some() { + return format!( + "{}-{}-{}", + surface, + target.workspace.as_deref().unwrap_or("workspace"), + target.window.as_deref().unwrap_or("window") + ); + } + return surface.to_string(); + } + if let Some(turn_id) = &input.turn_id { + return turn_id.clone(); + } + format!( + "{}-{}-{}", + input.cwd, + target.workspace.as_deref().unwrap_or("workspace"), + target.window.as_deref().unwrap_or("window") + ) +} + +/// Neutral success stdout for Codex hooks. JSON is safe for `Stop` and inert for the other MVP events. +pub(crate) fn neutral_hook_stdout() -> &'static str { + "{}" +} + +pub(crate) fn hooks_json_path() -> Result { + let base = std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".codex"))) + .ok_or_else(|| anyhow!("HOME/CODEX_HOME 환경변수를 찾을 수 없습니다"))?; + Ok(base.join("hooks.json")) +} + +pub(crate) fn hook_command_for_current_exe() -> Result { + if let Some(command) = std::env::var("UNDERSTATUS_CODEX_HOOK_COMMAND") + .ok() + .filter(|value| !value.trim().is_empty()) + { + return Ok(command); + } + let invocation = std::env::args_os() + .next() + .map(PathBuf::from) + .or_else(|| std::env::current_exe().ok()) + .ok_or_else(|| anyhow!("현재 실행 바이너리 경로 확인 실패"))?; + let stable_path = stable_invocation_path(invocation); + let path = stable_path + .to_str() + .ok_or_else(|| anyhow!("바이너리 경로에 비-UTF8 문자가 포함되어 있습니다"))?; + Ok(format!("{} cmux hook-codex", shell_quote(path))) +} + +pub(crate) fn install_user_hooks() -> Result { + let path = hooks_json_path()?; + let command = hook_command_for_current_exe()?; + let mut config = read_hooks_json(&path)?; + install_understatus_hooks_config(&mut config, &command); + write_hooks_json(&path, &config)?; + Ok(path) +} + +pub(crate) fn uninstall_user_hooks() -> Result { + let path = hooks_json_path()?; + let mut config = read_hooks_json(&path)?; + uninstall_understatus_hooks_config(&mut config); + write_hooks_json(&path, &config)?; + Ok(path) +} + +fn read_hooks_json(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(contents) => serde_json::from_str(&contents) + .with_context(|| format!("hooks.json 파싱 실패: {}", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(json!({ "hooks": {} })), + Err(error) => { + Err(error).with_context(|| format!("hooks.json 읽기 실패: {}", path.display())) + } + } +} + +fn write_hooks_json(path: &Path, config: &Value) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Codex 설정 디렉터리 생성 실패: {}", parent.display()))?; + } + let mut text = serde_json::to_string_pretty(config).context("hooks.json 직렬화 실패")?; + text.push('\n'); + let tmp = unique_tmp_path(path); + let existing_permissions = match std::fs::metadata(path) { + Ok(metadata) => Some(metadata.permissions()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(error).with_context(|| { + format!("기존 hooks.json metadata 확인 실패: {}", path.display()) + }); + } + }; + write_tmp_hooks_file(&tmp, text.as_bytes(), existing_permissions.as_ref())?; + std::fs::rename(&tmp, path) + .with_context(|| format!("hooks.json 교체 실패: {}", path.display()))?; + Ok(()) +} + +fn write_tmp_hooks_file( + tmp: &Path, + contents: &[u8], + existing_permissions: Option<&std::fs::Permissions>, +) -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + let mode = existing_permissions + .map(PermissionsExt::mode) + .unwrap_or(0o600); + options.mode(mode); + } + let mut file = options + .open(tmp) + .with_context(|| format!("hooks.json 임시 파일 생성 실패: {}", tmp.display()))?; + file.write_all(contents) + .with_context(|| format!("hooks.json 임시 파일 쓰기 실패: {}", tmp.display()))?; + file.sync_all() + .with_context(|| format!("hooks.json 임시 파일 fsync 실패: {}", tmp.display()))?; + if let Some(permissions) = existing_permissions.cloned() { + std::fs::set_permissions(tmp, permissions) + .with_context(|| format!("hooks.json 임시 파일 권한 복사 실패: {}", tmp.display()))?; + } + Ok(()) +} + +/// Pure config transform used by install and tests. +pub(crate) fn install_understatus_hooks_config(config: &mut Value, command: &str) { + let hooks = ensure_hooks_object(config); + for event in EVENTS { + remove_understatus_from_event(hooks, event); + let entry = hooks + .entry((*event).to_string()) + .or_insert_with(|| Value::Array(Vec::new())); + if !entry.is_array() { + *entry = Value::Array(Vec::new()); + } + entry + .as_array_mut() + .expect("event hooks normalized to array") + .push(hook_group(event, command)); + } +} + +/// Pure config transform used by uninstall and tests. +pub(crate) fn uninstall_understatus_hooks_config(config: &mut Value) { + let hooks = ensure_hooks_object(config); + for event in EVENTS { + remove_understatus_from_event(hooks, event); + let remove_event = hooks + .get(*event) + .and_then(Value::as_array) + .map(|items| items.is_empty()) + .unwrap_or(false); + if remove_event { + hooks.remove(*event); + } + } +} + +fn ensure_hooks_object(config: &mut Value) -> &mut Map { + if !config.is_object() { + *config = json!({ "hooks": {} }); + } + let root = config.as_object_mut().expect("config object"); + let mut migrated_events = Map::new(); + for event in EVENTS { + if let Some(value) = root.remove(*event) { + migrated_events.insert((*event).to_string(), value); + } + } + let hooks = root + .entry("hooks".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + if !hooks.is_object() { + *hooks = Value::Object(Map::new()); + } + let hooks = hooks.as_object_mut().expect("hooks object"); + for (event, value) in migrated_events { + match hooks.entry(event) { + serde_json::map::Entry::Vacant(entry) => { + entry.insert(value); + } + serde_json::map::Entry::Occupied(mut entry) => { + merge_hook_event_values(entry.get_mut(), value); + } + } + } + hooks +} + +fn merge_hook_event_values(existing: &mut Value, migrated: Value) { + match (existing, migrated) { + (Value::Array(existing_items), Value::Array(mut migrated_items)) => { + existing_items.append(&mut migrated_items); + } + (Value::Array(existing_items), migrated) => { + existing_items.push(migrated); + } + (existing, Value::Array(mut migrated_items)) => { + let previous = std::mem::take(existing); + migrated_items.insert(0, previous); + *existing = Value::Array(migrated_items); + } + (existing, migrated) => { + let previous = std::mem::take(existing); + *existing = Value::Array(vec![previous, migrated]); + } + } +} + +fn hook_group(event: &str, command: &str) -> Value { + let handler = json!({ + "type": "command", + "command": command, + "timeout": HOOK_TIMEOUT_SECS, + "statusMessage": "Updating CMUX understatus" + }); + match event { + "SessionStart" => json!({ "matcher": "startup|resume|clear|compact", "hooks": [handler] }), + "PostCompact" => json!({ "matcher": "manual|auto", "hooks": [handler] }), + _ => json!({ "hooks": [handler] }), + } +} + +fn remove_understatus_from_event(hooks: &mut Map, event: &str) { + let Some(groups) = hooks.get_mut(event).and_then(Value::as_array_mut) else { + return; + }; + for group in groups.iter_mut() { + if let Some(handlers) = group.get_mut("hooks").and_then(Value::as_array_mut) { + handlers.retain(|handler| { + !handler + .get("command") + .and_then(Value::as_str) + .is_some_and(is_understatus_hook_command) + }); + } + } + groups.retain(|group| { + group + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|handlers| !handlers.is_empty()) + }); +} + +fn is_understatus_hook_command(command: &str) -> bool { + command.contains(HOOK_COMMAND_SENTINEL) && command.contains("understatus") +} + +fn shell_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +fn stable_invocation_path(invocation: PathBuf) -> PathBuf { + let current_dir = std::env::current_dir().ok(); + stable_invocation_path_with_env( + invocation, + std::env::var_os("PATH").as_deref(), + current_dir.as_deref(), + ) + .or_else(|| std::env::current_exe().ok()) + .unwrap_or_else(|| PathBuf::from("understatus")) +} + +fn stable_invocation_path_with_env( + invocation: PathBuf, + path_env: Option<&std::ffi::OsStr>, + current_dir: Option<&Path>, +) -> Option { + if invocation.is_absolute() { + return Some(invocation); + } + let text = invocation.to_string_lossy(); + if !text.contains('/') && !text.contains('\\') { + if let Some(path_env) = path_env { + for dir in std::env::split_paths(path_env) { + let candidate = dir.join(&invocation); + if candidate.is_file() { + return Some(candidate); + } + } + } + return None; + } + current_dir.map(|cwd| cwd.join(&invocation)) +} + +fn unique_tmp_path(path: &Path) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("hooks.json"); + path.with_file_name(format!( + ".{file_name}.understatus.{}.{}.tmp", + std::process::id(), + nanos + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_hook_input_degrades_on_invalid_json() { + let input = parse_hook_input("{not json", "/tmp/proj"); + assert_eq!(input.cwd, "/tmp/proj"); + assert_eq!(input.hook_event_name, "Unknown"); + } + + #[test] + fn render_payload_maps_codex_hook_to_lterm_payload() { + let input = parse_hook_input( + r#"{"hook_event_name":"Stop","session_id":"sess-1","cwd":"/repo","turn_id":"turn-1"}"#, + "/fallback", + ); + let payload = render_payload( + &input, + &CmuxTarget { + workspace: Some("workspace:1".to_string()), + window: None, + }, + Some("surface-1"), + ); + let value: Value = serde_json::from_str(&payload).expect("payload json"); + assert_eq!(value["agent"], "codex"); + assert_eq!(value["cwd"], "/repo"); + assert_eq!(value["session_id"], "sess-1"); + assert_eq!(value["surface"], "surface-1"); + assert_eq!(value["workspace"], "workspace:1"); + } + + #[test] + fn owner_prefers_session_then_surface_target_then_turn() { + let target = CmuxTarget { + workspace: Some("workspace:1".to_string()), + window: None, + }; + let with_session = HookInput { + hook_event_name: "Stop".to_string(), + session_id: Some("session".to_string()), + turn_id: Some("turn".to_string()), + cwd: "/repo".to_string(), + }; + assert_eq!( + owner_key(&with_session, &target, Some("surface")), + "session" + ); + let without_session = HookInput { + session_id: None, + ..with_session + }; + assert_eq!( + owner_key(&without_session, &target, Some("surface")), + "surface-workspace:1-window" + ); + assert_eq!( + owner_key(&without_session, &CmuxTarget::default(), None), + "turn" + ); + } + + #[test] + fn install_preserves_existing_omx_hook_and_adds_timeout() { + let mut config = json!({ + "hooks": { + "Stop": [ + {"hooks": [{"type": "command", "command": "node codex-native-hook.js", "timeout": 30}]} + ] + } + }); + install_understatus_hooks_config(&mut config, "'/usr/bin/understatus' cmux hook-codex"); + let stop = config["hooks"]["Stop"].as_array().expect("Stop array"); + assert_eq!(stop.len(), 2); + assert_eq!(stop[0]["hooks"][0]["command"], "node codex-native-hook.js"); + let added = &stop[1]["hooks"][0]; + assert_eq!(added["timeout"], HOOK_TIMEOUT_SECS); + assert!(added["command"] + .as_str() + .expect("command") + .contains("cmux hook-codex")); + } + + #[test] + fn install_is_idempotent_and_uninstall_scoped() { + let command = "'/usr/bin/understatus' cmux hook-codex"; + let mut config = json!({ + "hooks": { + "UserPromptSubmit": [ + {"hooks": [{"type": "command", "command": "node codex-native-hook.js"}]} + ] + } + }); + install_understatus_hooks_config(&mut config, command); + install_understatus_hooks_config(&mut config, command); + let groups = config["hooks"]["UserPromptSubmit"] + .as_array() + .expect("groups"); + let understatus_count = groups + .iter() + .flat_map(|group| group["hooks"].as_array().into_iter().flatten()) + .filter(|hook| { + hook["command"] + .as_str() + .is_some_and(is_understatus_hook_command) + }) + .count(); + assert_eq!(understatus_count, 1); + + uninstall_understatus_hooks_config(&mut config); + let groups = config["hooks"]["UserPromptSubmit"] + .as_array() + .expect("groups"); + assert_eq!(groups.len(), 1); + assert_eq!( + groups[0]["hooks"][0]["command"], + "node codex-native-hook.js" + ); + } + + #[test] + fn install_normalizes_non_array_event_without_panic() { + let command = "'/usr/bin/understatus' cmux hook-codex"; + let mut config = json!({ + "hooks": { + "Stop": {"unexpected": "future-shape"} + } + }); + install_understatus_hooks_config(&mut config, command); + let groups = config["hooks"]["Stop"].as_array().expect("Stop array"); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0]["hooks"][0]["timeout"], HOOK_TIMEOUT_SECS); + } + + #[test] + fn install_creates_hooks_object_when_root_has_no_hooks_key() { + let command = "'/usr/bin/understatus' cmux hook-codex"; + let mut config = json!({"other": true}); + install_understatus_hooks_config(&mut config, command); + assert!(config["other"].as_bool().expect("preserve unrelated key")); + assert!(config["hooks"]["Stop"].is_array()); + assert!(config.get("Stop").is_none()); + } + + #[test] + fn install_migrates_legacy_top_level_event_into_hooks_object() { + let command = "'/usr/bin/understatus' cmux hook-codex"; + let mut config = json!({ + "Stop": [ + {"hooks": [{"type": "command", "command": "node codex-native-hook.js"}]} + ] + }); + install_understatus_hooks_config(&mut config, command); + assert!(config.get("Stop").is_none()); + let groups = config["hooks"]["Stop"].as_array().expect("Stop array"); + assert_eq!(groups.len(), 2); + assert_eq!( + groups[0]["hooks"][0]["command"], + "node codex-native-hook.js" + ); + } + + #[test] + fn install_merges_hooks_object_and_legacy_top_level_events() { + let command = "'/usr/bin/understatus' cmux hook-codex"; + let mut config = json!({ + "hooks": { + "Stop": [ + {"hooks": [{"type": "command", "command": "node existing-in-hooks.js"}]} + ] + }, + "Stop": [ + {"hooks": [{"type": "command", "command": "node legacy-top-level.js"}]} + ] + }); + install_understatus_hooks_config(&mut config, command); + assert!(config.get("Stop").is_none()); + let groups = config["hooks"]["Stop"].as_array().expect("Stop array"); + let commands: Vec<&str> = groups + .iter() + .flat_map(|group| group["hooks"].as_array().into_iter().flatten()) + .filter_map(|hook| hook["command"].as_str()) + .collect(); + assert!(commands.contains(&"node existing-in-hooks.js")); + assert!(commands.contains(&"node legacy-top-level.js")); + assert_eq!( + commands + .iter() + .filter(|command| command.contains("cmux hook-codex")) + .count(), + 1 + ); + } + + #[test] + fn stable_invocation_path_does_not_canonicalize_absolute_launcher() { + let path = stable_invocation_path(PathBuf::from("/opt/homebrew/bin/understatus")); + assert_eq!(path, PathBuf::from("/opt/homebrew/bin/understatus")); + } + + #[test] + fn stable_invocation_path_resolves_bare_name_from_path() { + let dir = std::env::temp_dir().join(format!( + "understatus-path-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + let launcher = dir.join("understatus"); + std::fs::write(&launcher, b"#!/bin/sh\n").expect("launcher"); + let path = stable_invocation_path_with_env( + PathBuf::from("understatus"), + Some(dir.as_os_str()), + None, + ) + .expect("path resolution"); + assert_eq!(path, launcher); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(unix)] + #[test] + fn write_hooks_json_preserves_existing_permissions() { + use std::os::unix::fs::PermissionsExt as _; + + let dir = std::env::temp_dir().join(format!( + "understatus-hooks-json-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).expect("temp dir"); + let path = dir.join("hooks.json"); + std::fs::write(&path, "{}").expect("seed hooks"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .expect("set seed perms"); + write_hooks_json(&path, &json!({"hooks": {}})).expect("write hooks"); + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/main.rs b/src/main.rs index d96d89e..dc31518 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,9 @@ mod chain; mod claude; +mod cmux; mod codex; +mod codex_hooks; mod config; mod install; mod render; @@ -21,12 +23,17 @@ mod themes; use std::io::{BufRead, IsTerminal, Read, Write}; use std::process::ExitCode; +use std::time::{Duration, Instant}; /// 렌더 핫패스에서 허용하는 stdin 최대 크기. /// /// Claude/lterm/codex statusline payload는 보통 한 줄의 작은 JSON이다. 파이프가 비정상적으로 큰 입력을 /// 밀어 넣어도 렌더가 무제한 메모리와 chain 전달 비용을 쓰지 않도록 1MiB에서 끊고 빈 입력으로 안전 저하한다. const MAX_RENDER_STDIN_BYTES: usize = 1024 * 1024; +/// Leave enough wall-clock room for hook stdout flush before Codex's installed 5s timeout. +const CODEX_HOOK_STDOUT_RESERVE_MS: u64 = 500; +/// Do not start CMUX work when there is too little budget left to finish a useful command. +const CODEX_HOOK_MIN_APPLY_BUDGET_MS: u64 = 250; /// 진입점: 서브커맨드를 디스패치한다. /// @@ -61,6 +68,7 @@ fn main() -> ExitCode { ExitCode::FAILURE } }, + Some("cmux") => run_cmux(&args), Some("theme") => run_theme(&args), Some("themes") => { print_themes(); @@ -340,6 +348,234 @@ fn run_pulse(args: &[String]) -> ExitCode { } } +/// cmux 하위 명령의 공통 옵션. +#[derive(Debug, Default)] +struct CmuxCliArgs { + workspace: Option, + window: Option, + session_key: Option, + quiet: bool, +} + +/// CMUX/Codex bridge 하위 명령을 실행한다. +fn run_cmux(args: &[String]) -> ExitCode { + let Some(action) = args.get(1).map(String::as_str) else { + eprintln!( + "understatus: cmux 하위 명령이 필요합니다(hook-codex|apply-codex|clear-codex|install-codex-hooks|uninstall-codex-hooks)." + ); + return ExitCode::FAILURE; + }; + + match action { + "hook-codex" => run_cmux_hook_codex(args), + "apply-codex" => run_cmux_apply_codex(args), + "clear-codex" => run_cmux_clear_codex(args), + "install-codex-hooks" => match codex_hooks::install_user_hooks() { + Ok(path) => { + eprintln!( + "understatus: Codex CMUX hooks installed at {}. Codex CLI에서 /hooks를 열어 새 understatus hook을 review/trust 해야 실행됩니다.", + path.display() + ); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("understatus: Codex CMUX hooks 설치 실패: {error:#}"); + ExitCode::FAILURE + } + }, + "uninstall-codex-hooks" => match codex_hooks::uninstall_user_hooks() { + Ok(path) => { + eprintln!( + "understatus: Codex CMUX hooks removed from {}.", + path.display() + ); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("understatus: Codex CMUX hooks 제거 실패: {error:#}"); + ExitCode::FAILURE + } + }, + other => { + eprintln!("understatus: 알 수 없는 cmux 하위 명령 '{other}'. --help 참조."); + ExitCode::FAILURE + } + } +} + +/// Codex hook target. This path is deliberately quiet and fail-soft. +fn run_cmux_hook_codex(args: &[String]) -> ExitCode { + let hook_started = Instant::now(); + let parsed = parse_cmux_cli_args(args, 2).unwrap_or_default(); + let raw = read_stdin(); + let fallback_cwd = current_dir_string(); + let input = codex_hooks::parse_hook_input(&raw, &fallback_cwd); + let explicit_target = cmux::CmuxTarget { + workspace: parsed.workspace, + window: parsed.window, + }; + let target = cmux::resolve_target(explicit_target, true); + 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); + let _ = cmux::apply_pills(&pills, &owner, target, mode); + } + } + print!("{}", codex_hooks::neutral_hook_stdout()); + let _ = std::io::stdout().flush(); + ExitCode::SUCCESS +} + +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); + let usable = hard_timeout.checked_sub(reserve)?; + let remaining = usable.checked_sub(hook_started.elapsed())?; + let remaining_ms = remaining.as_millis().try_into().unwrap_or(u64::MAX); + (remaining_ms >= CODEX_HOOK_MIN_APPLY_BUDGET_MS).then_some(remaining_ms) +} + +/// Manual/debug CMUX apply command. +fn run_cmux_apply_codex(args: &[String]) -> ExitCode { + let parsed = match parse_cmux_cli_args(args, 2) { + Ok(parsed) => parsed, + Err(message) => { + eprintln!("understatus: {message}"); + return ExitCode::FAILURE; + } + }; + let explicit_target = cmux::CmuxTarget { + workspace: parsed.workspace.clone(), + window: parsed.window.clone(), + }; + let target = cmux::resolve_target(explicit_target, false); + let raw = read_stdin(); + let fallback_cwd = current_dir_string(); + let surface = std::env::var("CMUX_SURFACE_ID").ok(); + let hook_like = raw.trim().is_empty() + || serde_json::from_str::(&raw) + .ok() + .and_then(|value| value.get("hook_event_name").cloned()) + .is_some(); + let input = codex_hooks::parse_hook_input(&raw, &fallback_cwd); + let payload = if hook_like { + codex_hooks::render_payload(&input, &target, surface.as_deref()) + } else { + raw + }; + let owner = parsed + .session_key + .unwrap_or_else(|| codex_hooks::owner_key(&input, &target, surface.as_deref())); + let pills = render_cmux_output_for_raw(Source::Codex, &payload); + let report = cmux::apply_pills( + &pills, + &owner, + target, + cmux::ApplyMode::manual(parsed.quiet), + ); + if !parsed.quiet { + eprintln!( + "understatus: cmux apply-codex set={} clear={} target_resolved={} success={}", + report.set_count, report.clear_count, report.target_resolved, report.command_success + ); + } + if report.command_success { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } +} + +/// Manual/debug clear command for previously applied Codex/CMUX pills. +fn run_cmux_clear_codex(args: &[String]) -> ExitCode { + let parsed = match parse_cmux_cli_args(args, 2) { + Ok(parsed) => parsed, + Err(message) => { + eprintln!("understatus: {message}"); + return ExitCode::FAILURE; + } + }; + let explicit_target = cmux::CmuxTarget { + workspace: parsed.workspace, + window: parsed.window, + }; + let target = cmux::resolve_target(explicit_target, false); + let fallback = codex_hooks::HookInput { + hook_event_name: "Manual".to_string(), + session_id: None, + turn_id: None, + cwd: current_dir_string(), + }; + let surface = std::env::var("CMUX_SURFACE_ID").ok(); + let owner = parsed + .session_key + .unwrap_or_else(|| codex_hooks::owner_key(&fallback, &target, surface.as_deref())); + let report = cmux::clear_owner(&owner, target, cmux::ApplyMode::manual(parsed.quiet)); + if !parsed.quiet { + eprintln!( + "understatus: cmux clear-codex clear={} target_resolved={} success={}", + report.clear_count, report.target_resolved, report.command_success + ); + } + if report.command_success { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } +} + +/// cmux 하위 명령 공통 옵션을 파싱한다. `args[start]`부터 옵션. +fn parse_cmux_cli_args(args: &[String], start: usize) -> Result { + let mut parsed = CmuxCliArgs::default(); + let mut index = start; + while index < args.len() { + match args[index].as_str() { + "--workspace" => { + parsed.workspace = Some( + args.get(index + 1) + .ok_or_else(|| "--workspace 뒤에 값이 필요합니다.".to_string())? + .clone(), + ); + index += 2; + } + "--window" => { + parsed.window = Some( + args.get(index + 1) + .ok_or_else(|| "--window 뒤에 값이 필요합니다.".to_string())? + .clone(), + ); + index += 2; + } + "--session-key" => { + parsed.session_key = Some( + args.get(index + 1) + .ok_or_else(|| "--session-key 뒤에 값이 필요합니다.".to_string())? + .clone(), + ); + index += 2; + } + "--quiet" => { + parsed.quiet = true; + index += 1; + } + "--yes" | "-y" => { + // install/uninstall consistency: accepted but currently no prompt is shown. + index += 1; + } + other => return Err(format!("알 수 없는 cmux 옵션 '{other}'. --help 참조.")), + } + } + Ok(parsed) +} + /// install 인자를 파싱한다(순수 함수). `--interval N`/`--theme NAME`/`--yes`/`-y`. /// /// # 반환 @@ -676,48 +912,35 @@ fn interpret_held_native_ctx( .filter(|percent| percent.is_finite() && *percent > 0.0 && *percent <= 100.0) } -/// 렌더 파이프라인을 실행하고 합성된 한 줄을 stdout에 출력한다. -/// -/// 계획서 §D-1의 8단계를 순서대로 호출한다. 본 함수는 실제 배선(stdin 읽기/단계 연결/출력)을 담당한다. -/// -/// # 인자 -/// - `source`: 입력 소스(claude=기존 동작, lterm=합성 JSON). lterm은 git 비활성·chain 기본 off. -/// - `oneline`: true면 chain을 수행하지 않고 코어 `render()` 1행만 **후행 개행 없이** 출력한다(spec §6.3). -/// - `surface_format`: 출력 표면(텍스트 vs cmux JSON). [`SurfaceFormat::CmuxStatus`]면 SGR 한 줄 대신 -/// cmux pill JSON 1줄을 출력한다(설계 §3.3). [`SurfaceFormat::Oneline`]은 비-cmux 텍스트 표면일 -/// 뿐이며 terse(1행/chain-skip) 여부는 `oneline` 인자와 직교다. 즉 `--surface-format oneline`(= -/// `Oneline`)이라도 `oneline=false`면 chain/compose + 후행 개행을 거치는 기존 경로를 그대로 탄다. -/// 수집부(parse + codex enrich + system sample)는 표면 분기와 무관하게 재사용된다. -fn run_render_pipeline(source: Source, oneline: bool, surface_format: SurfaceFormat) { - // (1) stdin 원본 보존(체이닝을 위해 raw 그대로 자식에 전달). - let raw_stdin = read_stdin(); +/// render 표면 분기 전에 필요한 수집 결과. `render` CLI와 CMUX applier가 공유한다. +struct RenderCollected { + raw_stdin: String, + claude_input: claude::ClaudeInput, + session_key: String, + cfg: config::Config, + snapshot: system::SystemSnapshot, + now_ms: u128, + pulse_on: bool, +} - // (2) 소스별 세션 정보 파싱(누락/null/깨진 JSON 안전). lterm은 git 비활성. +/// stdin 원문과 source에서 render 공통 입력을 수집한다. +fn collect_render_inputs(source: Source, raw_stdin: String) -> RenderCollected { + // 소스별 세션 정보 파싱(누락/null/깨진 JSON 안전). lterm/codex는 git 비활성 합성 JSON 파서. let mut claude_input = match source { Source::Claude => claude::parse_claude_input(&raw_stdin), - // lterm·codex는 동일 합성 JSON 파서를 공유한다(codex는 lterm 데몬 없이 tmux 등에서 직접 호출). Source::Lterm | Source::Codex => claude::parse_lterm_input(&raw_stdin), }; - // 세션 캐시 격리 키를 한 곳에서 1회 살균한다(§11.3). session_id 부재/빈 값은 "default"로 폴백. + // 세션 캐시 격리 키를 한 곳에서 1회 살균한다. session_id 부재/빈 값은 "default"로 폴백. let session_key = chain::sanitize_session_key(claude_input.session_id.as_deref().unwrap_or("")); - // (5) 설정 로드(부재/깨짐 시 기본값). let cfg = config::load_config(); - // (5') Codex 세션 심층판독 enrich(spec §7). **Source::Lterm·Codex 한정**: Claude 경로에서 모델 - // 별칭이 우연히 codex 계열이어도 ~/.codex를 읽지 않도록 여기서 게이팅한다(비트 동일 보존). - // `--source codex`는 lterm 데몬 없이 직접 호출하는 경로로, 같은 파서·enrich를 공유한다. - // enrich는 session_id를 바꾸지 않으므로 위 session_key 도출/이후 파이프라인에 영향 없다. if should_enrich_codex(source) { codex::maybe_enrich(&mut claude_input, &cfg); } - // (3)(4) 시스템 스냅샷 수집(더블샘플 CPU + 메모리 + 배터리). let snapshot = system::sample_system(&cfg, &session_key); - - // 지각성 불변식(계획서 §H-5, AC4): 한 펄스 주기 안에 그려지는 프레임 수가 6 이상이어야 - // 색 출렁임이 끊기지 않는다. 릴리스 출력/성능에 영향을 주지 않도록 debug 빌드에서만 검증한다. debug_assert!( theme::samples_per_period(&cfg, cfg.refresh.interval_seconds) >= 6, "펄스 지각성 불변식 위반: samples_per_period < 6 (pulse_period={}s, refreshInterval={}s)", @@ -725,22 +948,65 @@ fn run_render_pipeline(source: Source, oneline: bool, surface_format: SurfaceFor cfg.refresh.interval_seconds ); - // 히스테리시스: 직전 펄스 상태 읽기 → 게이트 → 다음 호출을 위해 기록. let prev_pulse_on = chain::read_prev_pulse_state(&session_key); let now_ms = now_millis(); let pulse_on = theme::pulse_gate(snapshot.cpu_percent, prev_pulse_on, &cfg); chain::write_pulse_state(pulse_on, &session_key); - // (5'') ctx 해석(Claude 소스 한정): native(used_percentage) 우선, 일시 누락 시 TTL 내 직전 - // native 유지, cold-start만 토큰 fallback. Claude Code가 used_percentage를 간헐적으로 누락해도 - // ctx 세그먼트가 사라지지 않게 하고, native↔토큰 분모 차이로 인한 값 튐(예: 86↔98)을 막는다. - // lterm/codex 경로는 context_window가 없어 자연 no-op이므로 Claude로 게이팅한다. if source == Source::Claude { resolve_claude_context(&mut claude_input, &session_key, now_ms, &cfg.context); - // rate-limit 카운트다운을 render 진입 전에 pre-resolve한다(Option C: 시간 산술은 render에 0). resolve_claude_rate_limits(&mut claude_input, now_ms); } + RenderCollected { + raw_stdin, + claude_input, + session_key, + cfg, + snapshot, + now_ms, + pulse_on, + } +} + +/// Raw payload를 같은 render 수집부로 처리해 CMUX pill 구조를 만든다. +fn render_cmux_output_for_raw(source: Source, raw_stdin: &str) -> render::CmuxStatusOutput { + let collected = collect_render_inputs(source, raw_stdin.to_string()); + render::render_cmux_pills( + &collected.claude_input, + &collected.snapshot, + &collected.cfg, + collected.now_ms, + collected.pulse_on, + ) +} + +/// 렌더 파이프라인을 실행하고 합성된 한 줄을 stdout에 출력한다. +/// +/// 계획서 §D-1의 8단계를 순서대로 호출한다. 본 함수는 실제 배선(stdin 읽기/단계 연결/출력)을 담당한다. +/// +/// # 인자 +/// - `source`: 입력 소스(claude=기존 동작, lterm=합성 JSON). lterm은 git 비활성·chain 기본 off. +/// - `oneline`: true면 chain을 수행하지 않고 코어 `render()` 1행만 **후행 개행 없이** 출력한다(spec §6.3). +/// - `surface_format`: 출력 표면(텍스트 vs cmux JSON). [`SurfaceFormat::CmuxStatus`]면 SGR 한 줄 대신 +/// cmux pill JSON 1줄을 출력한다(설계 §3.3). [`SurfaceFormat::Oneline`]은 비-cmux 텍스트 표면일 +/// 뿐이며 terse(1행/chain-skip) 여부는 `oneline` 인자와 직교다. 즉 `--surface-format oneline`(= +/// `Oneline`)이라도 `oneline=false`면 chain/compose + 후행 개행을 거치는 기존 경로를 그대로 탄다. +/// 수집부(parse + codex enrich + system sample)는 표면 분기와 무관하게 재사용된다. +fn run_render_pipeline(source: Source, oneline: bool, surface_format: SurfaceFormat) { + // (1) stdin 원본 보존(체이닝을 위해 raw 그대로 자식에 전달). + let raw_stdin = read_stdin(); + let collected = collect_render_inputs(source, raw_stdin); + let RenderCollected { + raw_stdin, + claude_input, + session_key, + cfg, + snapshot, + now_ms, + pulse_on, + } = collected; + // (6') --surface-format cmux-status: SGR 한 줄 대신 cmux pill JSON 1줄을 출력하고 종료한다. // 수집부(parse + codex enrich + system sample + ctx 해석)는 위에서 그대로 끝났으므로 재사용한다. // oneline 분기 직전에 분기해 chain/compose 경로를 타지 않는다(설계 §5.2). 직렬화 실패는 무패닉 @@ -833,6 +1099,14 @@ fn now_millis() -> u128 { .unwrap_or(0) } +/// 현재 작업 디렉터리를 UTF-8 문자열로 반환한다. 실패/비UTF-8이면 빈 문자열로 안전 저하한다. +fn current_dir_string() -> String { + std::env::current_dir() + .ok() + .and_then(|path| path.to_str().map(str::to_string)) + .unwrap_or_default() +} + /// 사용법(`--help`)을 stdout에 출력한다. fn print_help() { println!( @@ -843,6 +1117,9 @@ fn print_help() { \x20 understatus render [옵션] render 옵션과 함께 statusline 한 줄을 출력\n\ \x20 understatus install [옵션] 기존 statusLine을 보존(체이닝)하며 비파괴 설치\n\ \x20 understatus uninstall 원본 설정을 정확 복원하며 제거\n\ + \x20 understatus cmux install-codex-hooks Codex→CMUX sidebar hook 설치\n\ + \x20 understatus cmux uninstall-codex-hooks Codex→CMUX sidebar hook 제거\n\ + \x20 understatus cmux apply-codex [옵션] Codex payload를 CMUX sidebar pill로 적용\n\ \x20 understatus theme 설치 후 테마 교체(config.toml만 수정)\n\ \x20 understatus themes 사용 가능한 테마 목록 출력\n\ \x20 understatus pulse