diff --git a/package-lock.json b/package-lock.json index 31a52ff..74fc198 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "geist": "^1.7.2", "github-markdown-css": "^5.9.0", "lucide-react": "^1.21.0", + "motion": "^12.42.2", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", @@ -1370,7 +1371,6 @@ "node_modules/@emotion/is-prop-valid": { "version": "1.4.0", "license": "MIT", - "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0" } diff --git a/package.json b/package.json index c0765e1..b7e6c1c 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "geist": "^1.7.2", "github-markdown-css": "^5.9.0", "lucide-react": "^1.21.0", + "motion": "^12.42.2", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9bb6267..5e8a604 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -89,6 +89,12 @@ dependencies = [ "x11rb", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -557,6 +563,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -4438,6 +4450,7 @@ version = "0.1.6" dependencies = [ "dashmap", "dbiso", + "getrandom 0.2.17", "hephaestus", "junction", "libc", @@ -4453,6 +4466,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-updater", + "tiny_http", "url", ] @@ -4572,6 +4586,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 33fe4cf..6d55018 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,6 +43,11 @@ tauri-plugin-process = "2" # Verifies the signed remote agents manifest (config/agents.json). Already in the # tree via tauri-plugin-updater; named here so the verify command can use it. minisign-verify = "0.2" +# Loopback HTTP receiver for agent lifecycle hooks (agent_hooks.rs). A small, +# correct HTTP impl beats hand-rolling request parsing over raw TCP. +tiny_http = "0.12" +# OS entropy for the per-run hook-server auth token. +getrandom = "0.2" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/src/agent_hooks.rs b/src-tauri/src/agent_hooks.rs new file mode 100644 index 0000000..3ce8b06 --- /dev/null +++ b/src-tauri/src/agent_hooks.rs @@ -0,0 +1,222 @@ +//! Loopback receiver for agent lifecycle hooks. +//! +//! A managed hook script that Tempest installs into each agent's own config +//! (see `src/lib/agentHooks/`) POSTs one event per turn boundary / tool call / +//! permission prompt to `http://127.0.0.1:/hook/`. This module is a +//! **dumb, secure pipe**: it authenticates the request, reads which Tempest +//! session it belongs to (echoed back from the `TEMPEST_SESSION` env we inject +//! at spawn), and forwards the raw agent payload to the frontend as an +//! `agent-hook` event. All per-agent parsing lives in TypeScript next to the +//! agent registry — Rust never interprets the payload. +//! +//! The port is ephemeral and the token is per-run, both published to +//! `~/.tempest/hooks/endpoint.{env,cmd}` so a hook script re-sources the current +//! values even after the app restarts under a still-live PTY. + +use std::io::Read; +use std::path::Path; +use tauri::{AppHandle, Emitter}; + +/// Hard cap on a forwarded payload. Tool outputs can be large but a status hook +/// never needs more; anything past this is a malformed or hostile poster. +const MAX_BODY_BYTES: u64 = 1024 * 1024; + +#[derive(Clone, serde::Serialize)] +struct HookEvent { + agent: String, + session: String, + /// Lifecycle event name, when the script conveys it out-of-band via the + /// X-Tempest-Event header (agents like Antigravity whose payload doesn't + /// carry it). Empty when the event lives inside the payload itself. + event: String, + /// Raw agent payload (JSON text as the agent emitted it). Parsed in TS. + body: String, +} + +/// `~/.tempest/hooks` — holds the managed scripts, agent plugins, and the +/// endpoint files. Shared with the TS installer, which computes the same path +/// from the home dir. +pub fn hooks_dir() -> std::path::PathBuf { + super::global_home().join(".tempest").join("hooks") +} + +/// Atomic write (temp + rename) with mkdir -p, a same-content skip, and an +/// optional exec bit on unix. Backs both the endpoint files here and the +/// `hooks_write_atomic` command the installer uses for scripts and configs. +#[cfg_attr(not(unix), allow(unused_variables))] +pub fn write_atomic(path: &Path, contents: &str, executable: bool) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + // Skip a no-op rewrite so re-installing on every launch neither churns the + // disk nor rolls a backup forward over the last good copy. + if let Ok(existing) = std::fs::read_to_string(path) { + if existing == contents { + #[cfg(unix)] + if executable { + set_executable(path)?; + } + return Ok(()); + } + } + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "tmp".to_string()); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let tmp = path.with_file_name(format!(".{file_name}.{}.{nanos}.tmp", std::process::id())); + std::fs::write(&tmp, contents)?; + #[cfg(unix)] + if executable { + set_executable(&tmp)?; + } + // rename is atomic on the same filesystem; a crash mid-write leaves the + // original intact and only an orphan temp behind. + if let Err(e) = std::fs::rename(&tmp, path) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + Ok(()) +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) +} + +/// 32 hex chars of OS entropy — the shared secret a poster must present so a +/// stray local process can't spoof agent status. +fn random_token() -> String { + let mut buf = [0u8; 16]; + if getrandom::getrandom(&mut buf).is_err() { + // Entropy failure is near-impossible; fall back to a time+pid seed so the + // server still starts (degrades auth strength, never availability). + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let seed = nanos ^ (std::process::id() as u128); + return format!("{seed:032x}"); + } + buf.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Length-independent equality so token verification doesn't leak the secret +/// through response timing. Loopback makes this near-paranoia, but it's cheap. +fn constant_time_eq(a: &str, b: &str) -> bool { + let (a, b) = (a.as_bytes(), b.as_bytes()); + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for i in 0..a.len() { + diff |= a[i] ^ b[i]; + } + diff == 0 +} + +fn write_endpoint_files(dir: &Path, port: u16, token: &str) { + // POSIX `. endpoint.env` sources these; the shell reads KEY=VALUE lines. + let env = format!("TEMPEST_HOOK_PORT={port}\nTEMPEST_HOOK_TOKEN={token}\n"); + let _ = write_atomic(&dir.join("endpoint.env"), &env, false); + // cmd.exe `call endpoint.cmd` needs `set KEY=VALUE` with CRLF. + let cmd = format!("set TEMPEST_HOOK_PORT={port}\r\nset TEMPEST_HOOK_TOKEN={token}\r\n"); + let _ = write_atomic(&dir.join("endpoint.cmd"), &cmd, false); +} + +/// Start the loopback hook server. Best-effort: a bind failure logs and returns, +/// leaving sessions on the PTY-scraping fallback — status degrades, never breaks. +pub fn start(app: AppHandle) { + let dir = hooks_dir(); + let _ = std::fs::create_dir_all(&dir); + let token = random_token(); + + let server = match tiny_http::Server::http("127.0.0.1:0") { + Ok(s) => s, + Err(e) => { + eprintln!("[agent-hooks] failed to bind loopback server: {e}"); + return; + } + }; + let port = match server.server_addr().to_ip() { + Some(addr) => addr.port(), + None => { + eprintln!("[agent-hooks] server bound to a non-IP address"); + return; + } + }; + write_endpoint_files(&dir, port, &token); + + std::thread::spawn(move || { + for request in server.incoming_requests() { + handle_request(&app, &token, request); + } + }); +} + +// Takes the request by value: `Request::respond` consumes it, and each branch +// below responds exactly once before returning. +fn handle_request(app: &AppHandle, token: &str, mut request: tiny_http::Request) { + if *request.method() != tiny_http::Method::Post { + let _ = request.respond(tiny_http::Response::empty(404)); + return; + } + // URL is `/hook/`; agent selects the TS adapter. + let agent = request + .url() + .strip_prefix("/hook/") + .map(|a| a.split(['?', '/']).next().unwrap_or("").to_string()) + .unwrap_or_default(); + if agent.is_empty() { + let _ = request.respond(tiny_http::Response::empty(404)); + return; + } + + let mut got_token: Option = None; + let mut session: Option = None; + let mut event = String::new(); + for header in request.headers() { + let field = header.field.as_str().as_str(); + if field.eq_ignore_ascii_case("x-tempest-token") { + got_token = Some(header.value.as_str().to_string()); + } else if field.eq_ignore_ascii_case("x-tempest-session") { + session = Some(header.value.as_str().to_string()); + } else if field.eq_ignore_ascii_case("x-tempest-event") { + event = header.value.as_str().to_string(); + } + } + + if !got_token.map(|t| constant_time_eq(&t, token)).unwrap_or(false) { + let _ = request.respond(tiny_http::Response::empty(403)); + return; + } + let session = match session { + Some(s) if !s.is_empty() => s, + // An unattributed hook can't be routed to a session; drop it. + _ => { + let _ = request.respond(tiny_http::Response::empty(400)); + return; + } + }; + + let mut body = String::new(); + let _ = request + .as_reader() + .take(MAX_BODY_BYTES) + .read_to_string(&mut body); + + let _ = app.emit( + "agent-hook", + HookEvent { + agent, + session, + event, + body, + }, + ); + let _ = request.respond(tiny_http::Response::empty(200)); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 969d8e2..24d7721 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,8 @@ use tauri::ipc::Channel; use tauri::Emitter; use hephaestus::Isolate; +mod agent_hooks; + /// Process-global isolation backend (Job Objects on Windows). Provisioned lazily /// the first time a sandboxed PTY session is created. static ISOLATE: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -62,6 +64,39 @@ fn write_file(path: String, content: String) -> Result<(), String> { std::fs::write(&path, content).map_err(|e| e.to_string()) } +/// Atomic write (temp + rename, mkdir -p, optional exec bit) used by the agent- +/// hooks installer for managed scripts and for merged agent config files. The +/// atomicity matters: a crash mid-write must never leave an agent's own +/// settings.json truncated. +#[tauri::command(async)] +fn hooks_write_atomic(path: String, content: String, executable: bool) -> Result<(), String> { + agent_hooks::write_atomic(std::path::Path::new(&path), &content, executable) + .map_err(|e| e.to_string()) +} + +#[derive(serde::Serialize)] +struct HookPaths { + home: String, + hooks_dir: String, + endpoint_env: String, + endpoint_cmd: String, + windows: bool, +} + +/// Filesystem locations + platform the agent-hooks installer needs. Sourced from +/// Rust so the frontend never has to resolve the home dir or guess the OS. +#[tauri::command(async)] +fn hooks_paths() -> HookPaths { + let dir = agent_hooks::hooks_dir(); + HookPaths { + home: global_home().to_string_lossy().to_string(), + endpoint_env: dir.join("endpoint.env").to_string_lossy().to_string(), + endpoint_cmd: dir.join("endpoint.cmd").to_string_lossy().to_string(), + hooks_dir: dir.to_string_lossy().to_string(), + windows: cfg!(windows), + } +} + /// Returns the @usetempest/atlas package directory inside the runtime folder. /// Dev builds: src-tauri/resources/atlas/node_modules/@usetempest/atlas/ /// Release builds: /resources/atlas/node_modules/@usetempest/atlas/ @@ -559,6 +594,81 @@ async fn shell_run( Ok(()) } +/// Run a repo-supplied worktree hook (`tempest.yml` `setup:` / `teardown:`) to +/// completion, one command at a time, stopping at the first failure. +/// +/// Unlike `shell_run` this waits and reports the exit status: a worktree must +/// not be reported ready on the strength of a command that failed. Output lines +/// are streamed as `hook:{token}` events so the caller can show progress. +#[tauri::command(async)] +fn run_hook( + app: tauri::AppHandle, + token: String, + cwd: String, + commands: Vec, + env: Option>, +) -> Result<(), String> { + use std::io::{BufRead, BufReader}; + + let ev = format!("hook:{token}"); + let (program, flag) = if cfg!(windows) { ("cmd", "/C") } else { ("sh", "-c") }; + + for cmd_str in commands { + let _ = app.emit(&ev, format!("$ {cmd_str}")); + + let mut command = new_command(program); + command + .arg(flag) + .arg(&cmd_str) + .current_dir(&cwd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + // The config's `env` is already stripped of loader and DB-isolation + // variables by tempestConfig.ts before it reaches here. + if let Some(ref vars) = env { + for (k, v) in vars { + command.env(k, v); + } + } + + let mut child = command + .spawn() + .map_err(|e| format!("{cmd_str}: failed to start: {e}"))?; + + let stdout = child.stdout.take().ok_or("no stdout")?; + let stderr = child.stderr.take().ok_or("no stderr")?; + + // stderr on its own thread; stdout drained here. Both must be consumed + // or a chatty command fills its pipe buffer and blocks forever. + let app_err = app.clone(); + let ev_err = ev.clone(); + let err_thread = std::thread::spawn(move || { + let mut tail = String::new(); + for line in BufReader::new(stderr).lines().map_while(Result::ok) { + let _ = app_err.emit(&ev_err, &line); + tail = line; + } + tail + }); + + for line in BufReader::new(stdout).lines().map_while(Result::ok) { + let _ = app.emit(&ev, &line); + } + + let last_err = err_thread.join().unwrap_or_default(); + let status = child.wait().map_err(|e| format!("{cmd_str}: {e}"))?; + if !status.success() { + let code = status.code().map_or("signal".to_string(), |c| c.to_string()); + return Err(if last_err.is_empty() { + format!("`{cmd_str}` exited with {code}") + } else { + format!("`{cmd_str}` exited with {code}: {last_err}") + }); + } + } + Ok(()) +} + #[tauri::command] fn shell_kill(state: tauri::State<'_, RunState>, session_id: String) -> Result<(), String> { if let Some(mut child) = state.0.lock().unwrap().remove(&session_id) { @@ -3474,6 +3584,9 @@ pub fn run() { .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; app.manage(DbState(Mutex::new(conn))); tauri::async_runtime::spawn(async { let _ = dbiso::sweep_orphans().await; }); + // Loopback receiver for agent lifecycle hooks. Best-effort; a bind + // failure leaves sessions on PTY-scraped status. + agent_hooks::start(app.handle().clone()); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -3517,6 +3630,8 @@ pub fn run() { get_ide_panel_url, read_file, write_file, + hooks_write_atomic, + hooks_paths, start_atlas_index, get_atlas_graph, atlas_query, @@ -3532,6 +3647,7 @@ pub fn run() { db_sweep_orphans, shell_run, shell_kill, + run_hook, remove_atlas_index, start_atlas_daemon, stop_atlas_daemon, diff --git a/src/components/DynamicIsland.css b/src/components/DynamicIsland.css new file mode 100644 index 0000000..7948766 --- /dev/null +++ b/src/components/DynamicIsland.css @@ -0,0 +1,209 @@ +/* ─── Dynamic island — live quota, centred on the toolbar ───────────────────── + Motion lives in DynamicIsland.tsx: `layout` springs the shape to whatever the + content measures, so there are no widths or heights written down here. This + file is material and type only. */ + +/* Pinned by its top edge, not centred: opening must only ever grow downwards, + so the pill and the panel start at the same line. 11px puts the 22px pill on + the toolbar's icon row — `.bar` pads 8px off the top and `.bar-end` 8px off + the bottom, putting that row's midline at 22px. + + Height is left to shrink-wrap the island, and the box is click-through, so it + never steals a press meant for the bar underneath. */ +.island-anchor { + position: absolute; + top: 11px; + right: 0; + left: 0; + z-index: 20; + display: flex; + align-items: flex-start; + justify-content: center; + pointer-events: none; +} + +.island { + display: flex; + overflow: hidden; + border: 1px solid var(--tempest-island-border-closed); + border-radius: 999px; + background: var(--tempest-island-bg-closed); + /* Specular hairline along the top edge — the thing that makes it read as a + lit object rather than a flat chip. */ + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07); + color: var(--tempest-fg-default); + font-family: var(--tempest-font-sans, "Geist", sans-serif); + font-feature-settings: "tnum" 1; + outline: none; + pointer-events: auto; + transition: background 0.28s ease, border-color 0.28s ease, box-shadow 0.4s ease; + -webkit-app-region: no-drag; +} + +.island[data-open] { + border-color: var(--tempest-island-border-open); + background: var(--tempest-island-bg-open); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.09), + 0 1px 2px var(--tempest-ui-shadow-sm), + 0 16px 40px -8px var(--tempest-ui-shadow-lg); + -webkit-backdrop-filter: blur(24px) saturate(180%); + backdrop-filter: blur(24px) saturate(180%); +} + +/* ─── Tone ─────────────────────────────────────────────────────────────────── */ + +.island[data-level="ok"], +.island-row[data-level="ok"] { + --island-tone: var(--tempest-accent-green); +} + +.island[data-level="warn"], +.island-row[data-level="warn"] { + --island-tone: var(--tempest-accent-yellow); +} + +.island[data-level="crit"], +.island-row[data-level="crit"] { + --island-tone: var(--tempest-accent-red); +} + +/* ─── Collapsed pill ───────────────────────────────────────────────────────── */ + +.island-pill { + display: flex; + height: 22px; + align-items: center; + gap: 8px; + padding: 0 9px; +} + +/* Fill ring: a conic arc masked to an annulus. One element, no SVG. */ +.island-gauge { + width: 13px; + height: 13px; + flex-shrink: 0; + border-radius: 50%; + background: conic-gradient( + from -90deg, + var(--island-tone) calc(var(--fill, 0) * 1%), + var(--tempest-bg-hover) 0 + ); + -webkit-mask: radial-gradient(closest-side, transparent 57%, #000 58%); + mask: radial-gradient(closest-side, transparent 57%, #000 58%); +} + +.island[data-level="crit"] .island-gauge { + animation: island-breathe 2s ease-in-out infinite; +} + +@keyframes island-breathe { + 50% { opacity: 0.4; } +} + +.island-peak { + display: flex; + align-items: center; + gap: 10px; + font-size: 11px; + line-height: 1; + white-space: nowrap; +} + +.island-peak-label { + color: var(--tempest-fg-muted); + font-weight: 500; + letter-spacing: -0.005em; +} + +.island-peak-pct { + color: var(--island-tone); + font-variant-numeric: tabular-nums; + font-weight: 600; + letter-spacing: -0.01em; +} + +/* ─── Open panel ───────────────────────────────────────────────────────────── */ + +/* The only measurement in the file: bars are proportional, so the track needs a + floor. A longer label still widens the panel past it. */ +.island-panel { + min-width: 236px; + padding: 11px 13px 13px; +} + +.island-title { + margin-bottom: 10px; + color: var(--tempest-fg-subtle); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.island-row + .island-row { + margin-top: 12px; +} + +.island-row-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; +} + +.island-row-label { + overflow: hidden; + color: var(--tempest-fg-default); + font-size: 11.5px; + font-weight: 500; + letter-spacing: -0.008em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.island-row-pct { + flex-shrink: 0; + color: var(--island-tone); + font-size: 11px; + font-variant-numeric: tabular-nums; + font-weight: 600; + letter-spacing: -0.01em; +} + +.island-bar { + height: 3px; + overflow: hidden; + border-radius: 999px; + background: var(--tempest-bg-hover); +} + +/* Full width, scaled by the spring — so the fill animates on the compositor + rather than relaying out the track on every frame. */ +.island-bar-fill { + width: 100%; + height: 100%; + border-radius: inherit; + background: var(--island-tone); + transform-origin: left; +} + +.island-row-reset { + margin-top: 6px; + color: var(--tempest-fg-subtle); + font-size: 10px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.005em; + line-height: 1; +} + +@media (prefers-reduced-motion: reduce) { + .island { + transition: none; + } + + .island[data-level="crit"] .island-gauge { + animation: none; + } +} diff --git a/src/components/DynamicIsland.tsx b/src/components/DynamicIsland.tsx new file mode 100644 index 0000000..c5aa7d6 --- /dev/null +++ b/src/components/DynamicIsland.tsx @@ -0,0 +1,116 @@ +import { useState, type CSSProperties } from "react"; +import { AnimatePresence, motion, useReducedMotion, type Transition, type Variants } from "motion/react"; +import { formatReset, levelOf, pct, peakQuota, type QuotaWindow } from "../lib/quota"; +import "./DynamicIsland.css"; + +/** Arrives fast, overshoots a little, settles. Interruptible mid-flight. */ +const SPRING: Transition = { type: "spring", stiffness: 420, damping: 36, mass: 0.9 }; + +const PANEL: Variants = { + hidden: { opacity: 0 }, + shown: { opacity: 1, transition: { staggerChildren: 0.045, delayChildren: 0.05 } }, +}; + +const ROW: Variants = { + hidden: { opacity: 0, y: 8 }, + shown: { opacity: 1, y: 0 }, +}; + +/** + * Toolbar island for live rate-limit windows. + * + * Quiet when there is room — a fill ring and nothing else. As a window fills it + * widens and names the one that is filling. Hover (or focus) opens every window + * with its bar and reset time. + * + * Every size here comes from content: `layout` morphs the shape to whatever the + * labels need, so nothing is a hardcoded width waiting to truncate. + * + * Presentational: no polling, no fetching. Renders nothing without data, so it + * is inert until a reader supplies `quotas`. + */ +export function DynamicIsland({ quotas }: { quotas: QuotaWindow[] }) { + const [open, setOpen] = useState(false); + const still = useReducedMotion(); + const spring = still ? { duration: 0 } : SPRING; + + const peak = peakQuota(quotas); + if (!peak) return null; + + const level = levelOf(peak.used); + + return ( + // Full-width, click-through anchor: the island is flex-centred inside it, so + // `layout` owns the element's own transform without fighting a translate. +
+ setOpen(true)} + onMouseLeave={() => setOpen(false)} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + animate={{ borderRadius: open ? 18 : 999 }} + transition={spring} + > + + {!open ? ( + + {/* --fill drives the conic arc; the ring is one element, no SVG. */} + + {level !== "ok" && ( + + {peak.label} + {pct(peak.used)}% + + )} + + ) : ( + + Usage + {quotas.map(q => ( + +
+ {q.label} + {pct(q.used)}% +
+
+ +
+
{formatReset(q.resetsAt)}
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/components/ProjectSettingsPanel/PermissionsSection.tsx b/src/components/ProjectSettingsPanel/PermissionsSection.tsx index 9a04566..731790a 100644 --- a/src/components/ProjectSettingsPanel/PermissionsSection.tsx +++ b/src/components/ProjectSettingsPanel/PermissionsSection.tsx @@ -5,7 +5,9 @@ export function PermissionsSection({ value, onChange }: { onChange: (v: ProjectSettings["permissions"]) => void; }) { const allowSkip = value.allowSkipPermissions; - const setAllowSkip = (next: boolean) => onChange({ allowSkipPermissions: next }); + const setAllowSkip = (next: boolean) => onChange({ ...value, allowSkipPermissions: next }); + const allowHooks = value.allowRepoHooks; + const setAllowHooks = (next: boolean) => onChange({ ...value, allowRepoHooks: next }); return (
@@ -32,6 +34,26 @@ export function PermissionsSection({ value, onChange }: {
+ +
setAllowHooks(!allowHooks)}> +
+ Run worktree hooks without asking + + Runs this repo's{" "} + setup and teardown{" "} + commands from tempest.yml automatically. Off, you are + shown the exact commands and asked each time. + +
+ +
); diff --git a/src/components/ProjectSettingsPanel/useProjectSettings.ts b/src/components/ProjectSettingsPanel/useProjectSettings.ts index 58f38c0..10aff45 100644 --- a/src/components/ProjectSettingsPanel/useProjectSettings.ts +++ b/src/components/ProjectSettingsPanel/useProjectSettings.ts @@ -11,7 +11,7 @@ export interface ProjectSettings { sandbox: { mode: "off" | "monitor" | "enforce" }; network: { policy: "permissive" | "restrictive"; allowHosts: string[]; blockHosts: string[] }; filesystem: { rwPaths: string[]; roPaths: string[] }; - permissions: { allowSkipPermissions: boolean }; + permissions: { allowSkipPermissions: boolean; allowRepoHooks: boolean }; agents: { permitted: string[] }; database: { isolationEnabled: boolean }; /// OS-level quotas per session. `null` leaves a limit at the OS default. @@ -28,7 +28,9 @@ const DEFAULTS: ProjectSettings = { sandbox: { mode: "monitor" }, network: { policy: "permissive", allowHosts: ["api.anthropic.com", "*.github.com"], blockHosts: [] }, filesystem: { rwPaths: ["."], roPaths: [] }, - permissions: { allowSkipPermissions: true }, + // Hooks run commands the repo checked in, so they are opt-in: off means the + // user is asked before each run, never that the commands run unannounced. + permissions: { allowSkipPermissions: true, allowRepoHooks: false }, agents: { permitted: getAgents().map((a) => a.hint) }, database: { isolationEnabled: false }, resources: { maxMemoryMb: null, maxProcesses: null, maxDiskWriteMb: null, cpuWeight: null }, diff --git a/src/components/SettingsPanel/SecuritySection.tsx b/src/components/SettingsPanel/SecuritySection.tsx index 45646ad..20eb9cb 100644 --- a/src/components/SettingsPanel/SecuritySection.tsx +++ b/src/components/SettingsPanel/SecuritySection.tsx @@ -1,7 +1,13 @@ import { useSettings, updateSetting } from "../../store/appSettings"; +import { setPreciseAgentStatus } from "../../store/agentHooks"; export function SecuritySection() { const s = useSettings(); + const togglePreciseStatus = () => { + const next = !s.preciseAgentStatus; + updateSetting("preciseAgentStatus", next); + void setPreciseAgentStatus(next); + }; return (
Security
@@ -46,6 +52,27 @@ export function SecuritySection() {
+ +
+
+ Precise agent status (hooks) + + Install a managed lifecycle hook into supported agents' own configs so + working / waiting-for-you / done is driven by real events instead of + scraping terminal output. Preserves your existing hooks. Off removes the + managed hooks and falls back to the heuristic. Supported: Claude Code, + Gemini, Cursor, Copilot, Antigravity, Codex, Hermes, Opencode. + +
+ +
); diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 23538f0..374ba2c 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -1,12 +1,16 @@ import { Mark } from "../assets/Mark"; +import { DynamicIsland } from "./DynamicIsland"; +import type { QuotaWindow } from "../lib/quota"; interface Props { tabsMode: "designed" | "tabbed" | "ver1" | "designer"; projectName: string; rightActions: React.ReactNode; + /** Empty until a reader supplies it; the island renders nothing then. */ + quotas?: QuotaWindow[]; } -export function Toolbar({ tabsMode, projectName, rightActions }: Props) { +export function Toolbar({ tabsMode, projectName, rightActions, quotas = [] }: Props) { const modeClass = tabsMode === "tabbed" ? " tabs-tabbed" : tabsMode === "ver1" ? " tabs-ver1" : tabsMode === "designer" ? " tabs-designer" @@ -26,6 +30,10 @@ export function Toolbar({ tabsMode, projectName, rightActions }: Props) { )} + {/* Centred on the bar itself, not between the ends — so it holds the + middle no matter how wide the project name or the actions slot get. */} + + {/* Right end — actions slot */}
{rightActions} diff --git a/src/components/WorkspaceView.css b/src/components/WorkspaceView.css index 0221041..ed1f637 100644 --- a/src/components/WorkspaceView.css +++ b/src/components/WorkspaceView.css @@ -1434,6 +1434,54 @@ flex-shrink: 0; } +/* tempest.yml worktree hooks — consent prompt and run progress */ +.hook-consent, +.hook-progress { + width: 420px; +} + +.hook-consent .naming-modal-desc strong { + color: var(--tempest-fg-default); + font-weight: 500; +} + +.hook-cmd-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 180px; + overflow-y: auto; + padding: 10px; + background: var(--tempest-bg-editor); + border: 1px solid var(--tempest-border-default); + border-radius: 6px; +} + +.hook-cmd, +.hook-progress-line { + font-family: var(--font-mono); + font-size: 11px; + color: var(--tempest-fg-default); + white-space: pre-wrap; + word-break: break-all; +} + +.hook-progress-line { + display: block; + color: var(--tempest-fg-subtle); + padding: 10px; + background: var(--tempest-bg-editor); + border: 1px solid var(--tempest-border-default); + border-radius: 6px; + max-height: 96px; + overflow: hidden; +} + +.hook-spinner { + animation: spin 0.75s linear infinite; + flex-shrink: 0; +} + @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } diff --git a/src/components/WorkspaceView.tsx b/src/components/WorkspaceView.tsx index 901438a..78db241 100644 --- a/src/components/WorkspaceView.tsx +++ b/src/components/WorkspaceView.tsx @@ -49,6 +49,7 @@ import { SunMoon, GitBranch, Cog, + Loader, } from "lucide-react"; import { setWorkState, clearWorkState, getWorkState, setAttention, getAttention } from "../store/workState"; import { useKeybindings, matchesEvent, formatShortcut } from "../store/keybindings"; @@ -77,9 +78,11 @@ import { StatusBar } from "./StatusBar"; import { UpdateNotice } from "./UpdateNotice"; import { useAvailableUpdate, dismissUpdate, startUpdateChecks, installUpdate, openReleaseNotes } from "../store/updates"; import { startModelManifestFetch } from "../lib/remoteConfig"; +import { startAgentHooks } from "../store/agentHooks"; import { AtlasIndexModal } from "./AtlasIndexModal"; import { KnowledgeBasePage } from "./KnowledgeBasePage"; import { Toolbar } from "./Toolbar"; +import { SAMPLE_QUOTAS } from "../lib/quota"; import AgentTabs from "./AgentTabs"; import IconCapsule from "./IconCapsule"; import { SidebarWorkBadge, ProjectWorkBadge, AttentionPill } from "./SessionBadges"; @@ -113,6 +116,7 @@ function sbRows(live: Session[], ghosts: WorktreeSession[]): SbRow[] { } import { folderName, timeAgo } from "../lib/format"; +import { getHookCommands, runHook, type HookKind } from "../lib/worktreeHooks"; import { buildAgentArgs } from "../lib/agentArgs"; import { type SplitDir, @@ -246,13 +250,21 @@ export function WorkspaceView({ zen, name, path }: Props) { // Delete workspace dialog state const [deleteDialog, setDeleteDialog] = useState(null); + /// Pending consent for a repo's tempest.yml hook. `resolve` is the awaiting + /// hook runner — answering the dialog is what lets it continue. + const [hookConsent, setHookConsent] = useState< + { kind: HookKind; commands: string[]; cwd: string; resolve: (ok: boolean) => void } | null + >(null); + /// Latest output line while a hook is running, shown in whichever modal + /// triggered it. + const [hookRun, setHookRun] = useState<{ kind: HookKind; line: string } | null>(null); /// Message from a project-policy refusal at spawn time, or a failed update. /// Rendered as a dismissible notice near the bottom of the window. const [policyError, setPolicyError] = useState(null); // Update checks: one now, then daily for as long as the app stays open. const availableUpdate = useAvailableUpdate(); - useEffect(() => { startUpdateChecks(); void startModelManifestFetch(); }, []); + useEffect(() => { startUpdateChecks(); void startModelManifestFetch(); void startAgentHooks(); }, []); /// The Overview footer has to stay mounted while it retracts, so ✕ sets this /// instead of dismissing outright; the dismissal lands on animation end. @@ -735,6 +747,12 @@ export function WorkspaceView({ zen, name, path }: Props) { const { worktree, projectPath, projectId, sessionId, branchName } = deleteDialog; setDeleteDialog((d) => d ? { ...d, loading: true, error: null } : null); try { + // Teardown gets its turn while the worktree still exists. Failure is + // reported but never blocks the delete: the directory is going away, and + // a broken cleanup command must not strand a workspace on disk. + const failure = await runWorktreeHook("teardown", projectPath, projectId, worktree.path); + if (failure) console.warn(`[tempest.yml] teardown failed: ${failure}`); + // Single Rust round-trip: kill the PTY child, wait for it to exit, then // remove the worktree directory. Collapsing these two operations into one // invoke is the fix for the delete race — there is no longer any window @@ -911,7 +929,10 @@ export function WorkspaceView({ zen, name, path }: Props) { sandbox: sandboxParam, policy: policyParam, dbIsolation: projectSettings.database.isolationEnabled, - env: tempestConfig.env ?? null, + // TEMPEST_SESSION lets an installed agent hook (see src/lib/agentHooks) + // attribute its lifecycle POSTs back to this exact session, without + // touching the PTY spawn internals. Merged over tempest.yml env. + env: { ...(tempestConfig.env ?? {}), TEMPEST_SESSION: sessionId }, onEvent: channel, }); } catch (e) { @@ -1182,6 +1203,48 @@ export function WorkspaceView({ zen, name, path }: Props) { return name; } + // ── tempest.yml worktree hooks ───────────────────────────────────────────── + // `setup` runs once the worktree exists, `teardown` before it is removed. + // The commands come from a file the repo carries, so unless the project has + // opted into running them unprompted, the user is shown the exact commands + // and asked. Declining is not an error — the worktree is simply left as git + // made it. + // + // Returns the failure message, or null when the hook ran clean / was skipped. + async function runWorktreeHook( + kind: HookKind, + projectPath: string, + projectId: string, + cwd: string, + ): Promise { + let commands: string[]; + try { + commands = await getHookCommands(projectPath, kind); + } catch { + return null; // unreadable config is "no hooks", same as everywhere else + } + if (!commands.length) return null; + + const settings = await loadProjectSettings(projectId, projectPath); + if (!settings.permissions.allowRepoHooks) { + const approved = await new Promise((resolve) => + setHookConsent({ kind, commands, cwd, resolve }) + ); + setHookConsent(null); + if (!approved) return null; + } + + setHookRun({ kind, line: commands[0] }); + try { + await runHook(projectPath, kind, cwd, (line) => setHookRun({ kind, line })); + return null; + } catch (e) { + return String(e); + } finally { + setHookRun(null); + } + } + async function launchTerminalWorktree() { const activePath = getActivePath(); const workingProjectId = pendingProjectId; @@ -1227,6 +1290,11 @@ export function WorkspaceView({ zen, name, path }: Props) { const result = await createWorktree({ projectPath: activePath, name: branchName, existingBranch }); worktreePath = result.path; addWorktreeToState({ name: branchName, path: worktreePath }, workingProjectId); + // The worktree exists either way; a failed setup leaves it in the + // sidebar and keeps the modal open with the reason, rather than opening + // a session into a half-prepared checkout. + const failure = await runWorktreeHook("setup", activePath, workingProjectId ?? "", worktreePath); + if (failure) { setTerminalError(failure); return; } } await openSession(sessionName, worktreePath, workingProjectId ?? "", agent, prompt, undefined, undefined, undefined, undefined, false, undefined, undefined); resetTerminalModal(); @@ -1254,6 +1322,8 @@ export function WorkspaceView({ zen, name, path }: Props) { await gitInit(activePath); const result = await createWorktree({ projectPath: activePath, name: fullName }); addWorktreeToState({ name: fullName, path: result.path }, workingProjectId); + const failure = await runWorktreeHook("setup", activePath, workingProjectId ?? "", result.path); + if (failure) { setTerminalError(failure); return; } await openSession(sessionName, result.path, workingProjectId ?? "", agent, prompt, undefined); resetTerminalModal(); } catch (e) { @@ -1767,6 +1837,7 @@ export function WorkspaceView({ zen, name, path }: Props) { @@ -2928,6 +2999,58 @@ export function WorkspaceView({ zen, name, path }: Props) { /> )} + {/* Consent for a repo-supplied hook — the commands are shown verbatim, + because approving this runs code the repo carries, not the user's. */} + {hookConsent && createPortal( +
+
e.stopPropagation()}> +
+ + Run this repo's {hookConsent.kind} commands? +
+

+ tempest.yml in this repository asks to run the following in{" "} + {folderName(hookConsent.cwd)}. These commands come from the repo, not + from Tempest. +

+
+ {hookConsent.commands.map((c, i) => ( + {c} + ))} +
+
+ + +
+
+
, + document.body + )} + + {/* Hook progress — both callers block on it, so one overlay serves both. */} + {hookRun && createPortal( +
+
+
+ + Running {hookRun.kind}… +
+ {hookRun.line} +
+
, + document.body + )} + {showTerminalNaming && ( nul 2>nul`, + `if "%TEMPEST_HOOK_PORT%"=="" exit /b 0`, + `if "%TEMPEST_SESSION%"=="" exit /b 0`, + `"%SystemRoot%\\System32\\curl.exe" -sS -X POST "http://127.0.0.1:%TEMPEST_HOOK_PORT%/hook/antigravity" --connect-timeout 0.5 --max-time 1.5 -H "X-Tempest-Token: %TEMPEST_HOOK_TOKEN%" -H "X-Tempest-Session: %TEMPEST_SESSION%" -H "X-Tempest-Event: %TEMPEST_HOOK_EVENT%" -H "Content-Type: application/json" --data-binary @- >nul 2>nul`, + "exit /b 0", + "", + ].join("\r\n"); + } + return [ + "#!/bin/sh", + 'if [ "$TEMPEST_HOOK_EVENT" = "Stop" ]; then printf \'{"decision":""}\\n\'; else printf \'{}\\n\'; fi', + 'if [ -r "' + paths.endpointEnv + '" ]; then . "' + paths.endpointEnv + '" 2>/dev/null || :; fi', + 'if [ -z "$TEMPEST_HOOK_PORT" ] || [ -z "$TEMPEST_SESSION" ]; then exit 0; fi', + 'curl -sS -X POST "http://127.0.0.1:${TEMPEST_HOOK_PORT}/hook/antigravity" \\', + " --connect-timeout 0.5 --max-time 1.5 \\", + ' -H "X-Tempest-Token: ${TEMPEST_HOOK_TOKEN}" \\', + ' -H "X-Tempest-Session: ${TEMPEST_SESSION}" \\', + ' -H "X-Tempest-Event: ${TEMPEST_HOOK_EVENT}" \\', + ' -H "Content-Type: application/json" \\', + " --data-binary @- >/dev/null 2>&1 || true", + "exit 0", + "", + ].join("\n"); +} + +// Windows wrapper: set the event env, call the core; if the core is missing, +// still satisfy Antigravity's stdout contract so it never stalls. +function wrapperScript(event: string, coreFileName: string): string { + return [ + "@echo off", + "setlocal", + `set "${EVENT_ENV}=${event}"`, + `set "CORE=%~dp0${coreFileName}"`, + 'if exist "%CORE%" (', + ' call "%CORE%"', + " exit /b 0", + ")", + 'if /I "%' + EVENT_ENV + '%"=="Stop" (echo {"decision":""}) else (echo {})', + "exit /b 0", + "", + ].join("\r\n"); +} + +interface HookDef { + matcher?: string; + command?: string; + hooks?: { command?: string; [k: string]: unknown }[]; + [k: string]: unknown; +} + +function defManaged(def: HookDef): boolean { + return isManaged(def.command) || (Array.isArray(def.hooks) && def.hooks.some((h) => isManaged(h.command))); +} + +export const antigravityAdapter: HookAdapter = { + id: "antigravity", + coversWaiting: false, + + plan(paths: HookPaths): AdapterInstall { + const coreFileName = `${CORE_STEM}.${paths.windows ? "cmd" : "sh"}`; + const corePath = joinNative(paths.windows, paths.hooksDir, coreFileName); + + const scripts = [{ path: corePath, content: coreScript(paths), executable: !paths.windows }]; + if (paths.windows) { + for (const ev of EVENTS) { + scripts.push({ + path: joinNative(true, paths.hooksDir, ev.wrapper), + content: wrapperScript(ev.name, coreFileName), + executable: false, + }); + } + } + + const commandFor = (ev: AntigravityEvent): string => + paths.windows + ? wrapBarePathOrEncoded(joinNative(true, paths.hooksDir, ev.wrapper)) + : wrapPosix(corePath, { [EVENT_ENV]: ev.name }); + + const defFor = (ev: AntigravityEvent): HookDef => + ev.schema === "tool" + ? { matcher: "*", hooks: [{ type: "command", command: commandFor(ev), timeout: TIMEOUT_SECONDS }] } + : { type: "command", command: commandFor(ev), timeout: TIMEOUT_SECONDS }; + + const bundleOf = (config: JsonObject): Record => { + const b = config[BUNDLE_KEY]; + return b && typeof b === "object" && !Array.isArray(b) ? { ...(b as Record) } : {}; + }; + const sweep = (bundle: Record): Record => { + const next: Record = {}; + for (const [name, defs] of Object.entries(bundle)) { + if (!Array.isArray(defs)) { + next[name] = defs; + continue; + } + const cleaned = defs.filter((d) => !defManaged(d)); + if (cleaned.length > 0) next[name] = cleaned; + } + return next; + }; + + return { + scripts, + configs: [ + { + path: joinNative(paths.windows, paths.home, ".gemini", "config", "hooks.json"), + apply: (raw) => { + const config = parseObj(raw); + if (config === null) return null; + const bundle = sweep(bundleOf(config)); + for (const ev of EVENTS) bundle[ev.name] = [...(bundle[ev.name] ?? []), defFor(ev)]; + return serialize({ ...config, [BUNDLE_KEY]: bundle }); + }, + remove: (raw) => { + if (raw === null || raw.trim() === "") return null; + const config = parseObj(raw); + if (config === null) return null; + const before = config[BUNDLE_KEY]; + if (!before || typeof before !== "object") return null; + const bundle = sweep(bundleOf(config)); + const next: JsonObject = { ...config }; + if (Object.keys(bundle).length === 0) delete next[BUNDLE_KEY]; + else next[BUNDLE_KEY] = bundle; + const out = serialize(next); + return out === serialize(config) ? null : out; + }, + }, + ], + }; + }, + + parse(body: unknown): HookState | null { + if (!body || typeof body !== "object") return null; + const o = body as Record; + const event = o.hook_event_name; + if (event === "Stop") { + const fullyIdle = o.fullyIdle ?? o.fully_idle; + return fullyIdle === false ? "working" : "done"; + } + if (event === "PreInvocation" || event === "PostInvocation" || event === "PostToolUse") return "working"; + return null; + }, +}; + +function parseObj(raw: string | null): JsonObject | null { + if (raw === null || raw.trim() === "") return {}; + try { + const p = JSON.parse(raw); + return p && typeof p === "object" && !Array.isArray(p) ? (p as JsonObject) : null; + } catch { + return null; + } +} + +function serialize(config: JsonObject): string { + return JSON.stringify(config, null, 2) + "\n"; +} diff --git a/src/lib/agentHooks/adapters/claude.ts b/src/lib/agentHooks/adapters/claude.ts new file mode 100644 index 0000000..08c0718 --- /dev/null +++ b/src/lib/agentHooks/adapters/claude.ts @@ -0,0 +1,107 @@ +// Claude Code hook adapter. +// +// Claude runs hook commands through a shell (Git Bash on Windows), delivering the +// event as JSON on the hook's stdin. We register a managed script on the events +// that bound a turn, a tool call, or a permission prompt; the script forwards the +// stdin payload to Tempest's loopback server tagged with the session id we +// injected at spawn (`TEMPEST_SESSION`). Docs: docs.anthropic.com Claude Code hooks. + +import type { AdapterInstall, HookAdapter, HookPaths, HookState } from "../types"; +// Explicit .ts so the node self-check (installer.check.ts) can import this +// adapter directly; the bundler tsconfig allows importing .ts extensions. +import { applyJsonConfig, applyNestedHooks, removeJsonConfig, removeNestedHooks, type NestedEvent } from "../schema.ts"; +import { wrapGitBash, wrapPosix } from "../wrappers.ts"; +import { buildHookScript } from "../script.ts"; + +// Tool-lifecycle events carry a "*" matcher; the rest are bare lifecycle events. +const CLAUDE_EVENTS: NestedEvent[] = [ + { name: "UserPromptSubmit" }, + { name: "PreToolUse", matcher: true }, + { name: "PostToolUse", matcher: true }, + // Newer Claude emits a dedicated PermissionRequest; older builds signal via + // Notification. Subscribing both makes the "waiting" signal version-proof. + { name: "PermissionRequest", matcher: true }, + { name: "Notification" }, + { name: "Stop" }, +]; + +const SCRIPT_STEM = "tempest-claude-hook"; + +function fwd(p: string): string { + return p.replace(/\\/g, "/"); +} + +function join(sep: string, ...parts: string[]): string { + return parts.join(sep); +} + +function readString(o: Record, key: string): string | undefined { + const v = o[key]; + return typeof v === "string" ? v : undefined; +} + +function isAskUserQuestion(toolName: string | undefined): boolean { + return (toolName ?? "").trim().toLowerCase() === "askuserquestion"; +} + +export const claudeAdapter: HookAdapter = { + id: "claude", + // Claude emits PermissionRequest / permission Notifications → full authority. + coversWaiting: true, + + plan(paths: HookPaths): AdapterInstall { + const sep = paths.windows ? "\\" : "/"; + const scriptFileName = `${SCRIPT_STEM}.${paths.windows ? "cmd" : "sh"}`; + const scriptPathNative = join(sep, paths.hooksDir, scriptFileName); + const scriptContent = buildHookScript({ + route: "claude", + endpointCmd: paths.endpointCmd, + endpointEnv: paths.endpointEnv, + windows: paths.windows, + }); + const command = paths.windows ? wrapGitBash(fwd(scriptPathNative)) : wrapPosix(scriptPathNative); + return { + scripts: [{ path: scriptPathNative, content: scriptContent, executable: !paths.windows }], + configs: [ + { + path: join(sep, paths.home, ".claude", "settings.json"), + apply: (raw) => applyJsonConfig(raw, (c) => applyNestedHooks(c, command, scriptFileName, CLAUDE_EVENTS)), + remove: (raw) => removeJsonConfig(raw, (c) => removeNestedHooks(c, scriptFileName)), + }, + ], + }; + }, + + parse(body: unknown): HookState | null { + if (!body || typeof body !== "object") return null; + const o = body as Record; + const event = readString(o, "hook_event_name"); + const toolName = readString(o, "tool_name"); + switch (event) { + case "UserPromptSubmit": + return "working"; + case "PreToolUse": + // Claude blocks on AskUserQuestion for a human answer — surface it as + // "needs you", not a spinner that would decay to done. + return isAskUserQuestion(toolName) ? "waiting" : "working"; + case "PostToolUse": + return "working"; + case "PermissionRequest": + return "waiting"; + case "Notification": { + const nt = (readString(o, "notification_type") ?? readString(o, "notificationType") ?? "").toLowerCase(); + if (nt === "permission_prompt" || nt === "elicitation_dialog") return "waiting"; + const msg = (readString(o, "message") ?? "").toLowerCase(); + if (msg.includes("permission") || msg.includes("approve") || msg.includes("waiting for your input")) { + return "waiting"; + } + // Generic notifications (e.g. idle nudges) aren't a state transition. + return null; + } + case "Stop": + return "done"; + default: + return null; + } + }, +}; diff --git a/src/lib/agentHooks/adapters/codex.ts b/src/lib/agentHooks/adapters/codex.ts new file mode 100644 index 0000000..468236d --- /dev/null +++ b/src/lib/agentHooks/adapters/codex.ts @@ -0,0 +1,145 @@ +// Codex CLI hook adapter. +// +// Two files under ~/.codex: hooks.json declares the hook (PascalCase event keys), +// and config.toml must trust each handler via a `[hooks.state.""]` block or +// Codex silently skips it. We merge our managed hook LAST per event (preserving +// user hooks + their trust positions), record each event's group index, then +// write matching trust blocks with the reproduced trusted_hash. If the hash ever +// drifts from Codex's, Codex just ignores the hook and status falls back to PTY. +// Codex has PermissionRequest + auto-allowed request_user_input → coversWaiting. + +import type { AdapterInstall, HookAdapter, HookPaths, JsonObject, HookState } from "../types"; +import { applyJsonConfig, removeJsonConfig } from "../schema.ts"; +import { wrapBarePathOrEncoded, wrapPosix } from "../wrappers.ts"; +import { buildHookScript } from "../script.ts"; +import { joinNative } from "../paths.ts"; +import { CODEX_EVENT_LABEL, upsertTrustBlocks, removeTrustBlocks, type CodexTrustEntry } from "../codexTrust.ts"; + +// hooks.json event keys (PascalCase). Keep in sync with CODEX_EVENT_LABEL. +const CODEX_EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "PostToolUse", "Stop"] as const; +const SCRIPT_STEM = "tempest-codex-hook"; +const TIMEOUT_SECONDS = 10; + +const SNAKE_TO_PASCAL: Record = Object.fromEntries( + Object.entries(CODEX_EVENT_LABEL).map(([pascal, snake]) => [snake, pascal]), +); + +interface HookDef { + matcher?: string; + command?: string; + hooks?: { command?: string; [k: string]: unknown }[]; + [k: string]: unknown; +} + +function isManaged(command: string | undefined): boolean { + return !!command && command.replace(/\\/g, "/").includes(SCRIPT_STEM); +} + +function defManaged(def: HookDef): boolean { + return isManaged(def.command) || (Array.isArray(def.hooks) && def.hooks.some((h) => isManaged(h.command))); +} + +function isAskUserQuestion(name: unknown): boolean { + const n = typeof name === "string" ? name.trim().toLowerCase() : ""; + return n === "askuserquestion" || n === "request_user_input" || n === "requestuserinput"; +} + +export const codexAdapter: HookAdapter = { + id: "codex", + coversWaiting: true, + + plan(paths: HookPaths): AdapterInstall { + const scriptFileName = `${SCRIPT_STEM}.${paths.windows ? "cmd" : "sh"}`; + const scriptPath = joinNative(paths.windows, paths.hooksDir, scriptFileName); + const command = paths.windows ? wrapBarePathOrEncoded(scriptPath) : wrapPosix(scriptPath); + const hooksJsonPath = joinNative(paths.windows, paths.home, ".codex", "hooks.json"); + const configTomlPath = joinNative(paths.windows, paths.home, ".codex", "config.toml"); + + // Filled by the hooks.json apply, read by the config.toml apply (the engine + // runs the two edits in order). Keeps both files' positions consistent. + const positions: Record = {}; + + const hooksApply = (config: JsonObject): JsonObject => { + const prev = (config.hooks && typeof config.hooks === "object" ? config.hooks : {}) as Record; + const nextHooks: Record = { ...prev }; + for (const ev of CODEX_EVENTS) { + const current = Array.isArray(nextHooks[ev]) ? nextHooks[ev] : []; + const cleaned = current.filter((d) => !defManaged(d)); + positions[ev] = cleaned.length; // appended last → this is our group index + nextHooks[ev] = [...cleaned, { hooks: [{ type: "command", command, timeout: TIMEOUT_SECONDS }] }]; + } + return { ...config, hooks: nextHooks }; + }; + const hooksRemove = (config: JsonObject): { config: JsonObject; changed: boolean } => { + const prev = (config.hooks && typeof config.hooks === "object" ? config.hooks : null) as Record | null; + if (!prev) return { config, changed: false }; + const nextHooks: Record = {}; + let changed = false; + for (const [name, defs] of Object.entries(prev)) { + if (!Array.isArray(defs)) { + nextHooks[name] = defs; + continue; + } + const cleaned = defs.filter((d) => !defManaged(d)); + if (cleaned.length !== defs.length) changed = true; + if (cleaned.length > 0) nextHooks[name] = cleaned; + } + return { config: { ...config, hooks: nextHooks }, changed }; + }; + + const trustEntries = (): CodexTrustEntry[] => + CODEX_EVENTS.map((ev) => ({ + sourcePath: hooksJsonPath, + eventLabel: CODEX_EVENT_LABEL[ev], + groupIndex: positions[ev] ?? 0, + handlerIndex: 0, + command, + timeoutSec: TIMEOUT_SECONDS, + })); + + return { + scripts: [ + { + path: scriptPath, + content: buildHookScript({ route: "codex", endpointCmd: paths.endpointCmd, endpointEnv: paths.endpointEnv, windows: paths.windows }), + executable: !paths.windows, + }, + ], + configs: [ + { + path: hooksJsonPath, + apply: (raw) => applyJsonConfig(raw, hooksApply), + remove: (raw) => removeJsonConfig(raw, hooksRemove), + }, + { + path: configTomlPath, + apply: (raw) => { + // The hooks.json apply must have run first to fill positions; if it + // bailed (malformed), don't write trust for a hook we didn't install. + if (Object.keys(positions).length === 0) return null; + const next = upsertTrustBlocks(raw ?? "", trustEntries()); + return next === (raw ?? "") ? null : next; + }, + remove: (raw) => { + if (raw === null) return null; + const { content, changed } = removeTrustBlocks(raw, hooksJsonPath, new Set(Object.values(CODEX_EVENT_LABEL))); + return changed ? content : null; + }, + }, + ], + }; + }, + + parse(body: unknown): HookState | null { + if (!body || typeof body !== "object") return null; + const o = body as Record; + const rawEvent = o.hook_event_name; + const event = typeof rawEvent === "string" ? SNAKE_TO_PASCAL[rawEvent] ?? rawEvent : rawEvent; + const tool = o.tool_name ?? o.name; + const isUserInput = event === "PreToolUse" && isAskUserQuestion(tool); + if (event === "PermissionRequest" || isUserInput) return "waiting"; + if (event === "SessionStart" || event === "UserPromptSubmit" || event === "PreToolUse" || event === "PostToolUse") return "working"; + if (event === "Stop") return "done"; + return null; + }, +}; diff --git a/src/lib/agentHooks/adapters/copilot.ts b/src/lib/agentHooks/adapters/copilot.ts new file mode 100644 index 0000000..9a02571 --- /dev/null +++ b/src/lib/agentHooks/adapters/copilot.ts @@ -0,0 +1,132 @@ +// GitHub Copilot CLI hook adapter. +// +// Config: ~/.copilot/hooks/tempest.json. Copilot's schema puts the command on a +// `powershell` (Windows) or `bash` (posix) key of each definition, and a +// separate managed command is registered PER EVENT so the event name can be +// injected via env (Copilot's payload doesn't name the event). The event reaches +// the server through the X-Tempest-Event header the script sends from that env. +// Copilot has real permission signals (blocking Notification, AskUser tool) → +// coversWaiting = true. + +import type { AdapterInstall, ConfigEdit, HookAdapter, HookPaths, HookState, JsonObject } from "../types"; +import { applyJsonConfig, makeManagedMatcher, removeJsonConfig } from "../schema.ts"; +import { wrapPosix, wrapPowerShellInline } from "../wrappers.ts"; +import { buildHookScript } from "../script.ts"; +import { joinNative } from "../paths.ts"; + +// Only events that map to a state (Copilot also emits subagent/compact events +// we don't model). Notification/PermissionRequest carry the "needs you" signal. +const COPILOT_EVENTS = [ + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Stop", + "ErrorOccurred", + "PermissionRequest", + "Notification", +] as const; + +const SCRIPT_STEM = "tempest-copilot-hook"; +const EVENT_ENV = "TEMPEST_HOOK_EVENT"; + +interface CopilotDef { + command?: string; + bash?: string; + powershell?: string; + hooks?: { command?: string }[]; + [k: string]: unknown; +} + +function defManaged(def: CopilotDef, isManaged: (c: string | undefined) => boolean): boolean { + return ( + isManaged(def.command) || + isManaged(def.bash) || + isManaged(def.powershell) || + (Array.isArray(def.hooks) && def.hooks.some((h) => isManaged(h.command))) + ); +} + +function isAskUserTool(name: unknown): boolean { + const n = typeof name === "string" ? name.trim().toLowerCase() : ""; + return n === "askuser" || n === "askuserquestion"; +} + +export const copilotAdapter: HookAdapter = { + id: "copilot", + coversWaiting: true, + + plan(paths: HookPaths): AdapterInstall { + const scriptFileName = `${SCRIPT_STEM}.${paths.windows ? "cmd" : "sh"}`; + const scriptPath = joinNative(paths.windows, paths.hooksDir, scriptFileName); + const scriptContent = buildHookScript({ + route: "copilot", + endpointCmd: paths.endpointCmd, + endpointEnv: paths.endpointEnv, + windows: paths.windows, + emitEmptyJson: true, + eventEnvVar: EVENT_ENV, + }); + // Per-event command: inject the event name via env so the script reports it. + const perEvent = (event: string): CopilotDef => + paths.windows + ? { type: "command", powershell: wrapPowerShellInline(scriptPath, { [EVENT_ENV]: event }), timeoutSec: 5 } + : { type: "command", bash: wrapPosix(scriptPath, { [EVENT_ENV]: event }), timeoutSec: 5 }; + const isManaged = makeManagedMatcher(scriptFileName); + + const apply = (config: JsonObject): JsonObject => { + const prev = (config.hooks && typeof config.hooks === "object" ? config.hooks : {}) as Record; + const nextHooks: Record = { ...prev }; + for (const ev of COPILOT_EVENTS) { + const current = Array.isArray(nextHooks[ev]) ? nextHooks[ev] : []; + const cleaned = current.filter((d) => !defManaged(d, isManaged)); + nextHooks[ev] = [...cleaned, perEvent(ev)]; + } + return { ...config, hooks: nextHooks }; + }; + const remove = (config: JsonObject): { config: JsonObject; changed: boolean } => { + const prev = (config.hooks && typeof config.hooks === "object" ? config.hooks : null) as Record | null; + if (!prev) return { config, changed: false }; + const nextHooks: Record = {}; + let changed = false; + for (const [name, defs] of Object.entries(prev)) { + if (!Array.isArray(defs)) { + nextHooks[name] = defs; + continue; + } + const cleaned = defs.filter((d) => !defManaged(d, isManaged)); + if (cleaned.length !== defs.length) changed = true; + if (cleaned.length > 0) nextHooks[name] = cleaned; + } + return { config: { ...config, hooks: nextHooks }, changed }; + }; + + const config: ConfigEdit = { + path: joinNative(paths.windows, paths.home, ".copilot", "hooks", "tempest.json"), + apply: (raw) => applyJsonConfig(raw, apply), + remove: (raw) => removeJsonConfig(raw, remove), + }; + return { scripts: [{ path: scriptPath, content: scriptContent, executable: !paths.windows }], configs: [config] }; + }, + + parse(body: unknown): HookState | null { + if (!body || typeof body !== "object") return null; + const o = body as Record; + const event = o.hook_event_name; + const nt = (typeof o.notification_type === "string" ? o.notification_type : typeof o.notificationType === "string" ? o.notificationType : "").toLowerCase(); + if (event === "Notification") { + return nt === "permission_prompt" || nt === "elicitation_dialog" ? "waiting" : null; + } + if (event === "PreToolUse" || event === "PermissionRequest") { + return isAskUserTool(o.tool_name) ? "waiting" : "working"; + } + if (event === "SessionStart" || event === "UserPromptSubmit" || event === "PostToolUse" || event === "PostToolUseFailure") { + return "working"; + } + if (event === "Stop" || event === "SessionEnd") return "done"; + if (event === "ErrorOccurred") return o.recoverable === true ? "working" : "done"; + return null; + }, +}; diff --git a/src/lib/agentHooks/adapters/cursor.ts b/src/lib/agentHooks/adapters/cursor.ts new file mode 100644 index 0000000..caad9d5 --- /dev/null +++ b/src/lib/agentHooks/adapters/cursor.ts @@ -0,0 +1,107 @@ +// Cursor Agent hook adapter. +// +// Config: ~/.cursor/hooks.json. Cursor's schema differs from Claude's: the +// command sits DIRECTLY on the definition (`{ command, timeout }`, not nested +// under `hooks`), and the file requires a top-level `version: 1` +// (https://cursor.com/docs/hooks). Cursor treats its shell/MCP pre-execution +// gates as ordinary work (not approval prompts), so it has no "waiting" event: +// coversWaiting = false keeps the PTY attention path live. + +import type { AdapterInstall, HookAdapter, HookPaths, HookState, JsonObject } from "../types"; +import { applyJsonConfig, makeManagedMatcher, removeJsonConfig } from "../schema.ts"; +import { wrapEncodedPowerShell, wrapPosix } from "../wrappers.ts"; +import { buildHookScript } from "../script.ts"; +import { joinNative } from "../paths.ts"; + +const CURSOR_EVENTS = [ + "beforeSubmitPrompt", + "stop", + "preToolUse", + "postToolUse", + "postToolUseFailure", + "beforeShellExecution", + "beforeMCPExecution", + "afterAgentResponse", +] as const; + +const SCRIPT_STEM = "tempest-cursor-hook"; +const CURSOR_TIMEOUT_SECONDS = 10; + +interface CursorDef { + command?: string; + hooks?: { command?: string }[]; + [k: string]: unknown; +} + +function defManaged(def: CursorDef, isManaged: (c: string | undefined) => boolean): boolean { + return isManaged(def.command) || (Array.isArray(def.hooks) && def.hooks.some((h) => isManaged(h.command))); +} + +function applyCursorHooks(config: JsonObject, command: string, scriptFileName: string): JsonObject { + const isManaged = makeManagedMatcher(scriptFileName); + const prev = (config.hooks && typeof config.hooks === "object" ? config.hooks : {}) as Record; + const nextHooks: Record = { ...prev }; + for (const ev of CURSOR_EVENTS) { + const current = Array.isArray(nextHooks[ev]) ? nextHooks[ev] : []; + const cleaned = current.filter((d) => !defManaged(d, isManaged)); + nextHooks[ev] = [...cleaned, { command, timeout: CURSOR_TIMEOUT_SECONDS }]; + } + const next: JsonObject = { ...config, hooks: nextHooks }; + // Cursor's schema requires a top-level version; keep any user-pinned value. + if (next.version === undefined) next.version = 1; + return next; +} + +function removeCursorHooks(config: JsonObject, scriptFileName: string): { config: JsonObject; changed: boolean } { + const isManaged = makeManagedMatcher(scriptFileName); + const prev = (config.hooks && typeof config.hooks === "object" ? config.hooks : null) as Record | null; + if (!prev) return { config, changed: false }; + const nextHooks: Record = {}; + let changed = false; + for (const [name, defs] of Object.entries(prev)) { + if (!Array.isArray(defs)) { + nextHooks[name] = defs; + continue; + } + const cleaned = defs.filter((d) => !defManaged(d, isManaged)); + if (cleaned.length !== defs.length) changed = true; + if (cleaned.length > 0) nextHooks[name] = cleaned; + } + return { config: { ...config, hooks: nextHooks }, changed }; +} + +export const cursorAdapter: HookAdapter = { + id: "cursor", + coversWaiting: false, + + plan(paths: HookPaths): AdapterInstall { + const scriptFileName = `${SCRIPT_STEM}.${paths.windows ? "cmd" : "sh"}`; + const scriptPath = joinNative(paths.windows, paths.hooksDir, scriptFileName); + const scriptContent = buildHookScript({ + route: "cursor", + endpointCmd: paths.endpointCmd, + endpointEnv: paths.endpointEnv, + windows: paths.windows, + emitEmptyJson: true, + }); + const command = paths.windows ? wrapEncodedPowerShell(scriptPath) : wrapPosix(scriptPath); + return { + scripts: [{ path: scriptPath, content: scriptContent, executable: !paths.windows }], + configs: [ + { + path: joinNative(paths.windows, paths.home, ".cursor", "hooks.json"), + apply: (raw) => applyJsonConfig(raw, (c) => applyCursorHooks(c, command, scriptFileName)), + remove: (raw) => removeJsonConfig(raw, (c) => removeCursorHooks(c, scriptFileName)), + }, + ], + }; + }, + + parse(body: unknown): HookState | null { + if (!body || typeof body !== "object") return null; + const event = (body as Record).hook_event_name ?? (body as Record).hookEventName; + if (event === "stop" || event === "sessionEnd") return "done"; + if (typeof event === "string" && (CURSOR_EVENTS as readonly string[]).includes(event)) return "working"; + return null; + }, +}; diff --git a/src/lib/agentHooks/adapters/gemini.ts b/src/lib/agentHooks/adapters/gemini.ts new file mode 100644 index 0000000..760c622 --- /dev/null +++ b/src/lib/agentHooks/adapters/gemini.ts @@ -0,0 +1,73 @@ +// Gemini CLI hook adapter. +// +// Config: ~/.gemini/settings.json, Claude-nested schema. Quirks vs Claude: +// - Gemini's hook `timeout` unit is MILLISECONDS, not seconds. +// - Gemini parses the hook's stdout as JSON, so the script must print `{}`. +// - Gemini's tool events are BeforeTool/AfterTool (not Pre/PostToolUse), and it +// has NO permission-prompt hook — approvals are inline UI. So working/done are +// precise but "needs you" is NOT covered: coversWaiting = false keeps the PTY +// attention heuristics (Gemini's ✋ title) live. + +import type { AdapterInstall, HookAdapter, HookPaths, HookState } from "../types"; +import { applyJsonConfig, applyNestedHooks, removeJsonConfig, removeNestedHooks, type NestedEvent } from "../schema.ts"; +import { wrapEncodedPowerShell, wrapPosix } from "../wrappers.ts"; +import { buildHookScript } from "../script.ts"; +import { joinNative } from "../paths.ts"; + +// No matcher: Gemini emits a single managed entry per event bucket. +const GEMINI_EVENTS: NestedEvent[] = [ + { name: "BeforeAgent" }, + { name: "BeforeTool" }, + { name: "AfterTool" }, + { name: "AfterAgent" }, +]; + +const GEMINI_TIMEOUT_MS = 10_000; +const SCRIPT_STEM = "tempest-gemini-hook"; + +export const geminiAdapter: HookAdapter = { + id: "gemini", + // No permission event in Gemini's hook set — keep the title/OSC attention path. + coversWaiting: false, + + plan(paths: HookPaths): AdapterInstall { + const scriptFileName = `${SCRIPT_STEM}.${paths.windows ? "cmd" : "sh"}`; + const scriptPath = joinNative(paths.windows, paths.hooksDir, scriptFileName); + const scriptContent = buildHookScript({ + route: "gemini", + endpointCmd: paths.endpointCmd, + endpointEnv: paths.endpointEnv, + windows: paths.windows, + emitEmptyJson: true, + }); + // Gemini runs the hook command through an unspecified shell; the encoded + // PowerShell launcher runs it reliably on Windows. + const command = paths.windows ? wrapEncodedPowerShell(scriptPath) : wrapPosix(scriptPath); + return { + scripts: [{ path: scriptPath, content: scriptContent, executable: !paths.windows }], + configs: [ + { + path: joinNative(paths.windows, paths.home, ".gemini", "settings.json"), + apply: (raw) => + applyJsonConfig(raw, (c) => applyNestedHooks(c, command, scriptFileName, GEMINI_EVENTS, GEMINI_TIMEOUT_MS)), + remove: (raw) => removeJsonConfig(raw, (c) => removeNestedHooks(c, scriptFileName)), + }, + ], + }; + }, + + parse(body: unknown): HookState | null { + if (!body || typeof body !== "object") return null; + const event = (body as Record).hook_event_name; + switch (event) { + case "BeforeAgent": + case "BeforeTool": + case "AfterTool": + return "working"; + case "AfterAgent": + return "done"; + default: + return null; + } + }, +}; diff --git a/src/lib/agentHooks/adapters/hermes.ts b/src/lib/agentHooks/adapters/hermes.ts new file mode 100644 index 0000000..59ee72e --- /dev/null +++ b/src/lib/agentHooks/adapters/hermes.ts @@ -0,0 +1,212 @@ +// Hermes hook adapter. +// +// Hermes loads Python plugins from ~/.hermes/plugins// and enables them via +// a `plugins.enabled` list in ~/.hermes/config.yaml. We install a managed plugin +// (plugin.yaml manifest + __init__.py) that registers Hermes' lifecycle hooks and +// POSTs each to Tempest's server, then enable it in the YAML config. Mirrors +// Orca's Hermes service. pre_approval_request is a real "needs you" signal → +// coversWaiting = true. + +import { parse, stringify } from "yaml"; +import type { AdapterInstall, HookAdapter, HookPaths, HookState, JsonObject } from "../types"; +import { joinNative } from "../paths.ts"; + +const PLUGIN_NAME = "tempest-status"; +const MARKER = "Managed by Tempest. Do not edit; changes may be overwritten."; + +const HERMES_EVENTS = [ + "on_session_start", + "pre_llm_call", + "post_llm_call", + "pre_tool_call", + "post_tool_call", + "pre_approval_request", + "post_approval_response", + "on_session_end", + "on_session_finalize", + "on_session_reset", +] as const; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function strArr(v: unknown): string[] | null { + if (v === undefined) return []; + if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) return null; + return v as string[]; +} + +function pluginManifest(): string { + return [ + `# ${MARKER}`, + `name: ${PLUGIN_NAME}`, + "version: 1.0.0", + 'description: "Reports Hermes lifecycle events to Tempest."', + 'author: "Tempest"', + "kind: standalone", + "provides_hooks:", + ...HERMES_EVENTS.map((e) => ` - ${e}`), + "", + ].join("\n"); +} + +// The Python plugin: sources the endpoint file for the current port/token, reads +// the Tempest session id from env, and POSTs each lifecycle event (with its name) +// to /hook/hermes. Best-effort — any failure is swallowed so status can't break a turn. +function pluginInit(endpointEnv: string): string { + return `# ${MARKER} +from __future__ import annotations +import json, os, urllib.error, urllib.request + +EVENTS = ${JSON.stringify([...HERMES_EVENTS])} +ENDPOINT = ${JSON.stringify(endpointEnv)} +SELECTED = { + "pre_llm_call": ("user_message", "model"), + "post_llm_call": ("assistant_response", "model"), + "pre_tool_call": ("tool_name", "args"), + "post_tool_call": ("tool_name", "args"), + "pre_approval_request": ("command", "description"), + "post_approval_response": ("command", "choice"), +} +MAX_STR = 8192 + + +def _cap(v): + s = v if isinstance(v, str) else repr(v) + return s if len(s) <= MAX_STR else s[:MAX_STR] + "...[truncated]" + + +def _coords(): + port = os.environ.get("TEMPEST_HOOK_PORT", "") + token = os.environ.get("TEMPEST_HOOK_TOKEN", "") + try: + with open(ENDPOINT, "r", encoding="utf-8") as f: + for raw in f: + line = raw.strip() + if line.startswith("set "): + line = line[4:] + k, sep, val = line.partition("=") + if sep and k == "TEMPEST_HOOK_PORT": + port = val.rstrip("\\r") + elif sep and k == "TEMPEST_HOOK_TOKEN": + token = val.rstrip("\\r") + except OSError: + pass + return port, token + + +def _post(payload): + port, token = _coords() + session = os.environ.get("TEMPEST_SESSION", "") + if not port or not token or not session: + return + data = json.dumps(payload, separators=(",", ":")).encode("utf-8") + req = urllib.request.Request( + "http://127.0.0.1:" + port + "/hook/hermes", + data=data, method="POST", + headers={"Content-Type": "application/json", + "X-Tempest-Token": token, "X-Tempest-Session": session}) + try: + with urllib.request.urlopen(req, timeout=0.75): + pass + except (OSError, urllib.error.URLError): + return + + +def _payload(event_name, kwargs): + p = {"hook_event_name": event_name} + for key in SELECTED.get(event_name, ()): + if key in kwargs: + p[key] = _cap(kwargs[key]) + return p + + +def _make(event_name): + def _hook(**kwargs): + _post(_payload(event_name, kwargs)) + return _hook + + +def register(ctx): + for event_name in EVENTS: + ctx.register_hook(event_name, _make(event_name)) +`; +} + +export const hermesAdapter: HookAdapter = { + id: "hermes", + coversWaiting: true, + + plan(paths: HookPaths): AdapterInstall { + const pluginDir = joinNative(paths.windows, paths.home, ".hermes", "plugins", PLUGIN_NAME); + const configPath = joinNative(paths.windows, paths.home, ".hermes", "config.yaml"); + + return { + // The plugin files live under the Hermes home, not the shared hooks dir — + // ScriptFile.path is an absolute path, so that's fine. + scripts: [ + { path: joinNative(paths.windows, pluginDir, "plugin.yaml"), content: pluginManifest(), executable: false }, + { path: joinNative(paths.windows, pluginDir, "__init__.py"), content: pluginInit(paths.endpointEnv), executable: false }, + ], + configs: [ + { + path: configPath, + apply: (raw) => enableInYaml(raw), + remove: (raw) => disableInYaml(raw), + }, + ], + }; + }, + + parse(body: unknown): HookState | null { + if (!body || typeof body !== "object") return null; + const event = (body as Record).hook_event_name; + if (event === "pre_approval_request") return "waiting"; + if (event === "post_llm_call" || event === "on_session_end" || event === "on_session_finalize" || event === "on_session_reset") { + return "done"; + } + if ( + event === "on_session_start" || + event === "pre_llm_call" || + event === "pre_tool_call" || + event === "post_tool_call" || + event === "post_approval_response" + ) { + return "working"; + } + return null; + }, +}; + +function parseYaml(raw: string | null): JsonObject | null { + if (raw === null || raw.trim() === "") return {}; + try { + const p = parse(raw); + if (p === null || p === undefined) return {}; + return isRecord(p) ? (p as JsonObject) : null; + } catch { + return null; + } +} + +function enableInYaml(raw: string | null): string | null { + const config = parseYaml(raw); + if (config === null) return null; + const plugins = isRecord(config.plugins) ? { ...config.plugins } : {}; + const enabled = strArr(plugins.enabled) ?? []; + plugins.enabled = Array.from(new Set([...enabled, PLUGIN_NAME])).sort(); + const disabled = strArr(plugins.disabled); + plugins.disabled = disabled === null ? [] : disabled.filter((n) => n !== PLUGIN_NAME); + return stringify({ ...config, plugins }, { lineWidth: 0 }); +} + +function disableInYaml(raw: string | null): string | null { + if (raw === null || raw.trim() === "") return null; + const config = parseYaml(raw); + if (config === null || !isRecord(config.plugins)) return null; + const enabled = strArr(config.plugins.enabled); + if (enabled === null || !enabled.includes(PLUGIN_NAME)) return null; + const plugins = { ...config.plugins, enabled: enabled.filter((n) => n !== PLUGIN_NAME) }; + return stringify({ ...config, plugins }, { lineWidth: 0 }); +} diff --git a/src/lib/agentHooks/adapters/opencode.ts b/src/lib/agentHooks/adapters/opencode.ts new file mode 100644 index 0000000..f2bdc97 --- /dev/null +++ b/src/lib/agentHooks/adapters/opencode.ts @@ -0,0 +1,112 @@ +// Opencode hook adapter. +// +// Opencode auto-loads ESM plugins from ~/.config/opencode/plugin/. We drop a +// managed plugin that runs inside Opencode's process, listens to its event +// stream, and POSTs normalized lifecycle events to Tempest's server. Mirrors +// Orca's plugin mapping: session.status busy → SessionBusy, session.idle → +// SessionIdle, permission.asked → PermissionRequest, question.asked → +// AskUserQuestion, text parts → MessagePart. Opencode has real permission/ask +// signals → coversWaiting = true. + +import type { AdapterInstall, HookAdapter, HookPaths, HookState } from "../types"; +import { joinNative } from "../paths.ts"; + +const MARKER = "Managed by Tempest. Do not edit; changes may be overwritten."; +const REMOVED_STUB = `// ${MARKER} (removed)\nexport const TempestOpenCodeStatusPlugin = async () => ({});\n`; + +function pluginSource(endpointEnv: string): string { + return `// ${MARKER} +import { readFileSync } from "node:fs"; + +const ENDPOINT = ${JSON.stringify(endpointEnv)}; + +function coords() { + let port = process.env.TEMPEST_HOOK_PORT; + let token = process.env.TEMPEST_HOOK_TOKEN; + try { + for (const line of readFileSync(ENDPOINT, "utf8").split(/\\r?\\n/)) { + const m = line.replace(/^set\\s+/, "").match(/^([A-Z_]+)=(.*)$/); + if (!m) continue; + if (m[1] === "TEMPEST_HOOK_PORT") port = m[2].replace(/\\r$/, ""); + if (m[1] === "TEMPEST_HOOK_TOKEN") token = m[2].replace(/\\r$/, ""); + } + } catch {} + return { port, token }; +} + +async function post(hookEventName) { + const { port, token } = coords(); + const session = process.env.TEMPEST_SESSION; + if (!port || !token || !session) return; + try { + await fetch("http://127.0.0.1:" + port + "/hook/opencode", { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Tempest-Token": token, + "X-Tempest-Session": session, + }, + body: JSON.stringify({ hook_event_name: hookEventName }), + }); + } catch {} +} + +let lastStatus = ""; +async function setStatus(next) { + if (lastStatus === next) return; + lastStatus = next; + await post(next === "busy" ? "SessionBusy" : "SessionIdle"); +} + +// Opencode may invoke the factory with undefined during startup; accept an opaque +// arg rather than destructuring so that can't throw. +export const TempestOpenCodeStatusPlugin = async (_ctx) => ({ + event: async ({ event }) => { + if (!event || !event.type) return; + if (event.type === "permission.asked") return void post("PermissionRequest"); + if (event.type === "question.asked") return void post("AskUserQuestion"); + if (event.type === "message.part.updated") { + const part = event.properties && event.properties.part; + if (part && part.type === "text" && part.text) await post("MessagePart"); + return; + } + if (event.type === "session.idle" || event.type === "session.error") return void setStatus("idle"); + if (event.type === "session.status") { + const t = (event.properties && event.properties.status && event.properties.status.type) || (event.status && event.status.type); + if (t === "busy" || t === "retry") await setStatus("busy"); + else if (t === "idle") await setStatus("idle"); + } + }, +}); +`; +} + +export const opencodeAdapter: HookAdapter = { + id: "opencode", + coversWaiting: true, + + plan(paths: HookPaths): AdapterInstall { + const pluginPath = joinNative(paths.windows, paths.home, ".config", "opencode", "plugin", "tempest-status.js"); + const source = pluginSource(paths.endpointEnv); + return { + scripts: [], + configs: [ + { + path: pluginPath, + apply: () => source, + // Overwrite with a no-op plugin (we can't delete files from the engine). + remove: (raw) => (raw === null || raw === REMOVED_STUB ? null : REMOVED_STUB), + }, + ], + }; + }, + + parse(body: unknown): HookState | null { + if (!body || typeof body !== "object") return null; + const event = (body as Record).hook_event_name; + if (event === "SessionBusy" || event === "MessagePart") return "working"; + if (event === "SessionIdle") return "done"; + if (event === "PermissionRequest" || event === "AskUserQuestion") return "waiting"; + return null; + }, +}; diff --git a/src/lib/agentHooks/codexTrust.ts b/src/lib/agentHooks/codexTrust.ts new file mode 100644 index 0000000..8b6faf2 --- /dev/null +++ b/src/lib/agentHooks/codexTrust.ts @@ -0,0 +1,219 @@ +// Codex hook trust — the piece that makes Codex actually fire our hooks. +// +// Codex (0.129+) only runs a hook whose handler is trusted in config.toml via a +// `[hooks.state.""]` block carrying a `trusted_hash`. The hash is a SHA-256 +// over a canonical identity of the handler, reproduced here exactly from Codex's +// own algorithm (canonical_json + command_hook_hash), the same way Orca does it. +// A wrong hash isn't destructive — Codex just ignores the untrusted hook and we +// fall back to PTY scraping — but a right one gives precise status. +// +// Self-contained + synchronous (no Web Crypto) so the install path and the node +// self-check share one implementation; the SHA-256 is checked against a known +// vector in installer.check.ts. + +// PascalCase hooks.json event key → snake_case trust label (codex-rs serde). +export const CODEX_EVENT_LABEL: Record = { + SessionStart: "session_start", + UserPromptSubmit: "user_prompt_submit", + PreToolUse: "pre_tool_use", + PermissionRequest: "permission_request", + PostToolUse: "post_tool_use", + Stop: "stop", +}; + +export interface CodexTrustEntry { + sourcePath: string; // path to hooks.json (the "key_source") + eventLabel: string; // snake_case + groupIndex: number; + handlerIndex: number; + command: string; + timeoutSec: number; +} + +// Codex drops the matcher for these events before hashing; ours have no matcher +// anyway, so this only matters if a matcher were ever added. +function matcherForEvent(label: string, matcher: string | undefined): string | undefined { + if (label === "user_prompt_submit" || label === "stop") return undefined; + return matcher; +} + +// Recursively sort object keys (Codex's canonical_json); arrays keep order. +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalize); + if (value && typeof value === "object") { + const out: Record = {}; + for (const k of Object.keys(value as Record).sort()) { + out[k] = canonicalize((value as Record)[k]); + } + return out; + } + return value; +} + +// Reproduces Codex's command_hook_hash: sha256 of the canonical +// { event_name, matcher?, hooks: [handler] } identity. +export function computeTrustedHash(entry: CodexTrustEntry): string { + const handler: Record = { + type: "command", + command: entry.command, + timeout: Math.max(1, entry.timeoutSec), + async: false, + }; + const identity: Record = { event_name: entry.eventLabel, hooks: [handler] }; + const matcher = matcherForEvent(entry.eventLabel, undefined); + if (matcher !== undefined) identity.matcher = matcher; + const serialized = JSON.stringify(canonicalize(identity)); + return "sha256:" + sha256Hex(serialized); +} + +export function trustKey(entry: CodexTrustEntry): string { + return `${entry.sourcePath}:${entry.eventLabel}:${entry.groupIndex}:${entry.handlerIndex}`; +} + +function escapeTomlBasic(v: string): string { + return v.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +// Upsert a trust block for each entry into config.toml (append if absent, replace +// its hash/enabled if present). Byte-preserves the rest of the file. Returns the +// updated content, or the input unchanged when every block already matches. +export function upsertTrustBlocks(content: string, entries: CodexTrustEntry[]): string { + let out = content; + for (const entry of entries) { + const key = trustKey(entry); + const hash = computeTrustedHash(entry); + const block = [`[hooks.state."${escapeTomlBasic(key)}"]`, "enabled = true", `trusted_hash = "${hash}"`].join("\n"); + const range = findBlockRange(out, key); + if (range) { + out = out.slice(0, range.start) + block + out.slice(range.end); + } else { + const sep = out.length === 0 ? "" : out.endsWith("\n\n") ? "" : out.endsWith("\n") ? "\n" : "\n\n"; + out = `${out}${sep}${block}\n`; + } + } + return out; +} + +// Remove trust blocks whose key targets one of our (sourcePath, label) pairs. +export function removeTrustBlocks(content: string, sourcePath: string, labels: Set): { content: string; changed: boolean } { + let out = ""; + let cursor = 0; + let changed = false; + const headerRe = /^\[hooks\.state\."((?:[^"\\]|\\.)*)"\]\s*$/gm; + let m: RegExpExecArray | null; + const ranges: { start: number; end: number }[] = []; + while ((m = headerRe.exec(content)) !== null) { + const key = m[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + const parsed = parseKey(key); + if (parsed && samePath(parsed.sourcePath, sourcePath) && labels.has(parsed.eventLabel)) { + const start = m.index; + const end = nextHeaderOrEnd(content, headerRe.lastIndex); + ranges.push({ start, end }); + headerRe.lastIndex = end; + } + } + for (const r of ranges) { + out += content.slice(cursor, r.start); + cursor = r.end; + changed = true; + } + out += content.slice(cursor); + return { content: out, changed }; +} + +function samePath(a: string, b: string): boolean { + return a.replace(/\\/g, "/").toLowerCase() === b.replace(/\\/g, "/").toLowerCase(); +} + +function parseKey(key: string): { sourcePath: string; eventLabel: string } | null { + // key = :