From 5cf6891d3486ea160b6e90057bb1689124a027ff Mon Sep 17 00:00:00 2001 From: Alejxghx <239258588+Alejxghx@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:22:15 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(windows):=20a=C3=B1adir=20integraci?= =?UTF-8?q?=C3=B3n=20local=20de=20Codex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/windows-check.yml | 37 ++ docs/AGENTS.md | 16 +- windows/README.md | 62 +++ windows/dev/codex-preview.html | 17 + windows/dev/codex-preview.png | Bin 0 -> 28732 bytes windows/dev/codex-preview.ts | 45 ++ windows/hook/Cargo.toml | 4 +- windows/hook/src/main.rs | 395 ++++++++--------- windows/hook/src/tests.rs | 249 +++++++++++ windows/package.json | 1 + windows/scripts/test-relay.ps1 | 103 +++++ windows/src-tauri/src/hooks.rs | 510 ++-------------------- windows/src-tauri/src/hooks_config.rs | 582 +++++++++++++++++++++++++ windows/src-tauri/src/lib.rs | 27 +- windows/src-tauri/src/pipe.rs | 124 ++++-- windows/src/core/bridge.ts | 18 +- windows/src/core/layout.ts | 2 +- windows/src/core/state.ts | 26 +- windows/src/island/hooks.ts | 583 +++++++++++++------------- windows/src/island/integrations.ts | 7 + windows/src/island/island.ts | 14 +- windows/src/main.ts | 1 + windows/src/settings/main.ts | 44 +- windows/src/style.css | 21 + windows/src/views/integrations.ts | 11 +- windows/src/views/views.ts | 50 ++- windows/tests/hook-events.test.mjs | 316 ++++++++++++++ 27 files changed, 2191 insertions(+), 1074 deletions(-) create mode 100644 .github/workflows/windows-check.yml create mode 100644 windows/dev/codex-preview.html create mode 100644 windows/dev/codex-preview.png create mode 100644 windows/dev/codex-preview.ts create mode 100644 windows/hook/src/tests.rs create mode 100644 windows/scripts/test-relay.ps1 create mode 100644 windows/src-tauri/src/hooks_config.rs create mode 100644 windows/tests/hook-events.test.mjs 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 bb2cda228..84cc9e377 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -58,11 +58,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 de57792c9..fc5b3a2ee 100644 --- a/windows/README.md +++ b/windows/README.md @@ -68,6 +68,68 @@ 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 is still the separate Claude API feature. + +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 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 0000000000000000000000000000000000000000..65be6d2efec669220554b38cfd48cd1bf126dd3c GIT binary patch literal 28732 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 { + 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 18a015456..bc991980d 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" +description = "Relays Claude Code and Codex hook events to Coucou over a named pipe" 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 70ab3a3ef..df57ea2d7 100644 --- a/windows/hook/src/main.rs +++ b/windows/hook/src/main.rs @@ -1,55 +1,80 @@ -//! 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-`. -//! -//! 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, 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); - -/// `ERROR_PIPE_BUSY` — every instance is serving someone else right now. This is -/// the one error worth retrying: the server exists and a slot will free up. const ERROR_PIPE_BUSY: i32 = 231; - -/// 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"]; mod win; +#[cfg(test)] +mod tests; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Agent { Claude, Codex, External } + +#[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, +} + +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, + } +} + +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(Args { agent: agent_from_tag(tag), event: event.into(), agent_tag: tag.map(str::to_string) }) +} -/// `\\.\pipe\coucou-`. The SID keeps two accounts on the same machine from -/// ever meeting on the same pipe; the name falls back to the user name only if -/// the SID cannot be read at all, which should not happen. fn pipe_path() -> String { let key = win::current_user_sid() .unwrap_or_else(|| std::env::var("USERNAME").unwrap_or_else(|_| "user".into())); format!(r"\\.\pipe\coucou-{key}") } -/// Opens the pipe. Retries only while the server is busy: any other error means -/// there is nothing to talk to, and waiting would only delay Claude Code. +/// Refuse other users' servers before sending any session data. fn connect() -> Option { use std::os::windows::io::AsRawHandle; let path = pipe_path(); @@ -58,7 +83,6 @@ fn connect() -> Option { match std::fs::OpenOptions::new().read(true).write(true).open(&path) { Ok(file) => { let handle = windows::Win32::Foundation::HANDLE(file.as_raw_handle()); - // Somebody else's server on our pipe name gets nothing from us. return win::pipe_server_is_same_user(handle).then_some(file); } Err(err) => { @@ -72,212 +96,189 @@ fn connect() -> Option { } 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 }; - - // 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::>(); + 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 _ = tx.send(talk(&payload, waits_for_answer)); + 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); }); - - 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(); - } + 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(); + } + if let Some(json) = output_json(agent, &event, decision.as_deref()) { + let mut out = std::io::stdout(); + let _ = writeln!(out, "{json}"); + let _ = out.flush(); } - // Nothing printed: Claude Code asks in the terminal, as if we were not here. + // Exit also cancels any worker whose stdin/pipe operation hit our deadline. 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 +/// 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) +} + +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()); + } + None +} + +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() { - // "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(), + // 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}}}}}"# - )) + Some(format!(r#"{{"hookSpecificOutput":{{"hookEventName":"PermissionRequest","decision":{behavior}}}}}"#)) } -/// 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; - } - // Some shells hand us a UTF-8 BOM; serde_json would choke on it. - if raw.starts_with(&[0xEF, 0xBB, 0xBF]) { - raw.drain(..3); - } - - let mut payload = serde_json::from_slice::(&raw).ok()?; - let map = payload.as_object_mut()?; - - // 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; +/// 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())); } } - } - // 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())); - - for field in DROPPED_FIELDS { - map.remove(*field); - } - - 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()), - ); + 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 on Windows 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: std::fs::File, 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/hooks.rs b/windows/src-tauri/src/hooks.rs index 0e3e32d19..8520234b5 100644 --- a/windows/src-tauri/src/hooks.rs +++ b/windows/src-tauri/src/hooks.rs @@ -1,42 +1,12 @@ -// 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. - -use std::path::{Path, PathBuf}; +// 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::PathBuf; use serde::Serialize; -use serde_json::{json, Map, Value}; use tauri::{AppHandle, Manager}; use windows::Win32::System::SystemInformation::GetLocalTime; - -use crate::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}, settings}; +pub use crate::hooks_config::{HookAgent, HookPreview}; #[derive(Serialize)] #[serde(rename_all = "camelCase")] @@ -45,262 +15,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, -} - -fn home() -> PathBuf { - std::env::var_os("USERPROFILE") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(".")) -} - -pub fn settings_path() -> PathBuf { - home().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." - )), - } -} - -/// 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!({})) -} - -fn hook_command(event: &str) -> String { - let exe = settings::hook_exe_path().to_string_lossy().replace('\\', "/"); - format!("\"{exe}\" {event}") -} - -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) +fn config(agent: HookAgent) -> Result { + let path = config_path(agent, + std::env::var_os("USERPROFILE").map(PathBuf::from), + std::env::var_os("CODEX_HOME").map(PathBuf::from), + )?; + Ok(HookConfig { agent, path, hook_path: settings::hook_exe_path() }) } -/// 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 = unsafe { GetLocalTime() }; - format!( - "{:04}{:02}{:02}-{:02}{:02}{:02}", - t.wYear, t.wMonth, t.wDay, t.wHour, t.wMinute, t.wSecond - ) + format!("{:04}{:02}{:02}-{:02}{:02}{:02}", + t.wYear, t.wMonth, t.wDay, t.wHour, t.wMinute, t.wSecond) } -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""), - } -} - -// ── 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(), - }) +pub fn preview(install: bool, agent: HookAgent) -> Result { + config(agent)?.preview(install, &stamp()) } -/// 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'); - - // 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())); - std::fs::write(&temp, text.as_bytes()).map_err(|e| format!("write failed: {e}"))?; - 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 write(install: bool, fingerprint: &str, agent: HookAgent) -> Result { + config(agent)?.write(install, fingerprint, &stamp()) } /// Copies coucou-hook.exe into %LOCALAPPDATA%\Coucou\bin on launch. @@ -337,7 +88,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!( - "coucou-hook.exe not found — Claude Code hooks cannot work. Looked in: {}", + "coucou-hook.exe not found — agent hooks cannot work. Looked in: {}", tried.join(", ") )); return; @@ -358,208 +109,3 @@ pub fn ensure_hook_exe(app: &AppHandle) { } } } - -// ── 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"{}")); - } - - /// Everything filesystem-shaped lives in one test on purpose: it points - /// USERPROFILE 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("USERPROFILE", &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..a42c2bb95 --- /dev/null +++ b/windows/src-tauri/src/hooks_config.rs @@ -0,0 +1,582 @@ +//! 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("USERPROFILE must name an absolute user 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()); + } + 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. + let unsafe_path = executable.chars().any(|ch| { + matches!(ch, '"' | '\r' | '\n' | '\0') || match self.agent { + HookAgent::Codex => matches!(ch, '%' | '!'), + HookAgent::Claude => matches!(ch, '$' | '`'), + } + }); + 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 exe = self.hook_path.to_string_lossy().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}"))?; + 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(test)] +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))) + }; + match OpenOptions::new().create_new(true).write(true).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 +} diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 5d19db6c1..1e8201f58 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod claude; mod files; mod hooks; +mod hooks_config; mod integrations; mod island; mod log; @@ -23,7 +24,7 @@ use tauri_plugin_autostart::{ManagerExt, MacosLauncher}; use claude::{Chat, ChatContext, ChatReply}; use files::DroppedFile; -use hooks::{HookPreview, HookStatus}; +use hooks::{HookAgent, HookPreview, HookStatus}; use island::{PollGate, ScreenInfo}; use pipe::Pending; use settings::Settings; @@ -49,7 +50,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, @@ -184,17 +185,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. @@ -204,14 +205,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); diff --git a/windows/src-tauri/src/pipe.rs b/windows/src-tauri/src/pipe.rs index f8cd71c45..30a08a348 100644 --- a/windows/src-tauri/src/pipe.rs +++ b/windows/src-tauri/src/pipe.rs @@ -31,14 +31,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 { @@ -96,29 +97,9 @@ pub fn start(app: AppHandle) { } async fn handle(app: AppHandle, mut pipe: NamedPipeServer) { - 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) @@ -144,9 +125,9 @@ async fn handle(app: AppHandle, mut pipe: NamedPipeServer) { 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; @@ -154,6 +135,44 @@ async fn handle(app: AppHandle, mut pipe: NamedPipeServer) { let _ = pipe.disconnect(); } +/// 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 { @@ -225,3 +244,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/core/bridge.ts b/windows/src/core/bridge.ts index 2967337e7..ea9a05b00 100644 --- a/windows/src/core/bridge.ts +++ b/windows/src/core/bridge.ts @@ -60,22 +60,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 ────────────────────────────────────────────────── @@ -116,11 +117,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..17d09ae5c 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"), @@ -199,19 +211,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 +249,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 +263,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 4a6cc6d14..532a44c17 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: () => { diff --git a/windows/src/main.ts b/windows/src/main.ts index 5f1c3348c..ebd10abf8 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -29,6 +29,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); }; diff --git a/windows/src/settings/main.ts b/windows/src/settings/main.ts index 3ab9ab94a..5b95a6d2e 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 still uses Claude.", + })); + } + 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) { @@ -428,6 +438,9 @@ 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; @@ -441,7 +454,8 @@ async function main() { clear(root); root.append( h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), - claudeSection(status), + hooksSection("claude", status), + hooksSection("codex", codexStatus), apiSection(hasKey), integrationsSection(present), generalSection(), diff --git a/windows/src/style.css b/windows/src/style.css index 1da05cf94..e8378810f 100644 --- a/windows/src/style.css +++ b/windows/src/style.css @@ -511,6 +511,7 @@ body { gap: 4px; align-content: center; padding: 0 8px; + overflow-y: auto; } .pill { @@ -618,6 +619,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/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..ea487573a 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", @@ -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"; }, }; 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"); +}); From 3bdf38af2e5cfd22d06465b15d5ae86e142820d7 Mon Sep 17 00:00:00 2001 From: Alejxghx <239258588+Alejxghx@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:14:01 -0500 Subject: [PATCH 2/2] feat(chat): integrar la API de OpenAI y conservar Claude --- windows/README.md | 34 ++++- windows/src-tauri/src/chat.rs | 82 +++++++++++ windows/src-tauri/src/claude.rs | 50 ++----- windows/src-tauri/src/lib.rs | 18 ++- windows/src-tauri/src/openai.rs | 228 ++++++++++++++++++++++++++++++ windows/src-tauri/src/secrets.rs | 1 + windows/src-tauri/src/settings.rs | 38 +++++ windows/src/core/state.ts | 5 + windows/src/island/island.ts | 1 + windows/src/main.ts | 10 ++ windows/src/settings/main.ts | 75 +++++++--- windows/src/views/chat.ts | 7 +- windows/src/views/views.ts | 4 +- 13 files changed, 475 insertions(+), 78 deletions(-) create mode 100644 windows/src-tauri/src/chat.rs create mode 100644 windows/src-tauri/src/openai.rs diff --git a/windows/README.md b/windows/README.md index 3213d3239..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) @@ -72,7 +72,7 @@ It works from any terminal — Windows Terminal, PowerShell, VS Code, Git Bash. 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 is still the separate Claude API feature. +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 @@ -132,9 +132,31 @@ application build, and never executes the displayed commands. ## 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. @@ -188,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/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/lib.rs b/windows/src-tauri/src/lib.rs index 08a268d9b..fbb4f6ce2 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ // Coucou for Windows — app wiring and the commands the island calls. +mod chat; mod claude; mod files; mod hooks; @@ -7,6 +8,7 @@ mod hooks_config; mod integrations; mod island; mod log; +mod openai; mod pipe; mod platform; mod secrets; @@ -21,7 +23,8 @@ 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::{HookAgent, HookPreview, HookStatus}; use island::{PollGate, ScreenInfo}; @@ -61,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) }; @@ -248,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/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/state.ts b/windows/src/core/state.ts index 17d09ae5c..5e224f7cd 100644 --- a/windows/src/core/state.ts +++ b/windows/src/core/state.ts @@ -104,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 = { @@ -118,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; @@ -148,6 +152,7 @@ class AppState { noteMessage: string | null = null; searchResult: SearchResult | null = null; chatHistory: ChatMessage[] = []; + chatGeneration = 0; pendingApproval: ApprovalInfo | null = null; integrations: Record = {}; diff --git a/windows/src/island/island.ts b/windows/src/island/island.ts index f590d6927..d1e879a54 100644 --- a/windows/src/island/island.ts +++ b/windows/src/island/island.ts @@ -391,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 11932f8a4..de80cbbf4 100644 --- a/windows/src/main.ts +++ b/windows/src/main.ts @@ -56,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 5b95a6d2e..4726087fe 100644 --- a/windows/src/settings/main.ts +++ b/windows/src/settings/main.ts @@ -86,7 +86,7 @@ function hooksSection(agent: HookAgent, 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 still uses Claude.", + 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 })); @@ -181,7 +181,7 @@ function hooksSection(agent: HookAgent, status: HookStatus): HTMLElement { return section; } -// ── Claude API section ──────────────────────────────────────────────────────── +// ── Chat API sections ──────────────────────────────────────────────────────── const MODELS: [string, string][] = [ ["claude-opus-5", "Claude Opus 5"], @@ -189,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", @@ -206,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"; } @@ -220,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)}` })); @@ -232,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) { @@ -240,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, ); } @@ -443,6 +469,7 @@ async function main() { }; 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", @@ -456,7 +483,9 @@ async function main() { h("h1", {}, h("span", { text: "Coucou" }), h("span", { class: "version", text: version })), hooksSection("claude", status), hooksSection("codex", codexStatus), - apiSection(hasKey), + chatProviderSection(), + apiSection("claude", hasKey), + apiSection("openai", hasOpenAIKey), integrationsSection(present), generalSection(), h("div", { 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/views.ts b/windows/src/views/views.ts index ea487573a..943700de4 100644 --- a/windows/src/views/views.ts +++ b/windows/src/views/views.ts @@ -280,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() {} }; } @@ -507,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; }