diff --git a/CHANGELOG.md b/CHANGELOG.md index 4136efd..a165815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ All notable changes to gobby-cli are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.0] — gobby-hooks + +### Added + +#### gobby-hooks + +- **Factory droid hook route** — `ghook --gobby-owned --cli=droid --type=` now treats Factory's droid CLI as a first-class source. Droid hook stdin is passed through unchanged to the unified daemon endpoint as `{"hook_type": "", "input_data": , "source": "droid"}`, so the Gobby-side `DroidAdapter` owns the protocol translation. +- **Droid diagnose support** — `ghook --diagnose --cli=droid --type=SessionStart` now reports `cli_recognized: true` and `source: "droid"` so installers can probe for droid-capable ghook binaries. + +### Changed + +#### gobby-hooks + +- **Droid blocking semantics** — droid daemon responses with `continue:false` now exit 2 with the daemon reason while preserving the response JSON on stdout. Other droid block JSON is forwarded on stdout with exit 0 for droid's hook protocol, and daemon transport failures surface as exit 1 stderr diagnostics. + ## [0.3.1] — gobby-hooks ### Added diff --git a/Cargo.lock b/Cargo.lock index af27722..f5b2d72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -682,7 +682,7 @@ dependencies = [ [[package]] name = "gobby-hooks" -version = "0.3.1" +version = "0.4.0" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/crates/ghook/Cargo.toml b/crates/ghook/Cargo.toml index 8f7f654..7724848 100644 --- a/crates/ghook/Cargo.toml +++ b/crates/ghook/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gobby-hooks" -version = "0.3.1" +version = "0.4.0" edition = "2024" rust-version = "1.85" authors = ["Josh Wilhelmi "] diff --git a/crates/ghook/README.md b/crates/ghook/README.md index 0f0aa53..8434583 100644 --- a/crates/ghook/README.md +++ b/crates/ghook/README.md @@ -3,7 +3,7 @@ Sandbox-tolerant hook dispatcher for Gobby. `ghook` is invoked by host AI CLIs (Claude Code, Codex, Gemini CLI, Qwen -CLI) on lifecycle and tool-use events. It enqueues an envelope to +CLI, Factory droid) on lifecycle and tool-use events. It enqueues an envelope to `~/.gobby/hooks/inbox/` *before* attempting to POST to the local Gobby daemon — so the daemon's drain worker replays any envelope whose POST was lost to a sandbox FS-read denial, a network blip, or daemon restart. diff --git a/crates/ghook/schemas/inbox-envelope.v1.schema.json b/crates/ghook/schemas/inbox-envelope.v1.schema.json index 11f55b5..e09678d 100644 --- a/crates/ghook/schemas/inbox-envelope.v1.schema.json +++ b/crates/ghook/schemas/inbox-envelope.v1.schema.json @@ -40,7 +40,7 @@ "source": { "type": "string", "minLength": 1, - "description": "Source CLI identifier passed to the daemon (claude, codex, gemini, qwen)." + "description": "Source CLI identifier passed to the daemon (claude, codex, gemini, qwen, droid)." }, "headers": { "type": "object", diff --git a/crates/ghook/src/cli_config.rs b/crates/ghook/src/cli_config.rs index 0d6c752..cffe648 100644 --- a/crates/ghook/src/cli_config.rs +++ b/crates/ghook/src/cli_config.rs @@ -57,6 +57,12 @@ impl CliConfig { .collect(), json_error_exit_code: 2, }), + "droid" => Some(Self { + source: "droid", + critical_hooks: HashSet::new(), + terminal_context_hooks: HashSet::new(), + json_error_exit_code: 1, + }), _ => None, } } @@ -111,6 +117,16 @@ mod tests { assert_eq!(c.json_error_exit_code, 2); } + #[test] + fn droid_recognized_with_no_terminal_context_or_critical_hooks() { + let c = CliConfig::for_cli("droid").unwrap(); + assert_eq!(c.source, "droid"); + assert!(c.critical_hooks.is_empty()); + assert!(!c.wants_terminal_context("SessionStart")); + assert!(!c.wants_terminal_context("PreToolUse")); + assert_eq!(c.json_error_exit_code, 1); + } + #[test] fn unknown_cli_returns_none() { assert!(CliConfig::for_cli("cursor").is_none()); @@ -120,6 +136,7 @@ mod tests { fn cli_name_is_case_insensitive() { assert!(CliConfig::for_cli("CLAUDE").is_some()); assert!(CliConfig::for_cli("Codex").is_some()); + assert!(CliConfig::for_cli("Droid").is_some()); } #[test] diff --git a/crates/ghook/src/diagnose.rs b/crates/ghook/src/diagnose.rs index ea00123..3108e09 100644 --- a/crates/ghook/src/diagnose.rs +++ b/crates/ghook/src/diagnose.rs @@ -150,6 +150,16 @@ mod tests { assert!(d.terminal_context_enabled); } + #[test] + fn droid_session_start_is_recognized_noncritical_without_terminal_context() { + let d = diagnose("droid", "SessionStart"); + assert!(d.cli_recognized); + assert_eq!(d.source.as_deref(), Some("droid")); + assert!(!d.critical); + assert!(!d.terminal_context_enabled); + assert!(d.terminal_context_preview.is_none()); + } + fn compile_v2_schema() -> jsonschema::JSONSchema { let schema_bytes = include_bytes!("../schemas/diagnose-output.v2.schema.json"); let schema: serde_json::Value = serde_json::from_slice(schema_bytes).unwrap(); diff --git a/crates/ghook/src/envelope.rs b/crates/ghook/src/envelope.rs index 74ba55f..160e880 100644 --- a/crates/ghook/src/envelope.rs +++ b/crates/ghook/src/envelope.rs @@ -83,6 +83,31 @@ mod tests { assert!(v["enqueued_at"].as_str().unwrap().contains('T')); } + #[test] + fn droid_envelope_preserves_pascal_hook_and_source() { + let env = Envelope::new( + false, + "PreToolUse".into(), + json!({ + "session_id": "droid-session", + "transcript_path": "/tmp/droid.jsonl", + "cwd": "/tmp/project", + "permission_mode": "default", + "hook_event_name": "PreToolUse", + "tool_name": "Read", + "tool_input": {"file_path": "src/main.rs"} + }), + "droid".into(), + BTreeMap::new(), + ); + let v: Value = serde_json::to_value(&env).unwrap(); + + assert_eq!(v["hook_type"], "PreToolUse"); + assert_eq!(v["source"], "droid"); + assert_eq!(v["input_data"]["hook_event_name"], "PreToolUse"); + assert_eq!(v["input_data"]["tool_input"]["file_path"], "src/main.rs"); + } + #[test] fn empty_headers_serialize_as_empty_object() { let env = Envelope::new( diff --git a/crates/ghook/src/main.rs b/crates/ghook/src/main.rs index 4bde318..1a19d61 100644 --- a/crates/ghook/src/main.rs +++ b/crates/ghook/src/main.rs @@ -60,7 +60,7 @@ struct Args { #[arg(long)] version: bool, - /// Host CLI name (claude, codex, gemini, qwen). + /// Host CLI name (claude, codex, gemini, qwen, droid). #[arg(long)] cli: Option, @@ -303,7 +303,7 @@ fn emit_action(action: HookAction) -> ExitCode { } fn action_from_success_response( - _canonical_source: &str, + canonical_source: &str, hook_type: &str, response_body: &str, ) -> Result { @@ -319,6 +319,10 @@ fn action_from_success_response( let result: Value = serde_json::from_str(trimmed).map_err(|e| e.to_string())?; let serialized = serde_json::to_string(&result).map_err(|e| e.to_string())?; + if canonical_source == "droid" { + return Ok(action_from_droid_success(result, serialized)); + } + if is_blocked(&result) { if hook_type != "Stop" { return Ok(HookAction { @@ -341,12 +345,47 @@ fn action_from_success_response( }) } +fn action_from_droid_success(result: Value, serialized: String) -> HookAction { + if result + .as_object() + .and_then(|map| map.get("continue")) + .and_then(Value::as_bool) + == Some(false) + { + return HookAction { + exit_code: 2, + stdout_json: Some(serialized), + stderr_message: Some(extract_reason(&result)), + }; + } + + HookAction { + exit_code: 0, + stdout_json: json_value_is_meaningful(&result).then_some(serialized), + stderr_message: None, + } +} + fn action_from_failure( hook_type: &str, cfg: &CliConfig, failure_kind: transport::DeliveryFailureKind, detail: &str, ) -> HookAction { + if cfg.source == "droid" { + let message = match failure_kind { + transport::DeliveryFailureKind::Http => format!("Daemon error: {detail}"), + transport::DeliveryFailureKind::Connect => "Daemon unreachable".to_string(), + transport::DeliveryFailureKind::Timeout => "Hook execution timeout".to_string(), + transport::DeliveryFailureKind::Other => detail.to_string(), + }; + return HookAction { + exit_code: 1, + stdout_json: None, + stderr_message: Some(message), + }; + } + if cfg.is_critical_hook(hook_type) { let reason = match failure_kind { transport::DeliveryFailureKind::Http => format!( @@ -605,6 +644,42 @@ mod tests { ); } + #[test] + fn action_from_success_treats_droid_continue_false_as_exit_two_with_json() { + let action = action_from_success_response( + "droid", + "PreToolUse", + r#"{"continue":false,"stopReason":"Create a task first"}"#, + ) + .unwrap(); + + assert_eq!(action.exit_code, 2); + let stdout_json = action.stdout_json.unwrap(); + let parsed: Value = serde_json::from_str(&stdout_json).unwrap(); + assert_eq!(parsed["continue"], false); + assert_eq!( + action.stderr_message.as_deref(), + Some("Create a task first") + ); + } + + #[test] + fn action_from_success_preserves_droid_block_json_without_exit_two() { + let action = action_from_success_response( + "droid", + "Stop", + r#"{"decision":"block","reason":"Task still in progress"}"#, + ) + .unwrap(); + + assert_eq!(action.exit_code, 0); + let stdout_json = action.stdout_json.unwrap(); + let parsed: Value = serde_json::from_str(&stdout_json).unwrap(); + assert_eq!(parsed["decision"], "block"); + assert_eq!(parsed["reason"], "Task still in progress"); + assert_eq!(action.stderr_message, None); + } + #[test] fn action_from_failure_blocks_critical_hooks() { let action = action_from_failure( @@ -668,6 +743,22 @@ mod tests { ); } + #[test] + fn action_from_failure_returns_stderr_for_droid_transport_errors() { + let action = action_from_failure( + "PreToolUse", + &CliConfig::for_dispatch("droid"), + DeliveryFailureKind::Http, + "Internal Server Error", + ); + assert_eq!(action.exit_code, 1); + assert!(action.stdout_json.is_none()); + assert_eq!( + action.stderr_message.as_deref(), + Some("Daemon error: Internal Server Error") + ); + } + #[test] fn hooks_disabled_by_env_reads_env_var() { // Avoid racing other tests that read GOBBY_* env vars — touching the diff --git a/crates/ghook/src/transport.rs b/crates/ghook/src/transport.rs index f2fa4b3..09c4dec 100644 --- a/crates/ghook/src/transport.rs +++ b/crates/ghook/src/transport.rs @@ -439,6 +439,51 @@ mod tests { assert!(!path.exists()); } + #[test] + fn post_and_cleanup_sends_droid_source_to_unified_hooks_endpoint() { + let dir = tempdir().unwrap(); + let inbox = dir.path().join("inbox"); + let envelope = Envelope::new( + false, + "PreToolUse".into(), + serde_json::json!({ + "session_id": "droid-session", + "hook_event_name": "PreToolUse", + "tool_name": "Read", + "tool_input": {"file_path": "src/main.rs"} + }), + "droid".into(), + BTreeMap::new(), + ); + let path = enqueue_to(&envelope, &inbox).unwrap(); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + assert!(request.contains("POST /api/hooks/execute HTTP/1.1")); + assert!(request.contains("\"hook_type\":\"PreToolUse\"")); + assert!(request.contains("\"source\":\"droid\"")); + assert!(request.contains("\"input_data\":{\"hook_event_name\":\"PreToolUse\"")); + assert!(request.contains("\"tool_input\":{\"file_path\":\"src/main.rs\"}")); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}", + ) + .unwrap(); + }); + + let report = post_and_cleanup(&envelope, &path, &format!("http://{addr}")); + handle.join().unwrap(); + + assert_eq!(report.outcome, DeliveryOutcome::Delivered); + assert_eq!(report.failure_kind, None); + assert_eq!(report.status_code, Some(200)); + assert_eq!(report.response_body, Some("{}".to_string())); + assert!(!path.exists()); + } + #[test] fn post_and_cleanup_captures_http_error_body() { let dir = tempdir().unwrap(); diff --git a/docs/guides/ghook-development-guide.md b/docs/guides/ghook-development-guide.md index 2d3b20e..cfe9cae 100644 --- a/docs/guides/ghook-development-guide.md +++ b/docs/guides/ghook-development-guide.md @@ -5,7 +5,7 @@ Technical internals for developers and agents working in the ghook codebase. ## Architecture Overview ```text -host AI CLI (Claude Code / Codex / Gemini / Qwen) +host AI CLI (Claude Code / Codex / Gemini / Qwen / Droid) │ spawns: ghook --gobby-owned --cli= --type= [--critical] [--detach] │ pipes: stdin = hook payload (JSON object) ▼ @@ -55,7 +55,7 @@ The original Python `hook_dispatcher.py` ran inside the daemon process. That mad | Module | Responsibility | |--------|----------------| | `main.rs` | Arg parsing (clap), mode dispatch (`--gobby-owned`/`--diagnose`/`--version`), orchestrates the dispatch flow. | -| `cli_config.rs` | Per-CLI registry (claude/codex/gemini/qwen) — which hooks are critical, which want terminal context. Compile-time frozen. | +| `cli_config.rs` | Per-CLI registry (claude/codex/gemini/qwen/droid) — which hooks are critical, which want terminal context. Compile-time frozen. | | `envelope.rs` | `Envelope` struct + `SCHEMA_VERSION = 1`. Serializes to the inbox JSON shape. | | `transport.rs` | Inbox path resolution, atomic write, enqueue, POST + cleanup, quarantine for malformed stdin. | | `terminal_context.rs` | Captures parent PID, TTY, tmux pane/socket, `TERM_PROGRAM`, `GOBBY_*` env vars. Injects under `input_data.terminal_context`. | @@ -81,7 +81,7 @@ pub struct Envelope { pub critical: bool, pub hook_type: String, // host-CLI-specific pub input_data: Value, // verbatim stdin + optional terminal_context - pub source: String, // "claude" / "codex" / "gemini" / "qwen" / passthrough + pub source: String, // "claude" / "codex" / "gemini" / "qwen" / "droid" / passthrough pub headers: BTreeMap, } ``` @@ -152,8 +152,8 @@ Schema: ```json { "install_method": "github-release", - "install_source_url": "https://github.com/GobbyAI/gobby-cli/releases/download/ghook-v0.3.0/ghook-aarch64-apple-darwin.tar.gz", - "installed_version": "0.3.0", + "install_source_url": "https://github.com/GobbyAI/gobby-cli/releases/download/ghook-v0.4.0/ghook-aarch64-apple-darwin.tar.gz", + "installed_version": "0.4.0", "installed_at": "2026-04-22T18:30:00Z" } ``` @@ -318,7 +318,7 @@ Almost always config-only. ghook treats `--type` as opaque. To make a hook criti ## Versioning -ghook is at `0.3.0`. The envelope `SCHEMA_VERSION` is `1`; the diagnose-output schema is `2`. The three version numbers are independent: +ghook is at `0.4.0`. The envelope `SCHEMA_VERSION` is `1`; the diagnose-output schema is `2`. The three version numbers are independent: - **Crate version** bumps for any code change (binary behavior, dependencies, perf, etc.). - **Envelope `SCHEMA_VERSION`** bumps only when the inbox envelope shape changes in a way the daemon must explicitly handle. diff --git a/docs/guides/ghook-user-guide.md b/docs/guides/ghook-user-guide.md index bd5fc82..f25d7bd 100644 --- a/docs/guides/ghook-user-guide.md +++ b/docs/guides/ghook-user-guide.md @@ -1,6 +1,6 @@ # ghook User Guide -ghook is the sandbox-tolerant hook dispatcher Gobby uses to receive lifecycle and tool-use events from host AI CLIs (Claude Code, Codex, Gemini CLI, Qwen CLI). It enqueues an envelope to `~/.gobby/hooks/inbox/` *before* attempting to POST to the local Gobby daemon — so the daemon's drain worker can replay any envelope whose POST was lost to a sandbox FS-read denial, a network blip, or a daemon restart. +ghook is the sandbox-tolerant hook dispatcher Gobby uses to receive lifecycle and tool-use events from host AI CLIs (Claude Code, Codex, Gemini CLI, Qwen CLI, Factory droid). It enqueues an envelope to `~/.gobby/hooks/inbox/` *before* attempting to POST to the local Gobby daemon — so the daemon's drain worker can replay any envelope whose POST was lost to a sandbox FS-read denial, a network blip, or a daemon restart. You don't usually invoke ghook directly. The Gobby installer wires it into each host CLI's hook configuration. This guide explains what it does, how to verify it's working, and how to wire it manually if you need to. @@ -58,7 +58,7 @@ ghook --version | `--gobby-owned` | dispatch | Normal hook invocation. Reads stdin, enqueues, attempts POST. | | `--diagnose` | introspection | Prints a JSON snapshot of what *would* happen. No network, no envelope write. | | `--version` | metadata | Prints version and writes `~/.gobby/bin/.ghook-compatibility` for the daemon. | -| `--cli` | required for dispatch/diagnose | Host CLI name: `claude`, `codex`, `gemini`, `qwen`. Case-insensitive. | +| `--cli` | required for dispatch/diagnose | Host CLI name: `claude`, `codex`, `gemini`, `qwen`, `droid`. Case-insensitive. | | `--type` | required for dispatch/diagnose | Hook type. CLI-specific (e.g. `session-start` for Claude, `SessionStart` for Codex/Gemini/Qwen, `PreToolUse`, `PostToolUse`, `Stop`, `pre-compact`, `session-end`). | | `--critical` | dispatch | Compatibility flag accepted by ghook. Host-visible behavior is derived from the per-CLI dispatcher contract, matching `hook_dispatcher.py`. | | `--detach` | dispatch | After enqueue and project-root walk-up, call `setsid(2)` to escape the host CLI's process group before the POST. Useful for hooks where the host CLI tears down its session immediately. | @@ -138,7 +138,7 @@ Claude Code uses lowercase-hyphenated names internally for some hooks (`session- The `--critical` flag is on lifecycle hooks (`session-start`, `session-end`, `pre-compact`) because these set up state the daemon needs immediately. Tool-use hooks are non-critical — the envelope still spools, but a transient daemon outage won't block your tool call. -### Codex, Gemini, Qwen +### Codex, Gemini, Qwen, Droid Same pattern with different `--cli` and `--type` values. ghook's per-CLI registry (see `crates/ghook/src/cli_config.rs`) defines which hooks are critical and which receive enriched terminal context for each host CLI: @@ -148,6 +148,9 @@ Same pattern with different `--cli` and `--type` values. ghook's per-CLI registr | `codex` | `SessionStart`, `Stop` | `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `Stop` | | `gemini` | `SessionStart` | `SessionStart` | | `qwen` | `SessionStart` | `SessionStart` | +| `droid` | none | none | + +Droid uses PascalCase hook types (`SessionStart`, `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Notification`, `Stop`, `SubagentStop`, `PreCompact`, `SessionEnd`) and ghook forwards droid's stdin payload unchanged to the daemon with `source: "droid"`. Droid-specific block handling differs slightly from the other CLIs: daemon responses containing `continue:false` exit 2, while other meaningful response JSON is written to stdout with exit 0. Unknown `--cli` values fall back to conservative Claude-like dispatch behavior on the live path. Diagnose mode still reports unknown CLIs as unrecognized. @@ -159,7 +162,7 @@ Unknown `--cli` values fall back to conservative Claude-like dispatch behavior o $ ghook --diagnose --cli=claude --type=session-start { "schema_version": 2, - "ghook_version": "0.3.0", + "ghook_version": "0.4.0", "cli": "claude", "hook_type": "session-start", "source": "claude", @@ -179,7 +182,7 @@ $ ghook --diagnose --cli=claude --type=session-start }, "cli_recognized": true, "install_method": "github-release", - "install_source_url": "https://github.com/GobbyAI/gobby-cli/releases/download/ghook-v0.3.0/ghook-aarch64-apple-darwin.tar.gz" + "install_source_url": "https://github.com/GobbyAI/gobby-cli/releases/download/ghook-v0.4.0/ghook-aarch64-apple-darwin.tar.gz" } ```