diff --git a/.github/workflows/windows-check.yml b/.github/workflows/windows-check.yml new file mode 100644 index 000000000..70c06eec4 --- /dev/null +++ b/.github/workflows/windows-check.yml @@ -0,0 +1,37 @@ +name: Windows checks + +on: + pull_request: + paths: + - 'windows/**' + - '.github/workflows/windows-check.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + check: + runs-on: windows-latest + defaults: + run: + working-directory: windows + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: windows/package-lock.json + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + windows/target + key: windows-check-${{ hashFiles('windows/Cargo.lock') }} + - run: npm ci + - run: npm run test:hooks + - run: cargo test --workspace --release --locked + - run: npm run pack + - run: ./scripts/test-relay.ps1 diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 055a9afae..890444629 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -73,11 +73,17 @@ Send newline-terminated JSON to the socket: ## Supported events -All standard Claude Code hook events are supported, **except `PermissionRequest`**: -approval cards are not yet implemented for third-party agents (only Claude Code gets -one). A `PermissionRequest` from an external agent is answered immediately with no -decision, so the relay writes nothing and the agent re-asks in its terminal. -Approval support for other agents will be added with Codex support. +Generic third-party integrations support standard Claude Code hook events, +**except `PermissionRequest`**. These requests receive no decision, so the relay +writes nothing and the external agent re-asks in its terminal. +On Windows, Codex now has a dedicated opt-in integration with approval cards; +other third-party agents still fall back to their own approval flow. See +[Windows Codex setup](../windows/README.md#codex). Codex hook commands must use +`coucou-hook.exe --agent codex ` with an explicit event so that the +relay can return neutral valid JSON for `Stop` and `SubagentStop` even when +Coucou is closed. Codex activity is filtered to metadata; its permission requests +carry complete original tool-argument JSON. The generic payload routing described +above does not by itself install or trust Codex hooks. The pill lifecycle: diff --git a/windows/README.md b/windows/README.md index 60374024c..ed040ea4e 100644 --- a/windows/README.md +++ b/windows/README.md @@ -6,7 +6,7 @@ **Mochi doesn't get a notch on a PC — so it lives at the top of your screen instead.** -Approve Claude Code permissions, watch your session work, drop a file, chat with Claude, keep an eye on your services — without leaving what you're doing. +Approve agent permissions, watch your session work, drop a file, chat with Claude or OpenAI, keep an eye on your services — without leaving what you're doing. ![Windows 10/11](https://img.shields.io/badge/Windows-10%2F11-0078D4?logo=windows) ![Tauri 2](https://img.shields.io/badge/Tauri-2-FFC131?logo=tauri&logoColor=black) @@ -68,11 +68,95 @@ in time, Coucou stays quiet and Claude Code asks in the terminal as usual. It works from any terminal — Windows Terminal, PowerShell, VS Code, Git Bash. +## Codex + +Native Windows Codex sessions can use the same local relay as Claude Code. Codex +has its own pill, activity state and permission card. No API key is needed for +this integration; the built-in chat uses a separate Claude or OpenAI API key. + +1. Open **Settings… → Codex → Install hooks…** and review the proposed changes. +2. Confirm the write. Coucou merges only its handlers into + `%CODEX_HOME%\hooks.json`, or `%USERPROFILE%\.codex\hooks.json` when + `CODEX_HOME` is unset, and creates a dated backup if the file exists. +3. In a Codex version with [hooks support](https://learn.chatgpt.com/docs/hooks), + review and trust those hooks using `/hooks` in the CLI. Restart or open a new + session as needed. Installing hooks does not grant trust, and Coucou never + changes Codex's `config.toml`, authentication or approval policy. + +The supported events are `SessionStart`, `UserPromptSubmit`, `PreToolUse`, +`PostToolUse`, `PermissionRequest`, `Stop`, `SubagentStart`, `SubagentStop`, +`Interrupt` and `SessionEnd`. This integration is for local, native Windows +sessions. A Windows named pipe is not available to WSL or remote/cloud sessions. +Hosted tools may not emit local tool hooks; the indicator is not a complete +audit trail. + +Ordinary Codex activity forwards only local session identity, working directory, +event and tool names. It does not forward prompts, tool arguments/results, +transcript paths or assistant messages. A **permission request includes the exact +tool arguments**, because you need to see what you are approving. Those details +can contain sensitive text: review them locally and do not share screenshots of +the card casually. Nothing in this integration sends the events to a network +service or reads the session transcript. + +The relay labels Codex packets with `coucou_agent: "codex"`; an absent agent label +keeps the existing Claude behavior. A closed or unresponsive Coucou, declined +card, or unanswered request falls back to Codex's normal approval flow. Allow +and Deny apply only to that request; they do not create permanent approval rules. +Only one approval card is shown at a time; additional requests are returned to +their original agent. + +To remove the integration, use **Codex → Uninstall hooks…**. Unrelated hooks, +including other handlers sharing the same event group, are preserved. Invalid +configuration or a file changed since the preview is refused rather than +overwritten. + +### Checking a contribution + +```powershell +npm ci +npm run test:hooks +cargo test --workspace --release --locked +npm run pack +# Close Coucou first: the smoke test uses its local named-pipe name. +pwsh -File ./scripts/test-relay.ps1 +``` + +The relay smoke test exchanges synthetic events and approval decisions. It +does not execute the commands in those events or modify any agent configuration. + +For visual review, open `/dev/codex-preview.html` in the Vite development server. +It uses synthetic events with the real handler and views, is excluded from the +application build, and never executes the displayed commands. + +![Codex permission card with synthetic test arguments](dev/codex-preview.png) + ## Chat and keys -**Settings… → Claude** takes your Anthropic API key. Keys live in the **Windows -Credential Manager**, never on disk and never in the interface — the island can -only ask whether a key exists. Same for every integration key. +Choose **Settings… → Chat → Provider**, then save the matching key under +**Claude** (Anthropic) or **OpenAI**. Claude remains the default for existing +installations. Each provider keeps its own model setting. OpenAI defaults to +`gpt-4.1-mini`; you can enter another Responses-compatible model available to +your API account. API usage is billed by the selected provider. + +Keys are stored in the **Windows Credential Manager** (Secret Service on Linux), +not in preferences or chat history. After saving, the UI can only ask whether a +key exists. Requests and attachment encoding happen in Rust. + +OpenAI uses `https://api.openai.com/v1/responses`, with `store: false` and local +in-memory conversation history. It supports multiple turns, UTF-8 text/code +attachments up to 200 KB, and PDFs/images up to 20 MiB. Supported image formats +are PNG, JPEG, WebP and GIF (the API supports non-animated GIFs). The selected +model must support the attachment type. Live web search is currently available +only in the Claude chat. No provider is contacted automatically as a fallback. + +Changing the provider or its active model starts a fresh conversation and clears +the attached file. Old conversations are not forwarded to the newly selected +provider. A failed request does not enter the conversation history, and a late +response after reset is discarded. + +To test with your own key: choose OpenAI, save your key, open **Ask Mochi**, send +a short question, then a follow-up referring to the answer. Check a small text +attachment separately. Never paste keys into issue reports or screenshots. No telemetry. The only network requests Coucou makes are to the services you configure yourself. @@ -126,7 +210,7 @@ windows/ island/ state machine, hooks, integrations views/ every island view settings/ the settings window - src-tauri/ Rust backend: window, named pipe, Claude API, pollers + src-tauri/ Rust backend: window, named pipe, Claude/OpenAI APIs, pollers hook/ coucou-hook.exe, the Claude Code relay scripts/ icon generator ``` diff --git a/windows/dev/codex-preview.html b/windows/dev/codex-preview.html new file mode 100644 index 000000000..f577e9817 --- /dev/null +++ b/windows/dev/codex-preview.html @@ -0,0 +1,17 @@ + + +Coucou — Codex hook preview + + +
+ + + + diff --git a/windows/dev/codex-preview.png b/windows/dev/codex-preview.png new file mode 100644 index 000000000..65be6d2ef Binary files /dev/null and b/windows/dev/codex-preview.png differ diff --git a/windows/dev/codex-preview.ts b/windows/dev/codex-preview.ts new file mode 100644 index 000000000..763a75755 --- /dev/null +++ b/windows/dev/codex-preview.ts @@ -0,0 +1,45 @@ +// Browser-only visual fixture. Excluded from the application bundle by Vite's +// explicit entry points. The real hook handler and views receive synthetic data. +import "../src/style.css"; +import { State } from "../src/core/state"; +import { Island } from "../src/island/island"; +import { createHookHandlers } from "../src/island/hooks"; + +document.body.style.background = "#24272d"; +State.settings.soundEnabled = false; +State.settings.activeIntegrations = []; +State.loadIntegrationTasks(); +State.setFocus("integration_codex"); +const island = new Island(document.getElementById("root")!); +island.applySettings(); +island.launch(); +const handlers = createHookHandlers(island); +let turn = 0; +const status = () => { + document.getElementById("preview-status")!.textContent = + State.pendingApproval ? "Synthetic permission request is active" : `Preview: ${State.view}`; +}; +const send = (event: string, fields = {}) => handlers.handle({ + coucou_agent: "codex", session_id: "preview-session", turn_id: `preview-${turn}`, + cwd: "C:/demo/coucou", hook_event_name: event, ...fields, +}); +const activity = () => { + if (State.pendingApproval) handlers.approvalEnded(State.pendingApproval.requestId); + turn++; + send("UserPromptSubmit"); + send("PreToolUse", { tool_name: "Bash" }); + island.alert("overview"); + status(); +}; +document.getElementById("preview-activity")!.addEventListener("click", activity); +document.getElementById("preview-approval")!.addEventListener("click", () => { + activity(); + const input = { command: "npm run test:hooks", description: "Run local hook integration tests" }; + send("PermissionRequest", { request_id: `preview-request-${turn}`, tool_name: "Bash", + tool_input: input, coucou_tool_input_json: JSON.stringify(input, null, 2) }); + status(); +}); +document.getElementById("preview-finished")!.addEventListener("click", () => { activity(); send("Stop"); status(); }); +document.getElementById("preview-interrupt")!.addEventListener("click", () => { send("Interrupt"); island.alert("overview"); status(); }); +status(); +window.setTimeout(activity, 1800); diff --git a/windows/hook/Cargo.toml b/windows/hook/Cargo.toml index 975ffc7c8..591e046df 100644 --- a/windows/hook/Cargo.toml +++ b/windows/hook/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "coucou-hook" -description = "Relays Claude Code hook events to Coucou over a named pipe (Windows) or a Unix socket (Linux)" +description = "Relays Claude Code and Codex hooks over a Windows named pipe or Linux Unix socket" version.workspace = true edition.workspace = true license.workspace = true @@ -10,7 +10,7 @@ name = "coucou-hook" path = "src/main.rs" [dependencies] -serde_json = "1" +serde_json = { version = "1", features = ["raw_value"] } # Just enough Win32 to know our own SID and to check who is serving the pipe. [target.'cfg(windows)'.dependencies] diff --git a/windows/hook/src/main.rs b/windows/hook/src/main.rs index 5bb363781..814d63572 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -1,256 +1,263 @@ -//! coucou-hook — the relay Claude Code runs on every hook event. +//! Relay coding-agent lifecycle events over `\\.\pipe\coucou-`. //! -//! Reads the hook JSON on stdin, adds a little terminal context, and hands it to -//! Coucou over the named pipe `\\.\pipe\coucou-` (Windows) or the Unix -//! socket `$XDG_RUNTIME_DIR/coucou.sock` (Linux). -//! -//! Hard rule (docs/CLAUDE.md): **never block Claude Code.** -//! * If the pipe does not exist — Coucou is closed — we exit 0 immediately with -//! nothing on stdout, and the session carries on untouched. -//! * Every step runs under a deadline enforced by the main thread, so a pipe that -//! accepts the connection and then stops reading cannot wedge the session -//! either: we abandon the worker and exit. -//! * Only `PermissionRequest` waits for an answer, because approving from the -//! island is the whole point. No answer means empty stdout, and Claude Code -//! asks in the terminal exactly as if Coucou were not installed. -//! -//! Usage: `coucou-hook ` (the name is also read from the JSON). +//! Usage: `coucou-hook [--agent ] [EventName]`. The native Codex +//! integration requires `--agent codex ` for its stdout contract. +//! No app, malformed input, timeout or unknown reply means no approval decision: +//! the agent keeps its normal approval flow. Codex Stop/SubagentStop receive an +//! empty JSON object because their successful output must be JSON. use std::io::{Read, Write}; use std::sync::mpsc; -use std::time::Duration; +use std::time::{Duration, Instant}; +use serde_json::{Map, Value}; -/// Budget for getting a pipe connection. Beyond this Claude Code wins, always. const CONNECT_TIMEOUT: Duration = Duration::from_millis(300); -/// Whole-run budget for an event nobody waits on: connect and write, no more. +/// Includes connection, bounded stdin read, parsing and serialisation. +const PREPARE_BUDGET: Duration = Duration::from_secs(2); const FIRE_AND_FORGET_BUDGET: Duration = Duration::from_secs(2); -/// How long a permission prompt may stay on screen before the terminal takes over. const DECISION_BUDGET: Duration = Duration::from_secs(110); - -/// Fields that are pointless to forward and can be enormous (a whole file read, -/// a full command output). The island never shows them. -const DROPPED_FIELDS: &[&str] = &["tool_response", "transcript_path"]; -/// Longest string forwarded for any single field; the island truncates to far -/// less than this anyway. +/// Must fit the server's frame limit, including the terminating newline. +const MAX_PAYLOAD: usize = 1 << 20; +const MAX_REPLY: usize = 64; const MAX_FIELD_LEN: usize = 2_000; +const DROPPED_FIELDS: &[&str] = &["tool_response", "transcript_path"]; #[cfg(windows)] mod win; #[cfg(windows)] use win::connect; - #[cfg(target_os = "linux")] mod unix; #[cfg(target_os = "linux")] use unix::connect; +#[cfg(test)] +mod tests; -fn main() { - let Some((payload, event)) = read_event() else { std::process::exit(0) }; - - let waits_for_answer = event == "PermissionRequest"; - let budget = if waits_for_answer { DECISION_BUDGET } else { FIRE_AND_FORGET_BUDGET }; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Agent { Claude, Codex, External } - // The worker owns every blocking call. If it overruns the budget we simply - // stop listening and exit: the process dying takes the pipe handle with it. - // (No catch_unwind here — the release profile is panic = "abort", so it would - // be dead code. `talk` is written to have nothing to panic on instead.) - let (tx, rx) = mpsc::channel::>(); - std::thread::spawn(move || { - let _ = tx.send(talk(&payload, waits_for_answer)); - }); +#[derive(Clone, Debug, PartialEq, Eq)] +struct Args { + agent: Agent, + event: String, + /// None means stdin may supply a tag. An explicit, even invalid, tag wins. + agent_tag: Option, +} - if let Ok(Some(decision)) = rx.recv_timeout(budget) { - if let Some(json) = decision_json(&decision) { - let mut out = std::io::stdout(); - let _ = writeln!(out, "{json}"); - let _ = out.flush(); - } +fn agent_from_tag(tag: Option<&str>) -> Agent { + match tag { + Some("codex") => Agent::Codex, + Some("claude") => Agent::Claude, + Some(name) if valid_agent_name(name) => Agent::External, + _ => Agent::Claude, } - // Nothing printed: Claude Code asks in the terminal, as if we were not here. - std::process::exit(0); } -/// The documented PermissionRequest output. Anything we do not recognise prints -/// nothing at all rather than guessing — silence is the safe answer. -/// See https://code.claude.com/docs/en/hooks -fn decision_json(decision: &str) -> Option { - let behavior = match decision.trim() { - // "always" still answers a plain allow; remembering it is the island's - // business, not Claude Code's. - "allow" | "always" => r#"{"behavior":"allow"}"#.to_string(), - "deny" => r#"{"behavior":"deny","message":"Denied from Coucou"}"#.to_string(), +fn valid_agent_name(name: &str) -> bool { + (1..=24).contains(&name.len()) + && name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} + +/// Legacy installations may read the event from JSON. Codex requires argv so +/// even unreadable stdin has the correct neutral Stop/SubagentStop output. +fn parse_args(args: &[String]) -> Option { + let (tag, event) = match args { + [] => (None, ""), + [flag] if flag == "--agent" => (Some(""), ""), + [event] if !event.starts_with('-') => (None, event.as_str()), + [flag, agent] if flag == "--agent" && agent != "codex" => (Some(agent.as_str()), ""), + [flag, agent, event] if flag == "--agent" && !event.starts_with('-') && !event.is_empty() => { + (Some(agent.as_str()), event.as_str()) + } _ => return None, }; - Some(format!( - r#"{{"hookSpecificOutput":{{"hookEventName":"PermissionRequest","decision":{behavior}}}}}"# - )) + Some(Args { agent: agent_from_tag(tag), event: event.into(), agent_tag: tag.map(str::to_string) }) } -/// Reads stdin and returns the payload to forward plus the event name. -fn read_event() -> Option<(String, String)> { - let mut raw = Vec::new(); - if std::io::stdin().read_to_end(&mut raw).is_err() || raw.is_empty() { - return None; +fn main() { + let Some(args) = parse_args(&std::env::args().skip(1).collect::>()) else { + std::process::exit(0); + }; + let mut event = args.event.clone(); + let mut agent = args.agent; + let started = Instant::now(); + let worker_args = args.clone(); + // Connect first: a closed app should not wait for stdin to reach EOF. The + // worker owns all blocking calls, including stdin, under the main deadline. + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let prepared = (|| { + let pipe = connect()?; + let raw = read_bounded(std::io::stdin(), MAX_PAYLOAD)?; + let cwd = std::env::current_dir().ok().map(|p| p.to_string_lossy().into_owned()); + let (payload, event, agent) = prepare_event(&raw, &worker_args, cwd.as_deref())?; + Some((pipe, payload, event, agent)) + })(); + let _ = tx.send(prepared); + }); + let mut decision = None; + if let Ok(Some((pipe, payload, received_event, received_agent))) = rx.recv_timeout(PREPARE_BUDGET) { + event = received_event; + agent = received_agent; + let waits_for_answer = waits_for_approval(agent, &event); + let total_budget = if waits_for_answer { DECISION_BUDGET } else { FIRE_AND_FORGET_BUDGET }; + let budget = total_budget.saturating_sub(started.elapsed()); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(talk(pipe, &payload, waits_for_answer)); + }); + decision = rx.recv_timeout(budget).ok().flatten(); } - // Some shells hand us a UTF-8 BOM; serde_json would choke on it. - if raw.starts_with(&[0xEF, 0xBB, 0xBF]) { - raw.drain(..3); + if let Some(json) = output_json(agent, &event, decision.as_deref()) { + let mut out = std::io::stdout(); + let _ = writeln!(out, "{json}"); + let _ = out.flush(); } + // Exit also cancels any worker whose stdin/pipe operation hit our deadline. + std::process::exit(0); +} - let mut payload = serde_json::from_slice::(&raw).ok()?; - let map = payload.as_object_mut()?; +/// Read at most limit + 1 bytes, including for an endless stdin stream. +fn read_bounded(reader: impl Read, limit: usize) -> Option> { + let mut raw = Vec::new(); + reader.take((limit + 1) as u64).read_to_end(&mut raw).ok()?; + (raw.len() <= limit).then_some(raw) +} - // Parse argv: "coucou-hook.exe [--agent ] []" - // --agent tags the payload with coucou_agent so the app routes to the right pill. - // Absent or invalid names are validated and discarded by the app, not here. - let mut agent = String::new(); - let mut arg_event = String::new(); - { - let mut it = std::env::args().skip(1); - while let Some(arg) = it.next() { - if arg == "--agent" { - agent = it.next().unwrap_or_default(); - } else if arg_event.is_empty() { - arg_event = arg; - } - } +fn output_json(agent: Agent, event: &str, decision: Option<&str>) -> Option { + if waits_for_approval(agent, event) { return decision.and_then(decision_json); } + // Never request continuation, block completion, or inject model context. + if agent == Agent::Codex && matches!(event, "Stop" | "SubagentStop") { + return Some("{}".into()); } - // Which agent this hook was installed for. Absent means Claude Code, - // so existing hook commands keep working unchanged. - if !agent.is_empty() { - map.insert("coucou_agent".into(), serde_json::Value::String(agent)); - } - let event = map - .get("hook_event_name") - .and_then(|v| v.as_str()) - .map(str::to_string) - .filter(|s| !s.is_empty()) - .unwrap_or(arg_event); - map.insert("hook_event_name".into(), serde_json::Value::String(event.clone())); + None +} - for field in DROPPED_FIELDS { - map.remove(*field); - } +fn waits_for_approval(agent: Agent, event: &str) -> bool { + agent != Agent::External && event == "PermissionRequest" +} + +/// Shared schema: https://learn.chatgpt.com/docs/hooks#permissionrequest +fn decision_json(decision: &str) -> Option { + let behavior = match decision.trim() { + // Remembering a choice is never delegated to the agent's permissions. + "allow" | "always" => r#"{"behavior":"allow"}"#, + "deny" => r#"{"behavior":"deny","message":"Denied from Coucou"}"#, + _ => return None, + }; + Some(format!(r#"{{"hookSpecificOutput":{{"hookEventName":"PermissionRequest","decision":{behavior}}}}}"#)) +} - let cwd_missing = map - .get("cwd") - .and_then(|v| v.as_str()) - .map(str::is_empty) - .unwrap_or(true); - if cwd_missing { - if let Ok(cwd) = std::env::current_dir() { - map.insert( - "cwd".into(), - serde_json::Value::String(cwd.to_string_lossy().to_string()), - ); +/// Codex uses a fresh allowlist. Ordinary activity never forwards prompts, +/// messages, tool arguments/results or transcript locations. +fn prepare_event(raw: &[u8], args: &Args, fallback_cwd: Option<&str>) -> Option<(String, String, Agent)> { + if raw.len() > MAX_PAYLOAD { return None; } + let raw = raw.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(raw); + let payload: Value = serde_json::from_slice(raw).ok()?; + let source = payload.as_object()?; + // Match upstream's metadata protocol. Explicit argv always overrides stdin; + // absent or invalid tags retain the established Claude fallback behavior. + let tag = args.agent_tag.as_deref().or_else(|| source.get("coucou_agent").and_then(Value::as_str)); + let agent = agent_from_tag(tag); + let input_event = source.get("hook_event_name").and_then(Value::as_str).filter(|s| !s.is_empty()); + // Do not let mismatching Codex argv/JSON turn an observation into approval. + // Keep legacy Claude JSON-event precedence for existing installations. + if agent == Agent::Codex && !args.event.is_empty() && input_event.is_some_and(|e| e != args.event) { return None; } + let event = input_event.unwrap_or(&args.event).to_string(); + if event.is_empty() { return None; } + let mut map = if agent == Agent::Codex { + let mut safe = Map::new(); + for field in ["session_id", "turn_id", "cwd", "tool_name", "tool_use_id", "agent_id", "agent_type"] { + if let Some(Value::String(value)) = source.get(field) { + safe.insert(field.into(), Value::String(value.clone())); + } + } + if safe.get("session_id").and_then(Value::as_str).is_none_or(str::is_empty) { return None; } + if event == "PermissionRequest" { + let input = source.get("tool_input").filter(|input| !input.is_null())?; + if safe.get("tool_name").and_then(Value::as_str).is_none_or(str::is_empty) { return None; } + // Includes the full command/patch/MCP arguments and optional + // tool_input.description. These must remain exact for approval. + safe.insert("tool_input".into(), input.clone()); + // Preserve the source JSON as well: parsing numeric MCP arguments + // into Value or JavaScript numbers can round their exact value. + // The approval view renders this string verbatim, without parsing. + let raw_fields: std::collections::BTreeMap = + serde_json::from_slice(raw).ok()?; + let raw_input = raw_fields.get("tool_input")?.get(); + safe.insert("coucou_tool_input_json".into(), Value::String(raw_input.into())); + if let Some(Value::String(mode)) = source.get("permission_mode") { + safe.insert("permission_mode".into(), Value::String(mode.clone())); + } + } + safe.insert("coucou_agent".into(), Value::String("codex".into())); + safe + } else { + let mut legacy = source.clone(); + for field in DROPPED_FIELDS { legacy.remove(*field); } + legacy.remove("coucou_agent"); + if agent == Agent::External { + legacy.insert("coucou_agent".into(), Value::String(tag?.into())); } + legacy + }; + map.insert("hook_event_name".into(), Value::String(event.clone())); + if map.get("cwd").and_then(Value::as_str).is_none_or(str::is_empty) { + if let Some(cwd) = fallback_cwd { map.insert("cwd".into(), Value::String(cwd.into())); } } - - // Which terminal the session runs in. Unlike macOS, Coucou here accepts - // events from every terminal, so this is context only — never a filter. - for (key, var) in [ - ("term_program", "TERM_PROGRAM"), - ("wt_session", "WT_SESSION"), - ("term_session_id", "TERM_SESSION_ID"), - ("vscode_pid", "VSCODE_PID"), - ("session_pid", "CLAUDE_CODE_SSE_PORT"), - ] { - if !map.contains_key(key) { - let value = std::env::var(var).unwrap_or_default(); - map.insert(key.into(), serde_json::Value::String(value)); + if agent != Agent::Codex { + for (key, var) in [ + ("term_program", "TERM_PROGRAM"), ("wt_session", "WT_SESSION"), + ("term_session_id", "TERM_SESSION_ID"), ("vscode_pid", "VSCODE_PID"), + ("session_pid", "CLAUDE_CODE_SSE_PORT"), + ] { + map.entry(key).or_insert_with(|| Value::String(std::env::var(var).unwrap_or_default())); } } - - truncate_strings(&mut payload); - + let mut payload = Value::Object(map); + // Keep legacy activity display limits. Never truncate an approval command, + // patch or arbitrary tool argument: reject oversized frames altogether. + if agent != Agent::Codex && (event != "PermissionRequest" || agent == Agent::External) { + truncate_strings(&mut payload); + } let mut line = payload.to_string(); line.push('\n'); - Some((line, event)) + (line.len() <= MAX_PAYLOAD).then_some((line, event, agent)) } -/// Caps every string in the payload. A single Write can carry a whole file. -fn truncate_strings(value: &mut serde_json::Value) { +fn truncate_strings(value: &mut Value) { match value { - serde_json::Value::String(s) => { + Value::String(s) => { if s.len() > MAX_FIELD_LEN { - // Cut on a char boundary; a lone byte index can split UTF-8. let mut end = MAX_FIELD_LEN; - while end > 0 && !s.is_char_boundary(end) { - end -= 1; - } + while end > 0 && !s.is_char_boundary(end) { end -= 1; } s.truncate(end); s.push('…'); } } - serde_json::Value::Array(items) => items.iter_mut().for_each(truncate_strings), - serde_json::Value::Object(map) => map.values_mut().for_each(truncate_strings), + Value::Array(items) => items.iter_mut().for_each(truncate_strings), + Value::Object(map) => map.values_mut().for_each(truncate_strings), _ => {} } } -/// Connect, send, and — for a permission request — wait for the island's word. -fn talk(payload: &str, waits_for_answer: bool) -> Option { - let mut pipe = connect()?; - - if pipe.write_all(payload.as_bytes()).is_err() { - return None; - } +fn talk(mut pipe: impl Read + Write, payload: &str, waits_for_answer: bool) -> Option { + pipe.write_all(payload.as_bytes()).ok()?; let _ = pipe.flush(); - - if !waits_for_answer { - return None; - } - + if !waits_for_answer { return None; } let mut buf = Vec::new(); - let mut chunk = [0u8; 1024]; + let mut chunk = [0u8; MAX_REPLY + 1]; loop { match pipe.read(&mut chunk) { Ok(0) => break, Ok(n) => { buf.extend_from_slice(&chunk[..n]); - if buf.contains(&b'\n') { - break; - } + if buf.len() > MAX_REPLY { return None; } + if buf.contains(&b'\n') { break; } } - Err(_) => break, + Err(_) => return None, } } - let answer = String::from_utf8_lossy(&buf).trim().to_string(); + let answer = std::str::from_utf8(&buf).ok()?.trim().to_string(); (!answer.is_empty()).then_some(answer) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn decision_json_matches_the_documented_shape() { - assert_eq!( - decision_json("allow").unwrap(), - r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}"# - ); - assert_eq!( - decision_json("deny").unwrap(), - r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied from Coucou"}}}"# - ); - // "always" is an island concept; Claude Code just gets an allow. - assert!(decision_json("always").unwrap().contains(r#""behavior":"allow""#)); - } - - #[test] - fn anything_unrecognised_prints_nothing() { - assert!(decision_json("").is_none()); - assert!(decision_json("maybe").is_none()); - // The shape the app used to send must not be mistaken for a decision. - assert!(decision_json(r#"{"permissionDecision":"allow"}"#).is_none()); - } - - #[test] - fn long_strings_are_cut_on_a_char_boundary() { - let mut v = serde_json::json!({ "tool_input": { "content": "é".repeat(4000) } }); - truncate_strings(&mut v); - let s = v["tool_input"]["content"].as_str().unwrap(); - assert!(s.len() <= MAX_FIELD_LEN + 4); - assert!(s.ends_with('…')); - } -} diff --git a/windows/hook/src/tests.rs b/windows/hook/src/tests.rs new file mode 100644 index 000000000..43d156b7b --- /dev/null +++ b/windows/hook/src/tests.rs @@ -0,0 +1,249 @@ +use super::*; +use serde_json::json; + +fn args(agent: Agent, event: &str) -> Args { + let agent_tag = match agent { + Agent::Claude => None, + Agent::Codex => Some("codex".into()), + Agent::External => Some("my-tool".into()), + }; + Args { agent, event: event.into(), agent_tag } +} + +fn prepared(agent: Agent, event: &str, input: Value) -> Value { + let (line, received_event, received_agent) = prepare_event(input.to_string().as_bytes(), &args(agent, event), Some(r"C:\fallback")).unwrap(); + assert_eq!(received_event, event); + assert_eq!(received_agent, agent); + assert!(line.ends_with('\n')); + serde_json::from_str(&line).unwrap() +} + +#[test] +fn parses_legacy_and_explicit_provider_commands() { + let parse = |a: &[&str]| parse_args(&a.iter().map(|s| s.to_string()).collect::>()); + assert_eq!(parse(&[]), Some(args(Agent::Claude, ""))); + assert_eq!(parse(&["Stop"]), Some(args(Agent::Claude, "Stop"))); + assert_eq!(parse(&["--agent", "codex", "Stop"]), Some(args(Agent::Codex, "Stop"))); + let claude = parse(&["--agent", "claude", "Stop"]).unwrap(); + assert_eq!(claude.agent, Agent::Claude); + assert_eq!(claude.agent_tag.as_deref(), Some("claude")); + assert_eq!(parse(&["--agent", "my-tool"]), Some(args(Agent::External, ""))); + assert_eq!(parse(&["--agent", "my-tool", "Stop"]), Some(args(Agent::External, "Stop"))); + assert_eq!(parse(&["--agent"]).unwrap().agent, Agent::Claude); + for invalid in [vec!["--agent", "codex"], vec!["Stop", "extra"], vec!["--agent", "codex", ""]] { + assert!(parse(&invalid).is_none()); + } +} + +#[test] +fn codex_activity_is_allowlisted_and_preserves_routing() { + for event in ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "SubagentStop", "Stop"] { + let value = prepared(Agent::Codex, event, json!({ + "session_id": "s", "turn_id": "t", "cwd": "C:\\project", "tool_name": "Bash", + "tool_use_id": "call", "agent_id": "child", "agent_type": "review", + "prompt": "secret", "message": "secret", "last_assistant_message": "secret", + "transcript_path": "secret", "agent_transcript_path": "secret", + "tool_input": {"command": "secret"}, "tool_response": "secret", + "future_field": {"nested": "secret"}, "coucou_agent": "spoof" + })); + assert_eq!(value, json!({ + "hook_event_name": event, "coucou_agent": "codex", "session_id": "s", "turn_id": "t", + "cwd": "C:\\project", "tool_name": "Bash", "tool_use_id": "call", "agent_id": "child", "agent_type": "review" + })); + assert!(!value.to_string().contains("secret")); + } +} + +#[test] +fn approvals_preserve_complete_command_and_context_for_both_agents() { + let command = format!("{}; Remove-Item sensitive", "é".repeat(4000)); + let input = json!({"command": command, "description": "Needs elevated access", "nested": ["all arguments", {"flag": true}]}); + for agent in [Agent::Claude, Agent::Codex] { + let value = prepared(agent, "PermissionRequest", json!({ + "session_id": "s", "tool_name": "Bash", "tool_input": input, + "permission_mode": "default", "prompt": "not forwarded by Codex" + })); + assert_eq!(value["tool_input"], input); + assert_eq!(value["permission_mode"], "default"); + assert_eq!(value["cwd"], r"C:\fallback"); + if agent == Agent::Codex { + assert!(value.get("prompt").is_none()); + assert_eq!(value["coucou_tool_input_json"], input.to_string()); + } + } +} + +#[test] +fn codex_accepts_complete_non_object_tool_arguments() { + for input in [json!(["arg", 4]), json!("raw tool argument")] { + let value = prepared(Agent::Codex, "PermissionRequest", json!({"session_id":"s", "tool_name":"mcp__test", "tool_input":input})); + assert_eq!(value["tool_input"], input); + } +} + +#[test] +fn codex_approval_keeps_exact_numeric_argument_text() { + // Both Value and JSON.parse would otherwise round these MCP arguments. + let input = r#"{ "amount": 9007199254740993, "decimal": 0.100000000000000000001, "large": 99999999999999999999999999 }"#; + let raw = format!(r#"{{"session_id":"s","tool_name":"mcp__test","tool_input":{input}}}"#); + let (line, _, _) = prepare_event(raw.as_bytes(), &args(Agent::Codex, "PermissionRequest"), None).unwrap(); + let value: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(value["coucou_tool_input_json"], input); +} + +#[test] +fn incomplete_or_mismatched_codex_approvals_are_not_forwarded() { + for input in [ + json!({"session_id":"s", "tool_name":"Bash"}), + json!({"session_id":"s", "tool_name":"Bash", "tool_input":null}), + json!({"session_id":"s", "tool_input":{}}), + json!({"tool_name":"Bash", "tool_input":{}}), + json!({"session_id":"s", "tool_name":"Bash", "tool_input":{}, "hook_event_name":"PreToolUse"}), + ] { + assert!(prepare_event(input.to_string().as_bytes(), &args(Agent::Codex, "PermissionRequest"), None).is_none()); + } +} + +#[test] +fn oversized_or_invalid_input_is_rejected_instead_of_truncated() { + let large = json!({"session_id":"s", "tool_name":"Bash", "tool_input":{"command":"x".repeat(MAX_PAYLOAD)}}).to_string(); + assert!(prepare_event(large.as_bytes(), &args(Agent::Codex, "PermissionRequest"), None).is_none()); + // Raw JSON fits exactly but adding the tag/newline exceeds the pipe frame. + let small = json!({"session_id":"s", "tool_name":"Bash", "tool_input":{"command":""}}).to_string(); + let exact = small.replace("\"command\":\"\"", &format!("\"command\":\"{}\"", "x".repeat(MAX_PAYLOAD - small.len()))); + assert_eq!(exact.len(), MAX_PAYLOAD); + assert!(prepare_event(exact.as_bytes(), &args(Agent::Codex, "PermissionRequest"), None).is_none()); + for raw in [b"bad".as_slice(), b"[]", b"null", b""] { + assert!(prepare_event(raw, &args(Agent::Codex, "Stop"), None).is_none()); + } +} + +#[test] +fn bounded_reader_stops_an_endless_stream() { + assert!(read_bounded(std::io::repeat(b'x'), 32).is_none()); + assert_eq!(read_bounded(&b"1234"[..], 4).unwrap(), b"1234"); +} + +#[test] +fn bom_and_missing_json_event_use_explicit_event() { + let mut raw = vec![0xEF, 0xBB, 0xBF]; + raw.extend_from_slice(br#"{"session_id":"s"}"#); + let (line, event, _) = prepare_event(&raw, &args(Agent::Codex, "Stop"), None).unwrap(); + assert_eq!(event, "Stop"); + assert_eq!(serde_json::from_str::(&line).unwrap()["coucou_agent"], "codex"); +} + +#[test] +fn codex_stop_output_stays_neutral_without_a_server_or_with_bad_reply() { + for event in ["Stop", "SubagentStop"] { + for reply in [None, Some("allow"), Some("deny"), Some("nonsense")] { + assert_eq!(output_json(Agent::Codex, event, reply).as_deref(), Some("{}")); + } + assert!(output_json(Agent::Claude, event, None).is_none()); + } + for event in ["SessionStart", "SessionEnd", "Interrupt", "UserPromptSubmit", "PreToolUse", "PostToolUse", "SubagentStart"] { + assert!(output_json(Agent::Codex, event, Some("allow")).is_none()); + } + assert!(output_json(Agent::Codex, "PermissionRequest", None).is_none()); +} + +#[test] +fn decision_json_matches_the_documented_shape_for_both_agents() { + for agent in [Agent::Claude, Agent::Codex] { + assert_eq!(output_json(agent, "PermissionRequest", Some("allow")).unwrap(), r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}"#); + assert_eq!(output_json(agent, "PermissionRequest", Some("deny")).unwrap(), r#"{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":"Denied from Coucou"}}}"#); + assert!(output_json(agent, "PermissionRequest", Some("always")).unwrap().contains(r#""behavior":"allow""#)); + for invalid in ["", "maybe", "allow\ndeny", r#"{"permissionDecision":"allow"}"#] { + assert!(output_json(agent, "PermissionRequest", Some(invalid)).is_none()); + } + } +} + +#[test] +fn legacy_activity_still_drops_outputs_and_truncates_on_char_boundary() { + let value = prepared(Agent::Claude, "PreToolUse", json!({ + "tool_input":{"content":"é".repeat(4000)}, "prompt":"legacy prompt", + "tool_response":"large output", "transcript_path":"private", "coucou_agent":"INVALID" + })); + let content = value["tool_input"]["content"].as_str().unwrap(); + assert!(content.len() <= MAX_FIELD_LEN + 4); + assert!(content.ends_with('…')); + assert_eq!(value["prompt"], "legacy prompt"); + for dropped in ["tool_response", "transcript_path", "coucou_agent"] { assert!(value.get(dropped).is_none()); } +} + +#[test] +fn external_names_use_upstream_validation_and_invalid_names_fall_back() { + for name in ["a", "tool-123", "-", "abcdefghijklmnopqrstuvwx"] { + assert!(valid_agent_name(name)); + let parsed = parse_args(&["--agent".into(), name.into(), "Stop".into()]).unwrap(); + assert_eq!(parsed.agent, Agent::External); + } + for name in ["", "Upper", "with_space", "with space", "é", "abcdefghijklmnopqrstuvwxy"] { + assert!(!valid_agent_name(name)); + let parsed = parse_args(&["--agent".into(), name.into(), "Stop".into()]).unwrap(); + assert_eq!(parsed.agent, Agent::Claude); + let (line, _, agent) = prepare_event(br#"{"coucou_agent":"my-tool"}"#, &parsed, None).unwrap(); + assert_eq!(agent, Agent::Claude, "an explicit invalid argv tag overrides stdin"); + assert!(serde_json::from_str::(&line).unwrap().get("coucou_agent").is_none()); + } + assert_eq!(agent_from_tag(Some("claude")), Agent::Claude); +} + +#[test] +fn generic_command_uses_stdin_event_and_preserves_upstream_activity() { + let parsed = parse_args(&["--agent".into(), "my-tool".into()]).unwrap(); + let (line, event, agent) = prepare_event(br#"{ + "hook_event_name":"UserPromptSubmit", "prompt":"display this activity", + "tool_input":{"command":"echo demo"}, "tool_response":"drop this output", + "transcript_path":"drop this path", "coucou_agent":"another-tool" + }"#, &parsed, Some(r"C:\project")).unwrap(); + let value: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(agent, Agent::External); + assert_eq!(event, "UserPromptSubmit"); + assert_eq!(value["coucou_agent"], "my-tool"); + assert_eq!(value["prompt"], "display this activity"); + assert_eq!(value["tool_input"]["command"], "echo demo"); + assert_eq!(value["cwd"], r"C:\project"); + assert!(value.get("tool_response").is_none()); + assert!(value.get("transcript_path").is_none()); +} + +#[test] +fn stdin_tag_is_preserved_when_argv_does_not_override_it() { + let (line, event, agent) = prepare_event(br#"{ + "hook_event_name":"Stop", "coucou_agent":"my-tool", "message":"done" + }"#, &args(Agent::Claude, ""), None).unwrap(); + assert_eq!(agent, Agent::External); + assert_eq!(event, "Stop"); + assert_eq!(serde_json::from_str::(&line).unwrap()["coucou_agent"], "my-tool"); + assert!(output_json(agent, &event, None).is_none()); + + let explicit = parse_args(&["--agent".into(), "claude".into(), "Stop".into()]).unwrap(); + let (line, _, agent) = prepare_event(br#"{"coucou_agent":"my-tool"}"#, &explicit, None).unwrap(); + assert_eq!(agent, Agent::Claude); + assert!(serde_json::from_str::(&line).unwrap().get("coucou_agent").is_none()); +} + +#[test] +fn source_codex_tag_still_applies_native_privacy_when_connected() { + let (line, event, agent) = prepare_event(br#"{ + "hook_event_name":"Stop", "coucou_agent":"codex", "session_id":"s", + "prompt":"secret", "tool_input":{"command":"secret"}, "last_assistant_message":"secret" + }"#, &args(Agent::Claude, "Stop"), None).unwrap(); + assert_eq!(agent, Agent::Codex); + assert!(!line.contains("secret")); + assert_eq!(output_json(agent, &event, None).as_deref(), Some("{}")); +} + +#[test] +fn external_permissions_never_wait_for_or_emit_decisions() { + let value = prepared(Agent::External, "PermissionRequest", json!({"tool_name":"Bash","tool_input":{"command":"echo demo"}})); + assert_eq!(value["coucou_agent"], "my-tool"); + assert!(!waits_for_approval(Agent::External, "PermissionRequest")); + for decision in [None, Some("allow"), Some("deny"), Some("always")] { + assert!(output_json(Agent::External, "PermissionRequest", decision).is_none()); + } + assert!(waits_for_approval(Agent::Claude, "PermissionRequest")); + assert!(waits_for_approval(Agent::Codex, "PermissionRequest")); +} diff --git a/windows/package.json b/windows/package.json index 22931d9bf..1c358be97 100644 --- a/windows/package.json +++ b/windows/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", + "test:hooks": "node --test tests/hook-events.test.mjs", "icons": "node scripts/gen-icons.mjs", "tauri": "tauri", "predev": "cargo build --release -p coucou-hook", diff --git a/windows/scripts/test-relay.ps1 b/windows/scripts/test-relay.ps1 new file mode 100644 index 000000000..eefe5b373 --- /dev/null +++ b/windows/scripts/test-relay.ps1 @@ -0,0 +1,103 @@ +#requires -Version 7.0 +param([string]$Relay = (Join-Path $PSScriptRoot '..\target\release\coucou-hook.exe')) +$ErrorActionPreference = 'Stop' +if (Get-Process coucou -ErrorAction SilentlyContinue) { + throw 'Close Coucou before running the relay smoke test; it uses the same local pipe.' +} +$Relay = (Resolve-Path -LiteralPath $Relay).Path +$pipeName = 'coucou-' + [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + +function Assert-True([bool]$Condition, [string]$Message) { + if (-not $Condition) { throw $Message } +} + +function Start-Relay([string]$Event, [string]$Agent = 'codex') { + $info = [Diagnostics.ProcessStartInfo]::new($Relay) + $info.UseShellExecute = $false + $info.CreateNoWindow = $true + $info.RedirectStandardInput = $true + $info.RedirectStandardOutput = $true + $info.RedirectStandardError = $true + if ($Agent -ne 'claude') { $info.ArgumentList.Add('--agent'); $info.ArgumentList.Add($Agent) } + $info.ArgumentList.Add($Event) + return [Diagnostics.Process]::Start($info) +} + +function Invoke-Exchange([string]$Event, [hashtable]$Fields, [string]$Reply = '', [string]$Agent = 'codex') { + $server = [IO.Pipes.NamedPipeServerStream]::new($pipeName, [IO.Pipes.PipeDirection]::InOut, + 1, [IO.Pipes.PipeTransmissionMode]::Byte, [IO.Pipes.PipeOptions]::Asynchronous) + $process = $null + try { + $connected = $server.WaitForConnectionAsync() + $process = Start-Relay $Event $Agent + $Fields.hook_event_name = $Event + $process.StandardInput.WriteLine(($Fields | ConvertTo-Json -Compress -Depth 12)) + $process.StandardInput.Close() + Assert-True ($connected.Wait(3000)) "Relay did not connect for $Event" + $reader = [IO.StreamReader]::new($server, [Text.UTF8Encoding]::new($false), $false, 4096, $true) + $lineTask = $reader.ReadLineAsync() + Assert-True ($lineTask.Wait(3000)) "Relay did not send $Event" + $payload = $lineTask.Result | ConvertFrom-Json + if ($Reply) { + $writer = [IO.StreamWriter]::new($server, [Text.UTF8Encoding]::new($false), 4096, $true) + $writer.WriteLine($Reply) + $writer.Flush() + } + $server.Dispose() + Assert-True ($process.WaitForExit(3000)) "Relay did not exit for $Event" + Assert-True ($process.ExitCode -eq 0) 'Relay returned a failure exit code' + return @{ Payload = $payload; Output = $process.StandardOutput.ReadToEnd() } + } finally { + $server.Dispose() + if ($process -and -not $process.HasExited) { $process.Kill() } + if ($process) { $process.Dispose() } + } +} + +$private = 'PRIVATE_CONTENT_MUST_NOT_BE_FORWARDED' +$event = Invoke-Exchange 'PreToolUse' @{ + session_id='smoke-session'; turn_id='smoke-turn'; cwd='C:\example\project'; + tool_name='Bash'; prompt=$private; transcript_path=$private; + tool_input=@{command=$private}; tool_response=$private +} +Assert-True ($event.Payload.coucou_agent -eq 'codex') 'Missing Codex provider' +Assert-True ($event.Payload.session_id -eq 'smoke-session') 'Session identity was lost' +Assert-True (-not (($event.Payload | ConvertTo-Json -Depth 12).Contains($private))) 'Private activity data leaked' +Assert-True ([string]::IsNullOrWhiteSpace($event.Output)) 'Activity hook changed model context' + +$command = 'echo first' + "`n" + ('x' * 2200) + "`n" + 'echo last' +foreach ($decision in @('allow', 'deny')) { + $approval = Invoke-Exchange 'PermissionRequest' @{ + session_id='smoke-session'; turn_id='smoke-turn'; tool_name='Bash'; + tool_input=@{command=$command; description='Synthetic test; no command is executed'} + } $decision + Assert-True ($approval.Payload.tool_input.command -ceq $command) 'Approval command was truncated' + $answer = $approval.Output | ConvertFrom-Json + Assert-True ($answer.hookSpecificOutput.decision.behavior -eq $decision) 'Wrong approval response' +} +$fallback = Invoke-Exchange 'PermissionRequest' @{session_id='smoke-session';tool_name='Bash';tool_input=@{command='echo test'}} 'unknown' +Assert-True ([string]::IsNullOrWhiteSpace($fallback.Output)) 'Unknown decision must defer to Codex' + +$legacy = Invoke-Exchange 'PreToolUse' @{tool_name='Read';tool_input=@{file_path='example.txt'}} '' 'claude' +Assert-True ($legacy.Payload.tool_input.file_path -eq 'example.txt') 'Legacy Claude events changed' + +$external = Invoke-Exchange 'PreToolUse' @{session_id='external-session';tool_name='Read';tool_input=@{file_path='example.txt'}} '' 'my-tool' +Assert-True ($external.Payload.coucou_agent -eq 'my-tool') 'Upstream third-party routing was lost' +Assert-True ([string]::IsNullOrWhiteSpace($external.Output)) 'External activity changed model context' + +foreach ($name in @('Stop', 'SubagentStop')) { + $stopped = Invoke-Exchange $name @{session_id='smoke-session';last_assistant_message=$private} + Assert-True ($stopped.Output.Trim() -eq '{}') "$name must return neutral valid JSON" + $clock = [Diagnostics.Stopwatch]::StartNew() + $process = Start-Relay $name + try { + # Intentionally leave stdin open: an absent app must never hold Codex up. + Assert-True ($process.WaitForExit(2000)) 'Closed Coucou blocked the relay' + Assert-True ($process.StandardOutput.ReadToEnd().Trim() -eq '{}') 'Missing neutral fallback' + } finally { + if (-not $process.HasExited) { $process.Kill() } + $process.Dispose() + } + Write-Output "$name without Coucou: $($clock.ElapsedMilliseconds) ms" +} +Write-Output 'PASS: relay privacy, identity, exact approval arguments, allow/deny/fallback, Claude compatibility, closed-app behavior.' diff --git a/windows/src-tauri/src/chat.rs b/windows/src-tauri/src/chat.rs new file mode 100644 index 000000000..630171b9c --- /dev/null +++ b/windows/src-tauri/src/chat.rs @@ -0,0 +1,82 @@ +//! Shared, transactional chat history. Provider changes never replay another +//! provider's conversation, and a reset invalidates a request already in flight. +use std::sync::Mutex; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Provider { + #[default] + Claude, + Openai, +} + +#[derive(Default)] +struct Conversation { + identity: Option<(Provider, String)>, + generation: u64, + messages: Vec, +} + +#[derive(Default)] +pub struct Chat { + pub turn: tokio::sync::Mutex<()>, + conversation: Mutex, +} + +impl Chat { + pub fn reset(&self) { + let mut state = self.conversation.lock().unwrap(); + state.generation += 1; + state.messages.clear(); + } + + pub fn begin(&self, provider: Provider, model: &str) -> (u64, Vec) { + let mut state = self.conversation.lock().unwrap(); + let identity = (provider, model.to_owned()); + if state.identity.as_ref() != Some(&identity) { + state.generation += 1; + state.messages.clear(); + state.identity = Some(identity); + } + (state.generation, state.messages.clone()) + } + + pub fn commit(&self, generation: u64, messages: Vec) -> Result<(), String> { + let mut state = self.conversation.lock().unwrap(); + if state.generation != generation { + return Err("Conversation changed. Please send your message again.".into()); + } + state.messages = messages; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn provider_and_model_changes_do_not_replay_history() { + let chat = Chat::default(); + let (generation, _) = chat.begin(Provider::Claude, "claude"); + chat.commit(generation, vec![json!({"private": "old conversation"})]).unwrap(); + assert_eq!(chat.begin(Provider::Claude, "claude").1.len(), 1); + let (next, messages) = chat.begin(Provider::Openai, "gpt"); + assert!(messages.is_empty()); + assert!(chat.commit(generation, vec![json!("late response")]).is_err()); + chat.commit(next, vec![json!("openai")]).unwrap(); + assert!(chat.begin(Provider::Openai, "another-model").1.is_empty()); + } + + #[test] + fn reset_invalidates_inflight_requests() { + let chat = Chat::default(); + let (generation, _) = chat.begin(Provider::Openai, "gpt"); + chat.reset(); + assert!(chat.commit(generation, vec![json!("late response")]).is_err()); + assert!(chat.begin(Provider::Openai, "gpt").1.is_empty()); + } +} diff --git a/windows/src-tauri/src/claude.rs b/windows/src-tauri/src/claude.rs index 060a57c64..3cf2a1c06 100644 --- a/windows/src-tauri/src/claude.rs +++ b/windows/src-tauri/src/claude.rs @@ -4,7 +4,7 @@ // Everything happens here rather than in the island: the API key never leaves // the Credential Manager, and file bytes never cross the IPC boundary. -use std::sync::Mutex; +use crate::chat::{Chat, Provider}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -27,34 +27,6 @@ You have web search access and can help with absolutely anything — research, c Respond in the user's language. Be thorough and complete — use as much detail as the task requires. \ No markdown formatting (no **, no ##, no bullet dashes). Use plain text with line breaks."; -#[derive(Default)] -pub struct Chat { - /// Full multi-turn history, including tool_use / tool_result blocks. - messages: Mutex>, -} - -impl Chat { - pub fn reset(&self) { - self.messages.lock().unwrap().clear(); - } - - fn is_empty(&self) -> bool { - self.messages.lock().unwrap().is_empty() - } - - fn push(&self, message: Value) { - self.messages.lock().unwrap().push(message); - } - - fn pop(&self) { - self.messages.lock().unwrap().pop(); - } - - fn snapshot(&self) -> Vec { - self.messages.lock().unwrap().clone() - } -} - #[derive(Debug, Clone, Deserialize)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum ChatContext { @@ -79,11 +51,12 @@ pub async fn send( let key = secrets::get("anthropic-api-key") .ok_or_else(|| "API key missing. Open settings.".to_string())?; + let (generation, mut messages) = chat.begin(Provider::Claude, model); let mut content: Vec = Vec::new(); // File / window context rides along with the first message only, exactly // like ClaudeService.chat(). - if chat.is_empty() { + if messages.is_empty() { match &context { Some(ChatContext::File { name, path }) => { if let Some(block) = file_block(path) { @@ -103,7 +76,7 @@ pub async fn send( } content.push(json!({ "type": "text", "text": query })); - chat.push(json!({ "role": "user", "content": content })); + messages.push(json!({ "role": "user", "content": content })); let body = json!({ "model": model, @@ -111,20 +84,13 @@ pub async fn send( "system": SYSTEM_PROMPT, "tools": [{ "type": "web_search_20260209", "name": "web_search", "max_uses": 5 }], "fallbacks": "default", - "messages": chat.snapshot(), + "messages": messages, }); - let response = match call(&key, &body).await { - Ok(v) => v, - Err(err) => { - chat.pop(); // keep the history consistent with what the model saw - return Err(err); - } - }; + let response = call(&key, &body).await?; // A policy decline comes back as HTTP 200 with stop_reason "refusal". if response.get("stop_reason").and_then(Value::as_str) == Some("refusal") { - chat.pop(); let why = response .get("stop_details") .and_then(|d| d.get("explanation")) @@ -134,13 +100,12 @@ pub async fn send( } let Some(blocks) = response.get("content").and_then(Value::as_array).cloned() else { - chat.pop(); return Err("Unexpected API response.".into()); }; // Store the whole content — tool_use / tool_result blocks included — so the // next turn has the right context. - chat.push(json!({ "role": "assistant", "content": blocks.clone() })); + messages.push(json!({ "role": "assistant", "content": blocks.clone() })); let text = blocks .iter() @@ -154,6 +119,7 @@ pub async fn send( if text.is_empty() { return Err("No response text.".into()); } + chat.commit(generation, messages)?; Ok(ChatReply { text }) } diff --git a/windows/src-tauri/src/hooks.rs b/windows/src-tauri/src/hooks.rs index 3da1d0e5a..5ee06c474 100644 --- a/windows/src-tauri/src/hooks.rs +++ b/windows/src-tauri/src/hooks.rs @@ -1,40 +1,11 @@ -// Claude Code hook installation. -// -// The rule from CLAUDE.md is strict and is followed to the letter: -// read %USERPROFILE%\.claude\settings.json, take a dated backup, merge without -// touching anybody else's hooks, show the diff, and write only after an explicit -// click. Uninstall removes Coucou's entries and nothing else. -// -// The command is only the quoted exe path in forward slashes plus the event name: -// on Windows Claude Code runs hook commands through Git Bash, and anything with -// PowerShell or cmd in it breaks. +// Agent-specific hook installation. Configuration changes are previewed first, +// backed up, and applied only after an explicit click in the settings window. use std::path::{Path, PathBuf}; - use serde::Serialize; -use serde_json::{json, Map, Value}; use tauri::{AppHandle, Manager}; -use crate::{platform, settings}; - -/// Every event the island reacts to, with the hook timeout written to settings.json. -/// PermissionRequest waits for a human, so it gets the decision timeout + 10 s. -pub const HOOK_EVENTS: &[(&str, u64)] = &[ - ("SessionStart", 10), - ("SessionEnd", 10), - ("UserPromptSubmit", 10), - ("PreToolUse", 10), - ("PostToolUse", 10), - ("PostToolUseFailure", 10), - ("PermissionRequest", 120), - ("Notification", 10), - ("Stop", 10), - ("StopFailure", 10), - ("SubagentStart", 10), - ("SubagentStop", 10), -]; - -/// Marker that identifies a Coucou entry inside settings.json. -const MARKER: &str = "coucou-hook"; +use crate::{hooks_config::{config_path, HookConfig}, platform, settings}; +pub use crate::hooks_config::{HookAgent, HookPreview}; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -43,306 +14,43 @@ pub struct HookStatus { pub settings_path: String, pub hook_path: String, pub hook_ready: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, } -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct HookPreview { - pub diff: String, - pub backup: String, - pub settings_path: String, - /// Identifies the bytes this diff was computed from; handed back to `write` - /// so we only ever apply what the user actually looked at. - pub fingerprint: String, -} - -pub fn settings_path() -> PathBuf { - platform::home_dir().join(".claude").join("settings.json") -} - -/// Reads `~/.claude/settings.json`. -/// -/// The only error that means "start from nothing" is the file not being there. -/// Everything else — a lock held by another process, a permission problem, JSON -/// we cannot parse — is reported, because the alternative is treating somebody's -/// unreadable settings as an empty object and then writing that back over them. -fn read_settings() -> Result { - let path = settings_path(); - match std::fs::read(&path) { - Ok(bytes) => parse_settings(&bytes, &path.display().to_string()), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), - // A lock, a permission problem, a bad drive: all of them mean we do not - // know what is in there, and not knowing is not the same as empty. - Err(err) => Err(format!("Can't read {}: {err}", path.display())), - } -} - -/// The parsing half of `read_settings`, split out so it can be tested without a -/// home directory. -fn parse_settings(bytes: &[u8], path: &str) -> Result { - // PowerShell writes a UTF-8 BOM with `Set-Content -Encoding utf8`, and - // serde_json refuses it. Stripping it is safe and well defined; guessing at - // anything else is not. - let text = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); - if text.iter().all(u8::is_ascii_whitespace) { - return Ok(json!({})); - } - match serde_json::from_slice::(text) { - Ok(v) if v.is_object() => Ok(v), - Ok(_) => Err(format!("{path} isn't a JSON object — Coucou won't touch it.")), - Err(err) => Err(format!( - "{path} isn't valid JSON ({err}). Fix or move it, then try again — Coucou won't overwrite it." - )), - } +fn config(agent: HookAgent) -> Result { + let path = config_path(agent, + Some(platform::home_dir()), + std::env::var_os("CODEX_HOME").map(PathBuf::from), + )?; + Ok(HookConfig { agent, path, hook_path: settings::hook_exe_path() }) } -/// The settings as they are, or an empty object when we cannot tell. Only for -/// read-only paths like `status()`, which must never fail loudly; anything that -/// writes uses `read_settings()` and surfaces the error instead. -fn read_settings_lossy() -> Value { - read_settings().unwrap_or_else(|_| json!({})) -} - -#[cfg(windows)] -fn hook_command(event: &str) -> String { - let exe = settings::hook_exe_path().to_string_lossy().replace('\\', "/"); - format!("\"{exe}\" {event}") -} - -/// Claude Code runs the command through `sh`, which still reads `$`, `` ` `` -/// and `\` inside double quotes. Single quotes keep the path a path, whatever -/// the home directory is called. -#[cfg(unix)] -fn hook_command(event: &str) -> String { - format!("{} {event}", sh_quote(&settings::hook_exe_path().to_string_lossy())) -} - -/// `s` as one single-quoted shell word: `'` becomes `'\''`, nothing else is -/// special inside single quotes. -#[cfg(unix)] -fn sh_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', r"'\''")) -} - -fn entry_is_ours(entry: &Value) -> bool { - entry - .get("hooks") - .and_then(Value::as_array) - .map(|hooks| { - hooks.iter().any(|h| { - h.get("command") - .and_then(Value::as_str) - .map(|c| c.contains(MARKER)) - .unwrap_or(false) - }) - }) - .unwrap_or(false) -} - -/// Settings with Coucou's hooks added; everything else is left untouched. -fn merged(existing: &Value) -> Value { - let mut root = existing.as_object().cloned().unwrap_or_default(); - let mut hooks = root - .get("hooks") - .and_then(Value::as_object) - .cloned() - .unwrap_or_else(Map::new); - - for (event, timeout) in HOOK_EVENTS { - let mut list = hooks - .get(*event) - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - list.retain(|entry| !entry_is_ours(entry)); - list.push(json!({ - "hooks": [{ - "type": "command", - "command": hook_command(event), - "timeout": timeout, - }] - })); - hooks.insert((*event).to_string(), Value::Array(list)); - } - - root.insert("hooks".into(), Value::Object(hooks)); - Value::Object(root) -} - -/// Settings with every Coucou entry removed, and nothing else changed. -fn without_ours(existing: &Value) -> Value { - let mut root = existing.as_object().cloned().unwrap_or_default(); - let Some(hooks) = root.get("hooks").and_then(Value::as_object).cloned() else { - return Value::Object(root); - }; - let mut out = Map::new(); - for (event, value) in hooks { - match value.as_array() { - Some(list) => { - let kept: Vec = - list.iter().filter(|e| !entry_is_ours(e)).cloned().collect(); - if !kept.is_empty() { - out.insert(event, Value::Array(kept)); - } - } - None => { - out.insert(event, value); - } - } - } - if out.is_empty() { - root.remove("hooks"); - } else { - root.insert("hooks".into(), Value::Object(out)); - } - Value::Object(root) -} - -fn pretty(v: &Value) -> String { - serde_json::to_string_pretty(v).unwrap_or_default() -} - -/// Down to the second: installing then uninstalling in the same minute must not -/// quietly overwrite the first backup. fn stamp() -> String { let t = platform::local_time(); - format!( - "{:04}{:02}{:02}-{:02}{:02}{:02}", - t.year, t.month, t.day, t.hour, t.minute, t.second - ) -} - -fn backup_path() -> PathBuf { - let p = settings_path(); - p.with_file_name(format!("settings.json.bak-{}", stamp())) -} - -/// Identifies the exact bytes a preview was computed from. FNV-1a is plenty: -/// the question is only "is this still the file I showed the user?". -fn fingerprint(bytes: &[u8]) -> String { - let mut hash: u64 = 0xcbf2_9ce4_8422_2325; - for b in bytes { - hash ^= *b as u64; - hash = hash.wrapping_mul(0x1000_0000_01b3); - } - format!("{hash:016x}") -} - -fn current_fingerprint() -> String { - match std::fs::read(settings_path()) { - Ok(bytes) => fingerprint(&bytes), - Err(_) => fingerprint(b""), - } + format!("{:04}{:02}{:02}-{:02}{:02}{:02}", + t.year, t.month, t.day, t.hour, t.minute, t.second) } -// ── Public API ──────────────────────────────────────────────────────────────── - -pub fn status() -> HookStatus { - let current = read_settings_lossy(); - let installed = current - .get("hooks") - .and_then(Value::as_object) - .map(|hooks| { - hooks - .values() - .filter_map(Value::as_array) - .flatten() - .any(entry_is_ours) - }) - .unwrap_or(false); +pub fn status(agent: HookAgent) -> HookStatus { + let config = config(agent); let hook_path = settings::hook_exe_path(); + let result = config.as_ref().map_err(Clone::clone).and_then(HookConfig::installed); HookStatus { - installed, - settings_path: settings_path().to_string_lossy().to_string(), - hook_ready: hook_path.exists(), - hook_path: hook_path.to_string_lossy().to_string(), + installed: result.as_ref().copied().unwrap_or(false), + settings_path: config.as_ref().map(|config| config.path.to_string_lossy().into_owned()).unwrap_or_default(), + hook_path: hook_path.to_string_lossy().into_owned(), + hook_ready: hook_path.is_file(), + error: result.err(), } } -pub fn preview(install: bool) -> Result { - let current = read_settings()?; - let next = if install { merged(¤t) } else { without_ours(¤t) }; - Ok(HookPreview { - diff: unified_diff(&pretty(¤t), &pretty(&next)), - backup: backup_path().to_string_lossy().to_string(), - settings_path: settings_path().to_string_lossy().to_string(), - fingerprint: current_fingerprint(), - }) -} - -/// Writes the merged (or cleaned) settings after taking a dated backup. -/// -/// `fingerprint` is the one the preview was computed from. If the file changed -/// in between — another tool, another window, the user's own editor — we stop -/// and make them look at a fresh diff, because the only thing worse than not -/// installing the hooks is silently reverting somebody else's edit. -pub fn write(install: bool, fingerprint: &str) -> Result { - let path = settings_path(); - let dir = path.parent().unwrap_or(Path::new(".")); - std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; - - // Read before the backup: an unreadable file must abort before we touch - // anything at all. - let current = read_settings()?; - if current_fingerprint() != fingerprint { - return Err(format!( - "{} changed since the preview. Nothing was written — review the new diff.", - path.display() - )); - } - - let backup = backup_path(); - if path.exists() { - std::fs::copy(&path, &backup).map_err(|e| format!("backup failed: {e}"))?; - } - - let next = if install { merged(¤t) } else { without_ours(¤t) }; - let mut text = pretty(&next); - text.push('\n'); - - // A dotfiles setup often makes settings.json a symlink: write to the file it - // points at, so the link survives the rename below. - #[cfg(unix)] - let path = std::fs::canonicalize(&path).unwrap_or(path); - - // Write beside the target and rename over it: a crash or a full disk leaves - // the original settings.json intact rather than half a file. - let temp = path.with_extension(format!("json.coucou-{}", std::process::id())); - if let Err(err) = write_like(&temp, &path, text.as_bytes()) { - let _ = std::fs::remove_file(&temp); - return Err(format!("write failed: {err}")); - } - if let Err(err) = std::fs::rename(&temp, &path) { - let _ = std::fs::remove_file(&temp); - return Err(format!("write failed: {err}")); - } - Ok(backup.to_string_lossy().to_string()) +pub fn preview(install: bool, agent: HookAgent) -> Result { + config(agent)?.preview(install, &stamp()) } -/// Writes `bytes` to `temp`, which is about to replace `original`. -/// -/// On Linux a fresh file would get the umask's 0644, and settings.json can hold -/// API keys in its `env` block: the new file is created readable by us only, -/// then given the original's permissions, so the rename never widens them. -fn write_like(temp: &Path, original: &Path, bytes: &[u8]) -> std::io::Result<()> { - use std::io::Write; - let mut options = std::fs::OpenOptions::new(); - options.write(true).create(true).truncate(true); - #[cfg(unix)] - std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); - let mut file = options.open(temp)?; - file.write_all(bytes)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(original) - .map(|m| m.permissions().mode() & 0o777) - .unwrap_or(0o600); - file.set_permissions(std::fs::Permissions::from_mode(mode))?; - } - #[cfg(not(unix))] - let _ = original; - Ok(()) +pub fn write(install: bool, fingerprint: &str, agent: HookAgent) -> Result { + config(agent)?.write(install, fingerprint, &stamp()) } /// Copies the relay (coucou-hook.exe / coucou-hook) into the local data dir's @@ -382,7 +90,7 @@ pub fn ensure_hook_exe(app: &AppHandle) { let tried: Vec = candidates.iter().map(|p| p.display().to_string()).collect(); let Some(src) = candidates.into_iter().find(|p| p.exists()) else { crate::log::line(format!( - "{} not found — Claude Code hooks cannot work. Looked in: {}", + "{} not found — agent hooks cannot work. Looked in: {}", platform::HOOK_EXE, tried.join(", ") )); @@ -428,248 +136,3 @@ fn install_relay(src: &Path, dest: &Path) { crate::log::line(format!("could not install {}: {err}", platform::HOOK_EXE)); } } - -// ── Minimal unified diff (LCS) ──────────────────────────────────────────────── - -/// settings.json is short, so a plain O(n·m) LCS is the simplest honest diff. -fn unified_diff(before: &str, after: &str) -> String { - let a: Vec<&str> = before.lines().collect(); - let b: Vec<&str> = after.lines().collect(); - let (n, m) = (a.len(), b.len()); - - let mut lcs = vec![vec![0usize; m + 1]; n + 1]; - for i in (0..n).rev() { - for j in (0..m).rev() { - lcs[i][j] = if a[i] == b[j] { - lcs[i + 1][j + 1] + 1 - } else { - lcs[i + 1][j].max(lcs[i][j + 1]) - }; - } - } - - let mut out: Vec = Vec::new(); - let (mut i, mut j) = (0usize, 0usize); - while i < n && j < m { - if a[i] == b[j] { - out.push(format!(" {}", a[i])); - i += 1; - j += 1; - } else if lcs[i + 1][j] >= lcs[i][j + 1] { - out.push(format!("- {}", a[i])); - i += 1; - } else { - out.push(format!("+ {}", b[j])); - j += 1; - } - } - while i < n { - out.push(format!("- {}", a[i])); - i += 1; - } - while j < m { - out.push(format!("+ {}", b[j])); - j += 1; - } - - // Keep three lines of context around each change so the panel stays readable. - let changed: Vec = out - .iter() - .enumerate() - .filter(|(_, l)| l.starts_with('+') || l.starts_with('-')) - .map(|(i, _)| i) - .collect(); - if changed.is_empty() { - return "No change.".into(); - } - let mut keep = vec![false; out.len()]; - for idx in changed { - let lo = idx.saturating_sub(3); - let hi = (idx + 4).min(out.len()); - for k in lo..hi { - keep[k] = true; - } - } - let mut result = String::new(); - let mut gap = false; - for (idx, line) in out.iter().enumerate() { - if keep[idx] { - result.push_str(line); - result.push('\n'); - gap = false; - } else if !gap { - result.push_str(" …\n"); - gap = true; - } - } - result -} - -#[cfg(test)] -mod tests { - use super::*; - - const WHERE: &str = "settings.json"; - - #[test] - fn a_utf8_bom_is_stripped_not_treated_as_corruption() { - // PowerShell 5's `Set-Content -Encoding utf8` produces exactly this. - let mut bytes = vec![0xEF, 0xBB, 0xBF]; - bytes.extend_from_slice(br#"{"model":"opus","hooks":{}}"#); - let parsed = parse_settings(&bytes, WHERE).expect("a BOM must not defeat the parser"); - assert_eq!(parsed["model"], "opus"); - } - - #[test] - fn unreadable_content_is_an_error_never_an_empty_object() { - // This is the whole bug: returning {} here meant `merged()` produced a - // file containing nothing but Coucou's hooks, and the write replaced - // everything the user had. - for bad in [&b"{ not json"[..], &b"[1,2,3]"[..], &b"\"a string\""[..]] { - assert!( - parse_settings(bad, WHERE).is_err(), - "content we cannot use must refuse, not come back empty" - ); - } - } - - #[test] - fn empty_and_whitespace_files_start_from_nothing() { - assert_eq!(parse_settings(b"", WHERE).unwrap(), json!({})); - assert_eq!(parse_settings(b" - ", WHERE).unwrap(), json!({})); - } - - #[test] - fn merging_keeps_every_other_setting_and_every_foreign_hook() { - let existing = serde_json::json!({ - "model": "claude-opus-5", - "theme": "dark", - "enabledPlugins": ["a", "b"], - "hooks": { - "PreToolUse": [ - { "hooks": [{ "type": "command", "command": "someone-elses-tool.exe" }] } - ], - "SomeEventWeDoNotTouch": [ - { "hooks": [{ "type": "command", "command": "keep-me.exe" }] } - ] - } - }); - - let after = merged(&existing); - assert_eq!(after["model"], "claude-opus-5"); - assert_eq!(after["theme"], "dark"); - assert_eq!(after["enabledPlugins"], serde_json::json!(["a", "b"])); - - let pre = after["hooks"]["PreToolUse"].as_array().unwrap(); - assert!( - pre.iter().any(|e| serde_json::to_string(e).unwrap().contains("someone-elses-tool.exe")), - "another tool's hook was dropped" - ); - assert!(pre.iter().any(entry_is_ours), "our own hook was not added"); - assert!(after["hooks"]["SomeEventWeDoNotTouch"].is_array()); - - // And removing ours puts it back exactly as it was. - let cleaned = without_ours(&after); - assert_eq!(cleaned, existing); - } - - #[test] - fn a_fingerprint_notices_any_change() { - assert_eq!(fingerprint(b"{}"), fingerprint(b"{}")); - assert_ne!(fingerprint(b"{}"), fingerprint(b"{ }")); - assert_ne!(fingerprint(b""), fingerprint(b"{}")); - } - - #[cfg(unix)] - #[test] - fn the_hook_path_is_one_shell_word_whatever_it_contains() { - assert_eq!(sh_quote("/home/a b/x"), "'/home/a b/x'"); - // $, backticks, backslashes and double quotes stay literal in single quotes. - assert_eq!(sh_quote(r#"/h/$(id)`x`\"y"#), r#"'/h/$(id)`x`\"y'"#); - // A single quote closes, escapes and reopens. - assert_eq!(sh_quote("/h/it's"), r"'/h/it'\''s'"); - } - - /// settings.json can carry API keys in its `env` block: rewriting it must - /// never make it readable by more people than before. - #[cfg(unix)] - #[test] - fn rewriting_settings_never_widens_its_permissions() { - use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join(format!("coucou-perm-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - let original = dir.join("settings.json"); - let temp = dir.join("settings.json.new"); - let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; - - for wanted in [0o600, 0o640, 0o644] { - std::fs::write(&original, b"{}").unwrap(); - std::fs::set_permissions(&original, std::fs::Permissions::from_mode(wanted)).unwrap(); - let _ = std::fs::remove_file(&temp); - write_like(&temp, &original, b"{\"a\":1}").unwrap(); - assert_eq!(mode(&temp), wanted, "the rewrite must keep {wanted:o}"); - } - - // No original: ours only. - std::fs::remove_file(&original).unwrap(); - let _ = std::fs::remove_file(&temp); - write_like(&temp, &original, b"{}").unwrap(); - assert_eq!(mode(&temp), 0o600); - - let _ = std::fs::remove_dir_all(&dir); - } - - /// Everything filesystem-shaped lives in one test on purpose: it points - /// the home directory at a temp directory, and that is process-wide. - #[test] - fn writing_backs_up_preserves_and_refuses_a_changed_file() { - let tmp = std::env::temp_dir().join(format!("coucou-hooks-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&tmp); - std::fs::create_dir_all(tmp.join(".claude")).unwrap(); - std::env::set_var(platform::HOME_VAR, &tmp); - - let path = settings_path(); - assert!(path.starts_with(&tmp), "the test must not touch the real home"); - - // A real-shaped file, written the way PowerShell 5 would: UTF-8 with BOM. - let original = r#"{"model":"claude-opus-5","theme":"dark","tui":{"x":1},"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"other-tool.exe"}]}]}}"#; - let mut bytes = vec![0xEF, 0xBB, 0xBF]; - bytes.extend_from_slice(original.as_bytes()); - std::fs::write(&path, &bytes).unwrap(); - - // Install. - let plan = preview(true).expect("a BOM must not stop the preview"); - assert!(plan.diff.contains("coucou-hook"), "the diff must show what changes"); - let backup = write(true, &plan.fingerprint).expect("install should succeed"); - - // The backup holds the original bytes, BOM and all. - assert_eq!(std::fs::read(&backup).unwrap(), bytes); - - // Everything else survived, and so did the other tool's hook. - let after: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(after["model"], "claude-opus-5"); - assert_eq!(after["theme"], "dark"); - assert_eq!(after["tui"]["x"], 1); - let pre = after["hooks"]["PreToolUse"].as_array().unwrap(); - assert!(pre.iter().any(|e| serde_json::to_string(e).unwrap().contains("other-tool.exe"))); - assert!(status().installed); - - // A file that moved since the preview is refused, and left alone. - let stale = preview(false).unwrap(); - std::fs::write(&path, br#"{"model":"someone-else-edited-this"}"#).unwrap(); - let err = write(false, &stale.fingerprint).unwrap_err(); - assert!(err.contains("changed since the preview"), "got: {err}"); - let untouched: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); - assert_eq!(untouched["model"], "someone-else-edited-this"); - - // Content we cannot parse is refused before anything is written. - std::fs::write(&path, b"{ broken").unwrap(); - assert!(preview(true).is_err()); - assert!(write(true, "whatever").is_err()); - assert_eq!(std::fs::read(&path).unwrap(), b"{ broken"); - - let _ = std::fs::remove_dir_all(&tmp); - } -} diff --git a/windows/src-tauri/src/hooks_config.rs b/windows/src-tauri/src/hooks_config.rs new file mode 100644 index 000000000..e54aad687 --- /dev/null +++ b/windows/src-tauri/src/hooks_config.rs @@ -0,0 +1,632 @@ +//! Pure, testable hook configuration edits. Never runs a hook or changes trust. + +use std::fs::OpenOptions; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{atomic::{AtomicU64, Ordering}, Mutex}; + +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum HookAgent { + #[default] + Claude, + Codex, +} + +const CLAUDE_EVENTS: &[(&str, u64)] = &[ + ("SessionStart", 10), ("SessionEnd", 10), ("UserPromptSubmit", 10), + ("PreToolUse", 10), ("PostToolUse", 10), ("PostToolUseFailure", 10), + ("PermissionRequest", 120), ("Notification", 10), ("Stop", 10), + ("StopFailure", 10), ("SubagentStart", 10), ("SubagentStop", 10), +]; + +// Codex caps SessionEnd and Interrupt at three seconds. The relay normally +// exits immediately if Coucou is absent; only approval waits for a human. +const CODEX_EVENTS: &[(&str, u64)] = &[ + ("SessionStart", 10), ("SessionEnd", 3), ("UserPromptSubmit", 10), + ("PreToolUse", 10), ("PostToolUse", 10), ("PermissionRequest", 120), + ("Stop", 10), ("SubagentStart", 10), ("SubagentStop", 10), ("Interrupt", 3), +]; + +impl HookAgent { + fn events(self) -> &'static [(&'static str, u64)] { + match self { Self::Claude => CLAUDE_EVENTS, Self::Codex => CODEX_EVENTS } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HookPreview { + pub diff: String, + pub backup: String, + pub settings_path: String, + /// Binds the reviewed bytes, operation, agent, paths and proposed result. + pub fingerprint: String, +} + +pub struct HookConfig { + pub agent: HookAgent, + pub path: PathBuf, + pub hook_path: PathBuf, +} + +/// Resolve an explicit CODEX_HOME in the same working directory as Coucou. +/// Never silently substitute the current directory for a missing user profile. +pub fn config_path( + agent: HookAgent, + user_profile: Option, + codex_home: Option, +) -> Result { + let codex_home = codex_home.filter(|path| !path.as_os_str().is_empty()); + let directory = match (agent, codex_home) { + (HookAgent::Codex, Some(path)) => path, + _ => { + let home = user_profile.filter(|path| path.is_absolute()) + .ok_or("The user home must name an absolute directory; Coucou won't guess a hook configuration location")?; + home.join(if agent == HookAgent::Codex { ".codex" } else { ".claude" }) + } + }; + let filename = if agent == HookAgent::Codex { "hooks.json" } else { "settings.json" }; + std::path::absolute(directory.join(filename)) + .map_err(|err| format!("Can't resolve the hook configuration location: {err}")) +} + +struct Snapshot { + bytes: Option>, + value: Value, +} + +fn parse(bytes: &[u8], path: &Path) -> Result { + let bytes = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(bytes); + let value: Value = serde_json::from_slice(bytes) + .map_err(|err| format!("{} isn't valid JSON ({err}). Coucou won't overwrite it.", path.display()))?; + validate(&value).map_err(|err| format!("{}: {err}. Coucou won't overwrite it.", path.display()))?; + Ok(value) +} + +fn validate(value: &Value) -> Result<(), String> { + let root = value.as_object().ok_or("configuration must be a JSON object")?; + if let Some(hooks) = root.get("hooks") { + let hooks = hooks.as_object().ok_or("hooks must be an object")?; + for (event, groups) in hooks { + let groups = groups.as_array().ok_or_else(|| format!("hooks.{event} must be an array"))?; + for group in groups { + let group = group.as_object().ok_or_else(|| format!("hooks.{event} contains a non-object group"))?; + let handlers = group.get("hooks").and_then(Value::as_array) + .ok_or_else(|| format!("hooks.{event} group must contain a hooks array"))?; + for handler in handlers { + let handler = handler.as_object().ok_or_else(|| format!("hooks.{event} contains a non-object handler"))?; + let kind = handler.get("type").and_then(Value::as_str) + .ok_or_else(|| format!("hooks.{event} handler must have a type"))?; + if kind == "command" && handler.get("command").and_then(Value::as_str).is_none() { + return Err(format!("hooks.{event} command handler must have a command string")); + } + } + } + } + } + Ok(()) +} + +impl HookConfig { + fn validate_paths(&self) -> Result<(), String> { + if !self.path.is_absolute() { + return Err("Hook configuration must use an absolute path".into()); + } + if !self.hook_path.is_absolute() { + return Err("The hook relay must use an absolute path; check LOCALAPPDATA".into()); + } + #[cfg(windows)] + let executable = self.hook_path.to_str().ok_or("The hook relay path isn't valid Unicode")?; + // Both agents pass this through a shell. Quotes protect spaces and '&' + // but not cmd.exe %variables% / delayed !variables!, or Bash $() and + // backticks. Refuse these uncommon installation paths instead of + // guessing at cross-shell escaping or executing a different command. + #[cfg(windows)] + let unsafe_path = executable.chars().any(|ch| { + matches!(ch, '"' | '\r' | '\n' | '\0') || match self.agent { + HookAgent::Codex => matches!(ch, '%' | '!'), + HookAgent::Claude => matches!(ch, '$' | '`'), + } + }); + #[cfg(windows)] + if unsafe_path { + return Err("The hook relay path contains shell expansion characters. Move Coucou to a path without them before installing hooks".into()); + } + Ok(()) + } + + fn read(&self) -> Result { + self.validate_paths()?; + match std::fs::read(&self.path) { + Ok(bytes) => Ok(Snapshot { value: parse(&bytes, &self.path)?, bytes: Some(bytes) }), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Snapshot { bytes: None, value: json!({}) }), + Err(err) => Err(format!("Can't read {}: {err}", self.path.display())), + } + } + + fn command(&self, event: &str) -> String { + let path = self.hook_path.to_string_lossy(); + #[cfg(windows)] + let exe = format!("\"{}\"", path.replace('\\', "/")); + #[cfg(unix)] + let exe = format!("'{}'", path.replace('\'', "'\\''")); + match self.agent { + HookAgent::Claude => format!("{exe} {event}"), + HookAgent::Codex => format!("{exe} --agent codex {event}"), + } + } + + fn owns(&self, event: &str, handler: &Value) -> bool { + // Never use a substring marker: a logger mentioning coucou-hook is not + // ours. A changed Windows override is another command, so preserve it. + self.agent.events().iter().any(|(name, _)| *name == event) + && handler.get("type").and_then(Value::as_str) == Some("command") + && handler.get("command").and_then(Value::as_str) == Some(self.command(event).as_str()) + && ["commandWindows", "command_windows"].iter().all(|key| { + handler.get(*key).map_or(true, |v| v.as_str() == Some(self.command(event).as_str())) + }) + } + + fn changed(&self, current: &Value, install: bool) -> Value { + let mut next = current.clone(); + if let Some(hooks) = next.get_mut("hooks").and_then(Value::as_object_mut) { + let had_events = !hooks.is_empty(); + hooks.retain(|event, groups| { + let groups = groups.as_array_mut().expect("validated hook groups"); + let was_empty = groups.is_empty(); + groups.retain_mut(|group| { + let handlers = group["hooks"].as_array_mut().expect("validated handlers"); + let previous_len = handlers.len(); + handlers.retain(|handler| !self.owns(event, handler)); + // Preserve empty foreign groups, and all metadata / foreign + // handlers when one matcher group contains several hooks. + previous_len == handlers.len() || !handlers.is_empty() + }); + was_empty || !groups.is_empty() + }); + if had_events && hooks.is_empty() { + next.as_object_mut().unwrap().remove("hooks"); + } + } + if install { + let hooks = next.as_object_mut().unwrap().entry("hooks").or_insert_with(|| json!({})); + for (event, timeout) in self.agent.events() { + let groups = hooks.as_object_mut().unwrap().entry(*event).or_insert_with(|| json!([])); + groups.as_array_mut().unwrap().push(json!({ "hooks": [{ + "type": "command", "command": self.command(event), "timeout": timeout, + }] })); + } + } + next + } + + pub fn installed(&self) -> Result { + let snapshot = self.read()?; + Ok(snapshot.value.get("hooks").and_then(Value::as_object).is_some_and(|hooks| { + hooks.iter().any(|(event, groups)| groups.as_array().unwrap().iter() + .any(|group| group["hooks"].as_array().unwrap().iter().any(|handler| self.owns(event, handler)))) + })) + } + + fn fingerprint(&self, snapshot: &Snapshot, install: bool) -> String { + // Hash a structured envelope, including missing-vs-existing state. Read + // once: hashing a second read could approve bytes different from the diff. + let envelope = json!([ + format!("{:?}", self.agent), self.path, self.hook_path, install, + snapshot.bytes, self.changed(&snapshot.value, install), + ]); + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for b in serde_json::to_vec(&envelope).expect("JSON envelope") { + hash ^= b as u64; + hash = hash.wrapping_mul(0x100_0000_01b3); + } + format!("{hash:016x}") + } + + fn backup_base(&self, stamp: &str) -> PathBuf { + let name = self.path.file_name().unwrap_or_default().to_string_lossy(); + self.path.with_file_name(format!("{name}.bak-{stamp}")) + } + + pub fn preview(&self, install: bool, stamp: &str) -> Result { + let current = self.read()?; + let next = self.changed(¤t.value, install); + Ok(HookPreview { + diff: unified_diff(&pretty(¤t.value), &pretty(&next)), + backup: self.backup_base(stamp).to_string_lossy().into_owned(), + settings_path: self.path.to_string_lossy().into_owned(), + fingerprint: self.fingerprint(¤t, install), + }) + } + + pub fn write(&self, install: bool, fingerprint: &str, stamp: &str) -> Result { + self.write_checked(install, fingerprint, stamp, || {}) + } + + fn write_checked(&self, install: bool, fingerprint: &str, stamp: &str, before_replace: impl FnOnce()) -> Result { + static WRITES: Mutex<()> = Mutex::new(()); + let _lock = WRITES.lock().map_err(|_| "Hook configuration writer is unavailable")?; + let current = self.read()?; + let stale = || format!("{} changed since the preview. Nothing was written — review the new diff.", self.path.display()); + if self.fingerprint(¤t, install) != fingerprint { return Err(stale()); } + let next = self.changed(¤t.value, install); + if next == current.value { return Ok(String::new()); } + let dir = self.path.parent().ok_or("Hook configuration has no parent directory")?; + std::fs::create_dir_all(dir).map_err(|err| err.to_string())?; + + let backup = if let Some(bytes) = current.bytes.as_deref() { + // create_new reserves a distinct backup, even for repeated changes + // within one second. Copy the reviewed bytes, never a second read. + let (path, mut file) = unique_file(&self.backup_base(stamp))?; + file.write_all(bytes).and_then(|_| file.sync_all()).map_err(|err| format!("backup failed: {err}"))?; + path.to_string_lossy().into_owned() + } else { String::new() }; + + let (temp, mut file) = unique_file(&self.path.with_extension("json.coucou-tmp"))?; + let write_result = (|| { + file.write_all(format!("{}\n", pretty(&next)).as_bytes()) + .and_then(|_| file.sync_all()).map_err(|err| format!("write failed: {err}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&self.path) + .map(|m| m.permissions().mode() & 0o777).unwrap_or(0o600); + file.set_permissions(std::fs::Permissions::from_mode(mode)) + .map_err(|err| format!("permissions failed: {err}"))?; + } + drop(file); + before_replace(); + // Catch edits made during backup / serialization as well as stale + // previews. Other applications do not participate in our mutex. + if self.read()?.bytes != current.bytes { return Err(stale()); } + std::fs::rename(&temp, &self.path).map_err(|err| format!("write failed: {err}")) + })(); + if write_result.is_err() { let _ = std::fs::remove_file(&temp); } + write_result?; + Ok(backup) + } +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + use std::ops::Deref; + + struct TestConfig { dir: PathBuf, config: HookConfig } + + impl TestConfig { + fn new(agent: HookAgent) -> Self { + static NEXT: AtomicU64 = AtomicU64::new(0); + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("coucou-hooks-{}-{nonce}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir(&dir).unwrap(); + let config = HookConfig { + agent, + path: dir.join("hooks.json"), + hook_path: PathBuf::from("C:/Test User/AppData/Local/Coucou/bin/coucou-hook.exe"), + }; + Self { dir, config } + } + } + impl Deref for TestConfig { type Target = HookConfig; fn deref(&self) -> &Self::Target { &self.config } } + impl Drop for TestConfig { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.dir); } } + + #[test] + fn parses_a_powershell_utf8_bom() { + let mut bytes = vec![0xef, 0xbb, 0xbf]; + bytes.extend_from_slice(br#"{"theme":"dark","hooks":{}}"#); + assert_eq!(parse(&bytes, Path::new("test.json")).unwrap()["theme"], "dark"); + } + + #[test] + fn resolves_codex_home_without_changing_process_environment() { + let profile = PathBuf::from("C:/Test User"); + assert_eq!(config_path(HookAgent::Claude, Some(profile.clone()), Some(PathBuf::from("C:/Codex"))).unwrap(), profile.join(".claude/settings.json")); + assert_eq!(config_path(HookAgent::Codex, Some(profile.clone()), None).unwrap(), profile.join(".codex/hooks.json")); + assert_eq!(config_path(HookAgent::Codex, None, Some(PathBuf::from("C:/Custom Codex"))).unwrap(), PathBuf::from("C:/Custom Codex/hooks.json")); + let relative = config_path(HookAgent::Codex, None, Some(PathBuf::from("custom-codex"))).unwrap(); + assert!(relative.is_absolute()); + assert_eq!(relative, std::env::current_dir().unwrap().join("custom-codex/hooks.json")); + assert!(config_path(HookAgent::Claude, None, None).is_err()); + assert!(config_path(HookAgent::Codex, Some(PathBuf::from("relative-profile")), None).is_err()); + } + + #[test] + fn refuses_relay_paths_that_shells_would_expand() { + for (agent, unsafe_names) in [ + (HookAgent::Codex, ["%PATH%", "!PATH!", "broken\npath", "broken\"path"]), + (HookAgent::Claude, ["$(command)", "`command`", "broken\rpath", "broken\"path"]), + ] { + let mut config = TestConfig::new(agent); + for name in unsafe_names { + config.config.hook_path = PathBuf::from(format!("C:/{name}/coucou-hook.exe")); + assert!(config.preview(true, "20260930-120000").is_err()); + assert!(config.write(true, "anything", "20260930-120000").is_err()); + assert_eq!(std::fs::read_dir(&config.dir).unwrap().count(), 0); + } + config.config.hook_path = PathBuf::from("relative/coucou-hook.exe"); + assert!(config.validate_paths().is_err()); + config.config.hook_path = PathBuf::from("C:/Test User & Team/coucou-hook.exe"); + assert!(config.validate_paths().is_ok(), "quoted spaces and ampersands are literal"); + } + } + + #[test] + fn refuses_malformed_json_and_hook_structure_without_writing() { + let config = TestConfig::new(HookAgent::Codex); + for bytes in [b"".as_slice(), b" ", b"{ broken", b"[]", b"null", + br#"{"hooks":[]}"#, br#"{"hooks":{"Stop":{}}}"#, + br#"{"hooks":{"Stop":[null]}}"#, br#"{"hooks":{"Stop":[{}]}}"#, + br#"{"hooks":{"Stop":[{"hooks":[{}]}]}}"#, + br#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":42}]}]}}"#] { + std::fs::write(&config.path, bytes).unwrap(); + assert!(config.preview(true, "20260930-120000").is_err()); + assert!(config.write(true, "anything", "20260930-120000").is_err()); + assert_eq!(std::fs::read(&config.path).unwrap(), bytes); + assert_eq!(std::fs::read_dir(&config.dir).unwrap().count(), 1, "must not create a backup or temp for malformed input"); + } + } + + #[test] + fn codex_uses_supported_events_and_short_shutdown_timeouts() { + let config = TestConfig::new(HookAgent::Codex); + let next = config.changed(&json!({}), true); + let hooks = next["hooks"].as_object().unwrap(); + assert_eq!(hooks.len(), 10); + for event in ["Notification", "PostToolUseFailure", "StopFailure"] { assert!(!hooks.contains_key(event)); } + for event in ["Interrupt", "SessionEnd"] { assert_eq!(hooks[event][0]["hooks"][0]["timeout"], 3); } + assert_eq!(hooks["PermissionRequest"][0]["hooks"][0]["timeout"], 120); + assert_eq!(hooks["Stop"][0]["hooks"][0]["command"], "\"C:/Test User/AppData/Local/Coucou/bin/coucou-hook.exe\" --agent codex Stop"); + assert_eq!(config.changed(&next, true), next, "install must be idempotent"); + } + + #[test] + fn claude_command_and_events_stay_compatible() { + let config = TestConfig::new(HookAgent::Claude); + let next = config.changed(&json!({}), true); + assert_eq!(next["hooks"].as_object().unwrap().len(), 12); + assert_eq!(next["hooks"]["Stop"][0]["hooks"][0]["command"], "\"C:/Test User/AppData/Local/Coucou/bin/coucou-hook.exe\" Stop"); + assert!(next["hooks"]["Notification"].is_array()); + assert_eq!(config.changed(&next, false), json!({})); + } + + #[test] + fn preserves_foreign_handlers_and_group_metadata_when_removing_ours() { + let config = TestConfig::new(HookAgent::Codex); + let foreign = json!({"type":"command", "command":"logger.exe coucou-hook"}); + let different_override = json!({"type":"command", "command":config.command("Stop"), "commandWindows":"someone-else.exe"}); + let original = json!({ "description":"Keep me", "extra":{"theme":"dark"}, "hooks":{ + "Stop":[{"matcher":".*", "metadata":{"keep":true}, "hooks":[ + foreign.clone(), {"type":"command", "command":config.command("Stop")}, different_override.clone() + ]}], + "OtherFutureEvent":[{"hooks":[{"type":"mcp_tool","server":"policy","tool":"check"}]}], + "PostToolUse":[], "PreToolUse":[{"hooks":[], "keep":true}] + }}); + let mut expected = original.clone(); + expected["hooks"]["Stop"][0]["hooks"] = json!([foreign, different_override]); + assert_eq!(config.changed(&original, false), expected); + let installed = config.changed(&expected, true); + // An event containing only our newly added handler is removed again; + // empty event arrays have no handlers to preserve. + expected["hooks"].as_object_mut().unwrap().remove("PostToolUse"); + assert_eq!(config.changed(&installed, false), expected); + } + + #[test] + fn keeps_commands_for_another_agent_or_install_path() { + let config = TestConfig::new(HookAgent::Codex); + let foreign = json!({"hooks":{"Stop":[{"hooks":[ + {"type":"command","command":"\"C:/Other/coucou-hook.exe\" --agent codex Stop"}, + {"type":"command","command":"\"C:/Test User/AppData/Local/Coucou/bin/coucou-hook.exe\" Stop"}, + {"type":"command","command":format!("{} && other.exe", config.command("Stop"))} + ]}]}}); + assert_eq!(config.changed(&foreign, false), foreign); + assert_eq!(config.changed(&json!({"hooks":{}}), false), json!({"hooks":{}})); + } + + #[test] + fn preview_binds_agent_operation_paths_and_missing_state() { + let config = TestConfig::new(HookAgent::Codex); + let snapshot = config.read().unwrap(); + let install = config.fingerprint(&snapshot, true); + assert_ne!(install, config.fingerprint(&snapshot, false)); + for other in [ + HookConfig { agent: HookAgent::Claude, path: config.path.clone(), hook_path: config.hook_path.clone() }, + HookConfig { agent: HookAgent::Codex, path: config.dir.join("other.json"), hook_path: config.hook_path.clone() }, + HookConfig { agent: HookAgent::Codex, path: config.path.clone(), hook_path: PathBuf::from("C:/Other/coucou-hook.exe") }, + ] { assert_ne!(install, other.fingerprint(&snapshot, true)); } + std::fs::write(&config.path, b"{}").unwrap(); + assert_ne!(install, config.fingerprint(&config.read().unwrap(), true)); + assert!(config.write(true, &install, "20260930-120000").is_err()); + assert_eq!(std::fs::read(&config.path).unwrap(), b"{}"); + } + + #[test] + fn backs_up_exact_bytes_and_never_overwrites_same_second_backups() { + let config = TestConfig::new(HookAgent::Codex); + let bytes = b"\xef\xbb\xbf{\"description\":\"preserve\",\"hooks\":{\"PreToolUse\":[{\"hooks\":[{\"type\":\"command\",\"command\":\"other.exe\"}]}]}}"; + std::fs::write(&config.path, bytes).unwrap(); + let plan = config.preview(true, "20260930-120000").unwrap(); + assert!(plan.diff.contains("--agent codex")); + let first = config.write(true, &plan.fingerprint, "20260930-120000").unwrap(); + assert_eq!(std::fs::read(&first).unwrap(), bytes); + assert!(config.installed().unwrap()); + let installed_bytes = std::fs::read(&config.path).unwrap(); + let plan = config.preview(false, "20260930-120000").unwrap(); + let second = config.write(false, &plan.fingerprint, "20260930-120000").unwrap(); + assert_ne!(first, second); + assert_eq!(std::fs::read(first).unwrap(), bytes); + assert_eq!(std::fs::read(second).unwrap(), installed_bytes); + assert_eq!(config.read().unwrap().value, parse(bytes, &config.path).unwrap()); + assert!(!config.installed().unwrap()); + } + + #[test] + fn changed_file_is_refused_before_a_backup() { + let config = TestConfig::new(HookAgent::Codex); + std::fs::write(&config.path, b"{}").unwrap(); + let plan = config.preview(true, "20260930-120000").unwrap(); + std::fs::write(&config.path, b"{\"description\":\"external edit\"}").unwrap(); + assert!(config.write(true, &plan.fingerprint, "20260930-120000").unwrap_err().contains("changed since the preview")); + assert_eq!(config.read().unwrap().value["description"], "external edit"); + assert_eq!(std::fs::read_dir(&config.dir).unwrap().count(), 1); + } + + #[test] + fn edit_during_backup_is_not_overwritten_and_temp_is_cleaned_up() { + let config = TestConfig::new(HookAgent::Codex); + std::fs::write(&config.path, b"{}").unwrap(); + let plan = config.preview(true, "20260930-120000").unwrap(); + let external = br#"{"description":"changed while writing"}"#; + let err = config.write_checked(true, &plan.fingerprint, "20260930-120000", || { + std::fs::write(&config.path, external).unwrap(); + }).unwrap_err(); + assert!(err.contains("changed since the preview")); + assert_eq!(std::fs::read(&config.path).unwrap(), external); + assert_eq!(std::fs::read(config.backup_base("20260930-120000")).unwrap(), b"{}"); + assert_eq!(std::fs::read_dir(&config.dir).unwrap().count(), 2, "failed writes must remove temporary files"); + } + + #[test] + fn missing_file_install_and_noop_uninstall_need_no_fake_backup() { + let config = TestConfig::new(HookAgent::Codex); + let plan = config.preview(false, "20260930-120000").unwrap(); + assert_eq!(config.write(false, &plan.fingerprint, "20260930-120000").unwrap(), ""); + assert!(!config.path.exists()); + let plan = config.preview(true, "20260930-120000").unwrap(); + assert_eq!(config.write(true, &plan.fingerprint, "20260930-120000").unwrap(), ""); + assert!(config.installed().unwrap()); + assert_eq!(std::fs::read_dir(&config.dir).unwrap().count(), 1); + } +} + +fn unique_file(base: &Path) -> Result<(PathBuf, std::fs::File), String> { + static SEQUENCE: AtomicU64 = AtomicU64::new(0); + for attempt in 0..100 { + let path = if attempt == 0 { base.to_owned() } else { + base.with_file_name(format!("{}.{}-{}", base.file_name().unwrap_or_default().to_string_lossy(), + std::process::id(), SEQUENCE.fetch_add(1, Ordering::Relaxed))) + }; + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + match options.open(&path) { + Ok(file) => return Ok((path, file)), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(format!("Can't create {}: {err}", path.display())), + } + } + Err("Can't reserve a unique hook configuration backup or temporary file".into()) +} + +fn pretty(value: &Value) -> String { serde_json::to_string_pretty(value).expect("JSON settings") } + +/// settings.json is short, so a plain O(n·m) LCS is the simplest honest diff. +fn unified_diff(before: &str, after: &str) -> String { + let a: Vec<&str> = before.lines().collect(); + let b: Vec<&str> = after.lines().collect(); + let (n, m) = (a.len(), b.len()); + + let mut lcs = vec![vec![0usize; m + 1]; n + 1]; + for i in (0..n).rev() { + for j in (0..m).rev() { + lcs[i][j] = if a[i] == b[j] { + lcs[i + 1][j + 1] + 1 + } else { + lcs[i + 1][j].max(lcs[i][j + 1]) + }; + } + } + + let mut out: Vec = Vec::new(); + let (mut i, mut j) = (0usize, 0usize); + while i < n && j < m { + if a[i] == b[j] { + out.push(format!(" {}", a[i])); + i += 1; + j += 1; + } else if lcs[i + 1][j] >= lcs[i][j + 1] { + out.push(format!("- {}", a[i])); + i += 1; + } else { + out.push(format!("+ {}", b[j])); + j += 1; + } + } + while i < n { + out.push(format!("- {}", a[i])); + i += 1; + } + while j < m { + out.push(format!("+ {}", b[j])); + j += 1; + } + + // Keep three lines of context around each change so the panel stays readable. + let changed: Vec = out + .iter() + .enumerate() + .filter(|(_, l)| l.starts_with('+') || l.starts_with('-')) + .map(|(i, _)| i) + .collect(); + if changed.is_empty() { + return "No change.".into(); + } + let mut keep = vec![false; out.len()]; + for idx in changed { + let lo = idx.saturating_sub(3); + let hi = (idx + 4).min(out.len()); + for k in lo..hi { + keep[k] = true; + } + } + let mut result = String::new(); + let mut gap = false; + for (idx, line) in out.iter().enumerate() { + if keep[idx] { + result.push_str(line); + result.push('\n'); + gap = false; + } else if !gap { + result.push_str(" …\n"); + gap = true; + } + } + result +} + +#[cfg(all(test, unix))] +mod unix_tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn shell_paths_and_rewrites_preserve_linux_security() { + let dir = std::env::temp_dir().join(format!("coucou-config-port-{}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + let config = HookConfig { agent: HookAgent::Claude, + path: dir.join("settings.json"), hook_path: PathBuf::from("/home/a b/it's$(id)/coucou-hook") }; + assert_eq!(config.command("Stop"), "'/home/a b/it'\\''s$(id)/coucou-hook' Stop"); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + for wanted in [0o600, 0o640, 0o644] { + std::fs::write(&config.path, br#"{"env":{"PRIVATE":"test-only"},"hooks":{"Custom":[{"hooks":[{"type":"command","command":"other"}]}]}}"#).unwrap(); + std::fs::set_permissions(&config.path, std::fs::Permissions::from_mode(wanted)).unwrap(); + let plan = config.preview(true, "test").unwrap(); + let backup = config.write(true, &plan.fingerprint, "test").unwrap(); + assert_eq!(mode(&config.path), wanted); + assert_eq!(mode(Path::new(&backup)), 0o600); + assert_eq!(config.read().unwrap().value["env"]["PRIVATE"], "test-only"); + assert!(config.read().unwrap().value["hooks"]["Custom"].is_array()); + } + std::fs::remove_file(&config.path).unwrap(); + let plan = config.preview(true, "new").unwrap(); + config.write(true, &plan.fingerprint, "new").unwrap(); + assert_eq!(mode(&config.path), 0o600); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 714103e59..fbb4f6ce2 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -1,11 +1,14 @@ // Coucou for Windows — app wiring and the commands the island calls. +mod chat; mod claude; mod files; mod hooks; +mod hooks_config; mod integrations; mod island; mod log; +mod openai; mod pipe; mod platform; mod secrets; @@ -20,9 +23,10 @@ use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; use tauri_plugin_autostart::{ManagerExt, MacosLauncher}; -use claude::{Chat, ChatContext, ChatReply}; +use chat::{Chat, Provider}; +use claude::{ChatContext, ChatReply}; use files::DroppedFile; -use hooks::{HookPreview, HookStatus}; +use hooks::{HookAgent, HookPreview, HookStatus}; use island::{PollGate, ScreenInfo}; use pipe::Pending; use settings::Settings; @@ -48,7 +52,7 @@ pub struct BootInfo { fn boot(app: AppHandle, shared: State) -> BootInfo { let mut settings = shared.settings.lock().unwrap().clone(); // The real state of ~/.claude/settings.json wins over whatever we stored. - settings.hooks_installed = hooks::status().installed; + settings.hooks_installed = hooks::status(HookAgent::Claude).installed; let screen = island::screen_info(&app, &settings.screen); BootInfo { settings, @@ -60,11 +64,14 @@ fn boot(app: AppHandle, shared: State) -> BootInfo { } #[tauri::command] -fn save_settings(app: AppHandle, shared: State, settings: Settings) { +fn save_settings(app: AppHandle, shared: State, chat: State, settings: Settings) { let (screen_changed, autostart_changed) = { let mut current = shared.settings.lock().unwrap(); let screen_changed = current.screen != settings.screen; let autostart_changed = current.autostart != settings.autostart; + if current.chat_provider != settings.chat_provider || current.chat_model() != settings.chat_model() { + chat.reset(); + } *current = settings.clone(); (screen_changed, autostart_changed) }; @@ -177,17 +184,17 @@ fn set_paused(paused: bool) { integrations::set_paused(paused); } -// ── Claude Code hooks ───────────────────────────────────────────────────────── +// ── Agent hooks ─────────────────────────────────────────────────────────────── #[tauri::command] -fn hooks_status() -> HookStatus { - hooks::status() +fn hooks_status(agent: Option) -> HookStatus { + hooks::status(agent.unwrap_or_default()) } /// Returns the diff the user has to look at before anything is written. #[tauri::command] -fn hooks_preview(install: bool) -> Result { - hooks::preview(install) +fn hooks_preview(install: bool, agent: Option) -> Result { + hooks::preview(install, agent.unwrap_or_default()) } /// Only ever called from an explicit click in the settings window. @@ -197,14 +204,20 @@ fn hooks_apply( shared: State, install: bool, fingerprint: String, + agent: Option, ) -> Result { // The fingerprint comes from the preview the user actually looked at, so a // settings.json that changed in between is refused rather than overwritten. - let backup = hooks::write(install, &fingerprint)?; + let agent = agent.unwrap_or_default(); + let backup = hooks::write(install, &fingerprint, agent)?; let updated = { let mut current = shared.settings.lock().unwrap(); - current.hooks_installed = install; - let _ = settings::save(¤t); + // Keep the legacy Claude preference compatible. Codex state is read + // from hooks.json, so removing it externally cannot leave stale state. + if agent == HookAgent::Claude { + current.hooks_installed = hooks::status(agent).installed; + let _ = settings::save(¤t); + } current.clone() }; let _ = app.emit("settings-changed", updated); @@ -241,8 +254,12 @@ async fn chat_send( query: String, context: Option, ) -> Result { - let model = shared.settings.lock().unwrap().model.clone(); - claude::send(&chat, &model, query, context).await + let _turn = chat.turn.try_lock().map_err(|_| "A chat request is already running. Please wait.")?; + let settings = shared.settings.lock().unwrap().clone(); + match settings.chat_provider { + Provider::Claude => claude::send(&chat, settings.chat_model(), query, context).await, + Provider::Openai => openai::send(&chat, settings.chat_model(), query, context).await, + } } #[tauri::command] diff --git a/windows/src-tauri/src/openai.rs b/windows/src-tauri/src/openai.rs new file mode 100644 index 000000000..020da3ef8 --- /dev/null +++ b/windows/src-tauri/src/openai.rs @@ -0,0 +1,228 @@ +//! OpenAI Responses client. Credentials and file bytes stay in Rust. History is +//! held locally, with store:false and no implicit fallback to another provider. +use std::{io::Read, path::Path, time::Duration}; +use serde_json::{json, Value}; +use crate::{chat::{Chat, Provider}, claude::{base64_for, ChatContext, ChatReply}, secrets}; + +const ENDPOINT: &str = "https://api.openai.com/v1/responses"; +pub const DEFAULT_MODEL: &str = "gpt-4.1-mini"; +const MAX_ATTACHMENT: u64 = 20 * 1024 * 1024; +const MAX_TEXT: u64 = 200_000; +const INSTRUCTIONS: &str = "You are Mochi, a personal AI assistant living at the top of the user's screen. \ +Respond in the user's language. Help with questions and the files the user shares. \ +You have no live web search or computer tools in this chat. \ +Use plain text with line breaks, without markdown formatting."; + +pub async fn send(chat: &Chat, model: &str, query: String, context: Option) -> Result { + let key = secrets::get("openai-api-key") + .ok_or("OpenAI API key missing. Open Settings → OpenAI.")?; + send_to(chat, model, query, context, &key, ENDPOINT).await +} + +async fn send_to(chat: &Chat, model: &str, query: String, context: Option, key: &str, endpoint: &str) -> Result { + if model.trim().is_empty() { + return Err("Choose an OpenAI model in settings.".into()); + } + let (generation, mut messages) = chat.begin(Provider::Openai, model); + let content = user_content(query, if messages.is_empty() { context } else { None })?; + messages.push(json!({"role": "user", "content": content})); + let body = json!({ + "model": model, + "instructions": INSTRUCTIONS, + "input": messages, + "max_output_tokens": 4096, + "store": false, + "include": ["reasoning.encrypted_content"], + }); + let response = call(key, endpoint, &body).await?; + let (text, output) = reply(&response)?; + messages.extend(output); + chat.commit(generation, messages)?; + Ok(ChatReply { text }) +} + +fn user_content(query: String, context: Option) -> Result, String> { + let mut content = Vec::new(); + match context { + Some(ChatContext::File { name, path }) => { + content.push(file_block(&path)?); + content.push(json!({"type": "input_text", "text": format!("File: {name}")})); + } + Some(ChatContext::Window { app_name, title, url }) => { + let mut text = format!("Context — App: {app_name}, Window: {title}"); + if let Some(url) = url { text.push_str(&format!(", URL: {url}")); } + content.push(json!({"type": "input_text", "text": text})); + } + None => {} + } + content.push(json!({"type": "input_text", "text": query})); + Ok(content) +} + +fn file_block(path: &str) -> Result { + let path = Path::new(path); + let ext = path.extension().and_then(|v| v.to_str()).unwrap_or("").to_lowercase(); + let mime = match ext.as_str() { + "pdf" => Some("application/pdf"), + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + _ => None, + }; + let file = std::fs::File::open(path).map_err(|_| "Could not read the attached file.")?; + if !file.metadata().map_err(|_| "Could not inspect the attached file.")?.is_file() { + return Err("Attach a regular file.".into()); + } + let limit = if mime.is_some() { MAX_ATTACHMENT } else { MAX_TEXT }; + let mut bytes = Vec::new(); + file.take(limit + 1).read_to_end(&mut bytes).map_err(|_| "Could not read the attached file.")?; + if bytes.len() as u64 > limit { + return Err(if mime.is_some() { "Attachments must be at most 20 MiB." } else { "Text attachments must be at most 200 KB." }.into()); + } + match mime { + Some("application/pdf") => Ok(json!({ + "type": "input_file", + "filename": path.file_name().and_then(|v| v.to_str()).unwrap_or("document.pdf"), + "file_data": format!("data:application/pdf;base64,{}", base64_for(&bytes)), + })), + Some(mime) => Ok(json!({"type": "input_image", "image_url": format!("data:{mime};base64,{}", base64_for(&bytes))})), + None => { + let text = String::from_utf8(bytes).map_err(|_| "Unsupported attachment. Use PDF, PNG, JPEG, GIF, WebP or UTF-8 text.")?; + Ok(json!({"type": "input_text", "text": format!("File contents:\n{text}")})) + } + } +} + +async fn call(key: &str, endpoint: &str, body: &Value) -> Result { + let response = reqwest::Client::builder() + .timeout(Duration::from_secs(90)) + .redirect(reqwest::redirect::Policy::none()) + .build().map_err(|e| e.to_string())? + .post(endpoint).bearer_auth(key).json(body) + .send().await.map_err(|e| format!("OpenAI network error: {e}"))?; + let status = response.status(); + let value: Value = response.json().await + .map_err(|_| format!("OpenAI API {status}: invalid JSON response."))?; + if !status.is_success() { + let detail = value["error"]["message"].as_str().unwrap_or("Request failed."); + // Invalid-key errors can echo part of the credential; never show them. + let detail = if status == reqwest::StatusCode::UNAUTHORIZED { + "Authentication failed. Check your OpenAI API key in settings.".to_string() + } else { + detail.replace(key, "[redacted]").chars().take(500).collect() + }; + return Err(format!("OpenAI API {status}: {detail}")); + } + Ok(value) +} + +fn reply(response: &Value) -> Result<(String, Vec), String> { + if response["status"].as_str() != Some("completed") { + let reason = response["incomplete_details"]["reason"].as_str() + .or(response["error"]["code"].as_str()).unwrap_or("unexpected response status"); + return Err(format!("OpenAI response incomplete ({reason}). Try a shorter request.")); + } + let output = response["output"].as_array().ok_or("Unexpected OpenAI response: no output.")?; + let mut texts = Vec::new(); + for item in output { + if item["type"] != "message" { continue; } + for block in item["content"].as_array().into_iter().flatten() { + match block["type"].as_str() { + Some("output_text") => if let Some(text) = block["text"].as_str() { texts.push(text); }, + Some("refusal") => return Err(block["refusal"].as_str().unwrap_or("OpenAI declined this request.").into()), + _ => {} + } + } + } + let text = texts.join("\n").trim().to_string(); + if text.is_empty() { return Err("OpenAI returned no response text.".into()); } + // Replay output items, including encrypted reasoning, for stateless turns. + Ok((text, output.clone())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn response() -> Value { + json!({"status": "completed", "output": [ + {"type": "reasoning", "id": "r1", "encrypted_content": "opaque"}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Hola"}]} + ]}) + } + + #[test] + fn rejects_incomplete_refused_and_empty_output() { + assert_eq!(reply(&response()).unwrap().0, "Hola"); + for bad in [json!({"status":"incomplete","incomplete_details":{"reason":"max_output_tokens"}}), + json!({"status":"completed","output":[]}), + json!({"status":"completed","output":[{"type":"message","content":[{"type":"refusal","refusal":"Declined"}]}]})] { + assert!(reply(&bad).is_err()); + } + } + + #[test] + fn attachments_are_encoded_and_bounded() { + let dir = std::env::temp_dir().join(format!("coucou-openai-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for (name, kind, bytes) in [("test.pdf", "input_file", b"fixture".as_slice()), ("test.png", "input_image", b"fixture".as_slice()), ("test.txt", "input_text", "Español".as_bytes())] { + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + let block = file_block(path.to_str().unwrap()).unwrap(); + assert_eq!(block["type"], kind); + if kind == "input_file" { assert_eq!(block["file_data"], "data:application/pdf;base64,Zml4dHVyZQ=="); } + } + let path = dir.join("large.txt"); + std::fs::File::create(&path).unwrap().set_len(MAX_TEXT + 1).unwrap(); + assert!(file_block(path.to_str().unwrap()).is_err()); + std::fs::write(&path, [0xff, 0xfe]).unwrap(); + assert!(file_block(path.to_str().unwrap()).is_err()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn http_auth_multiturn_and_failure_rollback() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = format!("http://{}/v1/responses", listener.local_addr().unwrap()); + let worker = std::thread::spawn(move || { + let mut bodies = Vec::new(); + for index in 0..3 { + let (mut stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut request = Vec::new(); + let header_end = loop { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + if request.ends_with(b"\r\n\r\n") { break request.len(); } + }; + let headers = String::from_utf8_lossy(&request).to_lowercase(); + assert!(headers.contains("authorization: bearer test-key")); + let len: usize = headers.lines().find_map(|l| l.strip_prefix("content-length: ")).unwrap().parse().unwrap(); + request.resize(header_end + len, 0); + stream.read_exact(&mut request[header_end..]).unwrap(); + bodies.push(serde_json::from_slice::(&request[header_end..]).unwrap()); + let (status, body) = if index == 1 { ("401 Unauthorized", json!({"error":{"message":"Invalid test-key"}})) } else { ("200 OK", response()) }; + let body = body.to_string(); + write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + } + bodies + }); + let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + runtime.block_on(async { + let chat = Chat::default(); + assert_eq!(send_to(&chat, DEFAULT_MODEL, "Hola".into(), None, "test-key", &endpoint).await.unwrap().text, "Hola"); + let error = send_to(&chat, DEFAULT_MODEL, "failed turn".into(), None, "test-key", &endpoint).await.err().unwrap(); + assert!(!error.contains("test-key")); + send_to(&chat, DEFAULT_MODEL, "Continue".into(), None, "test-key", &endpoint).await.unwrap(); + }); + let bodies = worker.join().unwrap(); + assert_eq!(bodies[0]["store"], false); + assert!(bodies[0].get("previous_response_id").is_none()); + assert_eq!(bodies[2]["input"].as_array().unwrap().len(), 4); + assert_eq!(bodies[2]["input"][1]["encrypted_content"], "opaque"); + assert!(!bodies[2].to_string().contains("failed turn")); + } +} diff --git a/windows/src-tauri/src/pipe.rs b/windows/src-tauri/src/pipe.rs index 36b6316c2..0203735c8 100644 --- a/windows/src-tauri/src/pipe.rs +++ b/windows/src-tauri/src/pipe.rs @@ -33,14 +33,15 @@ use tokio::sync::mpsc; use crate::island::WINDOW_LABEL; use crate::log; -/// Slightly under coucou-hook's own 110 s wait, so we always answer first. -const DECISION_TIMEOUT: Duration = Duration::from_secs(108); +/// Leave room inside the relay's 110 s total for its 2 s preparation and ACK. +const DECISION_TIMEOUT: Duration = Duration::from_secs(106); /// How long the island gets to say "the card is up". This is the whole of B4: /// without it, an island that is paused, hidden behind a crashed webview or /// simply not listening would leave Claude Code staring at a prompt nobody can /// see for nearly two minutes. const ACK_TIMEOUT: Duration = Duration::from_millis(800); const MAX_PAYLOAD: usize = 1 << 20; +const READ_TIMEOUT: Duration = Duration::from_secs(2); /// What the island can say about a permission request. pub enum Reply { @@ -48,7 +49,7 @@ pub enum Reply { Ack, /// A human clicked: `allow` or `deny`. Decision(String), - /// Nobody can act on it — paused, or another request already holds the card. + /// Nobody can act on it — paused, or another request already holds the card. Decline, } @@ -58,7 +59,7 @@ pub struct Pending(pub Mutex>>); static COUNTER: AtomicU64 = AtomicU64::new(1); -/// `\\.\pipe\coucou-` — must match coucou-hook's `pipe_path()` exactly. +/// `\\.\pipe\coucou-` — must match coucou-hook's `pipe_path()` exactly. #[cfg(windows)] pub fn pipe_name() -> String { let key = crate::platform::current_user_sid() @@ -166,29 +167,9 @@ impl Relay for NamedPipeServer { impl Relay for tokio::net::UnixStream {} async fn handle(app: AppHandle, mut pipe: impl Relay) { - let mut buf = Vec::new(); - let mut chunk = [0u8; 4096]; - loop { - match pipe.read(&mut chunk).await { - Ok(0) => break, - Ok(n) => { - buf.extend_from_slice(&chunk[..n]); - if buf.contains(&b'\n') || buf.len() > MAX_PAYLOAD { - break; - } - } - Err(_) => return, - } - } - let line = match buf.iter().position(|b| *b == b'\n') { - Some(i) => &buf[..i], - None => &buf[..], - }; - let Ok(mut payload) = serde_json::from_slice::(line) else { return }; - if !payload.is_object() { + let Ok(Some(mut payload)) = tokio::time::timeout(READ_TIMEOUT, read_payload(&mut pipe)).await else { return; - } - + }; let event = payload .get("hook_event_name") .and_then(Value::as_str) @@ -214,9 +195,9 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) { let decision = wait_for_decision(&id, &mut rx).await; app.state::().0.lock().unwrap().remove(&id); + // A card must stop accepting clicks as soon as its request is no longer live. + let _ = app.emit_to(WINDOW_LABEL, "approval-ended", json!({ "request_id": id })); - // No decision: say nothing at all. coucou-hook then writes nothing to stdout - // and Claude Code asks in the terminal, exactly as if Coucou were closed. if let Some(d) = decision { let _ = pipe.write_all(format!("{d}\n").as_bytes()).await; let _ = pipe.flush().await; @@ -224,6 +205,44 @@ async fn handle(app: AppHandle, mut pipe: impl Relay) { pipe.finish(); } +/// Reject incomplete and oversized frames instead of presenting partial approvals. +async fn read_payload(pipe: &mut (impl tokio::io::AsyncRead + Unpin)) -> Option { + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + match pipe.read(&mut chunk).await { + Ok(0) => return None, + Ok(n) => { + buf.extend_from_slice(&chunk[..n]); + if buf.len() > MAX_PAYLOAD { + return None; + } + if buf.contains(&b'\n') { + break; + } + } + Err(_) => return None, + } + } + let line = match buf.iter().position(|b| *b == b'\n') { + Some(i) => &buf[..i], + None => &buf[..], + }; + let mut payload = serde_json::from_slice::(line).ok()?; + if !payload.is_object() { + return None; + } + // Keep upstream's third-party routing contract. Invalid tags fall back to + // Claude; valid external agents are displayed but cannot approve requests. + let valid_agent = payload.get("coucou_agent").and_then(Value::as_str) + .is_some_and(|name| !name.is_empty() && name.len() <= 24 + && name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')); + if !valid_agent { + payload.as_object_mut()?.remove("coucou_agent"); + } + Some(payload) +} + /// Two waits: a short one for "the card is up", then the long one for a human. async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option { match tokio::time::timeout(ACK_TIMEOUT, rx.recv()).await { @@ -234,12 +253,12 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option { - log::line(format!("hook id={id} not shown — terminal takes over")); + log::line(format!("hook id={id} not shown — terminal takes over")); return None; } Ok(None) => return None, Err(_) => { - log::line(format!("hook id={id} island never acknowledged — terminal takes over")); + log::line(format!("hook id={id} island never acknowledged — terminal takes over")); return None; } } @@ -254,7 +273,7 @@ async fn wait_for_decision(id: &str, rx: &mut mpsc::Receiver) -> Option { - log::line(format!("hook id={id} timed out — terminal takes over")); + log::line(format!("hook id={id} timed out — terminal takes over")); None } } @@ -270,7 +289,7 @@ fn send(app: &AppHandle, request_id: &str, reply: Reply, keep: bool) { Some(tx) => { let _ = tx.try_send(reply); } - None => log::line(format!("reply for id={request_id} — no pending request")), + None => log::line(format!("reply for id={request_id} — no pending request")), } } @@ -279,7 +298,7 @@ pub fn acknowledge(app: &AppHandle, request_id: &str) { send(app, request_id, Reply::Ack, true); } -/// Nobody can act on this one — paused, or another card already holds the view. +/// Nobody can act on this one — paused, or another card already holds the view. pub fn decline(app: &AppHandle, request_id: &str) { log::line(format!("decline id={request_id}")); send(app, request_id, Reply::Decline, false); @@ -295,3 +314,56 @@ pub fn answer(app: &AppHandle, request_id: &str, decision: &str) { log::line(format!("decision id={request_id} {word}")); send(app, request_id, Reply::Decision(word.to_string()), false); } + +#[cfg(test)] +mod tests { + use super::*; + + fn read(bytes: &[u8]) -> Option { + tokio::runtime::Builder::new_current_thread().build().unwrap() + .block_on(read_payload(&mut &bytes[..])) + } + + #[test] + fn complete_frames_preserve_permission_arguments() { + let payload = json!({ + "coucou_agent": "codex", "hook_event_name": "PermissionRequest", + "tool_input": { "command": "echo first\necho second", "description": "two lines" } + }); + let frame = format!("{payload}\n"); + assert_eq!(read(frame.as_bytes()), Some(payload)); + assert!(read(b"{\"hook_event_name\":\"Stop\"}\n").is_some()); + } + + #[test] + fn invalid_incomplete_or_oversized_frames_are_rejected() { + assert!(read(b"{} ").is_none()); + assert!(read(b"[]\n").is_none()); + assert!(read(b"{broken}\n").is_none()); + let oversized = format!("{{\"command\":\"{}\"}}\n", "x".repeat(MAX_PAYLOAD)); + assert!(read(oversized.as_bytes()).is_none()); + } + + #[test] + fn third_party_tags_route_and_invalid_tags_fall_back_to_claude() { + let payload = read(b"{\"coucou_agent\":\"my-tool\"}\n").unwrap(); + assert_eq!(payload["coucou_agent"], "my-tool"); + for frame in [b"{\"coucou_agent\":null}\n".as_slice(), + b"{\"coucou_agent\":\"Invalid_name\"}\n", + b"{\"coucou_agent\":\"abcdefghijklmnopqrstuvwxyz\"}\n"] { + assert_eq!(read(frame), Some(json!({}))); + } + } + + #[test] + fn a_declined_or_closed_request_does_not_approve() { + let runtime = tokio::runtime::Builder::new_current_thread().enable_time().build().unwrap(); + runtime.block_on(async { + let (tx, mut rx) = mpsc::channel(4); + tx.send(Reply::Decline).await.unwrap(); + assert_eq!(wait_for_decision("test", &mut rx).await, None); + drop(tx); + assert_eq!(wait_for_decision("test", &mut rx).await, None); + }); + } +} diff --git a/windows/src-tauri/src/secrets.rs b/windows/src-tauri/src/secrets.rs index 5c37b6529..ba1c8a4e7 100644 --- a/windows/src-tauri/src/secrets.rs +++ b/windows/src-tauri/src/secrets.rs @@ -8,6 +8,7 @@ const SERVICE: &str = "fr.louisraille.coucou"; /// Every key Coucou may store. Anything outside this list is refused. pub const KNOWN_KEYS: &[&str] = &[ "anthropic-api-key", + "openai-api-key", "n8n-url", "n8n-api-key", "vercel-token", diff --git a/windows/src-tauri/src/settings.rs b/windows/src-tauri/src/settings.rs index 2a8d7d359..49efcfa89 100644 --- a/windows/src-tauri/src/settings.rs +++ b/windows/src-tauri/src/settings.rs @@ -20,6 +20,23 @@ pub struct Settings { /// Defaulted explicitly so a settings.json written by an older build still loads. #[serde(default = "default_model")] pub model: String, + #[serde(default)] + pub chat_provider: crate::chat::Provider, + #[serde(default = "default_openai_model")] + pub openai_model: String, +} + +fn default_openai_model() -> String { + crate::openai::DEFAULT_MODEL.to_string() +} + +impl Settings { + pub fn chat_model(&self) -> &str { + match self.chat_provider { + crate::chat::Provider::Claude => &self.model, + crate::chat::Provider::Openai => &self.openai_model, + } + } } fn default_model() -> String { @@ -43,10 +60,31 @@ impl Default for Settings { autostart: false, hooks_installed: false, model: default_model(), + chat_provider: crate::chat::Provider::default(), + openai_model: default_openai_model(), } } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn older_settings_keep_claude_and_existing_preferences() { + let mut value = serde_json::to_value(Settings::default()).unwrap(); + value.as_object_mut().unwrap().remove("chatProvider"); + value.as_object_mut().unwrap().remove("openaiModel"); + value["model"] = "existing-claude-model".into(); + value["soundVolume"] = 0.05.into(); + let settings: Settings = serde_json::from_value(value).unwrap(); + assert_eq!(settings.chat_provider, crate::chat::Provider::Claude); + assert_eq!(settings.chat_model(), "existing-claude-model"); + assert_eq!(settings.sound_volume, 0.05); + assert_eq!(settings.openai_model, crate::openai::DEFAULT_MODEL); + } +} + pub use crate::platform::{config_dir, local_dir}; pub fn hook_exe_path() -> PathBuf { diff --git a/windows/src/core/bridge.ts b/windows/src/core/bridge.ts index 24aaf55ff..e4f7846fa 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -62,22 +62,23 @@ export const Bridge = { /** Writes to %LOCALAPPDATA%\Coucou\coucou.log, next to the Rust lines. */ log: (message: string) => call("log_line", { message }), - // ── Claude Code hooks ───────────────────────────────────────────────────── - hooksStatus: () => call("hooks_status"), + // ── Coding agent hooks ──────────────────────────────────────────────────── + hooksStatus: (agent: HookAgent = "claude") => call("hooks_status", { agent }), /** Diff to show before anything is written. `install: false` previews removal. */ - hooksPreview: (install: boolean) => callOrThrow("hooks_preview", { install }), + hooksPreview: (install: boolean, agent: HookAgent = "claude") => + callOrThrow("hooks_preview", { install, agent }), /** - * Writes ~/.claude/settings.json — only ever after an explicit click, and only + * Writes the selected agent's hooks file only after an explicit click, and only * when the file still matches the preview the user looked at. */ - hooksApply: (install: boolean, fingerprint: string) => - callOrThrow("hooks_apply", { install, fingerprint }), + hooksApply: (install: boolean, fingerprint: string, agent: HookAgent = "claude") => + callOrThrow("hooks_apply", { install, fingerprint, agent }), approvalDecision: (requestId: string, decision: "allow" | "deny") => call("approval_decision", { requestId, decision }), /** "The card is up" — until this lands the relay only waits a moment. */ approvalAck: (requestId: string) => call("approval_ack", { requestId }), - /** "Nobody can act on this" — Claude Code asks in the terminal right away. */ + /** "Nobody can act on this" — the coding agent asks in the terminal right away. */ approvalDecline: (requestId: string) => call("approval_decline", { requestId }), // ── Chat, files, secrets ────────────────────────────────────────────────── @@ -118,11 +119,14 @@ export interface DroppedFile { size: number; } +export type HookAgent = "claude" | "codex"; + export interface HookStatus { installed: boolean; settingsPath: string; hookPath: string; hookReady: boolean; + error?: string | null; } export interface HookPreview { diff --git a/windows/src/core/layout.ts b/windows/src/core/layout.ts index 6f70eea4c..5e0885783 100644 --- a/windows/src/core/layout.ts +++ b/windows/src/core/layout.ts @@ -69,7 +69,7 @@ export const WAKE_STRIP_H = 6; export const VIEW_LAYOUTS: Record = { overview: { height: 160, botX: 68, botY: null, botDiameter: 58, agentMode: "pills" }, empty: { height: 160, botX: 70, botY: null, botDiameter: 62, agentMode: "none" }, - approval: { height: 160, botX: 62, botY: null, botDiameter: 56, agentMode: "column" }, + approval: { height: 300, botX: 62, botY: null, botDiameter: 56, agentMode: "column" }, question: { height: 160, botX: 62, botY: null, botDiameter: 56, agentMode: "column" }, error: { height: 160, botX: 62, botY: null, botDiameter: 58, agentMode: "column" }, finished: { height: 160, botX: 62, botY: null, botDiameter: 58, agentMode: "column" }, diff --git a/windows/src/core/state.ts b/windows/src/core/state.ts index 01236b80a..5e224f7cd 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -3,7 +3,16 @@ import type { BotEmoteName, BotStateName, IslandMode, IslandViewName } from "./layout"; import type { EyeShape } from "../mochi/engine"; -export type AgentSource = "claudeCode" | "n8n" | "agent"; +export type AgentSource = "claudeCode" | "codex" | "n8n" | "agent"; + +export function isCodingAgent(task: AgentTask): boolean { + return task.source === "claudeCode" || task.source === "codex" || task.source === "agent"; +} + +export function agentLabel(task: AgentTask | null): string { + if (task?.source === "agent") return task.name; + return task?.source === "codex" ? "Codex" : task?.source === "n8n" ? "n8n" : "Claude Code"; +} export type PillBadge = "approval" | "finished" | "error"; export interface AgentTask { @@ -19,10 +28,12 @@ export interface AgentTask { miniEye?: EyeShape | null; pillBadge?: PillBadge | null; sessionCwd?: string | null; + sessionId?: string; } export interface ApprovalInfo { requestId: string; + taskId: string; sessionId: string; tool: string; command: string; @@ -59,6 +70,7 @@ const task = ( /** AgentTask.integrationAgents — same ids, names and colours as macOS. */ export const INTEGRATION_AGENTS: AgentTask[] = [ task("integration_claude", "VS Code", "#F5F6F8", "claudeCode"), + task("integration_codex", "Codex", "#A8DCCB", "codex"), task("integration_resend", "Resend", "#22C55E", "n8n"), task("integration_n8n", "n8n", "#F29B38", "n8n"), task("integration_vercel", "Vercel", "#7C5CFF", "n8n"), @@ -92,6 +104,8 @@ export interface Settings { hooksInstalled: boolean; /** Claude model used by the chat. */ model: string; + chatProvider: "claude" | "openai"; + openaiModel: string; } export const DEFAULT_SETTINGS: Settings = { @@ -106,6 +120,8 @@ export const DEFAULT_SETTINGS: Settings = { autostart: false, hooksInstalled: false, model: "claude-opus-5", + chatProvider: "claude", + openaiModel: "gpt-4.1-mini", }; type Listener = () => void; @@ -136,6 +152,7 @@ class AppState { noteMessage: string | null = null; searchResult: SearchResult | null = null; chatHistory: ChatMessage[] = []; + chatGeneration = 0; pendingApproval: ApprovalInfo | null = null; integrations: Record = {}; @@ -199,19 +216,20 @@ class AppState { this.notify(); } - /** loadIntegrationTasks() — VS Code always on, the rest opt-in (max 4). */ + /** Coding agents are always available; API integrations remain opt-in. */ loadIntegrationTasks() { for (const proto of INTEGRATION_AGENTS) { const shouldLoad = - proto.id === "integration_claude" || this.settings.activeIntegrations.includes(proto.id); + isCodingAgent(proto) || this.settings.activeIntegrations.includes(proto.id); const idx = this.tasks.findIndex((t) => t.id === proto.id); if (shouldLoad && idx < 0) this.tasks.push({ ...proto, steps: [] }); if (!shouldLoad && idx >= 0) this.tasks.splice(idx, 1); } - // Order: integration_claude first, then agent_* pills (visible in slice(0,4)), + // Order: integration_claude first, then dynamic agent_* pills, // then other integrations in declaration order. const order = INTEGRATION_AGENTS.map((t) => t.id); this.tasks.sort((a, b) => { + if (a.id === b.id) return 0; const isAgentA = a.id.startsWith("agent_"); const isAgentB = b.id.startsWith("agent_"); // integration_claude always first @@ -236,8 +254,7 @@ class AppState { this.notify(); } - /** Creates a dynamic agent_ pill on first event; no-ops if it already exists. - * Inserted right after integration_claude so it appears in the visible slice(0,4). */ + /** Creates a dynamic agent_ pill on first event, directly after Claude Code. */ upsertExternalAgent(id: string, name: string, color: string) { if (this.tasks.some((t) => t.id === id)) return; const at = this.tasks.findIndex((t) => t.id === "integration_claude") + 1; @@ -251,7 +268,7 @@ class AppState { } toggleIntegration(id: string) { - if (id === "integration_claude") return; + if (id === "integration_claude" || id === "integration_codex") return; const active = this.settings.activeIntegrations; if (active.includes(id)) { this.settings.activeIntegrations = active.filter((x) => x !== id); diff --git a/windows/src/island/hooks.ts b/windows/src/island/hooks.ts index d90a78f09..f04ae365d 100644 --- a/windows/src/island/hooks.ts +++ b/windows/src/island/hooks.ts @@ -1,335 +1,342 @@ -// Claude Code hook events → island state. -// Port of HookServer.processEvent / processPermissionRequest from the macOS app. -// Difference from macOS: no terminal filter. On Windows the hook fires from any -// terminal (Windows Terminal, VS Code, PowerShell…) and all of them are handled. - -import { Bridge, onEvent } from "../core/bridge"; +// Local coding-agent hook events → independent sessions and approval ownership. +import { Bridge, onEvent, type HookAgent } from "../core/bridge"; +import type { BotStateName } from "../core/layout"; import { Sound } from "../core/sound"; -import { State } from "../core/state"; +import { State, type ApprovalInfo } from "../core/state"; import type { Island } from "./island"; -const CLAUDE_ID = "integration_claude"; - -/** Clears the approval card if no decision was made before the hook gave up. */ -let pendingTimeout: number | null = null; - -interface HookPayload { +export interface HookPayload { + coucou_agent?: string; hook_event_name?: string; request_id?: string; session_id?: string; + turn_id?: string; cwd?: string; message?: string; - /** UserPromptSubmit carries `prompt`; `message` belongs to Notification/Stop. */ prompt?: string; tool_name?: string; - tool_input?: Record; - /** Optional agent tag: lowercase, digits and hyphens, ≤ 24 chars. */ - coucou_agent?: string; + tool_input?: unknown; + /** Original argument JSON preserves large numbers and every displayed byte. */ + coucou_tool_input_json?: string; + permission_mode?: string; } -/** Same rule as HookServer.validateAgent on macOS. "claude" is reserved. */ -function validateAgent(raw: string | undefined): string | null { - if (!raw || raw.length > 24 || raw === "claude") return null; - if (!/^[a-z0-9-]+$/.test(raw)) return null; - return raw; +const TASK_IDS = { claude: "integration_claude", codex: "integration_codex" } as const; +const NAMES = { claude: "VS Code", codex: "Codex" } as const; +/** Preserve the upstream tag contract; Codex has its own first-class provider. */ +function validateAgent(raw: string | undefined): string { + return typeof raw === "string" && /^[a-z0-9-]{1,24}$/.test(raw) ? raw : "claude"; +} +function isExternalAgent(agent: string): boolean { + return agent !== "claude" && agent !== "codex"; +} +function taskIdFor(agent: string): string { + return agent === "claude" || agent === "codex" ? TASK_IDS[agent] : `agent_${agent}`; +} +function nameFor(agent: string): string { + return agent === "claude" || agent === "codex" ? NAMES[agent] : agent; } - const FALLBACK_COLORS = ["#22C55E", "#EAB308", "#60A5FA", "#E879F9"]; - function agentColor(name: string): string { - let h = 0; - for (let i = 0; i < name.length; i++) { - h = (Math.imul(31, h) + name.charCodeAt(i)) | 0; - } - return FALLBACK_COLORS[Math.abs(h) % FALLBACK_COLORS.length]; + let hash = 0; + for (let i = 0; i < name.length; i++) hash = (Math.imul(31, hash) + name.charCodeAt(i)) | 0; + return FALLBACK_COLORS[Math.abs(hash) % FALLBACK_COLORS.length]; } - const PROJECT_ALIASES: Record = { - "notch-buddy": "Notch Buddy", - notchbuddy: "Notch Buddy", - notch_buddy: "Notch Buddy", + "notch-buddy": "Notch Buddy", notchbuddy: "Notch Buddy", notch_buddy: "Notch Buddy", }; - -function aliasProjectName(name: string): string { - return PROJECT_ALIASES[name.toLowerCase()] ?? name; -} - -function lastPathComponent(p: string): string { - const cleaned = p.replace(/[\\/]+$/, ""); - const idx = Math.max(cleaned.lastIndexOf("\\"), cleaned.lastIndexOf("/")); - return idx >= 0 ? cleaned.slice(idx + 1) : cleaned; +function lastPathComponent(path: string): string { + return path.replace(/[\\/]+$/, "").split(/[\\/]/).at(-1) ?? ""; } - -/** frenchStep() — same labels as the macOS app. */ const TOOL_LABELS: Record = { - Bash: "Exécute", - Read: "Lit", - Write: "Écrit", - Edit: "Modifie", - Glob: "Cherche", - Grep: "Recherche", - WebSearch: "Recherche web", - WebFetch: "Récupère", - TodoWrite: "Tâches", - Task: "Agent", - LS: "Liste", - MultiEdit: "Modifie", - NotebookEdit: "Notebook", - PowerShell: "Exécute", + Bash: "Exécute", Read: "Lit", Write: "Écrit", Edit: "Modifie", Glob: "Cherche", + Grep: "Recherche", WebSearch: "Recherche web", WebFetch: "Récupère", TodoWrite: "Tâches", + Task: "Agent", LS: "Liste", MultiEdit: "Modifie", NotebookEdit: "Notebook", PowerShell: "Exécute", }; - -function stepLabel(tool: string, input: Record): string { +function inputRecord(input: unknown): Record { + return input && typeof input === "object" && !Array.isArray(input) + ? input as Record : {}; +} +function stepLabel(tool: string, input: unknown): string { const label = TOOL_LABELS[tool] ?? tool; - const str = (k: string) => (typeof input[k] === "string" ? (input[k] as string) : null); + const record = inputRecord(input); + const str = (k: string) => typeof record[k] === "string" ? record[k] as string : null; const cmd = str("command"); if (cmd) return `${label} · ${cmd.slice(0, 40)}`; - const path = str("path"); + const path = str("path") ?? str("file_path"); if (path) return `${label} · ${lastPathComponent(path)}`; - const file = str("file_path"); - if (file) return `${label} · ${lastPathComponent(file)}`; const query = str("query"); - if (query) return `${label} · ${query.slice(0, 40)}`; - return label; + return query ? `${label} · ${query.slice(0, 40)}` : label; } - -/** - * What the Allow button actually authorises. Approving "Write" tells you nothing - * — approving `Write · C:\…\.env` tells you everything, and the difference is - * the whole point of approving from the island rather than blind. - * - * Ordered by how specific the field is, so an unfamiliar tool still shows - * whatever identifying string it carries instead of falling back to its name. - */ -const APPROVAL_FIELDS = [ - "command", // Bash, PowerShell - "file_path", // Write, Edit, MultiEdit, NotebookEdit - "path", // Read, LS - "url", // WebFetch - "query", // WebSearch - "pattern", // Glob, Grep - "prompt", // Task -] as const; - -function approvalTarget(tool: string, input: Record): string { - for (const field of APPROVAL_FIELDS) { +function approvalTarget(agent: HookAgent, payload: HookPayload): string { + const tool = payload.tool_name ?? "Tool"; + if (agent === "codex") { + // Keep exact MCP scalar/array/object arguments; the card scrolls, not truncates. + const cwd = payload.cwd ? `\nWorking directory: ${payload.cwd}` : ""; + const session = payload.session_id ? `\nSession: ${payload.session_id}` : ""; + const mode = payload.permission_mode ? `\nPermission mode: ${payload.permission_mode}` : ""; + return `${tool}${cwd}${session}${mode}\n${payload.coucou_tool_input_json}`; + } + const input = inputRecord(payload.tool_input); + for (const field of ["command", "file_path", "path", "url", "query", "pattern", "prompt"]) { const value = input[field]; - if (typeof value === "string" && value.trim()) { - return `${tool} · ${value.trim()}`; - } + if (typeof value === "string" && value.trim()) return `${tool} · ${value.trim()}`; } return tool; } - -function upsert(projectName: string, cwd: string) { - const t = State.tasks.find((x) => x.id === CLAUDE_ID); - if (!t) return; - t.name = projectName; - if (cwd) t.sessionCwd = cwd; -} - -function clearSession() { - const t = State.tasks.find((x) => x.id === CLAUDE_ID); - if (!t) return; - t.steps = []; - t.stepIndex = 0; - t.name = "VS Code"; - t.pillBadge = null; -} - -export function registerHookHandlers(island: Island) { - void onEvent("hook", (payload) => handleHook(island, payload)); +interface Session { + agent: string; + id: string; + name: string; + cwd: string | null; + state: BotStateName; + steps: string[]; + phase: "active" | "stopped" | "ended"; + generation: number; + turnId?: string; + closedTurns: Set; } - -function handleHook(island: Island, payload: HookPayload) { - if (State.paused) { - // Silence here used to cost Claude Code nearly two minutes: the relay waited - // for a decision from an island that had already decided not to look. Say so, - // and the terminal takes the question immediately. - if (payload.request_id) void Bridge.approvalDecline(payload.request_id); - return; - } - - const name = payload.hook_event_name ?? ""; - const cwd = payload.cwd ?? ""; - const raw = lastPathComponent(cwd); - const projectName = aliasProjectName(raw || "Session"); - - // Route to the right pill. Valid coucou_agent → dynamic "agent_" pill. - // "claude" is reserved; absent or invalid → Claude Code pill unchanged. - const validAgent = validateAgent(payload.coucou_agent); - const agentId = validAgent ? `agent_${validAgent}` : CLAUDE_ID; - const isExternalAgent = validAgent !== null; - - const focused = State.focusId === agentId; - - /** Alerts force the island open; work events only reveal the compact island. */ - const surface = (view: Parameters[0], isAlert: boolean) => { - if (State.mode === "expanded") { - if (isAlert) island.setView(view); - } else if (isAlert) { - island.alert(view); - } else if (State.mode === "hidden") { - island.reveal(); - } +type HookIsland = Pick; + +/** Timers/session ownership belong to this island, rather than global hook state. */ +export function createHookHandlers(island: HookIsland) { + const sessions = new Map(); + const selected = new Map(); + let pendingTimeout: number | null = null; + let activeApproval: ApprovalInfo | null = null; + const append = (session: Session, text: string) => { + session.steps.push(text); + if (session.steps.length > 20) session.steps.shift(); }; - - /** Ensure the agent pill exists (no-op for Claude Code). */ - const ensurePill = () => { - if (isExternalAgent) { - State.upsertExternalAgent(agentId, validAgent!, agentColor(validAgent!)); - } else { - upsert(projectName, cwd); + const paint = (session: Session) => { + if (selected.get(session.agent) !== session) return; + const task = State.tasks.find((t) => t.id === taskIdFor(session.agent)); + if (!task) return; + task.name = session.name; + task.sessionId = session.id; + task.sessionCwd = session.cwd; + task.state = session.state; + task.steps = [...session.steps]; + task.stepIndex = Math.max(0, task.steps.length - 1); + }; + const clearApproval = (requestId: string, decline = false) => { + const pending = activeApproval; + if (!pending || pending.requestId !== requestId) return; + activeApproval = null; + if (decline) void Bridge.approvalDecline(requestId); + if (pendingTimeout != null) window.clearTimeout(pendingTimeout); + pendingTimeout = null; + State.pendingApproval = null; + State.isPinned = false; + island.dropPin(); + const session = [...sessions.values()].find((s) => + taskIdFor(s.agent) === pending.taskId && s.id === pending.sessionId); + if (session?.state === "approval") { + session.state = session.phase === "active" ? "working" : "idle"; + paint(session); } + State.setPillBadge(pending.taskId, null); + if (State.view === "approval") island.setView(State.defaultView()); + State.notify(); }; - - switch (name) { - case "SessionStart": - ensurePill(); - surface("overview", false); - Sound.play("work"); - break; - - case "UserPromptSubmit": { - ensurePill(); - State.updateTask(agentId, "thinking"); - // The field is `prompt`; reading `message` meant this step was always blank. - const asked = payload.prompt ?? payload.message; - if (asked) State.appendStep(agentId, asked.slice(0, 60)); - surface("overview", false); - break; + const surface = (agent: string, view: Parameters[0], alert: boolean) => { + if (alert && State.focusId !== taskIdFor(agent)) return; + if (State.pendingApproval && view !== "approval") return; + if (State.mode === "expanded") { + if (alert) island.setView(view); + } else if (alert) island.alert(view); + else if (State.mode === "hidden") island.reveal(); + }; + const handle = (payload: HookPayload) => { + // A local click clears the shared card before the backend completion event. + if (activeApproval && !State.pendingApproval) clearApproval(activeApproval.requestId); + const agent = validateAgent(payload.coucou_agent); + const external = isExternalAgent(agent); + if (State.paused || (agent === "codex" && !payload.session_id)) { + if (payload.request_id) void Bridge.approvalDecline(payload.request_id); + return; } - - case "PreToolUse": { - ensurePill(); - State.updateTask(agentId, "working"); - const tool = payload.tool_name ?? "Tool"; - State.appendStep(agentId, stepLabel(tool, payload.tool_input ?? {})); - surface("overview", false); - break; + const event = payload.hook_event_name ?? ""; + const taskId = taskIdFor(agent); + const sessionId = payload.session_id ?? "legacy"; + const key = `${agent}:${sessionId}`; + const requestId = payload.request_id ?? ""; + const isPermission = event === "PermissionRequest"; + if (isPermission) { + // Generic tags retain activity-only support; their terminal owns approval. + if (external || !requestId || (agent === "codex" && !payload.coucou_tool_input_json) + || (State.pendingApproval && State.pendingApproval.requestId !== requestId)) { + if (requestId) void Bridge.approvalDecline(requestId); + return; + } + // A retry must not extend the lifetime or change the displayed arguments. + if (State.pendingApproval?.requestId === requestId) return; } - - case "PostToolUse": - State.updateTask(agentId, "working"); - break; - - case "PostToolUseFailure": - State.updateTask(agentId, "working"); - State.appendStep(agentId, "⚠ failed"); - break; - - case "Notification": { - const message = payload.message ?? ""; - const lower = message.toLowerCase(); - if (lower.includes("rate limit") || lower.includes("limite d")) { - State.updateTask(agentId, "ratelimit"); - Sound.play("rate"); - } else if (message.endsWith("?")) { - State.updateTask(agentId, "question"); - State.appendStep(agentId, message); + let session = sessions.get(key); + const startsTurn = event === "SessionStart" || event === "UserPromptSubmit"; + if (session && !startsTurn) { + const post = event === "PostToolUse" || event === "PostToolUseFailure"; + const lateTurn = payload.turn_id && payload.turn_id === session.turnId; + const terminal = event === "SessionEnd" || event === "Interrupt" || event === "StopFailure"; + const closedTurn = payload.turn_id && session.closedTurns.has(payload.turn_id); + const otherTurn = payload.turn_id && session.turnId && payload.turn_id !== session.turnId + && !["PreToolUse", "PermissionRequest", "SessionEnd"].includes(event); + if (session.phase === "ended" || (closedTurn && event !== "SessionEnd") || otherTurn + || (session.phase === "stopped" && (post || (lateTurn && !terminal)))) { + if (isPermission && requestId) void Bridge.approvalDecline(requestId); + return; } - break; } - - case "Stop": - State.updateTask(agentId, "finished"); - if (payload.message) State.appendStep(agentId, payload.message.slice(0, 60)); - Sound.play("finish"); - if (focused) surface("finished", true); - else State.setPillBadge(agentId, "finished"); - window.setTimeout(() => { - if (isExternalAgent) { - State.removeTask(agentId); - } else { - State.updateTask(agentId, "idle"); - State.setPillBadge(agentId, null); - } - }, 5200); - break; - - case "StopFailure": - State.updateTask(agentId, "error"); - Sound.play("error"); - if (focused) surface("error", true); - else State.setPillBadge(agentId, "error"); - break; - - case "SessionEnd": - if (isExternalAgent) { - State.removeTask(agentId); - } else { - State.updateTask(agentId, "idle"); - clearSession(); + if (!session) { + session = { agent, id: sessionId, name: nameFor(agent), cwd: null, + state: "idle", steps: [], phase: "active", generation: 0, closedTurns: new Set() }; + sessions.set(key, session); + if (sessions.size > 256) { + const old = [...sessions.entries()].find(([, s]) => selected.get(s.agent) !== s); + if (old) sessions.delete(old[0]); } - break; - - case "SubagentStart": - State.appendStep(agentId, "+ subagent"); - break; - - case "SubagentStop": - State.appendStep(agentId, "• subagent done"); - break; - - case "PermissionRequest": { - // External agents do not get an approval card — showing one would look like - // a Claude Code request. Decline immediately so the agent re-asks in its - // terminal. Approval support for other agents will come with Codex support. - if (isExternalAgent) { - if (payload.request_id) void Bridge.approvalDecline(payload.request_id); + } + if (payload.cwd) { + session.cwd = payload.cwd; + const raw = lastPathComponent(payload.cwd); + if (!external) session.name = (PROJECT_ALIASES[raw.toLowerCase()] ?? raw) || nameFor(agent); + } + const pending = State.pendingApproval; + const ownsApproval = pending?.taskId === taskId && pending.sessionId === sessionId; + const blockedByApproval = pending?.taskId === taskId && !ownsApproval; + const foreground = startsTurn || event === "PreToolUse" || isPermission; + if (external && foreground) State.upsertExternalAgent(taskId, agent, agentColor(agent)); + if ((!selected.has(agent) || foreground) && !blockedByApproval) { + selected.set(agent, session); + if (!ownsApproval) State.setPillBadge(taskId, null); + } + if (payload.turn_id && session.turnId && payload.turn_id !== session.turnId) { + session.closedTurns.add(session.turnId); + if (session.closedTurns.size > 32) session.closedTurns.delete(session.closedTurns.values().next().value!); + } + if (startsTurn || event === "PreToolUse") { + session.phase = "active"; + session.generation++; + session.turnId = payload.turn_id; + } else if (payload.turn_id && session.phase === "active") session.turnId = payload.turn_id; + + switch (event) { + case "SessionStart": + if (!ownsApproval) session.state = "idle"; + surface(agent, "overview", false); + Sound.play("work"); break; - } - - const requestId = payload.request_id ?? ""; - // One card, one request. A second one must never quietly replace the first - // — that would leave a human staring at request B while request A waits for - // a decision nobody can give. Hand it straight back to the terminal. - if (State.pendingApproval && State.pendingApproval.requestId !== requestId) { - if (requestId) void Bridge.approvalDecline(requestId); + case "UserPromptSubmit": + if (!ownsApproval) session.state = "thinking"; + if (agent === "codex") append(session, "Working on your request"); + else if (payload.prompt ?? payload.message) append(session, (payload.prompt ?? payload.message)!.slice(0, 60)); + surface(agent, "overview", false); + break; + case "PreToolUse": + if (!ownsApproval) session.state = "working"; + append(session, agent === "codex" ? payload.tool_name ?? "Tool" : stepLabel(payload.tool_name ?? "Tool", payload.tool_input)); + surface(agent, "overview", false); + break; + case "PostToolUse": + case "PostToolUseFailure": + if (!ownsApproval) session.state = "working"; + if (event === "PostToolUseFailure") append(session, "⚠ failed"); + break; + case "Notification": { + const message = payload.message ?? ""; + if (/rate limit|limite d/i.test(message)) { + if (!ownsApproval) session.state = "ratelimit"; + Sound.play("rate"); + } else if (message.endsWith("?")) { + if (!ownsApproval) session.state = "question"; + append(session, agent === "codex" ? "Answer in Codex" : message); + } break; } - upsert(projectName, cwd); - if (pendingTimeout != null) window.clearTimeout(pendingTimeout); - const tool = payload.tool_name ?? "Tool"; - const input = payload.tool_input ?? {}; - State.pendingApproval = { - requestId, - sessionId: payload.session_id ?? "", - tool, - command: approvalTarget(tool, input), - }; - // The relay's short ack window closes in 800 ms; everything below this - // line is synchronous, so the card really is up by the time it lands. - if (requestId) void Bridge.approvalAck(requestId); - State.updateTask(CLAUDE_ID, "approval"); - State.isPinned = true; - Sound.play("approval"); - if (focused) { - island.alert("approval"); - } else { - // Another agent holds the view, so the card would yank it away. The badge - // is the signal instead — but it has to be on screen for that to mean - // anything, hence the reveal. We just told the relay a human can act. - State.setPillBadge(CLAUDE_ID, "approval"); - island.reveal(); + case "Stop": { + if (ownsApproval) clearApproval(pending.requestId, true); + session.phase = "stopped"; + session.state = "finished"; + const generation = ++session.generation; + if (agent !== "codex" && payload.message) append(session, payload.message.slice(0, 60)); + if (selected.get(agent) === session) { + Sound.play("finish"); + if (State.focusId === taskId) surface(agent, "finished", true); + else State.setPillBadge(taskId, "finished"); + } + const stopped = session; + window.setTimeout(() => { + if (stopped.generation !== generation || stopped.state !== "finished") return; + stopped.state = "idle"; + if (selected.get(agent) === stopped) { + if (external) { + State.removeTask(taskId); + selected.delete(agent); + return; + } + State.setPillBadge(taskId, null); + paint(stopped); + State.notify(); + } + }, 5200); + break; } - // Coucou answers within 108 s or not at all; after that the terminal has - // taken over and the card would be lying. - pendingTimeout = window.setTimeout(() => { - pendingTimeout = null; - if (!State.pendingApproval) return; - State.pendingApproval = null; - State.isPinned = false; - island.dropPin(); - State.updateTask(CLAUDE_ID, "working"); - State.setPillBadge(CLAUDE_ID, null); - if (State.view === "approval") island.setView(State.defaultView()); - State.notify(); - }, 110_000); - break; + case "StopFailure": + case "Interrupt": + case "SessionEnd": + if (ownsApproval) clearApproval(pending.requestId, true); + session.generation++; + session.phase = event === "SessionEnd" ? "ended" : "stopped"; + session.state = event === "StopFailure" ? "error" : "idle"; + if (event === "Interrupt") append(session, "Interrupted"); + if (event === "SessionEnd") { + session.name = nameFor(agent); + session.cwd = null; + session.steps = []; + } + if (selected.get(agent) === session) { + if (event === "SessionEnd" && external) { + State.removeTask(taskId); + selected.delete(agent); + break; + } + State.setPillBadge(taskId, event === "StopFailure" ? "error" : null); + if (event === "StopFailure") { + Sound.play("error"); + surface(agent, "error", true); + } + } + break; + case "SubagentStart": append(session, "+ subagent"); break; + case "SubagentStop": append(session, "• subagent done"); break; + case "PermissionRequest": + // The generic-provider path returned above; narrowing preserves this invariant. + if (agent !== "claude" && agent !== "codex") return; + session.state = "approval"; + State.pendingApproval = { requestId, taskId, sessionId, + tool: payload.tool_name ?? "Tool", command: approvalTarget(agent, payload) }; + activeApproval = State.pendingApproval; + State.isPinned = true; + paint(session); + Sound.play("approval"); + if (State.focusId === taskId) island.alert("approval"); + else { + State.setPillBadge(taskId, "approval"); + island.reveal(); + } + void Bridge.approvalAck(requestId); + pendingTimeout = window.setTimeout(() => clearApproval(requestId, true), 110_000); + break; + default: return; } + paint(session); + State.notify(); + }; + return { handle, approvalEnded: (requestId: string) => clearApproval(requestId) }; +} - default: - break; - } - State.notify(); +export function registerHookHandlers(island: Island) { + const handlers = createHookHandlers(island); + void onEvent("hook", handlers.handle); + void onEvent<{ request_id: string }>("approval-ended", (payload) => handlers.approvalEnded(payload.request_id)); } diff --git a/windows/src/island/integrations.ts b/windows/src/island/integrations.ts index 862ffb14b..39e3461a0 100644 --- a/windows/src/island/integrations.ts +++ b/windows/src/island/integrations.ts @@ -37,6 +37,13 @@ export async function refreshConfigured() { data: {}, error: null, loaded: false, configured: false, }; State.integrations.integration_claude = { ...claude, configured: hooks }; + const codexStatus = await Bridge.hooksStatus("codex"); + const codex = State.integrations.integration_codex ?? { + data: {}, error: null, loaded: false, configured: false, + }; + State.integrations.integration_codex = { + ...codex, configured: codexStatus?.installed ?? false, error: codexStatus?.error ?? null, + }; State.notify(); } diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index 7fdb61802..d1e879a54 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -10,7 +10,7 @@ import { type IslandMode, type IslandViewName, } from "../core/layout"; import { Sound } from "../core/sound"; -import { State } from "../core/state"; +import { State, isCodingAgent } from "../core/state"; import { BotEngine, hexToRGB } from "../mochi/engine"; import { Greeting } from "../mochi/greeting"; import { createMiniBot, pruneMiniBots, syncMiniBotStates, tickMiniBots } from "../mochi/minibots"; @@ -109,6 +109,8 @@ export class Island { collapse: () => this.collapse(), setFocus: (id) => { State.setFocus(id); + if (State.pendingApproval?.taskId === id) this.alert("approval"); + else if (State.view === "approval") this.setView(State.defaultView()); Sound.play("blip"); }, openTerminal: () => { @@ -127,24 +129,24 @@ export class Island { integration_notion: "https://notion.so", integration_calcom: "https://app.cal.com/bookings", }; - if (task.id === "integration_claude") void Bridge.openInVSCode(task.sessionCwd ?? null); + if (isCodingAgent(task)) void Bridge.openInVSCode(task.sessionCwd ?? null); else if (task.id === "integration_n8n") void Bridge.openN8n(); else if (urls[task.id]) void Bridge.openUrl(urls[task.id]); }, openUrl: (url) => { if (url) void Bridge.openUrl(url); }, - decide: (d) => { + decide: (d, requestId) => { const req = State.pendingApproval; void Bridge.log(`decide ${d} req=${req?.requestId ?? "none"}`); - if (!req) return; + if (!req || req.requestId !== requestId) return; Sound.play(d === "deny" ? "blip" : "approve"); void Bridge.approvalDecision(req.requestId, d); State.pendingApproval = null; State.isPinned = false; this.fsm.pinned = false; - State.updateTask("integration_claude", "working"); - State.setPillBadge("integration_claude", null); + State.updateTask(req.taskId, "working"); + State.setPillBadge(req.taskId, null); this.setView(State.defaultView()); }, toggleSound: () => { @@ -389,6 +391,7 @@ export class Island { State.droppedFile = { name, path }; State.promptContext = { kind: "file", name, path }; State.chatHistory = []; + State.chatGeneration++; void Bridge.chatReset(); UploadSeq.performDrop(State.uploadDuration); diff --git a/windows/src/main.ts b/windows/src/main.ts index f65d5e757..de80cbbf4 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -30,6 +30,7 @@ async function main() { const setPaused = (on: boolean) => { if (State.paused === on) return; State.paused = on; + if (on && State.pendingApproval) void Bridge.approvalDecline(State.pendingApproval.requestId); void Bridge.setPaused(on); }; @@ -55,6 +56,16 @@ async function main() { // The settings window writes preferences; apply them here without a restart. await onEvent("settings-changed", (s) => { + const previous = State.settings; + const model = (v: Settings) => v.chatProvider === "openai" ? v.openaiModel : v.model; + if (s.chatProvider !== previous.chatProvider || model(s) !== model(previous)) { + State.chatGeneration++; + State.chatHistory = []; + State.droppedFile = null; + State.promptContext = null; + State.stateOverride = null; + State.notify(); + } State.settings = { ...State.settings, ...s }; island.applySettings(); State.loadIntegrationTasks(); diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..4726087fe 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -3,7 +3,7 @@ // integrations land here too in a later stage. import "./settings.css"; -import { Bridge, onEvent, type HookStatus } from "../core/bridge"; +import { Bridge, onEvent, type HookAgent, type HookStatus } from "../core/bridge"; import { DEFAULT_SETTINGS, type Settings } from "../core/state"; import { h, clear } from "../views/dom"; @@ -41,25 +41,27 @@ function renderDiff(text: string): HTMLElement { return box; } -// ── Claude Code section ─────────────────────────────────────────────────────── +// ── Coding agent hooks ──────────────────────────────────────────────────────── -function claudeSection(status: HookStatus): HTMLElement { +function hooksSection(agent: HookAgent, status: HookStatus): HTMLElement { + const label = agent === "codex" ? "Codex" : "Claude Code"; + const fileName = agent === "codex" ? "hooks.json" : "settings.json"; const body = h("div", { style: "display:flex;flex-direction:column;gap:12px" }); const section = h( "section", {}, - h("h2", {}, statusDot(status.installed), h("span", { text: "Claude Code" })), + h("h2", {}, statusDot(status.installed), h("span", { text: label })), body, ); const rebuild = async () => { - const fresh = await Bridge.hooksStatus(); - if (fresh) Object.assign(status, fresh); + const fresh = await Bridge.hooksStatus(agent); + if (fresh) Object.assign(status, fresh, { error: fresh.error }); clear(body); draw(); const head = section.querySelector("h2")!; clear(head); - head.append(statusDot(status.installed), h("span", { text: "Claude Code" })); + head.append(statusDot(status.installed), h("span", { text: label })); }; function draw() { @@ -67,11 +69,11 @@ function claudeSection(status: HookStatus): HTMLElement { h("div", { class: "hint", text: status.installed - ? "Coucou is hooked into your Claude Code sessions. Tool calls, questions and permission requests show up in the island, and you can answer them there." - : "Install the hooks to see your Claude Code sessions in the island and approve permissions without leaving what you are doing.", + ? `Coucou hooks are installed for ${label}. Session activity and permission requests can appear in the island.` + : `Install the hooks to see your ${label} sessions in the island and review permission requests.`, }), h("div", { class: "row" }, - h("label", { text: "settings.json" }), + h("label", { text: fileName }), h("span", { class: "path", text: status.settingsPath }), ), h("div", { class: "row" }, @@ -81,6 +83,14 @@ function claudeSection(status: HookStatus): HTMLElement { ), ); + if (agent === "codex") { + body.append(h("div", { + class: "hint", + text: "Requires a Codex version with hooks support enabled. Review and trust Coucou's hooks in Codex with /hooks; installation alone does not enable trust. Activity sends local session metadata and tool names only. Permission requests include their exact arguments so you can review them. The built-in chat uses the API provider selected below.", + })); + } + if (status.error) body.append(h("div", { class: "notice err", text: status.error })); + if (!status.hookReady) { body.append(h("div", { class: "notice warn", @@ -114,7 +124,7 @@ function claudeSection(status: HookStatus): HTMLElement { async function showPreview(install: boolean) { let preview; try { - preview = await Bridge.hooksPreview(install); + preview = await Bridge.hooksPreview(install, agent); } catch (err) { // An unreadable or invalid settings.json stops here rather than being // treated as empty and written over. @@ -134,7 +144,7 @@ function claudeSection(status: HookStatus): HTMLElement { h("div", { class: "hint", text: install - ? "This is exactly what will change in your settings.json. Your own hooks are left untouched." + ? `This is exactly what will change in your ${fileName}. Your own hooks are left untouched.` : "This removes Coucou's entries only. Your own hooks are left untouched.", }), renderDiff(preview.diff), @@ -149,11 +159,11 @@ function claudeSection(status: HookStatus): HTMLElement { confirm.addEventListener("click", async () => { confirm.disabled = true; try { - const backup = await Bridge.hooksApply(install, preview.fingerprint); + const backup = await Bridge.hooksApply(install, preview.fingerprint, agent); clear(body); body.append(h("div", { class: "notice ok", - text: `Done. Previous settings saved as ${backup}. Open a new Claude Code session to pick the hooks up.`, + text: `Done. ${backup ? `Previous settings saved as ${backup}. ` : ""}${agent === "codex" && install ? "Open a new Codex session and review and trust these commands in /hooks." : `Open a new ${label} session to pick up the change.`}`, })); window.setTimeout(() => void rebuild(), 2600); } catch (err) { @@ -171,7 +181,7 @@ function claudeSection(status: HookStatus): HTMLElement { return section; } -// ── Claude API section ──────────────────────────────────────────────────────── +// ── Chat API sections ──────────────────────────────────────────────────────── const MODELS: [string, string][] = [ ["claude-opus-5", "Claude Opus 5"], @@ -179,13 +189,29 @@ const MODELS: [string, string][] = [ ["claude-haiku-4-5", "Claude Haiku 4.5"], ]; -function apiSection(hasKey: boolean): HTMLElement { +function chatProviderSection(): HTMLElement { + const provider = h("select", { "aria-label": "Chat provider" }) as HTMLSelectElement; + provider.append(h("option", { value: "claude", text: "Claude (Anthropic)" }), h("option", { value: "openai", text: "OpenAI" })); + provider.value = settings.chatProvider; + provider.addEventListener("change", () => { + settings.chatProvider = provider.value as Settings["chatProvider"]; + void save(); + }); + return h("section", {}, h("h2", { text: "Chat" }), + h("div", { class: "row" }, h("label", { text: "Provider" }), provider), + h("div", { class: "hint", text: "Changing the provider or active model starts a new conversation and clears the attached file. Messages and attachments go only to the selected provider." })); +} + +function apiSection(provider: Settings["chatProvider"], hasKey: boolean): HTMLElement { + const isOpenAI = provider === "openai"; + const key = isOpenAI ? "openai-api-key" : "anthropic-api-key"; + const keyPlaceholder = isOpenAI ? "sk-..." : "sk-ant-..."; const dot = statusDot(hasKey); - const state = h("span", { class: "hint", text: hasKey ? "Key saved in the Windows Credential Manager." : "No key yet — the chat needs one." }); + const state = h("span", { class: "hint", text: hasKey ? "Key saved in the system credential store." : "No key yet — the chat needs one." }); const field = h("input", { type: "password", - placeholder: hasKey ? "•••••••••••• (stored)" : "sk-ant-...", + placeholder: hasKey ? "•••••••••••• (stored)" : keyPlaceholder, style: "flex:1 1 auto;min-width:0", autocomplete: "off", spellcheck: "false", @@ -196,12 +222,12 @@ function apiSection(hasKey: boolean): HTMLElement { const feedback = h("div", {}); async function refresh() { - const present = (await Bridge.secretPresent("anthropic-api-key")) ?? false; + const present = (await Bridge.secretPresent(key)) ?? false; dot.style.background = present ? "#22c55e" : "#f4505e"; state.textContent = present - ? "Key saved in the Windows Credential Manager." + ? "Key saved in the system credential store." : "No key yet — the chat needs one."; - field.placeholder = present ? "•••••••••••• (stored)" : "sk-ant-..."; + field.placeholder = present ? "•••••••••••• (stored)" : keyPlaceholder; clearBtn.style.display = present ? "" : "none"; } @@ -210,9 +236,9 @@ function apiSection(hasKey: boolean): HTMLElement { if (!value) return; clear(feedback); try { - await Bridge.secretSet("anthropic-api-key", value); + await Bridge.secretSet(key, value); field.value = ""; - feedback.append(h("div", { class: "notice ok", text: "Saved. It never touches disk." })); + feedback.append(h("div", { class: "notice ok", text: "Saved securely in the system credential store." })); await refresh(); } catch (err) { feedback.append(h("div", { class: "notice err", text: `Could not save: ${String(err)}` })); @@ -222,7 +248,7 @@ function apiSection(hasKey: boolean): HTMLElement { clearBtn.addEventListener("click", async () => { clear(feedback); try { - await Bridge.secretClear("anthropic-api-key"); + await Bridge.secretClear(key); feedback.append(h("div", { class: "notice ok", text: "Key removed." })); await refresh(); } catch (err) { @@ -230,26 +256,36 @@ function apiSection(hasKey: boolean): HTMLElement { } }); - const model = h("select", {}) as HTMLSelectElement; - for (const [id, label] of MODELS) model.append(h("option", { value: id, text: label })); - if (!MODELS.some(([id]) => id === settings.model)) { - model.append(h("option", { value: settings.model, text: settings.model })); + let model: HTMLInputElement | HTMLSelectElement; + if (isOpenAI) { + model = h("input", { type: "text", value: settings.openaiModel, placeholder: "gpt-4.1-mini", "aria-label": "OpenAI model", spellcheck: "false" }) as HTMLInputElement; + model.addEventListener("change", () => { + const value = model.value.trim(); + if (!value) { model.value = settings.openaiModel; return; } + settings.openaiModel = value; + void save(); + }); + } else { + model = h("select", { "aria-label": "Claude model" }) as HTMLSelectElement; + for (const [id, label] of MODELS) model.append(h("option", { value: id, text: label })); + if (!MODELS.some(([id]) => id === settings.model)) { + model.append(h("option", { value: settings.model, text: settings.model })); + } + model.value = settings.model; + model.addEventListener("change", () => { settings.model = model.value; void save(); }); } - model.value = settings.model; - model.addEventListener("change", () => { - settings.model = model.value; - void save(); - }); - clearBtn.style.display = hasKey ? "" : "none"; return h( "section", {}, - h("h2", {}, dot, h("span", { text: "Claude" })), + h("h2", {}, dot, h("span", { text: isOpenAI ? "OpenAI" : "Claude" })), state, h("div", { class: "row" }, h("label", { text: "API key" }), field, saveBtn, clearBtn), h("div", { class: "row" }, h("label", { text: "Model" }), model), + h("div", { class: "hint", text: isOpenAI + ? "Use an OpenAI API key and a Responses-compatible model available to your account. Supports text, images and PDFs; live web search is not enabled. API usage has its own billing." + : "Uses the Anthropic API, including web search." }), feedback, ); } @@ -428,8 +464,12 @@ async function main() { const status = (await Bridge.hooksStatus()) ?? { installed: false, settingsPath: "", hookPath: "", hookReady: false, }; + const codexStatus = (await Bridge.hooksStatus("codex")) ?? { + installed: false, settingsPath: "", hookPath: "", hookReady: false, + }; const hasKey = (await Bridge.secretPresent("anthropic-api-key")) ?? false; + const hasOpenAIKey = (await Bridge.secretPresent("openai-api-key")) ?? false; const keys = [ "stripe-api-key", "github-token", "vercel-token", @@ -441,8 +481,11 @@ async function main() { clear(root); root.append( h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), - claudeSection(status), - apiSection(hasKey), + hooksSection("claude", status), + hooksSection("codex", codexStatus), + chatProviderSection(), + apiSection("claude", hasKey), + apiSection("openai", hasOpenAIKey), integrationsSection(present), generalSection(), h("div", { diff --git a/windows/src/style.css b/windows/src/style.css index b2cf6756c..2dfb0fe39 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -518,6 +518,7 @@ body { gap: 4px; align-content: center; padding: 0 8px; + overflow-y: auto; } .pill { @@ -625,6 +626,26 @@ body { user-select: text; } +.approval-code { + min-height: 32px; + max-height: 180px; + white-space: pre-wrap; + overflow: auto; + overflow-wrap: anywhere; + text-overflow: clip; + flex: 1 1 auto; +} + +.stack.approval-stack { + justify-content: flex-start; + gap: 8px; +} + +.approval-stack > :first-child, +.approval-stack > .actions { + flex-shrink: 0; +} + .stack { position: relative; z-index: 1; diff --git a/windows/src/views/chat.ts b/windows/src/views/chat.ts index 2cd253f7b..f80167051 100644 --- a/windows/src/views/chat.ts +++ b/windows/src/views/chat.ts @@ -63,6 +63,7 @@ export function buildPrompt(onHeightChange: () => void): ViewHost { if (!query || sending) return; input.value = ""; sending = true; + const generation = State.chatGeneration; Sound.play("send"); State.chatHistory.push({ id: nextId++, role: "user", content: query }); @@ -76,10 +77,13 @@ export function buildPrompt(onHeightChange: () => void): ViewHost { try { const reply = await Bridge.chatSend(query, context); + if (generation !== State.chatGeneration) return; State.chatHistory.push({ id: nextId++, role: "assistant", content: reply.text }); State.stateOverride = null; Sound.play("finish"); } catch (err) { + if (generation !== State.chatGeneration) return; + State.chatHistory.pop(); State.stateOverride = null; State.noteMessage = String(err).replace(/^Error:\s*/, ""); State.view = "note"; @@ -122,7 +126,8 @@ export function buildPrompt(onHeightChange: () => void): ViewHost { log.scrollTop = log.scrollHeight; } - input.placeholder = State.chatHistory.length === 0 ? "Ask me anything…" : "Continue…"; + const provider = State.settings.chatProvider === "openai" ? "OpenAI" : "Claude"; + input.placeholder = State.chatHistory.length === 0 ? `Ask ${provider}…` : `Continue with ${provider}…`; input.disabled = sending; }, focus() { diff --git a/windows/src/views/integrations.ts b/windows/src/views/integrations.ts index b8ad73c8f..769b744d0 100644 --- a/windows/src/views/integrations.ts +++ b/windows/src/views/integrations.ts @@ -6,7 +6,7 @@ import { h, svg, clear, dot } from "./dom"; import { ICONS } from "./icons"; -import { State, type AgentTask } from "../core/state"; +import { State, isCodingAgent, type AgentTask } from "../core/state"; import { Bridge } from "../core/bridge"; /** Same shape as the Swift `timeAgo` computed properties. */ @@ -59,12 +59,13 @@ function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { const error = info?.error ?? null; // The Claude Code pill is about hooks, not a key — the macOS wording would be // misleading here. - const missing = task.id === "integration_claude" ? "Hooks not installed" : "Key not configured"; - const label = error ?? (configured ? "Connected · loading…" : missing); + const coding = isCodingAgent(task); + const missing = coding ? "Hooks not installed" : "Key not configured"; + const label = error ?? (configured ? (coding ? "Hooks installed · waiting for session" : "Connected · loading…") : missing); const statusColor = error || !configured ? "#F4505E" : "#22C55E"; const actions = h("div", { class: "int-actions" }); - if (task.id === "integration_claude") { + if (coding) { actions.append( h("button", { class: "link-btn", @@ -92,7 +93,7 @@ function idleCard(task: AgentTask, openSettings: () => void): HTMLElement { }), ); } - if (configured) { + if (configured && !coding) { actions.append( h("button", { class: "link-btn", diff --git a/windows/src/views/views.ts b/windows/src/views/views.ts index ac0ac7b6f..943700de4 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -5,7 +5,7 @@ import { h, svg, clear, dot } from "./dom"; import { ICONS } from "./icons"; import { Ticker } from "./ticker"; -import { State, type AgentTask } from "../core/state"; +import { State, agentLabel, isCodingAgent, type AgentTask } from "../core/state"; import { washRGBA, type IslandViewName, type Wash } from "../core/layout"; import { createMiniBot, pruneMiniBots } from "../mochi/minibots"; import { buildPrompt } from "./chat"; @@ -20,7 +20,7 @@ export interface ViewActions { /** The ↗ button: opens whatever the focused pill points at. */ openTarget(): void; openUrl(url: string): void; - decide(d: "allow" | "deny"): void; + decide(d: "allow" | "deny", requestId: string): void; toggleSound(): void; setVolume(v: number): void; setAutoClose(seconds: number): void; @@ -172,10 +172,9 @@ function buildOverview(actions: ViewActions): ViewHost { mode = null; } - // VS Code with a live Claude Code session keeps the ticker; every other - // pill shows its own card, exactly like IntegrationCardView. + // Coding agents share the session ticker, with independent owner labels. const sessionActive = - task?.id === "integration_claude" && (task.state !== "idle" || task.steps.length > 0); + task && isCodingAgent(task) && (task.source === "agent" || task.state !== "idle" || task.steps.length > 0); if (task && sessionActive) { if (mode !== "ticker") { @@ -188,7 +187,7 @@ function buildOverview(actions: ViewActions): ViewHost { who.append( dot(task.color, 7), h("span", { class: "name", text: task.name }), - h("span", { class: "tool", text: task.source === "claudeCode" ? "Claude Code" : "n8n" }), + h("span", { class: "tool", text: agentLabel(task) }), ); if (task.steps.length > 1) { who.append(h("span", { @@ -214,7 +213,7 @@ function buildOverview(actions: ViewActions): ViewHost { jump.style.display = detailOpen ? "none" : ""; - const others = State.otherTasks.slice(0, 4); + const others = State.otherTasks; const pillKey = others.map((t) => `${t.id}:${t.pillBadge ?? ""}`).join("|"); if (pillKey !== pillIds) { pillIds = pillKey; @@ -227,7 +226,7 @@ function buildOverview(actions: ViewActions): ViewHost { } function buildPill(task: AgentTask, actions: ViewActions): HTMLElement { - const label = task.id === "integration_claude" ? "VS Code" : task.name; + const label = task.source === "claudeCode" ? "VS Code" : task.source === "codex" ? "Codex" : task.name; const canvas = createMiniBot(task, 24); const pill = h( "div", @@ -281,7 +280,7 @@ function buildEmpty(actions: ViewActions): ViewHost { h("div", { class: "sub", text: "Drop a file or window, or ask me anything." }), ), h("div", { class: "grow" }), - btn("Ask Claude", "primary", () => actions.setView("prompt")), + btn("Ask Mochi", "primary", () => actions.setView("prompt")), ); return { el: h("div", { class: "view" }, card(null, body)), sync() {} }; } @@ -290,28 +289,33 @@ function buildEmpty(actions: ViewActions): ViewHost { function buildApproval(actions: ViewActions): ViewHost { const who = h("div"); - const code = h("div", { class: "code" }); + const code = h("div", { class: "code approval-code", tabindex: 0, "aria-label": "Exact permission request" }); const row = h("div", { class: "actions" }); - const el = h("div", { class: "view" }, card("amber", stack(116, 16, who, code, row))); + const body = stack(116, 16, who, code, row); + body.classList.add("approval-stack"); + const el = h("div", { class: "view" }, card("amber", body)); let rowKey = ""; return { el, sync() { clear(who); - who.append(agentWho(State.focusTask, "needs permission")); + const pending = State.pendingApproval; + const owner = State.tasks.find((task) => task.id === pending?.taskId) ?? null; + who.append(agentWho(owner, `${agentLabel(owner)} needs permission`)); // The whole point of approving here rather than in the terminal: this line // is the command, the file path or the URL being authorised, not just the // name of the tool asking. - code.textContent = State.pendingApproval?.command || State.pendingApproval?.tool || "…"; - // Two buttons, built once. Rebuilding them between a mouse-down and a - // mouse-up would swallow the click, and there is nothing left to vary: - // "Always" is gone until the remembered-rules list exists to back it. - if (rowKey === "built") return; - rowKey = "built"; + code.textContent = pending?.command || pending?.tool || "No pending request"; + // Bind each button to the displayed request, including across a mouse click + // racing with an expired card and a new request from another agent. + const requestId = pending?.requestId ?? ""; + if (rowKey === requestId) return; + rowKey = requestId; clear(row); + if (!requestId) return; row.append( - btn("Deny", "secondary", () => actions.decide("deny"), "N"), - btn("Allow", "primary", () => actions.decide("allow"), "Y"), + btn("Deny", "secondary", () => actions.decide("deny", requestId), "N"), + btn("Allow", "primary", () => actions.decide("allow", requestId), "Y"), ); }, }; @@ -328,9 +332,9 @@ function buildQuestion(): ViewHost { el, sync() { clear(who); - who.append(agentWho(State.focusTask, "Claude Code is asking a question")); + who.append(agentWho(State.focusTask, `${agentLabel(State.focusTask)} is asking a question`)); const task = State.focusTask; - title.textContent = task?.steps.at(-1) ?? "Claude needs an answer."; + title.textContent = task?.steps.at(-1) ?? `${agentLabel(task)} needs an answer.`; clear(row); row.append(h("div", { class: "sub", text: "Answer in your terminal — Coucou can't reply for you yet." })); }, @@ -353,7 +357,7 @@ function buildError(actions: ViewActions): ViewHost { sync() { const task = State.focusTask; clear(who); - who.append(agentWho(task, task?.source === "n8n" ? "n8n" : "Claude Code")); + who.append(agentWho(task, agentLabel(task))); title.textContent = task?.source === "n8n" ? "Workflow stopped." : "Session stopped on an error."; detail.textContent = task?.steps.at(-1) ?? "No detail available."; }, @@ -374,7 +378,7 @@ function buildFinished(actions: ViewActions): ViewHost { el, sync() { clear(who); - who.append(agentWho(State.focusTask, "Claude Code finished")); + who.append(agentWho(State.focusTask, `${agentLabel(State.focusTask)} finished`)); title.textContent = State.focusTask?.steps.at(-1) ?? "Session finished"; }, }; @@ -503,7 +507,7 @@ export function buildViews( map.set("choose", buildChoose(actions)); // Not in the Windows v1: sending a file by email, window attach + web result. map.set("mail", buildPlaceholder("Sending by email isn't in this version.", "")); - map.set("searching", buildPlaceholder("Claude is searching…", "")); + map.set("searching", buildPlaceholder("Mochi is searching…", "")); map.set("result", buildPlaceholder("Result", "")); return map; } diff --git a/windows/tests/hook-events.test.mjs b/windows/tests/hook-events.test.mjs new file mode 100644 index 000000000..570471cfb --- /dev/null +++ b/windows/tests/hook-events.test.mjs @@ -0,0 +1,316 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { build } from "esbuild"; +import { fileURLToPath } from "node:url"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +// Bundle the actual event handler, replacing only OS/UI side effects. No webview, +// running Coucou, live configuration, or third-party test framework is needed. +const result = await build({ + stdin: { + contents: `export { createHookHandlers } from "./island/hooks"; + export { State, DEFAULT_SETTINGS, isCodingAgent, agentLabel } from "./core/state"; + export { calls } from "./core/bridge";`, + resolveDir: fileURLToPath(new URL("../src", import.meta.url)), loader: "ts", + }, + bundle: true, write: false, platform: "node", format: "esm", + plugins: [{ name: "hook-side-effects", setup(build) { + build.onResolve({ filter: /(?:\.\.\/|\.\/)core\/(bridge|sound)$/ }, args => + ({ path: args.path.endsWith("bridge") ? "bridge" : "sound", namespace: "mock" })); + build.onLoad({ filter: /.*/, namespace: "mock" }, args => ({ contents: + args.path === "sound" ? `export const Sound = { play() {} };` : ` + export const calls = []; + export const Bridge = { + approvalAck(id) { calls.push(["ack", id]); }, + approvalDecline(id) { calls.push(["decline", id]); }, + }; + export function onEvent() {}`, + })); + // Resolve this small TS graph directly; esbuild otherwise scans ancestor + // directories for configuration, which a sandboxed Windows runner may deny. + build.onResolve({ filter: /^\./ }, args => ({ + path: resolve(args.namespace === "source" ? dirname(args.importer) + : fileURLToPath(new URL("../src", import.meta.url)), `${args.path}.ts`), + namespace: "source", + })); + build.onLoad({ filter: /.*/, namespace: "source" }, async args => ({ + contents: await readFile(args.path, "utf8"), loader: "ts", + })); + } }], +}); +const { createHookHandlers, State, DEFAULT_SETTINGS, isCodingAgent, agentLabel, calls } = await import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString("base64")}` +); + +function setup() { + let timerId = 0; + const timers = new Map(); + globalThis.window = { + setTimeout(fn, delay) { timers.set(++timerId, { fn, delay }); return timerId; }, + clearTimeout(id) { timers.delete(id); }, + }; + State.tasks = []; + State.focusId = "integration_claude"; + State.settings = { ...DEFAULT_SETTINGS }; + State.pendingApproval = null; + State.paused = false; + State.isPinned = false; + State.mode = "expanded"; + State.view = "overview"; + State.loadIntegrationTasks(); + calls.length = 0; + const island = { + alert(view) { State.view = view; }, + setView(view) { State.view = view; }, + reveal() {}, dropPin() {}, + }; + const handlers = createHookHandlers(island); + return { + ...handlers, + send(event, options = {}) { + handlers.handle({ hook_event_name: event, coucou_agent: "codex", session_id: "c1", cwd: "C:\\work\\one", ...options }); + }, + task(agent = "codex") { return State.tasks.find(t => t.id === `integration_${agent}`); }, + timer(delay) { return [...timers.values()].find(t => t.delay === delay)?.fn; }, + }; +} +const request = (request_id = "r1", extra = {}) => ({ + request_id, tool_name: "shell", tool_input: { command: "echo hello" }, + coucou_tool_input_json: '{"command":"echo hello"}', ...extra, +}); + +test("legacy and explicit Claude events stay separate from Codex", () => { + const h = setup(); + h.send("PreToolUse", { coucou_agent: undefined, tool_name: "Read", tool_input: { path: "C:\\app\\legacy.txt" } }); + h.send("PreToolUse", { tool_name: "shell" }); + assert.equal(h.task("claude").steps.at(-1), "Lit · legacy.txt"); + assert.equal(h.task().steps.at(-1), "shell"); + h.send("UserPromptSubmit", { coucou_agent: "claude", prompt: "Claude task" }); + assert.equal(h.task("claude").state, "thinking"); + assert.equal(h.task().state, "working"); +}); + +test("Codex activity never shows prompts or raw commands even from an older relay", () => { + const h = setup(); + h.send("UserPromptSubmit", { prompt: "secret prompt" }); + h.send("PreToolUse", { tool_name: "shell", tool_input: { command: "secret command" } }); + h.send("Stop", { message: "secret answer" }); + assert.deepEqual(h.task().steps, ["Working on your request", "shell"]); +}); + +test("late post-tool events after Stop/Interrupt/SessionEnd cannot revive work", () => { + for (const event of ["Stop", "Interrupt", "SessionEnd"]) { + const h = setup(); + h.send("PreToolUse", { turn_id: "t1" }); + h.send(event, { turn_id: "t1" }); + const expected = event === "Stop" ? "finished" : "idle"; + h.send("PostToolUse", { turn_id: "t1" }); + h.send("PostToolUseFailure", { turn_id: "t1" }); + assert.equal(h.task().state, expected, event); + } +}); + +test("SessionEnd still clears a stopped turn", () => { + const h = setup(); + h.send("PreToolUse", { turn_id: "t1" }); + h.send("Stop", { turn_id: "t1" }); + h.send("SessionEnd", { turn_id: "t1" }); + assert.equal(h.task().name, "Codex"); + assert.equal(h.task().sessionCwd, null); + assert.deepEqual(h.task().steps, []); +}); + +test("finishing one session cannot stop another session or erase its badge", () => { + const h = setup(); + h.send("PreToolUse"); + h.send("Stop"); + const finishTimer = h.timer(5200); + h.send("PreToolUse", { session_id: "c2", cwd: "C:\\work\\two", tool_name: "apply_patch" }); + h.send("SessionEnd"); + finishTimer(); + assert.equal(h.task().sessionId, "c2"); + assert.equal(h.task().name, "two"); + assert.equal(h.task().state, "working"); + assert.equal(h.task().pillBadge, null); +}); + +test("late events and old completion timer cannot overwrite a newer turn", () => { + const h = setup(); + h.send("PreToolUse", { turn_id: "t1" }); + h.send("Stop", { turn_id: "t1" }); + const finishTimer = h.timer(5200); + h.send("UserPromptSubmit", { turn_id: "t2" }); + h.send("PreToolUse", { turn_id: "t2", tool_name: "apply_patch" }); + for (const event of ["PostToolUse", "Stop", "PreToolUse"]) h.send(event, { turn_id: "t1" }); + finishTimer(); + assert.equal(h.task().state, "working"); + assert.equal(h.task().steps.at(-1), "apply_patch"); +}); + +test("permission carries exact raw JSON and identifies its owning provider/session", () => { + const h = setup(); + const exact = '{"id":9007199254740993123,"command":"echo hello","description":"reason"}'; + h.send("PermissionRequest", request("r1", { coucou_tool_input_json: exact, permission_mode: "default" })); + assert.equal(State.pendingApproval.taskId, "integration_codex"); + assert.equal(State.pendingApproval.sessionId, "c1"); + assert.equal(State.pendingApproval.command, `shell\nWorking directory: C:\\work\\one\nSession: c1\nPermission mode: default\n${exact}`); + assert.deepEqual(calls, [["ack", "r1"]]); + h.send("PermissionRequest", request("r2", { coucou_agent: "claude", session_id: "a1" })); + assert.deepEqual(calls.at(-1), ["decline", "r2"]); + assert.equal(State.pendingApproval.requestId, "r1"); + assert.equal(h.task("claude").state, "idle"); +}); + +test("a permission after successive synthetic activity turns opens the approval view", () => { + const h = setup(); + State.focusId = "integration_codex"; + for (const turn_id of ["preview-1", "preview-2"]) { + h.send("UserPromptSubmit", { turn_id }); + h.send("PreToolUse", { turn_id, tool_name: "Bash" }); + } + h.send("PermissionRequest", request("preview-request-2", { turn_id: "preview-2" })); + assert.equal(State.view, "approval"); + assert.equal(State.pendingApproval.requestId, "preview-request-2"); + assert.equal(h.task().state, "approval"); +}); + +test("array/scalar MCP arguments remain exact and missing arguments decline", () => { + for (const raw of ['[1,"two",null]', '"literal argument"', 'null', 'true']) { + const h = setup(); + h.send("PermissionRequest", request("r1", { coucou_tool_input_json: raw })); + assert.equal(State.pendingApproval.command, `shell\nWorking directory: C:\\work\\one\nSession: c1\n${raw}`); + } + const h = setup(); + h.send("PermissionRequest", request("r1", { coucou_tool_input_json: undefined })); + assert.equal(State.pendingApproval, null); + assert.deepEqual(calls, [["decline", "r1"]]); +}); + +test("background activity cannot steal a pending session's pill or approval", () => { + const h = setup(); + State.focusId = "integration_codex"; + h.send("PermissionRequest", request()); + h.send("PreToolUse", { session_id: "c2", tool_name: "background" }); + h.send("Stop", { coucou_agent: "claude", session_id: "a1" }); + h.send("SessionEnd", { session_id: "c2" }); + assert.equal(h.task().sessionId, "c1"); + assert.equal(h.task().state, "approval"); + assert.equal(State.view, "approval"); + assert.equal(State.pendingApproval.requestId, "r1"); +}); + +test("approval-ended and captured timeout affect only their exact request", () => { + const h = setup(); + h.send("PermissionRequest", request("old")); + const staleTimeout = h.timer(110000); + h.approvalEnded("old"); + h.send("PermissionRequest", request("new", { session_id: "c2" })); + h.approvalEnded("old"); + staleTimeout(); + assert.equal(State.pendingApproval.requestId, "new"); + assert.equal(h.task().state, "approval"); + assert.equal(State.isPinned, true); + h.approvalEnded("new"); + assert.equal(State.pendingApproval, null); + assert.equal(h.task().state, "working"); + assert.equal(State.isPinned, false); +}); + +test("ending/interruption of another session leaves pending approval intact", () => { + const h = setup(); + h.send("PermissionRequest", request()); + h.send("Interrupt", { session_id: "c2" }); + assert.equal(State.pendingApproval.requestId, "r1"); + h.send("Interrupt"); + assert.equal(State.pendingApproval, null); + assert.deepEqual(calls.at(-1), ["decline", "r1"]); + assert.equal(h.task().state, "idle"); +}); + +test("paused, unidentified Codex and generic permissions fall back without acknowledgment", () => { + const h = setup(); + State.paused = true; + h.send("PermissionRequest", request("paused")); + State.paused = false; + h.send("PermissionRequest", request("unknown", { coucou_agent: "other" })); + h.send("PermissionRequest", request("missing", { session_id: undefined })); + assert.deepEqual(calls, [["decline", "paused"], ["decline", "unknown"], ["decline", "missing"]]); + assert.equal(State.pendingApproval, null); +}); + +test("valid external agents get distinct dynamic pills and keep their names", () => { + const h = setup(); + for (const coucou_agent of ["gemini", "my-tool", "a".repeat(24)]) { + h.send("UserPromptSubmit", { coucou_agent, prompt: "Plan the change" }); + h.send("PreToolUse", { coucou_agent, tool_name: "Read", tool_input: { path: "C:\\demo\\notes.md" } }); + const task = State.tasks.find(t => t.id === `agent_${coucou_agent}`); + assert.equal(task.source, "agent"); + assert.equal(task.isIntegration, false); + assert.equal(task.name, coucou_agent); + assert.equal(agentLabel(task), coucou_agent); + assert.equal(isCodingAgent(task), true); + assert.deepEqual(task.steps, ["Plan the change", "Lit · notes.md"]); + assert.equal(task.state, "working"); + } + State.loadIntegrationTasks(); + assert.equal(State.tasks[0].id, "integration_claude"); + assert.ok(State.tasks.slice(1, 4).every(t => t.source === "agent")); + assert.equal(State.tasks[4].id, "integration_codex"); + assert.equal(h.task().state, "idle"); + assert.equal(h.task("claude").state, "idle"); +}); + +test("absent, invalid and reserved Claude tags preserve the upstream Claude fallback", () => { + for (const coucou_agent of [undefined, "", "claude", "Bad-Name", "with space", "../name", "a".repeat(25)]) { + const h = setup(); + h.send("PreToolUse", { coucou_agent, tool_name: "Read", tool_input: { path: "fallback.txt" } }); + assert.equal(h.task("claude").state, "working", String(coucou_agent)); + assert.equal(h.task("claude").steps.at(-1), "Lit · fallback.txt"); + assert.equal(h.task().state, "idle"); + assert.equal(State.tasks.some(t => t.source === "agent"), false); + } +}); + +test("generic permissions decline without a card or a dynamic task", () => { + const h = setup(); + h.send("PermissionRequest", request("generic", { coucou_agent: "gemini" })); + assert.deepEqual(calls, [["decline", "generic"]]); + assert.equal(State.pendingApproval, null); + assert.equal(State.tasks.some(t => t.id === "agent_gemini"), false); + h.send("PermissionRequest", request("native")); + assert.equal(State.pendingApproval.taskId, "integration_codex"); + h.send("PermissionRequest", request("generic-2", { coucou_agent: "other-agent" })); + assert.equal(State.pendingApproval.requestId, "native"); + assert.deepEqual(calls.at(-1), ["decline", "generic-2"]); +}); + +test("generic Stop removes its pill after 5.2 seconds and SessionEnd removes it immediately", () => { + const h = setup(); + h.send("PreToolUse", { coucou_agent: "gemini" }); + h.send("Stop", { coucou_agent: "gemini", message: "Done" }); + assert.equal(State.tasks.find(t => t.id === "agent_gemini").state, "finished"); + h.timer(5200)(); + assert.equal(State.tasks.some(t => t.id === "agent_gemini"), false); + h.send("PostToolUse", { coucou_agent: "gemini" }); + assert.equal(State.tasks.some(t => t.id === "agent_gemini"), false); + h.send("SessionStart", { coucou_agent: "gemini" }); + State.setFocus("agent_gemini"); + h.send("SessionEnd", { coucou_agent: "gemini" }); + assert.equal(State.tasks.some(t => t.id === "agent_gemini"), false); + assert.equal(State.focusId, "integration_claude"); + assert.ok(h.task()); +}); + +test("a generic completion timer and old SessionEnd cannot remove newer activity", () => { + const h = setup(); + h.send("PreToolUse", { coucou_agent: "gemini", session_id: "g1", turn_id: "t1" }); + h.send("Stop", { coucou_agent: "gemini", session_id: "g1", turn_id: "t1" }); + const oldTimer = h.timer(5200); + h.send("PreToolUse", { coucou_agent: "gemini", session_id: "g2", turn_id: "t2" }); + oldTimer(); + h.send("SessionEnd", { coucou_agent: "gemini", session_id: "g1", turn_id: "t1" }); + const task = State.tasks.find(t => t.id === "agent_gemini"); + assert.equal(task.state, "working"); + assert.equal(task.sessionId, "g2"); +});