Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<PascalCaseHook>` 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": "<type>", "input_data": <stdin>, "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
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/ghook/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <hello@gobby.ai>"]
Expand Down
2 changes: 1 addition & 1 deletion crates/ghook/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines 5 to 7
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.
Expand Down
2 changes: 1 addition & 1 deletion crates/ghook/schemas/inbox-envelope.v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions crates/ghook/src/cli_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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());
Expand All @@ -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]
Expand Down
10 changes: 10 additions & 0 deletions crates/ghook/src/diagnose.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
25 changes: 25 additions & 0 deletions crates/ghook/src/envelope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
95 changes: 93 additions & 2 deletions crates/ghook/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

Expand Down Expand Up @@ -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<HookAction, String> {
Expand All @@ -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 {
Expand All @@ -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!(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions crates/ghook/src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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\"}"));
Comment on lines +466 to +469
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();
Expand Down
12 changes: 6 additions & 6 deletions docs/guides/ghook-development-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<c> --type=<t> [--critical] [--detach]
│ pipes: stdin = hook payload (JSON object)
Expand Down Expand Up @@ -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`. |
Expand All @@ -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<String, String>,
}
```
Expand Down Expand Up @@ -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"
}
```
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading