From cb350eba345b0c4bb9f2b1b821eceec124b630b8 Mon Sep 17 00:00:00 2001 From: Coden Date: Sat, 4 Jul 2026 23:49:46 +0900 Subject: [PATCH 1/6] Add Codex CMUX sidebar hook bridge Implement Codex hook installation, CMUX set-status application, stale key cleanup, and docs for cmux sidebar pills. Constraint: preserve existing Claude/lterm behavior and avoid cmux set-progress workspace-global updates Confidence: high Scope-risk: moderate Directive: Keep future edits limited to Codex/CMUX hook bridge, docs, and tests unless review expands scope Tested: cargo fmt --all; cargo test --all-targets --locked; cargo clippy --all-targets --locked -- -D warnings; git diff --check Not-tested: live Codex /hooks trust prompt interaction --- README.md | 36 +++- homebrew/README.md | 134 ++++++------- npm/README.md | 16 +- src/cmux.rs | 487 +++++++++++++++++++++++++++++++++++++++++++++ src/codex_hooks.rs | 378 +++++++++++++++++++++++++++++++++++ src/main.rs | 331 ++++++++++++++++++++++++++---- src/render.rs | 22 +- tests/oneline.rs | 163 +++++++++++++++ 8 files changed, 1442 insertions(+), 125 deletions(-) create mode 100644 src/cmux.rs create mode 100644 src/codex_hooks.rs 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/src/cmux.rs b/src/cmux.rs new file mode 100644 index 0000000..09b37a4 --- /dev/null +++ b/src/cmux.rs @@ -0,0 +1,487 @@ +//! 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 `cmux` CLI invocation from hook paths. +pub(crate) const DEFAULT_CMUX_TIMEOUT_MS: u64 = 5_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, +} + +impl ApplyMode { + pub(crate) fn hook() -> Self { + Self { + hook: true, + quiet: true, + timeout_ms: DEFAULT_CMUX_TIMEOUT_MS, + } + } + + pub(crate) fn manual(quiet: bool) -> Self { + Self { + hook: false, + quiet, + timeout_ms: DEFAULT_CMUX_TIMEOUT_MS, + } + } +} + +/// 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::new("cmux") + .args(["identify", "--json"]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + 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 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 { + for command in commands { + if command.args.iter().any(|arg| arg == "set-progress") { + return false; + } + if !execute_one(command, mode) { + return false; + } + } + true +} + +fn execute_one(command: &CmuxCommand, mode: ApplyMode) -> 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(mode.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 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()) + ); + } +} diff --git a/src/codex_hooks.rs b/src/codex_hooks.rs new file mode 100644 index 0000000..f0e82c5 --- /dev/null +++ b/src/codex_hooks.rs @@ -0,0 +1,378 @@ +//! Codex hook normalization and non-destructive `~/.codex/hooks.json` merge helpers. + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use serde_json::{json, Map, Value}; + +use crate::cmux::CmuxTarget; + +/// 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 { + let exe = std::env::current_exe().context("현재 실행 바이너리 경로 확인 실패")?; + let canonical = std::fs::canonicalize(&exe).unwrap_or(exe); + let path = canonical + .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 = path.with_extension("json.understatus.tmp"); + std::fs::write(&tmp, text) + .with_context(|| format!("hooks.json 임시 파일 쓰기 실패: {}", tmp.display()))?; + std::fs::rename(&tmp, path) + .with_context(|| format!("hooks.json 교체 실패: {}", path.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); + hooks + .entry((*event).to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + .as_array_mut() + .expect("event hooks must be 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"); + if root.get("hooks").is_some() { + if !root.get("hooks").is_some_and(Value::is_object) { + root.insert("hooks".to_string(), Value::Object(Map::new())); + } + root.get_mut("hooks") + .and_then(Value::as_object_mut) + .expect("hooks object") + } else { + root + } +} + +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('\'', "'\\''")) +} + +#[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" + ); + } +} diff --git a/src/main.rs b/src/main.rs index d96d89e..49cd731 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; @@ -61,6 +63,7 @@ fn main() -> ExitCode { ExitCode::FAILURE } }, + Some("cmux") => run_cmux(&args), Some("theme") => run_theme(&args), Some("themes") => { print_themes(); @@ -340,6 +343,219 @@ 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 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); + let _ = cmux::apply_pills(&pills, &owner, target, cmux::ApplyMode::hook()); + } + print!("{}", codex_hooks::neutral_hook_stdout()); + let _ = std::io::stdout().flush(); + ExitCode::SUCCESS +} + +/// 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 +892,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 +928,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 +1079,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 +1097,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