diff --git a/HOOKS.md b/HOOKS.md new file mode 100644 index 0000000..99a4b0f --- /dev/null +++ b/HOOKS.md @@ -0,0 +1,178 @@ +# Hooks + +xConsole's agent supports **lifecycle hooks** — the same model +[Claude Code](https://docs.claude.com/en/docs/claude-code/hooks) uses. A hook is one +of *your* shell commands that the agent runs at a defined point in a turn. A hook can: + +- **block a tool** before it runs (a guardrail), +- **inject context** the model sees this turn, +- **feed a tool's result back** to the model, or +- fire a **side-effect** when the turn ends (notification, formatter, audit log). + +Hooks are **opt-in**: with none configured the agent loop skips the hook path entirely +(0 ms overhead). Configure them in **Settings → Hooks**, or edit the file directly. + +## Configuration + +Hooks live in `hooks.json` in the agent home +(`%APPDATA%\com.xconsole.app\agent\hooks.json` on Windows). The format is Claude Code's +`settings.json` `hooks` block — either wrapped in `"hooks"` or bare: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "run_command|run_command_all", + "hooks": [ + { "type": "command", "command": "my-guard.sh", "timeout": 30 } + ] + } + ] + } +} +``` + +The config is **snapshotted at startup** (exactly like Claude Code), so a mid-session +edit — including one the agent itself might write — only takes effect after **Save & apply** +or **Reload** in Settings → Hooks (or a restart). Toggle the whole system off without +deleting your config via the **Enabled** switch (the `agent.hooks_enabled` setting). + +## Events + +| Event | When | Can it block? | +|---|---|---| +| `UserPromptSubmit` | before a turn runs | yes — rejects the turn | +| `PreToolUse` | before a tool runs | yes — the tool is not run | +| `PostToolUse` | after a tool runs | feeds a note back to the model | +| `Stop` | when the turn finishes | no (fire-and-forget side-effects) | + +`PreToolUse`/`PostToolUse` are **tool-scoped** — their `matcher` selects on the tool +name. `UserPromptSubmit`/`Stop` ignore the matcher. + +> xConsole runs the agent's tools itself only for its own providers (Ollama / OpenAI / +> Anthropic). Autonomous CLI providers (Cursor/Codex/OpenCode) do their own tool use, so +> `PreToolUse`/`PostToolUse` don't fire for them; `UserPromptSubmit`/`Stop` still do. + +### Matcher + +A `matcher` selects which tool a tool-event hook applies to: + +- omitted, `""`, or `"*"` → **every** tool +- an exact tool name → that tool (`"run_command"`, `"write_file"`, …) +- `a|b|c` → any of several (`"run_command|run_command_all|local_run_command"`) + +(Full regex isn't supported — alternation + wildcard covers the practical cases without +a new dependency.) Common tool names: `run_command`, `run_command_all`, `write_file`, +`read_file`, `local_run_command`, `local_write_file`, `upload_file`, `download_file`, +`web_search`, `web_fetch`, `terminal_send`, `canvas_open_terminal`, `memory_save`. + +## Input (stdin) + +Each command receives the event as a JSON object on **stdin**: + +```json +{ + "session_id": "…", + "cwd": "…", + "hook_event_name": "PreToolUse", + "tool_name": "run_command", + "tool_input": { "command": "rm -rf /", "vps_id": "…" }, + "tool_response": "…", // PostToolUse only + "prompt": "…", // UserPromptSubmit only + "workspace_id": "…", // when a workspace is active + "vps_targets": ["…"] // selected VPS ids +} +``` + +## Output (control protocol) + +A hook controls the agent through its **exit code** and/or a **JSON object on stdout**: + +| Exit code | Meaning | +|---|---| +| `0` | success. For `UserPromptSubmit`, plain stdout is added to the model's context. | +| `2` | **blocking** error — the tool/prompt is blocked; stderr is the reason shown to the model. | +| other | non-blocking error (logged; the agent proceeds). | + +For finer control, print a JSON object on stdout (combinable with the exit code): + +```jsonc +{ "decision": "block", "reason": "explained to the model" } +{ "continue": false, "stopReason": "halt the whole turn" } +{ "systemMessage": "shown to the user, not the model" } +{ "hookSpecificOutput": { "additionalContext": "injected for the model" } } +{ "hookSpecificOutput": { "permissionDecision": "deny", "permissionDecisionReason": "…" } } +``` + +> `permissionDecision: "deny"` blocks the tool. xConsole's **command-approval safety mode +> is independent** of hooks: a hook's `"allow"` does **not** bypass the approval gate (a +> deliberate, safer divergence from Claude Code — a hook can add a guardrail but can't +> silently remove the one the user set). + +## Examples + +**Block destructive commands on production targets** (`PreToolUse`, exit 2 = block): + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "run_command|run_command_all", + "hooks": [{ "type": "command", "command": "guard-rm.sh" }] + } + ] + } +} +``` + +```bash +#!/usr/bin/env bash +# guard-rm.sh — read the event, block obviously destructive commands. +cmd="$(jq -r '.tool_input.command // ""')" +case "$cmd" in + *"rm -rf /"*|*"mkfs"*|*":(){ :|:&};:"*) + echo "refusing destructive command: $cmd" >&2 + exit 2 ;; +esac +exit 0 +``` + +**Inject a standing reminder every turn** (`UserPromptSubmit`, stdout → context): + +```json +{ "hooks": { "UserPromptSubmit": [ { "hooks": [ + { "type": "command", "command": "echo Production servers are read-only unless I say otherwise." } +] } ] } } +``` + +**Notify when a turn finishes** (`Stop`, side-effect): + +```json +{ "hooks": { "Stop": [ { "hooks": [ + { "type": "command", "command": "notify-send 'xConsole' 'Agent finished a turn'" } +] } ] } } +``` + +## Security + +Hooks run shell commands **you** configure, with your account's permissions — the same +trust model as Claude Code. Only add commands you trust. Because the config is +snapshotted at startup, a prompt-injected agent can't add a hook that takes effect in the +same session. The command-approval safety mode still applies to every tool regardless of +hooks. + +## Implementation / verification + +- Engine: [`src-tauri/src/ai/hooks.rs`](src-tauri/src/ai/hooks.rs) — config parsing, + matcher matching, and output interpretation are pure (unit-tested); only the runner + spawns a process. Wired into the agent loop in + [`ai/agent.rs`](src-tauri/src/ai/agent.rs) (UserPromptSubmit / Stop) and + [`ai/tools.rs`](src-tauri/src/ai/tools.rs) `dispatch` (Pre/PostToolUse). +- Tests: `xconsole-bench selftest` runs the pure-logic checks **plus live hook + subprocesses** (exit-2 blocks, exit-0 allows). The `#[cfg(test)]` units in `hooks.rs` + cover the same logic. +- Benchmark: `xconsole-bench hooks` measures the per-tool-call overhead — see + [`bench/README.md`](bench/README.md). Baseline: ~135 ns to decide whether a hook fires; + ~38 ms for one no-op hook subprocess on Windows; **0 ms when no hooks are configured**. diff --git a/bench/README.md b/bench/README.md index 06015a9..3f6f5d1 100644 --- a/bench/README.md +++ b/bench/README.md @@ -27,9 +27,12 @@ Run: # Raw model latency (TTFT / gen tok/s) with and without the tool payload ./src-tauri/target/release/xconsole-bench.exe llm --model qwen3.5:9b -# Pure-logic self-tests (reflection / self-improvement + voice prompt) — no Ollama needed +# Pure-logic self-tests (reflection + voice prompt + hooks) + live hook subprocesses — no Ollama ./src-tauri/target/release/xconsole-bench.exe selftest +# Hooks dispatch overhead — what a PreToolUse hook adds per tool call (no Ollama) +./src-tauri/target/release/xconsole-bench.exe hooks --out bench/results/hooks.json + # Both eval + latency ./src-tauri/target/release/xconsole-bench.exe all --model qwen3.5:9b --out bench/results/all.json ``` @@ -58,6 +61,18 @@ must get right; the pass-rate is the quality signal we track: Columns: `ttft_ms` (time to first token), `total_ms` (whole turn), `gen_t/s` (generation tokens/sec), `ptok` (prompt tokens — how heavy the system prompt is). +**Hooks overhead** (`hooks` mode) — measures the cost of the Claude Code–style hooks +system (see [`HOOKS.md`](../HOOKS.md)): + +| metric | meaning | dev-machine baseline | +|---|---|---| +| `pure_select_ns` | per-tool-call cost to decide whether a hook fires | ~135 ns | +| `live_hook_ms` | one no-op hook subprocess (spawn + JSON on stdin) | ~38 ms (Windows `cmd /C`) | + +With **no hooks configured the loop skips the hook path entirely (0 ms)** — hooks are +opt-in, so they cost nothing until you add one. The `live_hook_ms` figure is dominated +by process-spawn latency (lower on Unix `sh -c`); a hook that does real work adds its own time. + ## 2. `ollama_latency.ps1` — zero-build latency probe Quick TTFT / tok/s read without compiling, straight against `/api/chat`: diff --git a/bench/results/hooks.json b/bench/results/hooks.json new file mode 100644 index 0000000..2a4d614 --- /dev/null +++ b/bench/results/hooks.json @@ -0,0 +1,7 @@ +{ + "block_works": true, + "live_hook_ms": 38.03333333333333, + "live_runs": 30, + "mode": "hooks", + "pure_select_ns": 135 +} \ No newline at end of file diff --git a/installer/build-single-exe.ps1 b/installer/build-single-exe.ps1 index 0937bce..9c9a121 100644 --- a/installer/build-single-exe.ps1 +++ b/installer/build-single-exe.ps1 @@ -5,10 +5,58 @@ # stub that embeds both into a single exe. On the MSVC toolchain the loader is # static-linked, so the installer is already a single exe and no stub is built. # +# Code signing (the durable fix for AV false positives) is applied automatically when a +# certificate is configured via environment variables — otherwise it is skipped. See +# installer/ANTIVIRUS.md for the full rationale and how to get a certificate. +# $env:XCONSOLE_SIGN_PFX = 'C:\path\to\cert.pfx' # PFX file, OR... +# $env:XCONSOLE_SIGN_THUMBPRINT= 'ABCD...' # ...a cert already in your store +# $env:XCONSOLE_SIGN_PASSWORD = '...' # PFX password (if using a PFX) +# $env:XCONSOLE_SIGN_TIMESTAMP = 'http://timestamp.digicert.com' # optional override +# # Usage: installer\build-single-exe.ps1 $ErrorActionPreference = 'Stop' $installer = Split-Path -Parent $MyInvocation.MyCommand.Path +# --- code signing helpers --------------------------------------------------------------- + +function Find-SignTool { + $st = Get-Command signtool.exe -ErrorAction SilentlyContinue + if ($st) { return $st.Source } + $kits = "${env:ProgramFiles(x86)}\Windows Kits\10\bin" + if (Test-Path $kits) { + # Newest SDK build, x64 binary. + $found = Get-ChildItem -Path $kits -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | + Where-Object { $_.FullName -match '\\x64\\' } | + Sort-Object FullName -Descending | Select-Object -First 1 + if ($found) { return $found.FullName } + } + return $null +} + +function Invoke-Sign([string]$path) { + $pfx = $env:XCONSOLE_SIGN_PFX + $thumb = $env:XCONSOLE_SIGN_THUMBPRINT + if (-not $pfx -and -not $thumb) { + Write-Host " (unsigned: set XCONSOLE_SIGN_PFX + XCONSOLE_SIGN_PASSWORD, or XCONSOLE_SIGN_THUMBPRINT, to sign — see installer/ANTIVIRUS.md)" -ForegroundColor DarkYellow + return + } + $signtool = Find-SignTool + if (-not $signtool) { + Write-Warning "signtool.exe not found (install the Windows SDK / 'App Installer') — cannot sign $path" + return + } + $ts = if ($env:XCONSOLE_SIGN_TIMESTAMP) { $env:XCONSOLE_SIGN_TIMESTAMP } else { 'http://timestamp.digicert.com' } + if ($pfx) { + & $signtool sign /fd SHA256 /tr $ts /td SHA256 /f $pfx /p $env:XCONSOLE_SIGN_PASSWORD $path + } else { + & $signtool sign /fd SHA256 /tr $ts /td SHA256 /sha1 $thumb $path + } + if ($LASTEXITCODE -ne 0) { Write-Warning "signing failed for $path" } + else { Write-Host " signed: $path" -ForegroundColor Green } +} + +# --- build ------------------------------------------------------------------------------ + Write-Host '[1/2] Building the installer...' -ForegroundColor Cyan Push-Location $installer try { cargo build --release } finally { Pop-Location } @@ -20,14 +68,20 @@ if (-not (Test-Path -LiteralPath $innerDll)) { Write-Host '' Write-Host 'WebView2Loader is statically linked (MSVC) - the installer is ALREADY a single exe:' -ForegroundColor Green Write-Host " $innerExe" + Invoke-Sign $innerExe exit 0 } Write-Host '[2/2] Building the single-exe stub (embeds the installer + WebView2Loader.dll)...' -ForegroundColor Cyan +# Sign the INNER exe before it is embedded, so the unpacked installer is signed too. +Invoke-Sign $innerExe Push-Location (Join-Path $installer 'stub') try { cargo build --release } finally { Pop-Location } $out = Join-Path $installer 'stub\target\release\xConsole-Setup.exe' +# Sign the final single-file launcher — this is the artifact users actually run. +Invoke-Sign $out + Write-Host '' Write-Host 'Single-file installer (ship THIS one):' -ForegroundColor Green Write-Host " $out" diff --git a/installer/stub/app.manifest b/installer/stub/app.manifest new file mode 100644 index 0000000..ba53327 --- /dev/null +++ b/installer/stub/app.manifest @@ -0,0 +1,47 @@ + + + + + xConsole Setup + + + + + + + + + + + + + + + + + + + + + + + + + + true + PerMonitorV2 + + + diff --git a/installer/stub/app.rc b/installer/stub/app.rc new file mode 100644 index 0000000..11ddcd8 --- /dev/null +++ b/installer/stub/app.rc @@ -0,0 +1,49 @@ +/* + * Win32 resource script for the xConsole Setup launcher (compiled by windres in build.rs). + * + * Embeds: + * 1. the application manifest (RT_MANIFEST, id 1), and + * 2. a full VERSIONINFO block so the launcher carries CompanyName / ProductName / + * FileDescription / versions / copyright like ordinary software. + * + * The launcher previously shipped with ZERO version metadata, which — combined with its + * embedded inner exe — made AV ML models score it as a malicious dropper. Numeric + * constants are hardcoded so this needs no SDK headers (windres alone, no winver.h). + */ + +1 24 "app.manifest" + +#define VER_FILEVERSION 0,1,0,0 +#define VER_PRODUCTVERSION 0,1,0,0 +#define VS_VERSION_INFO 1 +#define VOS_NT_WINDOWS32 0x00040004L +#define VFT_APP 0x00000001L + +VS_VERSION_INFO VERSIONINFO +FILEVERSION VER_FILEVERSION +PRODUCTVERSION VER_PRODUCTVERSION +FILEFLAGSMASK 0x3fL +FILEFLAGS 0x0L +FILEOS VOS_NT_WINDOWS32 +FILETYPE VFT_APP +FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "xConsole" + VALUE "FileDescription", "xConsole Setup" + VALUE "FileVersion", "0.1.0.0" + VALUE "InternalName", "xConsole-Setup" + VALUE "LegalCopyright", "Copyright (C) 2026 xConsole. Licensed under MIT." + VALUE "OriginalFilename", "xConsole-Setup.exe" + VALUE "ProductName", "xConsole Setup" + VALUE "ProductVersion", "0.1.0.0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/installer/stub/build.rs b/installer/stub/build.rs index 9dc2846..15444da 100644 --- a/installer/stub/build.rs +++ b/installer/stub/build.rs @@ -1,12 +1,26 @@ -//! Guard for the single-exe stub. It embeds the installer build output via -//! include_bytes!; fail early with a clear message (instead of a cryptic missing-path -//! error two dirs up) when the installer hasn't been built yet, and re-run when those -//! artifacts change so the embed never goes stale. +//! Build script for the single-exe stub. +//! +//! Two jobs: +//! 1. Guard the `include_bytes!` embed: fail early with a clear message (instead of a +//! cryptic missing-path error two dirs up) when the installer hasn't been built yet, +//! and re-run when those artifacts change so the embed never goes stale. +//! 2. Embed a Win32 resource (VERSIONINFO + application manifest) so the launcher is NOT +//! a blank-metadata PE. A no-metadata exe that writes and runs an embedded executable +//! is exactly what AV ML heuristics flag as a dropper (Symantec ML.Attribute, Elastic, +//! APEX all hit the old stub). Carrying normal company/product/version strings and a +//! manifest moves those models off "malicious". GNU-only by design: the stub is only +//! ever built on the GNU toolchain (under MSVC the installer is already a single +//! self-contained exe and no stub is produced), so we drive `windres` directly — no +//! extra crates, no SDK headers. use std::path::Path; +use std::process::Command; fn main() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + + // 1. Guard the embed inputs. // CARGO_MANIFEST_DIR = installer/stub -> parent = installer -> target/release. - let inner = Path::new(env!("CARGO_MANIFEST_DIR")) + let inner = Path::new(manifest_dir) .parent() .expect("stub manifest dir has a parent") .join("target") @@ -28,4 +42,42 @@ fn main() { ); } } + + // 2. Compile + link the version/manifest resource (Windows targets only). + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "windows" { + return; + } + let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR set by cargo"); + let rc = Path::new(manifest_dir).join("app.rc"); + let manifest = Path::new(manifest_dir).join("app.manifest"); + let obj = Path::new(&out_dir).join("app_res.o"); + println!("cargo:rerun-if-changed={}", rc.display()); + println!("cargo:rerun-if-changed={}", manifest.display()); + + // Allow override (e.g. x86_64-w64-mingw32-windres on some setups); default to PATH. + let windres = std::env::var("WINDRES").unwrap_or_else(|_| "windres".to_string()); + // Run from the crate dir so the .rc's relative "app.manifest" reference resolves. + let status = Command::new(&windres) + .current_dir(manifest_dir) + .args(["-i", "app.rc", "-O", "coff", "-o"]) + .arg(&obj) + .status(); + + match status { + Ok(s) if s.success() => { + // Hand the COFF object to the linker; its .rsrc section merges into the exe. + // -bins so it never perturbs build-script linking. + println!("cargo:rustc-link-arg-bins={}", obj.display()); + } + Ok(s) => println!( + "cargo:warning=windres exited with {s}; the stub will build WITHOUT version \ + metadata (it will keep working, but AV heuristics may flag it). Ensure MinGW's \ + windres is on PATH." + ), + Err(e) => println!( + "cargo:warning=could not run windres ({e}); the stub will build WITHOUT version \ + metadata. Ensure MinGW's windres is on PATH." + ), + } } diff --git a/installer/stub/src/main.rs b/installer/stub/src/main.rs index 7679b14..f4d4274 100644 --- a/installer/stub/src/main.rs +++ b/installer/stub/src/main.rs @@ -3,8 +3,10 @@ // The GNU build of the Tauri installer dynamically imports WebView2Loader.dll at LOAD // time (webview2-com-sys only static-links the loader under MSVC), so that exe cannot // even start without the DLL beside it. This launcher embeds the installer exe AND the -// loader DLL, unpacks both to a private %TEMP% folder, and runs the installer from -// there (where it finds the DLL). The user ships/handles a SINGLE exe. +// loader DLL, unpacks both to a private per-process folder under a named, app-owned +// `%LOCALAPPDATA%\xConsole-Setup` directory (a sibling of the install base, see +// `unpack_root`), and runs the installer from there (where it finds the DLL). The user +// ships/handles a SINGLE exe. // // It forwards CLI args verbatim (--install / --uninstall / --update / none=GUI) and // propagates the installer's exit code, so it's a transparent stand-in. It also tells @@ -23,17 +25,37 @@ use std::time::Duration; const INNER_EXE: &[u8] = include_bytes!("../../target/release/xConsole-Setup.exe"); const LOADER_DLL: &[u8] = include_bytes!("../../target/release/WebView2Loader.dll"); +/// Root for the per-process unpack dir. Prefer a NAMED per-user directory +/// (`%LOCALAPPDATA%\xConsole-Setup`) over a random name under `%TEMP%`: AV behavioral +/// heuristics specifically watch for an executable written to and run from %TEMP%, so +/// staging in a stable, clearly-named folder is both tidier and far less suspicious. +/// +/// It is deliberately a SIBLING of the install base (`%LOCALAPPDATA%\xConsole`), never a +/// child of it: `--uninstall` spawns a detached `rmdir /s /q "%LOCALAPPDATA%\xConsole"`, +/// and we must not stage the running inner exe inside the very tree being deleted. Falls +/// back to the system temp dir if LOCALAPPDATA is somehow unset. +fn unpack_root() -> PathBuf { + if let Ok(local) = std::env::var("LOCALAPPDATA") { + if !local.is_empty() { + return PathBuf::from(local).join("xConsole-Setup"); + } + } + std::env::temp_dir().join("xConsole-Setup") +} + /// Drop a diagnostic breadcrumb (there's no stderr in the windows subsystem). Per-pid so /// concurrent runs don't clobber each other. fn diag(msg: String) { - let p = std::env::temp_dir().join(format!("xConsole-Setup-{}-error.txt", std::process::id())); + let root = unpack_root(); + let _ = std::fs::create_dir_all(&root); + let p = root.join(format!("setup-{}-error.txt", std::process::id())); let _ = std::fs::write(p, msg); } fn main() { // A per-process unpack dir so concurrent runs (and a long-lived update window) never // clobber each other's files. - let dir = std::env::temp_dir().join(format!("xConsole-Setup-{}", std::process::id())); + let dir = unpack_root().join(std::process::id().to_string()); let exe = dir.join("xConsole-Setup.exe"); let dll = dir.join("WebView2Loader.dll"); diff --git a/installer/tauri.conf.json b/installer/tauri.conf.json index d65cf99..9e193b1 100644 --- a/installer/tauri.conf.json +++ b/installer/tauri.conf.json @@ -26,6 +26,11 @@ }, "bundle": { "active": false, + "publisher": "xConsole", + "copyright": "Copyright © 2026 xConsole. Licensed under MIT.", + "category": "DeveloperTool", + "shortDescription": "xConsole Setup", + "longDescription": "Official setup for xConsole — a multi-VPS canvas terminal with a built-in AI agent. Downloads the open-source toolchain and compiles xConsole from source on this PC.", "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src-tauri/src/ai/agent.rs b/src-tauri/src/ai/agent.rs index 88bac33..4d5e0ce 100644 --- a/src-tauri/src/ai/agent.rs +++ b/src-tauri/src/ai/agent.rs @@ -4,6 +4,7 @@ use crate::ai::context::{self, PromptContext}; use crate::ai::context_compact; use crate::ai::context_usage; +use crate::ai::hooks; use crate::ai::provider::{emit, ChatMessage, ChatRequest, EventSink, StreamEvent}; use crate::ai::tools::{self, ToolContext}; use crate::ai::vps_snapshot; @@ -54,6 +55,39 @@ pub async fn run_turn( .find(|m| m.role == "user") .map(|m| m.content.clone()) .unwrap_or_default(); + + // UserPromptSubmit hooks: fire before the turn runs. A hook can inject extra context + // (appended to the system prompt below) or block the turn outright (exit 2 / + // `decision:block` / `continue:false`). Only runs when something subscribes. + let mut hook_user_context: Option = None; + if tc.hooks.has_event(hooks::HookEvent::UserPromptSubmit) { + let cwd = hooks::cwd(); + let input = hooks::HookEventInput { + event: hooks::HookEvent::UserPromptSubmit, + session_id: &tc.session_id, + cwd: &cwd, + workspace_id: tc.workspace_id.as_deref(), + vps_targets: &tc.targets, + tool_name: None, + tool_input: None, + tool_response: None, + prompt: Some(&last_user_msg), + }; + let decision = hooks::run_event(&tc.hooks, &input).await; + if let Some(msg) = &decision.system_message { + emit(Some(sink), StreamEvent::Status(msg.clone())); + } + if decision.blocks() { + let reason = decision + .reason + .unwrap_or_else(|| "blocked by a UserPromptSubmit hook".to_string()); + emit(Some(sink), StreamEvent::Error(reason.clone())); + emit_ws("idle"); + return Err(reason); + } + hook_user_context = decision.additional_context; + } + let effective_intent = vps_snapshot::effective_user_intent(&messages); let casual_turn = vps_snapshot::is_casual_chat(&last_user_msg); let needs_live = vps_snapshot::needs_live_data(&messages); @@ -394,6 +428,12 @@ pub async fn run_turn( }), ); + // Fold in any context a UserPromptSubmit hook injected, so the model sees it this turn. + if let Some(extra) = &hook_user_context { + system.push_str("\n\n## Additional context (from a UserPromptSubmit hook)\n"); + system.push_str(extra); + } + let mut last = ChatMessage::assistant(""); let mut iters_used = 0usize; @@ -502,6 +542,32 @@ pub async fn run_turn( } } + // Stop hooks: fire once the turn has finished (notifications, formatting, running + // a test suite, etc.). xConsole doesn't force the agent to keep going, so this is + // fire-and-forget — any message/context the hook returns is surfaced as a status. + if tc.hooks.has_event(hooks::HookEvent::Stop) { + let cwd = hooks::cwd(); + let input = hooks::HookEventInput { + event: hooks::HookEvent::Stop, + session_id: &tc.session_id, + cwd: &cwd, + workspace_id: tc.workspace_id.as_deref(), + vps_targets: &tc.targets, + tool_name: None, + tool_input: None, + tool_response: None, + prompt: None, + }; + let decision = hooks::run_event(&tc.hooks, &input).await; + if let Some(msg) = decision + .system_message + .or(decision.additional_context) + .or(decision.reason) + { + emit(Some(sink), StreamEvent::Status(format!("Stop hook: {msg}"))); + } + } + emit(Some(sink), StreamEvent::Done); emit_ws("idle"); diff --git a/src-tauri/src/ai/cron.rs b/src-tauri/src/ai/cron.rs index 639aa3d..d95e67d 100644 --- a/src-tauri/src/ai/cron.rs +++ b/src-tauri/src/ai/cron.rs @@ -318,6 +318,22 @@ async fn run_prompt_job( } }); + // Hooks for an unattended run: load the current on-disk config (gated by the global + // enable setting). No managed snapshot here — a cron job is the user's own scheduled + // work, so it picks up the latest hooks.json. + let hooks_cfg = if ctx + .db + .get_setting("agent.hooks_enabled") + .ok() + .flatten() + .as_deref() + == Some("false") + { + crate::ai::hooks::HooksConfig::default() + } else { + crate::ai::hooks::HooksConfig::load(&ctx.home) + }; + let tc = ToolContext { app: ctx.app.clone(), db: ctx.db.clone(), @@ -335,6 +351,7 @@ async fn run_prompt_job( workspace_id: None, canvas: Vec::new(), edits: crate::ai::edits::EditJournal::new(), + hooks: hooks_cfg, }; let messages = vec![ChatMessage::user(job.payload.clone())]; diff --git a/src-tauri/src/ai/hooks.rs b/src-tauri/src/ai/hooks.rs new file mode 100644 index 0000000..c7676f6 --- /dev/null +++ b/src-tauri/src/ai/hooks.rs @@ -0,0 +1,759 @@ +//! Claude Code–style hooks: user-defined shell commands that fire on agent +//! lifecycle events. The same model Claude Code uses — a JSON config maps an +//! event (and, for tool events, a tool-name matcher) to one or more shell +//! commands. Each command receives the event payload as JSON on **stdin** and +//! controls the agent through its **exit code** and/or a JSON object on **stdout**. +//! +//! Events wired into xConsole's agent loop: +//! - `UserPromptSubmit` — before the turn runs (can inject extra context, or block). +//! - `PreToolUse` — before a tool runs (can block the tool, or add context). +//! - `PostToolUse` — after a tool runs (can feed the result back / add context). +//! - `Stop` — after the turn finishes (side-effects, notifications). +//! +//! Decision protocol (mirrors Claude Code): +//! - exit `0` → success. For `UserPromptSubmit`, plain stdout is injected as context. +//! - exit `2` → blocking error. The tool/prompt is blocked; stderr is the reason. +//! - other → non-blocking error (logged, the agent proceeds). +//! - A JSON object on stdout is the "advanced" path: `{"decision":"block","reason":…}`, +//! `{"continue":false}`, `{"hookSpecificOutput":{"permissionDecision":"deny"|"allow", +//! "additionalContext":…}}`, `systemMessage`, … +//! +//! Config is read from `hooks.json` in the agent home and **snapshotted at startup** +//! (managed [`HooksState`]) — exactly like Claude Code, so a mid-session edit (including +//! one the agent itself might write) does not take effect until an explicit reload. +//! +//! The config parsing, matcher matching, and output interpretation are PURE functions +//! (no I/O) so they're deterministic and unit-testable; only [`run_one`] spawns a process. + +use std::collections::BTreeMap; +use std::sync::{Arc, RwLock}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::ai::AgentHome; + +/// File in the agent home that holds the hooks config (Claude Code's `settings.json` +/// `hooks` block, standalone). +pub const HOOKS_FILE: &str = "hooks.json"; + +/// Default per-hook timeout when the config doesn't set one. Clamped to [1, 600]. +const DEFAULT_TIMEOUT_SECS: u64 = 60; +const MAX_TIMEOUT_SECS: u64 = 600; + +/// The lifecycle events a hook can subscribe to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HookEvent { + PreToolUse, + PostToolUse, + UserPromptSubmit, + Stop, + Notification, + SessionStart, + SessionEnd, +} + +impl HookEvent { + pub fn as_str(self) -> &'static str { + match self { + HookEvent::PreToolUse => "PreToolUse", + HookEvent::PostToolUse => "PostToolUse", + HookEvent::UserPromptSubmit => "UserPromptSubmit", + HookEvent::Stop => "Stop", + HookEvent::Notification => "Notification", + HookEvent::SessionStart => "SessionStart", + HookEvent::SessionEnd => "SessionEnd", + } + } + + /// Parse an event name from the config. Unknown keys are ignored (so the config + /// can hold forward-compatible keys without erroring). + pub fn from_str(s: &str) -> Option { + Some(match s { + "PreToolUse" => HookEvent::PreToolUse, + "PostToolUse" => HookEvent::PostToolUse, + "UserPromptSubmit" => HookEvent::UserPromptSubmit, + "Stop" => HookEvent::Stop, + "Notification" => HookEvent::Notification, + "SessionStart" => HookEvent::SessionStart, + "SessionEnd" => HookEvent::SessionEnd, + _ => return None, + }) + } + + /// Whether this event is tool-scoped (its matcher selects on a tool name). + fn is_tool_event(self) -> bool { + matches!(self, HookEvent::PreToolUse | HookEvent::PostToolUse) + } +} + +fn default_kind() -> String { + "command".to_string() +} + +/// One shell command to run for a matched event. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct HookCommand { + /// Only `"command"` is supported (matches Claude Code's hook type). + #[serde(rename = "type", default = "default_kind")] + pub kind: String, + /// The shell command line, run via `cmd /C` on Windows or `sh -c` elsewhere. + pub command: String, + /// Optional per-hook timeout in seconds. + #[serde(default)] + pub timeout: Option, +} + +/// A matcher group: a tool-name pattern plus the commands to run when it matches. +/// For non-tool events the matcher is ignored (the commands always run). +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct HookMatcher { + #[serde(default)] + pub matcher: Option, + #[serde(default)] + pub hooks: Vec, +} + +/// The parsed hooks configuration: event name → its matcher groups. +#[derive(Clone, Debug, Default)] +pub struct HooksConfig { + events: BTreeMap>, +} + +impl HooksConfig { + /// Parse a `hooks.json` document. Accepts either the wrapped form + /// `{"hooks":{"PreToolUse":[…]}}` (Claude Code's settings.json shape) or the bare + /// `{"PreToolUse":[…]}`. Unknown event keys are ignored. Pure. + pub fn parse(text: &str) -> Result { + let v: Value = + serde_json::from_str(text).map_err(|e| format!("hooks.json is not valid JSON: {e}"))?; + // Unwrap an optional top-level "hooks" key. + let root = v.get("hooks").unwrap_or(&v); + let obj = root + .as_object() + .ok_or_else(|| "hooks config must be a JSON object".to_string())?; + + let mut events = BTreeMap::new(); + for (key, val) in obj { + let Some(event) = HookEvent::from_str(key) else { + continue; // forward-compatible: skip keys we don't know + }; + let matchers: Vec = serde_json::from_value(val.clone()) + .map_err(|e| format!("hooks.{key} is malformed: {e}"))?; + events.insert(event.as_str().to_string(), matchers); + } + Ok(Self { events }) + } + + /// Load the snapshot from `hooks.json` in the agent home. A missing or empty file + /// yields an empty config (hooks are opt-in). A malformed file is ignored with a + /// log line rather than failing the app. + pub fn load(home: &AgentHome) -> Self { + let path = home.0.join(HOOKS_FILE); + match std::fs::read_to_string(&path) { + Ok(text) if !text.trim().is_empty() => Self::parse(&text).unwrap_or_else(|e| { + eprintln!("hooks: ignoring {}: {e}", path.display()); + Self::default() + }), + _ => Self::default(), + } + } + + /// Strict load used by the save/reload command path — surfaces parse errors so the + /// UI can show them. + pub fn load_strict(home: &AgentHome) -> Result { + let path = home.0.join(HOOKS_FILE); + match std::fs::read_to_string(&path) { + Ok(text) if !text.trim().is_empty() => Self::parse(&text), + _ => Ok(Self::default()), + } + } + + /// Total number of hook commands across every event (for status display). + pub fn total(&self) -> usize { + self.events + .values() + .flat_map(|ms| ms.iter()) + .map(|m| m.hooks.len()) + .sum() + } + + /// Number of hook commands subscribed to a single event. + pub fn count(&self, event: HookEvent) -> usize { + self.events + .get(event.as_str()) + .map(|ms| ms.iter().map(|m| m.hooks.len()).sum()) + .unwrap_or(0) + } + + /// Whether any command is configured for `event`. + pub fn has_event(&self, event: HookEvent) -> bool { + self.count(event) > 0 + } + + /// The commands that should fire for `event` (and, for tool events, `tool`). + /// Pure — this is what the runner iterates over. + pub fn select(&self, event: HookEvent, tool: Option<&str>) -> Vec { + let mut out = Vec::new(); + if let Some(matchers) = self.events.get(event.as_str()) { + for m in matchers { + let tool_for_match = if event.is_tool_event() { tool } else { None }; + if matcher_matches(m.matcher.as_deref(), tool_for_match) { + out.extend(m.hooks.iter().cloned()); + } + } + } + out + } +} + +/// Whether a matcher pattern selects a tool. Empty / `None` / `"*"` matches everything. +/// Otherwise the pattern is a `|`-separated list of exact tool names — Claude Code's +/// common matcher form, e.g. `"write_file|run_command"`. (Full regex isn't supported to +/// avoid a new dependency; alternation + wildcard covers the practical cases.) Pure. +pub fn matcher_matches(pattern: Option<&str>, tool: Option<&str>) -> bool { + let p = pattern.unwrap_or("").trim(); + if p.is_empty() || p == "*" { + return true; + } + // Non-tool events carry no tool name; a present matcher there still matches. + let Some(tool) = tool else { + return true; + }; + p.split('|').map(str::trim).any(|name| name == tool) +} + +// ---- Event input (stdin payload) ---------------------------------------- + +/// The JSON payload handed to a hook on stdin. Field set mirrors Claude Code's +/// (`session_id`, `cwd`, `hook_event_name`, `tool_name`, `tool_input`, `tool_response`, +/// `prompt`) plus xConsole context (`workspace_id`, `vps_targets`). +pub struct HookEventInput<'a> { + pub event: HookEvent, + pub session_id: &'a str, + pub cwd: &'a str, + pub workspace_id: Option<&'a str>, + pub vps_targets: &'a [String], + pub tool_name: Option<&'a str>, + pub tool_input: Option<&'a Value>, + pub tool_response: Option<&'a str>, + pub prompt: Option<&'a str>, +} + +impl HookEventInput<'_> { + pub fn to_json(&self) -> Value { + let mut m = serde_json::Map::new(); + m.insert("session_id".into(), json!(self.session_id)); + m.insert("cwd".into(), json!(self.cwd)); + m.insert("hook_event_name".into(), json!(self.event.as_str())); + if let Some(w) = self.workspace_id.filter(|s| !s.is_empty()) { + m.insert("workspace_id".into(), json!(w)); + } + if !self.vps_targets.is_empty() { + m.insert("vps_targets".into(), json!(self.vps_targets)); + } + if let Some(t) = self.tool_name { + m.insert("tool_name".into(), json!(t)); + } + if let Some(i) = self.tool_input { + m.insert("tool_input".into(), i.clone()); + } + if let Some(r) = self.tool_response { + m.insert("tool_response".into(), json!(r)); + } + if let Some(p) = self.prompt { + m.insert("prompt".into(), json!(p)); + } + Value::Object(m) + } +} + +/// The current working directory string for a hook payload (best-effort). +pub fn cwd() -> String { + std::env::current_dir() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +// ---- Decision (output interpretation) ----------------------------------- + +/// What a hook (or the merge of several) decided. The agent loop acts on this. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct HookDecision { + /// The action should be blocked (tool not run / prompt rejected). + pub block: bool, + /// Halt the whole turn (`continue:false`). + pub stop: bool, + /// Reason shown to the model / user. + pub reason: Option, + /// Extra context to inject (UserPromptSubmit / additionalContext). + pub additional_context: Option, + /// A message to surface to the user (not the model). + pub system_message: Option, + /// PreToolUse explicit permission: `"allow"` / `"deny"` / `"ask"`. + pub permission: Option, +} + +impl HookDecision { + /// Whether this decision blocks the action (explicit block or a deny permission). + pub fn blocks(&self) -> bool { + self.block || self.permission.as_deref() == Some("deny") + } +} + +/// Interpret ONE hook's result (exit code + stdout + stderr) for an event. Pure. +/// +/// A JSON object on stdout is the structured path; otherwise exit-code semantics apply. +/// Exit code `2` is always a blocking error (with stderr as the reason). +pub fn parse_output(event: HookEvent, exit_code: i32, stdout: &str, stderr: &str) -> HookDecision { + let mut d = HookDecision::default(); + let out = stdout.trim(); + + let parsed = if out.starts_with('{') { + serde_json::from_str::(out) + .ok() + .and_then(|v| v.as_object().cloned()) + } else { + None + }; + + if let Some(o) = &parsed { + if o.get("continue").and_then(Value::as_bool) == Some(false) { + d.stop = true; + d.block = true; + } + if let Some(s) = o.get("stopReason").and_then(Value::as_str) { + d.reason = Some(s.to_string()); + } + if let Some(s) = o.get("systemMessage").and_then(Value::as_str) { + d.system_message = Some(s.to_string()); + } + if o.get("decision").and_then(Value::as_str) == Some("block") { + d.block = true; + } + if let Some(r) = o.get("reason").and_then(Value::as_str) { + d.reason = Some(r.to_string()); + } + if let Some(hso) = o.get("hookSpecificOutput").and_then(Value::as_object) { + if let Some(c) = hso.get("additionalContext").and_then(Value::as_str) { + d.additional_context = Some(c.to_string()); + } + if let Some(pd) = hso.get("permissionDecision").and_then(Value::as_str) { + d.permission = Some(pd.to_string()); + if pd == "deny" { + d.block = true; + } + } + if let Some(pr) = hso.get("permissionDecisionReason").and_then(Value::as_str) { + d.reason = Some(pr.to_string()); + } + } + } else if exit_code == 0 + && !out.is_empty() + && matches!(event, HookEvent::UserPromptSubmit | HookEvent::SessionStart) + { + // Plain stdout from these events is injected as additional context. + d.additional_context = Some(out.to_string()); + } + + // Exit-code floor: 2 is a blocking error regardless of stdout. + if exit_code == 2 { + d.block = true; + if d.reason.is_none() { + let e = stderr.trim(); + if !e.is_empty() { + d.reason = Some(e.to_string()); + } + } + } + d +} + +/// Combine the decisions of every hook that fired for an event. Blocking wins; a `deny` +/// permission overrides an `allow`; reasons and contexts are concatenated. Pure. +pub fn merge(decisions: &[HookDecision]) -> HookDecision { + let mut m = HookDecision::default(); + let mut reasons = Vec::new(); + let mut contexts = Vec::new(); + let mut messages = Vec::new(); + for d in decisions { + m.block |= d.block; + m.stop |= d.stop; + match d.permission.as_deref() { + Some("deny") => m.permission = Some("deny".into()), + Some("allow") if m.permission.as_deref() != Some("deny") => { + m.permission = Some("allow".into()) + } + Some("ask") if m.permission.is_none() => m.permission = Some("ask".into()), + _ => {} + } + if let Some(r) = &d.reason { + reasons.push(r.clone()); + } + if let Some(c) = &d.additional_context { + contexts.push(c.clone()); + } + if let Some(s) = &d.system_message { + messages.push(s.clone()); + } + } + if !reasons.is_empty() { + m.reason = Some(reasons.join("; ")); + } + if !contexts.is_empty() { + m.additional_context = Some(contexts.join("\n")); + } + if !messages.is_empty() { + m.system_message = Some(messages.join("\n")); + } + m +} + +// ---- Runner (the only I/O in this module) ------------------------------- + +struct RunResult { + code: i32, + stdout: String, + stderr: String, +} + +fn build_shell(command: &str) -> tokio::process::Command { + #[cfg(windows)] + { + let mut c = crate::proc::quiet_tokio("cmd"); + c.arg("/C").arg(command); + c + } + #[cfg(not(windows))] + { + let mut c = crate::proc::quiet_tokio("sh"); + c.arg("-c").arg(command); + c + } +} + +/// Run a single hook command: spawn the shell, pipe `payload` to stdin, capture +/// stdout/stderr, and enforce the timeout (killing the child if it runs over). +async fn run_one(cmd: &HookCommand, payload: &str) -> RunResult { + use std::process::Stdio; + use tokio::io::AsyncWriteExt; + + let secs = cmd + .timeout + .unwrap_or(DEFAULT_TIMEOUT_SECS) + .clamp(1, MAX_TIMEOUT_SECS); + + let mut command = build_shell(&cmd.command); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let mut child = match command.spawn() { + Ok(c) => c, + Err(e) => { + return RunResult { + code: -1, + stdout: String::new(), + stderr: format!("hook spawn failed: {e}"), + } + } + }; + + // Feed the event payload, then close stdin so a reader sees EOF. + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(payload.as_bytes()).await; + // `stdin` drops here, closing the pipe. + } + + match tokio::time::timeout( + std::time::Duration::from_secs(secs), + child.wait_with_output(), + ) + .await + { + Ok(Ok(out)) => RunResult { + code: out.status.code().unwrap_or(-1), + stdout: String::from_utf8_lossy(&out.stdout).into_owned(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }, + Ok(Err(e)) => RunResult { + code: -1, + stdout: String::new(), + stderr: format!("hook io error: {e}"), + }, + // Timed out: the wait future is dropped, which (kill_on_drop) kills the child. + Err(_) => RunResult { + code: -1, + stdout: String::new(), + stderr: format!("hook timed out after {secs}s"), + }, + } +} + +/// Run every hook that matches `input`'s event (and tool), and merge their decisions. +/// Returns the default (no-op) decision when nothing matches. +pub async fn run_event(config: &HooksConfig, input: &HookEventInput<'_>) -> HookDecision { + let cmds = config.select(input.event, input.tool_name); + if cmds.is_empty() { + return HookDecision::default(); + } + let payload = input.to_json().to_string(); + let mut decisions = Vec::with_capacity(cmds.len()); + for c in &cmds { + if c.kind != "command" || c.command.trim().is_empty() { + continue; + } + let r = run_one(c, &payload).await; + decisions.push(parse_output(input.event, r.code, &r.stdout, &r.stderr)); + } + merge(&decisions) +} + +// ---- Managed snapshot state --------------------------------------------- + +/// The startup snapshot of the hooks config, shared as Tauri-managed state. Loaded once +/// at launch so mid-session edits to `hooks.json` (including ones the agent might write) +/// take effect only on an explicit reload — the same safety property Claude Code has. +#[derive(Clone)] +pub struct HooksState(Arc>); + +impl HooksState { + pub fn new(config: HooksConfig) -> Self { + Self(Arc::new(RwLock::new(config))) + } + + /// A cheap clone of the current snapshot for one turn. + pub fn snapshot(&self) -> HooksConfig { + self.0.read().map(|g| g.clone()).unwrap_or_default() + } + + /// Re-read `hooks.json` from disk and replace the snapshot. Returns the new hook + /// count, or an error if the file is malformed (the snapshot is left unchanged). + pub fn reload(&self, home: &AgentHome) -> Result { + let config = HooksConfig::load_strict(home)?; + let n = config.total(); + if let Ok(mut g) = self.0.write() { + *g = config; + } + Ok(n) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matcher_wildcard_and_alternation() { + assert!(matcher_matches(None, Some("run_command"))); + assert!(matcher_matches(Some(""), Some("run_command"))); + assert!(matcher_matches(Some("*"), Some("anything"))); + assert!(matcher_matches(Some("write_file|run_command"), Some("run_command"))); + assert!(!matcher_matches(Some("write_file|read_file"), Some("run_command"))); + // Non-tool events (tool = None) always match. + assert!(matcher_matches(Some("write_file"), None)); + } + + #[test] + fn parse_wrapped_and_bare_forms() { + let wrapped = r#"{"hooks":{"PreToolUse":[{"matcher":"run_command","hooks":[{"type":"command","command":"echo hi"}]}]}}"#; + let bare = r#"{"PreToolUse":[{"matcher":"run_command","hooks":[{"command":"echo hi"}]}]}"#; + let a = HooksConfig::parse(wrapped).unwrap(); + let b = HooksConfig::parse(bare).unwrap(); + assert_eq!(a.count(HookEvent::PreToolUse), 1); + assert_eq!(b.count(HookEvent::PreToolUse), 1); + // `type` defaults to "command" when omitted (bare form). + assert_eq!(b.select(HookEvent::PreToolUse, Some("run_command"))[0].kind, "command"); + } + + #[test] + fn parse_ignores_unknown_events_and_rejects_garbage() { + let cfg = HooksConfig::parse(r#"{"Bogus":[],"Stop":[{"hooks":[{"command":"x"}]}]}"#).unwrap(); + assert_eq!(cfg.count(HookEvent::Stop), 1); + assert!(HooksConfig::parse("not json").is_err()); + assert!(HooksConfig::parse("[1,2,3]").is_err()); + } + + #[test] + fn select_filters_by_tool_for_tool_events() { + let cfg = HooksConfig::parse( + r#"{"PreToolUse":[ + {"matcher":"run_command","hooks":[{"command":"a"}]}, + {"matcher":"write_file","hooks":[{"command":"b"}]}, + {"matcher":"*","hooks":[{"command":"c"}]} + ]}"#, + ) + .unwrap(); + let sel: Vec = cfg + .select(HookEvent::PreToolUse, Some("run_command")) + .into_iter() + .map(|c| c.command) + .collect(); + assert_eq!(sel, vec!["a", "c"]); // run_command matcher + wildcard, not write_file + } + + #[test] + fn output_exit2_blocks_with_stderr_reason() { + let d = parse_output(HookEvent::PreToolUse, 2, "", "nope, not allowed"); + assert!(d.blocks()); + assert_eq!(d.reason.as_deref(), Some("nope, not allowed")); + } + + #[test] + fn output_json_decision_block() { + let d = parse_output( + HookEvent::PreToolUse, + 0, + r#"{"decision":"block","reason":"dangerous"}"#, + "", + ); + assert!(d.blocks()); + assert_eq!(d.reason.as_deref(), Some("dangerous")); + } + + #[test] + fn output_permission_deny_and_allow() { + let deny = parse_output( + HookEvent::PreToolUse, + 0, + r#"{"hookSpecificOutput":{"permissionDecision":"deny","permissionDecisionReason":"blocked path"}}"#, + "", + ); + assert!(deny.blocks()); + assert_eq!(deny.reason.as_deref(), Some("blocked path")); + + let allow = parse_output( + HookEvent::PreToolUse, + 0, + r#"{"hookSpecificOutput":{"permissionDecision":"allow"}}"#, + "", + ); + assert!(!allow.blocks()); + assert_eq!(allow.permission.as_deref(), Some("allow")); + } + + #[test] + fn output_additional_context_paths() { + // Plain stdout on UserPromptSubmit becomes context. + let plain = parse_output(HookEvent::UserPromptSubmit, 0, "remember: prod is read-only", ""); + assert_eq!( + plain.additional_context.as_deref(), + Some("remember: prod is read-only") + ); + // But plain stdout on PreToolUse is NOT context (only success output, ignored). + let pre = parse_output(HookEvent::PreToolUse, 0, "some log line", ""); + assert!(pre.additional_context.is_none()); + // JSON additionalContext works on any event. + let j = parse_output( + HookEvent::PostToolUse, + 0, + r#"{"hookSpecificOutput":{"additionalContext":"linted clean"}}"#, + "", + ); + assert_eq!(j.additional_context.as_deref(), Some("linted clean")); + } + + #[test] + fn output_continue_false_stops() { + let d = parse_output(HookEvent::Stop, 0, r#"{"continue":false,"stopReason":"halt"}"#, ""); + assert!(d.stop); + assert_eq!(d.reason.as_deref(), Some("halt")); + } + + #[test] + fn merge_block_wins_and_concatenates() { + let a = HookDecision { + additional_context: Some("ctx-a".into()), + ..Default::default() + }; + let b = HookDecision { + block: true, + reason: Some("bad".into()), + permission: Some("deny".into()), + additional_context: Some("ctx-b".into()), + ..Default::default() + }; + let c = HookDecision { + permission: Some("allow".into()), + ..Default::default() + }; + let m = merge(&[a, b, c]); + assert!(m.blocks()); + assert_eq!(m.permission.as_deref(), Some("deny")); // deny beats allow + assert_eq!(m.additional_context.as_deref(), Some("ctx-a\nctx-b")); + assert_eq!(m.reason.as_deref(), Some("bad")); + } + + #[test] + fn input_payload_shape() { + let targets = vec!["vps-1".to_string()]; + let args = json!({"command":"ls"}); + let input = HookEventInput { + event: HookEvent::PreToolUse, + session_id: "s1", + cwd: "/tmp", + workspace_id: Some("ws1"), + vps_targets: &targets, + tool_name: Some("run_command"), + tool_input: Some(&args), + tool_response: None, + prompt: None, + }; + let v = input.to_json(); + assert_eq!(v["hook_event_name"], "PreToolUse"); + assert_eq!(v["tool_name"], "run_command"); + assert_eq!(v["tool_input"]["command"], "ls"); + assert_eq!(v["vps_targets"][0], "vps-1"); + assert_eq!(v["workspace_id"], "ws1"); + assert!(v.get("prompt").is_none()); + } + + #[tokio::test] + async fn live_runner_blocks_on_exit_2() { + // A real PreToolUse hook that exits 2 must block the tool. The shell wrapper is + // added by the runner, so "exit 2" is portable across cmd and sh. + let cfg = HooksConfig::parse( + r#"{"PreToolUse":[{"matcher":"*","hooks":[{"command":"exit 2"}]}]}"#, + ) + .unwrap(); + let targets: Vec = vec![]; + let args = json!({}); + let input = HookEventInput { + event: HookEvent::PreToolUse, + session_id: "s", + cwd: ".", + workspace_id: None, + vps_targets: &targets, + tool_name: Some("run_command"), + tool_input: Some(&args), + tool_response: None, + prompt: None, + }; + let d = run_event(&cfg, &input).await; + assert!(d.blocks(), "exit 2 hook should block"); + } + + #[tokio::test] + async fn live_runner_allows_on_exit_0() { + let cfg = + HooksConfig::parse(r#"{"PreToolUse":[{"matcher":"*","hooks":[{"command":"exit 0"}]}]}"#) + .unwrap(); + let targets: Vec = vec![]; + let args = json!({}); + let input = HookEventInput { + event: HookEvent::PreToolUse, + session_id: "s", + cwd: ".", + workspace_id: None, + vps_targets: &targets, + tool_name: Some("run_command"), + tool_input: Some(&args), + tool_response: None, + prompt: None, + }; + let d = run_event(&cfg, &input).await; + assert!(!d.blocks(), "exit 0 hook should not block"); + } +} diff --git a/src-tauri/src/ai/mod.rs b/src-tauri/src/ai/mod.rs index 2ce7bbe..3a10fa2 100644 --- a/src-tauri/src/ai/mod.rs +++ b/src-tauri/src/ai/mod.rs @@ -11,6 +11,7 @@ pub mod context_usage; pub mod conversations; pub mod cron; pub mod edits; +pub mod hooks; pub mod infra_tools; pub mod interaction; pub mod llama; diff --git a/src-tauri/src/ai/tools.rs b/src-tauri/src/ai/tools.rs index 80d0f5c..4116ccf 100644 --- a/src-tauri/src/ai/tools.rs +++ b/src-tauri/src/ai/tools.rs @@ -10,7 +10,7 @@ use crate::ai::web_tools; use crate::ai::provider::{emit, EventSink, StreamEvent, ToolCall, ToolDef, ActivityEvent}; use crate::ai::interaction::{PromptRegistry, SessionState}; use crate::ai::safety::{self, ApprovalRegistry}; -use crate::ai::{memory, skill_install, skill_scan, skills, workspace_context, AgentHome}; +use crate::ai::{hooks, memory, skill_install, skill_scan, skills, workspace_context, AgentHome}; use crate::secrets; use crate::ssh::{keygen, shell_quote, SessionManager}; use crate::storage::Db; @@ -45,6 +45,8 @@ pub struct ToolContext { pub canvas: Vec, /// Journal of files the agent edits this session (for the diff/changes panel). pub edits: crate::ai::edits::EditJournal, + /// Claude Code–style lifecycle hooks (snapshotted at startup). Empty = disabled. + pub hooks: crate::ai::hooks::HooksConfig, } /// Tool schemas advertised to the model. @@ -525,6 +527,47 @@ pub async fn dispatch(ctx: &ToolContext, call: &ToolCall, sink: &EventSink) -> S emit_skill_activity(ctx, call, sink); let args = &call.arguments; + + // PreToolUse hooks: a user-configured command can block this tool before it runs + // (exit 2 / `decision:block` / `permissionDecision:deny`) or inject extra context + // for the model. Fires only when something subscribes to PreToolUse (zero cost + // otherwise). See `ai::hooks`. + let mut hook_notes: Vec = Vec::new(); + if ctx.hooks.has_event(hooks::HookEvent::PreToolUse) { + let cwd = hooks::cwd(); + let input = hooks::HookEventInput { + event: hooks::HookEvent::PreToolUse, + session_id: &ctx.session_id, + cwd: &cwd, + workspace_id: ctx.workspace_id.as_deref(), + vps_targets: &ctx.targets, + tool_name: Some(&call.name), + tool_input: Some(&call.arguments), + tool_response: None, + prompt: None, + }; + let decision = hooks::run_event(&ctx.hooks, &input).await; + if let Some(msg) = &decision.system_message { + emit(Some(sink), StreamEvent::Status(msg.clone())); + } + if decision.blocks() { + let reason = decision + .reason + .unwrap_or_else(|| "blocked by a PreToolUse hook".to_string()); + emit( + Some(sink), + StreamEvent::Activity(ActivityEvent::ToolEnd { + id: call.id.clone(), + ok: false, + }), + ); + return format!("error: blocked by hook: {reason}"); + } + if let Some(extra) = decision.additional_context { + hook_notes.push(format!("[PreToolUse hook] {extra}")); + } + } + // Plan-mode guard: until the user approves a plan, block anything that would // change the PC or a server. Read-only inspection, ask_user, and present_plan // still run so the agent can investigate and propose its plan. @@ -580,6 +623,46 @@ pub async fn dispatch(ctx: &ToolContext, call: &ToolCall, sink: &EventSink) -> S } }; + // PostToolUse hooks: a user-configured command sees the tool result and can feed + // a note back to the model (a `decision:block` reason) or inject extra context. + if ctx.hooks.has_event(hooks::HookEvent::PostToolUse) { + let cwd = hooks::cwd(); + let input = hooks::HookEventInput { + event: hooks::HookEvent::PostToolUse, + session_id: &ctx.session_id, + cwd: &cwd, + workspace_id: ctx.workspace_id.as_deref(), + vps_targets: &ctx.targets, + tool_name: Some(&call.name), + tool_input: Some(&call.arguments), + tool_response: Some(&result), + prompt: None, + }; + let decision = hooks::run_event(&ctx.hooks, &input).await; + if let Some(msg) = &decision.system_message { + emit(Some(sink), StreamEvent::Status(msg.clone())); + } + if decision.blocks() { + let reason = decision + .reason + .clone() + .unwrap_or_else(|| "a PostToolUse hook flagged this result".to_string()); + hook_notes.push(format!("[PostToolUse hook] {reason}")); + } + if let Some(extra) = decision.additional_context { + hook_notes.push(format!("[PostToolUse hook] {extra}")); + } + } + + // Append any hook-injected context/feedback so the model sees it alongside the + // tool result. Kept after the result so it never changes the success/error prefix + // the loop keys off — except a PostToolUse block, which we surface as a note. + let result = if hook_notes.is_empty() { + result + } else { + format!("{result}\n\n{}", hook_notes.join("\n")) + }; + let ok = !result.starts_with("error:"); emit( Some(sink), diff --git a/src-tauri/src/bench.rs b/src-tauri/src/bench.rs index 17016f6..b425254 100644 --- a/src-tauri/src/bench.rs +++ b/src-tauri/src/bench.rs @@ -11,9 +11,11 @@ //! numbers reflect production behavior, not a stub. //! //! Usage: -//! xconsole-bench agent [--model qwen3.5:9b] [--base http://localhost:11434] [--ctx 65536] [--out results.json] -//! xconsole-bench llm [--model ...] [--ctx ...] +//! xconsole-bench agent [--model qwen3.5:9b] [--base http://localhost:11434] [--ctx 65536] [--out results.json] +//! xconsole-bench llm [--model ...] [--ctx ...] //! xconsole-bench all +//! xconsole-bench hooks [--out results.json] # hooks dispatch overhead (no model) +//! xconsole-bench selftest # pure-logic + live-hook checks (no model) //! //! These are REGRESSION benchmarks: run them, change a feature, run them again, //! and compare the JSON to see whether latency / pass-rate improved. @@ -74,9 +76,18 @@ async fn run_async(args: &[String]) -> i32 { println!("xConsole bench — mode={mode} model={model} base={base} num_ctx={num_ctx}"); - // Pure-logic self-tests (reflection, voice prompt) — no Ollama needed. + // Pure-logic self-tests (reflection, voice prompt, hooks) — no Ollama needed. if mode == "selftest" { - return selftest(); + let mut code = selftest(); + if selftest_hooks_live().await != 0 { + code = 1; + } + return code; + } + + // Hooks overhead benchmark — needs no model, so run before the Ollama preflight. + if mode == "hooks" { + return bench_hooks(out).await; } // Preflight: Ollama up and the model present? @@ -107,7 +118,7 @@ async fn run_async(args: &[String]) -> i32 { a } other => { - eprintln!("bench: unknown mode '{other}' (use: agent | llm | all)"); + eprintln!("bench: unknown mode '{other}' (use: agent | llm | all | hooks | selftest)"); return 1; } }; @@ -583,8 +594,150 @@ fn merge_reports(into: &mut Value, other: Value) { } } +// ---- Hooks overhead benchmark (no model needed) -------------------------- + +/// Measure what a Claude Code–style hook costs the agent loop: the pure config/select +/// path (nanoseconds) and a real no-op hook subprocess (the per-tool-call latency a +/// configured PreToolUse hook adds). No Ollama, fully headless. +async fn bench_hooks(out: Option) -> i32 { + use crate::ai::hooks::{self, HookEvent, HookEventInput, HooksConfig}; + + println!("\n=== HOOKS OVERHEAD ==="); + + // 1) Pure path: config.select() — what runs on EVERY tool call to decide whether a + // hook even fires. Should be negligible. + let cfg = HooksConfig::parse( + r#"{"PreToolUse":[{"matcher":"run_command|write_file","hooks":[{"command":"exit 0"}]}]}"#, + ) + .expect("valid config"); + let iters = 200_000u32; + let t0 = Instant::now(); + let mut acc = 0usize; + for _ in 0..iters { + acc += cfg.select(HookEvent::PreToolUse, Some("run_command")).len(); + } + std::hint::black_box(acc); + let pure_ns = t0.elapsed().as_nanos() / iters as u128; + println!("pure select() per call : {pure_ns} ns ({iters} iters)"); + + // 2) Live path: spawn a no-op hook (exit 0) through the real runner, JSON piped to + // stdin. This is the latency a configured PreToolUse hook adds to one tool call. + let targets: Vec = vec![]; + let args = json!({ "command": "ls -la" }); + let input = HookEventInput { + event: HookEvent::PreToolUse, + session_id: "bench", + cwd: ".", + workspace_id: None, + vps_targets: &targets, + tool_name: Some("run_command"), + tool_input: Some(&args), + tool_response: None, + prompt: None, + }; + // Warm the shell once (the very first spawn pays a one-off OS cost). + let _ = hooks::run_event(&cfg, &input).await; + let runs = 30u32; + let t1 = Instant::now(); + for _ in 0..runs { + let _ = hooks::run_event(&cfg, &input).await; + } + let live_ms = t1.elapsed().as_millis() as f64 / runs as f64; + println!("live no-op hook spawn : {live_ms:.2} ms ({runs} runs)"); + + // 3) Blocking hook (exit 2): confirm the block path works and costs the same order. + let block_cfg = + HooksConfig::parse(r#"{"PreToolUse":[{"matcher":"*","hooks":[{"command":"exit 2"}]}]}"#) + .unwrap(); + let blocked = hooks::run_event(&block_cfg, &input).await.blocks(); + println!("blocking hook (exit 2) : blocks = {blocked}"); + + println!( + "\nA tool call with a PreToolUse hook adds ~{live_ms:.1} ms (one process spawn). \ + With no hooks configured the loop skips the hook path entirely (0 ms)." + ); + + let report = json!({ + "mode": "hooks", + "pure_select_ns": pure_ns, + "live_hook_ms": live_ms, + "live_runs": runs, + "block_works": blocked, + }); + if let Some(path) = out { + match std::fs::write(&path, serde_json::to_string_pretty(&report).unwrap_or_default()) { + Ok(()) => println!("\nWrote results → {path}"), + Err(e) => eprintln!("bench: could not write {path}: {e}"), + } + } + if blocked { + 0 + } else { + 1 + } +} + // ---- Self-test (pure logic; runs without Ollama) ------------------------- +/// Live hooks self-test: spawns real hook subprocesses through the runner (so it can't +/// live in the sync `selftest()`). Returns 0 on success, 1 on any failure. +async fn selftest_hooks_live() -> i32 { + use crate::ai::hooks::{self, HookEvent, HookEventInput}; + + println!("\n=== SELFTEST: hooks live runner (spawns real subprocesses) ==="); + let mut ok = true; + let mut check = |name: &str, cond: bool| { + if cond { + println!(" PASS {name}"); + } else { + println!(" FAIL {name}"); + ok = false; + } + }; + + let targets: Vec = vec![]; + let args = json!({ "command": "ls" }); + let mk = |event| HookEventInput { + event, + session_id: "selftest", + cwd: ".", + workspace_id: None, + vps_targets: &targets, + tool_name: Some("run_command"), + tool_input: Some(&args), + tool_response: None, + prompt: None, + }; + + let block = + hooks::HooksConfig::parse(r#"{"PreToolUse":[{"matcher":"*","hooks":[{"command":"exit 2"}]}]}"#) + .unwrap(); + check( + "PreToolUse exit-2 hook blocks the tool", + hooks::run_event(&block, &mk(HookEvent::PreToolUse)).await.blocks(), + ); + + let allow = + hooks::HooksConfig::parse(r#"{"PreToolUse":[{"matcher":"*","hooks":[{"command":"exit 0"}]}]}"#) + .unwrap(); + check( + "PreToolUse exit-0 hook allows the tool", + !hooks::run_event(&allow, &mk(HookEvent::PreToolUse)).await.blocks(), + ); + + let empty = hooks::HooksConfig::default(); + check( + "no hooks configured → no-op decision", + !hooks::run_event(&empty, &mk(HookEvent::PreToolUse)).await.blocks(), + ); + + if ok { + 0 + } else { + 1 + } +} + fn selftest() -> i32 { use crate::ai::provider::ToolCall; use crate::ai::reflection; @@ -826,6 +979,56 @@ fn selftest() -> i32 { check("targets: bare 'both' is not multi-target", !user_asks_multiple_targets("both")); } + println!("\n=== SELFTEST: hooks config + decision parsing (pure) ==="); + { + use crate::ai::hooks::{self, HookEvent}; + let cfg = hooks::HooksConfig::parse( + r#"{"hooks":{"PreToolUse":[{"matcher":"run_command","hooks":[{"command":"exit 2"}]}],"UserPromptSubmit":[{"hooks":[{"command":"echo hi"}]}]}}"#, + ); + check("parses the Claude Code hooks.json shape", cfg.is_ok()); + if let Ok(cfg) = &cfg { + check("counts PreToolUse hooks", cfg.count(HookEvent::PreToolUse) == 1); + check( + "matcher selects the right tool only", + cfg.select(HookEvent::PreToolUse, Some("run_command")).len() == 1 + && cfg.select(HookEvent::PreToolUse, Some("write_file")).is_empty(), + ); + check( + "non-tool event ignores the matcher", + cfg.select(HookEvent::UserPromptSubmit, None).len() == 1, + ); + } + check("rejects malformed config", hooks::HooksConfig::parse("not json").is_err()); + check( + "wildcard matcher matches any tool", + hooks::matcher_matches(Some("*"), Some("anything")), + ); + let blocked = hooks::parse_output(HookEvent::PreToolUse, 2, "", "denied"); + check( + "exit 2 blocks with the stderr reason", + blocked.blocks() && blocked.reason.as_deref() == Some("denied"), + ); + let json_block = hooks::parse_output( + HookEvent::PreToolUse, + 0, + r#"{"decision":"block","reason":"nope"}"#, + "", + ); + check("decision:block is honored", json_block.blocks()); + let ctx = hooks::parse_output(HookEvent::UserPromptSubmit, 0, "extra context", ""); + check( + "UserPromptSubmit stdout becomes context", + ctx.additional_context.as_deref() == Some("extra context"), + ); + let allow = hooks::parse_output( + HookEvent::PreToolUse, + 0, + r#"{"hookSpecificOutput":{"permissionDecision":"allow"}}"#, + "", + ); + check("permission allow does not block", !allow.blocks()); + } + println!("\nSELFTEST: {pass} passed, {fail} failed"); if fail > 0 { 1 diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index da113f2..07bf95d 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -41,6 +41,7 @@ pub async fn ai_chat( session_state: State<'_, SessionState>, llama: State<'_, crate::ai::llama::LlamaServer>, edits: State<'_, crate::ai::edits::EditJournal>, + hooks_state: State<'_, crate::ai::hooks::HooksState>, session_id: String, messages: Vec, provider_id: Option, @@ -74,6 +75,19 @@ pub async fn ai_chat( targets.push(n.vps_id.clone()); } } + // Hooks: use the startup snapshot unless globally disabled. Empty = no hook code runs. + let hooks_cfg = if db_inner + .get_setting("agent.hooks_enabled") + .ok() + .flatten() + .as_deref() + == Some("false") + { + crate::ai::hooks::HooksConfig::default() + } else { + hooks_state.snapshot() + }; + let tc = ToolContext { app: app.clone(), db: db_inner.clone(), @@ -89,6 +103,7 @@ pub async fn ai_chat( workspace_id: workspace_id.filter(|s| !s.is_empty()), canvas, edits: edits.inner().clone(), + hooks: hooks_cfg, }; // If the chosen provider runs a local server, make sure it's up first so the @@ -666,6 +681,88 @@ pub fn save_user_doc(home: State<'_, AgentHome>, content: String) -> Result<(), crate::ai::memory::save_user(&home, &content) } +// ----- Hooks (Claude Code–style lifecycle hooks) ----- + +/// Per-event hook counts + enable state, for the settings UI. +#[derive(serde::Serialize)] +pub struct HooksStatus { + pub enabled: bool, + pub total: usize, + pub pre_tool_use: usize, + pub post_tool_use: usize, + pub user_prompt_submit: usize, + pub stop: usize, + /// A parse error in the on-disk `hooks.json`, if any (the live snapshot is unchanged). + pub error: Option, +} + +/// Raw text of `hooks.json` so the editor shows exactly what's on disk. Empty if absent. +#[tauri::command] +pub fn get_hooks_config(home: State<'_, AgentHome>) -> String { + std::fs::read_to_string(home.0.join(crate::ai::hooks::HOOKS_FILE)).unwrap_or_default() +} + +/// Validate + write `hooks.json`, then reload the startup snapshot so it takes effect. +/// Returns the number of hook commands loaded. A malformed config is rejected (not written). +#[tauri::command] +pub fn save_hooks_config( + home: State<'_, AgentHome>, + hooks_state: State<'_, crate::ai::hooks::HooksState>, + content: String, +) -> Result { + use crate::ai::hooks::{HooksConfig, HOOKS_FILE}; + let trimmed = content.trim(); + if !trimmed.is_empty() { + HooksConfig::parse(trimmed)?; // validate before persisting + } + let path = home.0.join(HOOKS_FILE); + std::fs::write(&path, &content) + .map_err(|e| format!("could not write {}: {e}", path.display()))?; + hooks_state.reload(&home) +} + +/// Reload `hooks.json` from disk into the live snapshot (returns the hook count). +#[tauri::command] +pub fn reload_hooks( + home: State<'_, AgentHome>, + hooks_state: State<'_, crate::ai::hooks::HooksState>, +) -> Result { + hooks_state.reload(&home) +} + +/// Current hooks status for the settings UI. +#[tauri::command] +pub fn hooks_status( + db: State<'_, Db>, + home: State<'_, AgentHome>, + hooks_state: State<'_, crate::ai::hooks::HooksState>, +) -> HooksStatus { + use crate::ai::hooks::{HookEvent, HooksConfig, HOOKS_FILE}; + let enabled = db + .get_setting("agent.hooks_enabled") + .ok() + .flatten() + .as_deref() + != Some("false"); + let cfg = hooks_state.snapshot(); + // Surface a parse error in the current file (the snapshot keeps the last good config). + let raw = std::fs::read_to_string(home.0.join(HOOKS_FILE)).unwrap_or_default(); + let error = if raw.trim().is_empty() { + None + } else { + HooksConfig::parse(&raw).err() + }; + HooksStatus { + enabled, + total: cfg.total(), + pre_tool_use: cfg.count(HookEvent::PreToolUse), + post_tool_use: cfg.count(HookEvent::PostToolUse), + user_prompt_submit: cfg.count(HookEvent::UserPromptSubmit), + stop: cfg.count(HookEvent::Stop), + error, + } +} + // ----- Skills ----- #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c94120a..6ddd6c6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -128,6 +128,12 @@ pub fn run() { app.manage(db); app.manage(sessions); app.manage(sftp); + // Claude Code–style lifecycle hooks: snapshot hooks.json at startup so a + // mid-session edit (incl. one the agent might write) only takes effect on + // an explicit reload. Loaded before agent_home is moved into managed state. + app.manage(ai::hooks::HooksState::new(ai::hooks::HooksConfig::load( + &agent_home, + ))); app.manage(agent_home); app.manage(approvals); app.manage(prompts); @@ -275,6 +281,10 @@ pub fn run() { commands::ai::save_soul, commands::ai::save_memory_doc, commands::ai::save_user_doc, + commands::ai::get_hooks_config, + commands::ai::save_hooks_config, + commands::ai::reload_hooks, + commands::ai::hooks_status, commands::ai::list_skills, commands::ai::get_skill, commands::ai::save_skill, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2a42e30..dac78d4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -25,6 +25,11 @@ }, "bundle": { "active": false, + "publisher": "xConsole", + "copyright": "Copyright © 2026 xConsole. Licensed under MIT.", + "category": "DeveloperTool", + "shortDescription": "xConsole", + "longDescription": "xConsole — a multi-VPS canvas terminal with a built-in AI agent, pure-Rust SSH/SFTP, and infrastructure tooling.", "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx index 4cb5681..a750037 100644 --- a/src/components/settings/SettingsModal.tsx +++ b/src/components/settings/SettingsModal.tsx @@ -12,6 +12,7 @@ import { SettingsIcon, ShieldIcon, SparkIcon, + TerminalIcon, } from "../icons"; import { GeneralSection } from "./sections/GeneralSection"; import { ThemeSection } from "./sections/ThemeSection"; @@ -19,6 +20,7 @@ import { ModelsSection } from "./sections/ModelsSection"; import { VoiceSection } from "./sections/VoiceSection"; import { ProvidersSection } from "./sections/ProvidersSection"; import { AgentSection } from "./sections/AgentSection"; +import { HooksSection } from "./sections/HooksSection"; import { SoulSection } from "./sections/SoulSection"; import { MemorySection } from "./sections/MemorySection"; import { SkillsSection } from "./sections/SkillsSection"; @@ -42,6 +44,7 @@ const CATEGORIES: Category[] = [ { id: "models", label: "Models", icon: BrainIcon, Component: ModelsSection }, { id: "voice", label: "Voice", icon: SparkIcon, Component: VoiceSection }, { id: "agent", label: "Agent & Safety", icon: BotIcon, Component: AgentSection }, + { id: "hooks", label: "Hooks", icon: TerminalIcon, Component: HooksSection }, { id: "soul", label: "Soul", icon: SparkIcon, Component: SoulSection }, { id: "memory", label: "Memory", icon: BrainIcon, Component: MemorySection }, { id: "skills", label: "Skills", icon: BookIcon, Component: SkillsSection }, diff --git a/src/components/settings/sections/HooksSection.tsx b/src/components/settings/sections/HooksSection.tsx new file mode 100644 index 0000000..079337e --- /dev/null +++ b/src/components/settings/sections/HooksSection.tsx @@ -0,0 +1,208 @@ +import { useEffect, useState } from "react"; +import { api, type HooksStatus } from "../../../lib/tauri"; +import { useSettingsStore } from "../../../stores/settingsStore"; +import { Button, Card, Field, SectionHeader, TextArea, Toggle } from "../ui"; + +const EXAMPLE = `{ + "hooks": { + "UserPromptSubmit": [ + { + "hooks": [ + { "type": "command", "command": "echo Reminder: production servers are read-only." } + ] + } + ], + "PreToolUse": [ + { + "matcher": "run_command|run_command_all", + "hooks": [ + { "type": "command", "command": "exit 0" } + ] + } + ], + "PostToolUse": [ + { + "matcher": "write_file|local_write_file", + "hooks": [ + { "type": "command", "command": "exit 0" } + ] + } + ] + } +}`; + +/** Claude Code–style lifecycle hooks: edit hooks.json, toggle the system, see status. */ +export function HooksSection() { + const enabledSetting = useSettingsStore((s) => s.settings["agent.hooks_enabled"]); + const setSetting = useSettingsStore((s) => s.set); + const enabled = enabledSetting !== "false"; + + const [draft, setDraft] = useState(""); + const [saved, setSaved] = useState(""); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [note, setNote] = useState(null); + + const refreshStatus = () => api.hooksStatus().then(setStatus); + + const load = async () => { + const [cfg] = await Promise.all([api.getHooksConfig(), refreshStatus()]); + setDraft(cfg); + setSaved(cfg); + }; + + useEffect(() => { + load(); + }, []); + + const dirty = draft !== saved; + + const save = async () => { + setBusy(true); + setError(null); + setNote(null); + try { + const count = await api.saveHooksConfig(draft); + setSaved(draft); + await refreshStatus(); + setNote(`Saved — ${count} hook${count === 1 ? "" : "s"} active.`); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + const reload = async () => { + setBusy(true); + setError(null); + setNote(null); + try { + await api.reloadHooks(); + await load(); + setNote("Reloaded from disk."); + } catch (e) { + setError(String(e)); + } finally { + setBusy(false); + } + }; + + return ( +
+ setSetting("agent.hooks_enabled", v ? "true" : "false")} + label={enabled ? "Enabled" : "Disabled"} + /> + } + /> + + +
+ + {status ? `${status.total} hook${status.total === 1 ? "" : "s"} loaded` : "…"} + + {status && ( + <> + PreToolUse · {status.pre_tool_use} + PostToolUse · {status.post_tool_use} + UserPromptSubmit · {status.user_prompt_submit} + Stop · {status.stop} + + )} +
+ {status?.error && ( +
+ hooks.json on disk has an error (the last valid config is still active): {status.error} +
+ )} + {!enabled && ( +
+ Hooks are disabled — no hook commands run, regardless of what's configured below. +
+ )} +
+ + + +