Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ Click the Coucou icon in the menu bar (macOS) or in the system tray (Windows, Li
| **Claude Code hooks** | live sessions and approvals | **Install hooks** — Coucou backs up `~/.claude/settings.json`, merges its hooks and shows you the diff before writing anything |
| **Claude plan** *(macOS, GitHub build)* | Plan usage gauge in the notch header | **Install relay** in Settings → Agents → Plan usage, then enable "Show in the notch" |
| **Gemini CLI hooks** *(macOS)* | Gemini CLI sessions in the island | **Install hooks** in Settings → Gemini CLI — backs up `~/.gemini/settings.json` |
| **Antigravity (agy) hooks** *(macOS)* | agy sessions in the island | **Install hooks** in Settings → Antigravity — backs up `~/.gemini/config/hooks.json` |
| **Antigravity (agy) hooks** *(macOS, Windows)* | agy sessions in the island | **Install hooks** in Settings → Antigravity — backs up `~/.gemini/config/hooks.json` (`%USERPROFILE%\.gemini\config\hooks.json` on Windows) |
| **Anthropic API key** | chat and questions about files | Settings → Anthropic API · Keychain / Windows Credential Manager / Secret Service |
| **Google AI API key** *(macOS)* | chat with Google AI (Gemini) | Settings → Chat — other providers · Keychain |
| **OpenAI API key** *(macOS)* | chat with OpenAI | Settings → Chat — other providers · Keychain |
Expand All @@ -232,7 +232,7 @@ If Coucou isn't running, the hook exits immediately: **Claude Code is never bloc
|---|---|---|
| Claude Code | Settings → Claude Code → **Install hooks** | No |
| Gemini CLI | Settings → Gemini CLI → **Install hooks** | Mac only |
| Antigravity | Settings → Antigravity → **Install hooks** | Mac only |
| Antigravity | Settings → Antigravity → **Install hooks** | No |
| Cursor | Hooks installed automatically alongside Claude Code | No |
| Codex | `--agent codex` flag; Settings → Codex → **Install hooks** | No |
| Copilot CLI | `--agent copilot` flag + camelCase events | No |
Expand Down
9 changes: 5 additions & 4 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,13 @@ Coucou events automatically.

`AfterModel` is not installed — it fires on every response chunk and would flood the island.

### Antigravity — `agy` (macOS)
### Antigravity — `agy` (macOS & Windows)

Coucou supports Antigravity out of the box via **Settings → Antigravity → Install hooks**.
The installer writes to `~/.gemini/config/hooks.json` (timeouts in seconds) and uses
`--agent antigravity`. The relay translates `toolCall.name` / `conversationId` to the
island's `tool_name` / `session_id`.
The installer writes to `~/.gemini/config/hooks.json` on macOS or `%USERPROFILE%\.gemini\config\hooks.json` on Windows
(timeouts in seconds) and uses `--agent antigravity`. The relay translates `toolCall.name` / `conversationId` to the
island's `tool_name` / `session_id`, and safely outputs `{"decision":"allow"}` on `PreToolUse` so Antigravity execution
proceeds uninterrupted.

| Antigravity event | Canonical event |
|---|---|
Expand Down
12 changes: 11 additions & 1 deletion windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ The relay (`coucou-hook.exe`) works with any tool that can run a command on hook
|---|---|---|
| Claude Code | **Settings → Claude Code → Install hooks** | `%USERPROFILE%\.claude\settings.json` |
| Gemini CLI | `--agent gemini` positional arg | `%USERPROFILE%\.gemini\settings.json` |
| Antigravity | `--agent antigravity` positional arg | `%USERPROFILE%\.config\antigravity\hooks.json` |
| Antigravity | **Settings → Antigravity → Install hooks** | `%USERPROFILE%\.gemini\config\hooks.json` |
| Cursor | hooks installed automatically | `%USERPROFILE%\.claude\settings.json` |
| Codex | `--agent codex` positional arg | `%USERPROFILE%\.codex\hooks.json` |
| Copilot CLI | `--agent copilot` positional arg + camelCase events | `%USERPROFILE%\.copilot\hooks\coucou.json` |
Expand All @@ -153,6 +153,16 @@ The relay (`coucou-hook.exe`) works with any tool that can run a command on hook

OpenCode, Amp and Hermes are not yet supported on Windows or Linux. Their integration uses a plugin that calls `/bin/sh` with macOS-specific paths; the plugin installer lives in the Mac app only.

### Google Antigravity

Antigravity hooks are natively supported and manageable through the settings UI:

- **1-Click Install:** In **Settings → Antigravity**, click **Install hooks** (or **Preview changes** to see a unified diff).
- **Config location:** Configured in `%USERPROFILE%\.gemini\config\hooks.json` with timestamped `.bak` backups before write.
- **Windows Command Safety:** Generates clean unquoted command lines (`coucou-hook.exe --agent antigravity <event>`) so Windows `cmd.exe /c` does not fail on escape quotes.
- **Protocol Normalization:** `coucou-hook.exe` automatically translates Antigravity protojson payloads (`toolCall` args/name, `conversationId` to `session_id`, `workspacePaths` to `cwd`, `PreInvocation` to `UserPromptSubmit`, `PostInvocation` to `PostToolUse`).
- **Non-blocking Contract:** Always responds `{"decision":"allow"}` on `PreToolUse` so Antigravity tools run unhindered while keeping Coucou's top island ticker live.

## What's different from the Mac version

- No notch, so the island lives at the top centre of the screen and retracts into
Expand Down
157 changes: 145 additions & 12 deletions windows/hook/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@ mod unix;
use unix::connect;

fn main() {
let Some((payload, event)) = read_event() else { std::process::exit(0) };
let Some((payload, event, agent)) = 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::<Option<String>>();
std::thread::spawn(move || {
let _ = tx.send(talk(&payload, waits_for_answer));
Expand All @@ -66,7 +66,22 @@ fn main() {
let _ = out.flush();
}
}
// Nothing printed: Claude Code asks in the terminal, as if we were not here.

// Antigravity, Gemini CLI, Muse Code and Copilot expect valid JSON on stdout
if agent.eq_ignore_ascii_case("antigravity") {
let mut out = std::io::stdout();
if event == "PreToolUse" {
let _ = writeln!(out, r#"{{"decision":"allow"}}"#);
} else {
let _ = writeln!(out, "{{}}");
}
let _ = out.flush();
} else if agent == "gemini" || agent == "muse" || agent == "copilot" {
let mut out = std::io::stdout();
let _ = writeln!(out, "{{}}");
let _ = out.flush();
}

std::process::exit(0);
}

Expand All @@ -86,8 +101,90 @@ fn decision_json(decision: &str) -> Option<String> {
))
}

/// Reads stdin and returns the payload to forward plus the event name.
fn read_event() -> Option<(String, String)> {
fn normalize_event_name(raw: &str) -> String {
match raw {
"PreInvocation" | "BeforeAgent" | "pre_invocation" | "user_prompt_submit" | "userPromptSubmitted" => {
"UserPromptSubmit".into()
}
"PostInvocation" | "AfterModel" | "post_invocation" => "PostToolUse".into(),
"BeforeTool" | "BeforeToolSelection" | "pre_tool_use" | "preToolUse" => "PreToolUse".into(),
"AfterTool" | "post_tool_use" | "postToolUse" => "PostToolUse".into(),
"AfterAgent" | "agentStop" | "stop" => "Stop".into(),
"startup" | "session_start" | "sessionStart" => "SessionStart".into(),
"exit" | "session_end" | "sessionEnd" => "SessionEnd".into(),
other => other.to_string(),
}
}

fn normalize_payload(map: &mut serde_json::Map<String, serde_json::Value>) {
// 1. Session ID normalization (Antigravity conversationId -> session_id)
if !map.contains_key("session_id") {
for key in ["conversationId", "conversation_id", "sessionId"] {
if let Some(v) = map.get(key).and_then(|x| x.as_str()) {
if !v.is_empty() {
map.insert("session_id".into(), serde_json::Value::String(v.to_string()));
break;
}
}
}
}

// 2. Tool name normalization (Antigravity toolCall.name -> tool_name)
if !map.contains_key("tool_name") {
if let Some(name) = map.get("toolName").and_then(|x| x.as_str()) {
map.insert("tool_name".into(), serde_json::Value::String(name.to_string()));
} else if let Some(tc) = map.get("toolCall").and_then(|x| x.as_object()) {
if let Some(name) = tc.get("name").and_then(|x| x.as_str()) {
map.insert("tool_name".into(), serde_json::Value::String(name.to_string()));
}
} else if let Some(tool) = map.get("tool").and_then(|x| x.as_str()) {
map.insert("tool_name".into(), serde_json::Value::String(tool.to_string()));
}
}

// 3. Tool input normalization (Antigravity toolCall.args -> tool_input)
if !map.contains_key("tool_input") {
if let Some(args) = map.get("toolArgs").and_then(|x| x.as_object()) {
map.insert("tool_input".into(), serde_json::Value::Object(args.clone()));
} else if let Some(tc) = map.get("toolCall").and_then(|x| x.as_object()) {
if let Some(args) = tc.get("args").and_then(|x| x.as_object()) {
let mut flat = args.clone();
for (src, dst) in [
("CommandLine", "command"),
("FilePath", "file_path"),
("Path", "path"),
("Url", "url"),
("Query", "query"),
("Pattern", "pattern"),
] {
if let Some(v) = flat.get(src).cloned() {
flat.insert(dst.into(), v);
}
}
map.insert("tool_input".into(), serde_json::Value::Object(flat));
}
}
}

// 4. Working directory normalization (Antigravity workspacePaths -> cwd)
let cwd_missing = map
.get("cwd")
.and_then(|v| v.as_str())
.map(str::is_empty)
.unwrap_or(true);
if cwd_missing {
if let Some(paths) = map.get("workspacePaths").and_then(|v| v.as_array()) {
if let Some(first) = paths.first().and_then(|p| p.as_str()) {
map.insert("cwd".into(), serde_json::Value::String(first.to_string()));
}
} else if let Some(workdir) = map.get("workdir").and_then(|v| v.as_str()) {
map.insert("cwd".into(), serde_json::Value::String(workdir.to_string()));
}
}
}

/// Reads stdin and returns the payload to forward plus the event name and agent.
fn read_event() -> Option<(String, String, String)> {
let mut raw = Vec::new();
if std::io::stdin().read_to_end(&mut raw).is_err() || raw.is_empty() {
return None;
Expand All @@ -102,7 +199,6 @@ fn read_event() -> Option<(String, String)> {

// Parse argv: "coucou-hook.exe [--agent <name>] [<EventName>]"
// --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();
{
Expand All @@ -115,19 +211,22 @@ fn read_event() -> Option<(String, String)> {
}
}
}
// 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));
map.insert("coucou_agent".into(), serde_json::Value::String(agent.clone()));
}
let event = map

let raw_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);

let event = normalize_event_name(&raw_event);
map.insert("hook_event_name".into(), serde_json::Value::String(event.clone()));

normalize_payload(map);

for field in DROPPED_FIELDS {
map.remove(*field);
}
Expand Down Expand Up @@ -165,7 +264,7 @@ fn read_event() -> Option<(String, String)> {

let mut line = payload.to_string();
line.push('\n');
Some((line, event))
Some((line, event, agent))
}

/// Caps every string in the payload. A single Write can carry a whole file.
Expand Down Expand Up @@ -253,4 +352,38 @@ mod tests {
assert!(s.len() <= MAX_FIELD_LEN + 4);
assert!(s.ends_with('…'));
}

#[test]
fn antigravity_payload_normalization() {
let mut map = match serde_json::json!({
"conversationId": "conv-12345",
"workspacePaths": ["C:\\Projects\\test"],
"toolCall": {
"name": "run_command",
"args": {
"CommandLine": "cargo check"
}
}
}) {
serde_json::Value::Object(m) => m,
_ => unreachable!(),
};

normalize_payload(&mut map);

assert_eq!(map.get("session_id").unwrap(), "conv-12345");
assert_eq!(map.get("cwd").unwrap(), "C:\\Projects\\test");
assert_eq!(map.get("tool_name").unwrap(), "run_command");
let tool_input = map.get("tool_input").unwrap().as_object().unwrap();
assert_eq!(tool_input.get("command").unwrap(), "cargo check");
}

#[test]
fn antigravity_event_normalization() {
assert_eq!(normalize_event_name("PreInvocation"), "UserPromptSubmit");
assert_eq!(normalize_event_name("PostInvocation"), "PostToolUse");
assert_eq!(normalize_event_name("PreToolUse"), "PreToolUse");
assert_eq!(normalize_event_name("PostToolUse"), "PostToolUse");
assert_eq!(normalize_event_name("Stop"), "Stop");
}
}
Loading