codex-agent-indicator 0.4.8

Low-overhead Codex task status indicator for Logitech G915 G-keys
mod config;
mod daemon;
mod device;
mod journal;
mod navigation;
mod protocol;
mod state;
mod update;
mod wire;

use std::env;
use std::fs;
use std::io::{self, Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::UnixDatagram;
use std::process::{Command, ExitCode};
use std::thread;
use std::time::{Duration, Instant, SystemTime};

use anyhow::{Context, Result, bail};
use config::{AppConfig, DEFAULT_CONFIG, Paths};
use state::StateKind;
use wire::{EventMessage, HookInput};

fn main() -> ExitCode {
    let command = env::args().nth(1).unwrap_or_else(|| "help".to_string());
    match run_command(&command) {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("codex-agent-indicator: {error:#}");
            ExitCode::FAILURE
        }
    }
}

fn run_command(command: &str) -> Result<()> {
    match command {
        "daemon" => daemon::run(Paths::discover()?),
        "hook" => forward_hook(),
        "init-config" => init_config(env::args().any(|argument| argument == "--force")),
        "set" => set_state(),
        "clear" => clear_state(),
        "reload" => send_event(&Paths::discover()?, &EventMessage::Reload),
        "status" => print_status(),
        "doctor" => doctor(),
        "update" => update::run(),
        "help" | "--help" | "-h" => {
            print_help();
            Ok(())
        }
        "--version" | "-V" | "version" => {
            println!("codex-agent-indicator {}", env!("CARGO_PKG_VERSION"));
            Ok(())
        }
        other => bail!("unknown command {other:?}; run with --help"),
    }
}

fn forward_hook() -> Result<()> {
    let mut input = String::new();
    io::stdin()
        .take(1_048_576)
        .read_to_string(&mut input)
        .context("failed to read Codex hook input")?;
    let hook: HookInput = serde_json::from_str(&input).context("invalid Codex hook input")?;
    send_event(&Paths::discover()?, &hook.into_event())
}

fn set_state() -> Result<()> {
    let mut arguments = env::args().skip(2);
    let state = arguments
        .next()
        .context("set requires a state")?
        .parse::<StateKind>()?;
    let session_id = arguments
        .next()
        .unwrap_or_else(|| "manual-preview".to_string());
    send_event(
        &Paths::discover()?,
        &EventMessage::Set { session_id, state },
    )
}

fn clear_state() -> Result<()> {
    let session_id = env::args().nth(2);
    send_event(&Paths::discover()?, &EventMessage::Clear { session_id })
}

fn send_event(paths: &Paths, message: &EventMessage) -> Result<()> {
    let content = serde_json::to_vec(message)?;
    let socket = UnixDatagram::unbound().context("failed to create indicator event socket")?;
    socket
        .send_to(&content, &paths.socket)
        .with_context(|| {
            format!(
                "indicator daemon is not reachable at {}; start or reinstall its LaunchAgent",
                paths.socket.display()
            )
        })?;
    Ok(())
}

fn init_config(force: bool) -> Result<()> {
    let paths = Paths::discover()?;
    if paths.config.exists() && !force {
        println!(
            "configuration already exists at {}; left unchanged",
            paths.config.display()
        );
        return Ok(());
    }

    if let Some(parent) = paths.config.parent() {
        fs::create_dir_all(parent)?;
        fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
    }
    fs::write(&paths.config, DEFAULT_CONFIG)
        .with_context(|| format!("failed to write {}", paths.config.display()))?;
    fs::set_permissions(&paths.config, fs::Permissions::from_mode(0o600))?;
    println!("wrote {}", paths.config.display());
    Ok(())
}

fn print_status() -> Result<()> {
    let paths = Paths::discover()?;
    let previous_modified = modified_at(&paths.status);
    if send_event(&paths, &EventMessage::Snapshot).is_ok() {
        wait_for_status_refresh(&paths.status, previous_modified);
    }
    let content = fs::read_to_string(&paths.status).with_context(|| {
        format!(
            "no daemon status at {}; the daemon may not be running",
            paths.status.display()
        )
    })?;
    println!("{content}");
    Ok(())
}

fn wait_for_status_refresh(path: &std::path::Path, previous_modified: Option<SystemTime>) {
    let deadline = Instant::now() + Duration::from_millis(500);
    while Instant::now() < deadline {
        if modified_at(path) != previous_modified {
            return;
        }
        thread::sleep(Duration::from_millis(10));
    }
}

fn modified_at(path: &std::path::Path) -> Option<SystemTime> {
    fs::metadata(path)
        .and_then(|metadata| metadata.modified())
        .ok()
}

fn doctor() -> Result<()> {
    let paths = Paths::discover()?;
    let config = AppConfig::load(&paths.config)?;
    config.validate()?;
    println!("Configuration: {} (valid)", paths.config.display());
    println!(
        "Daemon socket: {} ({})",
        paths.socket.display(),
        if paths.socket.exists() {
            "present"
        } else {
            "missing"
        }
    );

    let ghub_running = Command::new("/usr/bin/pgrep")
        .args(["-f", "/Applications/lghub.app"])
        .output()
        .map(|output| output.status.success())
        .unwrap_or(false);
    println!(
        "Logitech G HUB: {}",
        if ghub_running {
            "running; shared HID access is enabled, but G HUB may overwrite colours"
        } else {
            "not running"
        }
    );

    let summary = device::G915::probe(&config.device)?;
    println!("{}", serde_json::to_string_pretty(&summary)?);
    if !summary.feature_indices_queried {
        println!(
            "Warning: feature-index discovery did not fully respond; configured G915 fallbacks will be used."
        );
    }
    Ok(())
}

fn print_help() {
    let mut stdout = io::stdout().lock();
    let _ = writeln!(
        stdout,
        "\
codex-agent-indicator {}

USAGE:
    codex-agent-indicator daemon
    codex-agent-indicator hook                 # reads Codex hook JSON on stdin
    codex-agent-indicator set STATE [SESSION]
    codex-agent-indicator clear [SESSION]
    codex-agent-indicator reload
    codex-agent-indicator status
    codex-agent-indicator doctor
    codex-agent-indicator update
    codex-agent-indicator init-config [--force]

STATES:
    idle, working, approval, requested, done, error
",
        env!("CARGO_PKG_VERSION")
    );
}