Skip to content

Commit 972ab58

Browse files
joshwilhelmiclaude
andcommitted
chore(release): prep ghook 0.2.1 and gsqz 0.4.2
ghook 0.2.1: - Short-circuit when GOBBY_HOOKS_DISABLED=1 is set so daemon-spawned ACP subprocesses (gemini --acp, qwen --acp) don't register phantom sessions via inherited SessionStart hooks. Returns {} with exit 0 before enqueue, POST, or terminal-context capture. - Carry GOBBY_ACP_CHILD through terminal_context.capture() so the daemon's SESSION_START handler can still recognize ACP subprocesses even if the env short-circuit didn't fire. - Extend extract_reason() to check hookSpecificOutput.permissionDecisionReason (and .reason inside that object) after the top-level fallback keys. Modern Claude Code PreToolUse deny responses put the reason inside hookSpecificOutput; is_blocked() already recognized the nested shape, so extract_reason() was asymmetric and surfaced bare "Blocked by hook" instead of the daemon's actual message. gsqz 0.4.2: - Floor CompressionResult::savings_pct() at 0.0 so a badly-matched pipeline that grows output doesn't report negative savings to the daemon. Gobby-Task: #139 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 25bad53 commit 972ab58

8 files changed

Lines changed: 109 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ All notable changes to gobby-cli are documented in this file.
77
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
88
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

10+
## [0.2.1] — gobby-hooks
11+
12+
### Fixed
13+
14+
#### gobby-hooks
15+
16+
- **Drop phantom ACP session registrations**`ghook` now short-circuits when `GOBBY_HOOKS_DISABLED=1` is set in the process environment, returning `{}` with exit 0 before any side effect (no enqueue, no POST, no terminal-context capture). Daemon-spawned `gemini --acp` / `qwen --acp` subprocesses inherit the host CLI's SessionStart hook; this env flag lets the daemon mark them so they don't register phantom sessions.
17+
- **`gobby_acp_child` in terminal_context**`terminal_context.capture()` now includes `gobby_acp_child` (read from `GOBBY_ACP_CHILD`). The daemon's SESSION_START handler uses it as a second line of defense to recognize and drop registrations from ACP subprocesses even if the env short-circuit didn't fire.
18+
- **Surface nested `permissionDecisionReason` in block messages**`extract_reason` now also checks `hookSpecificOutput.permissionDecisionReason` (and `.reason` inside that object) after the top-level fallback keys. Modern Claude Code PreToolUse deny responses carry the reason inside `hookSpecificOutput`; `is_blocked` already recognized the nested shape, but `extract_reason` didn't — so denies surfaced as the bare "Blocked by hook" fallback instead of the daemon's actual message.
19+
20+
## [0.4.2] — gsqz
21+
22+
### Fixed
23+
24+
#### gsqz
25+
26+
- **Floor `savings_pct` at 0%** — when compressed output ends up larger than the original, `CompressionResult::savings_pct()` now returns `0.0` instead of a negative percentage. Prevents negative savings values from being reported to the daemon.
27+
1028
## [0.6.2] — gcode
1129

1230
### Added

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ghook/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "gobby-hooks"
3-
version = "0.2.0"
3+
version = "0.2.1"
44
edition = "2024"
55
rust-version = "1.85"
66
authors = ["Josh Wilhelmi <hello@gobby.ai>"]

crates/ghook/src/main.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,15 @@ fn run_gobby_owned(args: &Args) -> ExitCode {
125125
return ExitCode::from(2);
126126
};
127127

128+
// Daemon-spawned ACP subprocesses (gemini --acp, qwen --acp) set
129+
// GOBBY_HOOKS_DISABLED=1 to stop their inherited SessionStart hook from
130+
// registering phantom sessions. Short-circuit before any side effects: no
131+
// enqueue, no POST, no terminal-context enrichment.
132+
if hooks_disabled_by_env() {
133+
println!("{}", serde_json::json!({}));
134+
return ExitCode::SUCCESS;
135+
}
136+
128137
let cfg = CliConfig::for_dispatch(cli);
129138
let is_critical = cfg.is_critical_hook(hook_type);
130139

@@ -250,6 +259,10 @@ fn run_gobby_owned(args: &Args) -> ExitCode {
250259
emit_action(action)
251260
}
252261

262+
fn hooks_disabled_by_env() -> bool {
263+
std::env::var_os("GOBBY_HOOKS_DISABLED").is_some_and(|v| v == "1")
264+
}
265+
253266
fn detect_source(cfg: &CliConfig) -> String {
254267
if cfg.source != "claude" {
255268
return cfg.source.to_string();
@@ -391,13 +404,29 @@ fn extract_reason(result: &Value) -> String {
391404
let Some(map) = result.as_object() else {
392405
return "Blocked by hook".to_string();
393406
};
407+
394408
for key in ["stopReason", "user_message", "reason"] {
395409
if let Some(reason) = map.get(key).and_then(Value::as_str)
396410
&& !reason.is_empty()
397411
{
398412
return reason.to_string();
399413
}
400414
}
415+
416+
// Modern Claude Code PreToolUse denies put the reason inside
417+
// hookSpecificOutput.permissionDecisionReason — mirror the nested lookup
418+
// already done by is_blocked so users see the daemon's actual message
419+
// instead of the bare "Blocked by hook" fallback.
420+
if let Some(hook_specific) = map.get("hookSpecificOutput").and_then(Value::as_object) {
421+
for key in ["permissionDecisionReason", "reason"] {
422+
if let Some(reason) = hook_specific.get(key).and_then(Value::as_str)
423+
&& !reason.is_empty()
424+
{
425+
return reason.to_string();
426+
}
427+
}
428+
}
429+
401430
"Blocked by hook".to_string()
402431
}
403432

@@ -484,6 +513,23 @@ mod tests {
484513
assert_eq!(action.stderr_message, None);
485514
}
486515

516+
#[test]
517+
fn action_from_success_surfaces_nested_permission_decision_reason() {
518+
let action = action_from_success_response(
519+
"claude",
520+
"PreToolUse",
521+
r#"{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"Task #50 requires TDD expansion before edits"}}"#,
522+
)
523+
.unwrap();
524+
525+
assert_eq!(action.exit_code, 2);
526+
assert!(action.stdout_json.is_none());
527+
assert_eq!(
528+
action.stderr_message.as_deref(),
529+
Some("Task #50 requires TDD expansion before edits")
530+
);
531+
}
532+
487533
#[test]
488534
fn action_from_success_treats_stop_block_as_exit_two() {
489535
let action = action_from_success_response(
@@ -566,6 +612,41 @@ mod tests {
566612
);
567613
}
568614

615+
#[test]
616+
fn hooks_disabled_by_env_reads_env_var() {
617+
// Avoid racing other tests that read GOBBY_* env vars — touching the
618+
// process env from tests is inherently global, but the key we use is
619+
// unique to this check.
620+
// SAFETY: single-threaded Rust tests within this module; no other test
621+
// reads or writes GOBBY_HOOKS_DISABLED.
622+
unsafe {
623+
std::env::remove_var("GOBBY_HOOKS_DISABLED");
624+
}
625+
assert!(!hooks_disabled_by_env());
626+
627+
unsafe {
628+
std::env::set_var("GOBBY_HOOKS_DISABLED", "1");
629+
}
630+
assert!(hooks_disabled_by_env());
631+
632+
unsafe {
633+
std::env::set_var("GOBBY_HOOKS_DISABLED", "0");
634+
}
635+
assert!(!hooks_disabled_by_env(), "only '1' should short-circuit");
636+
637+
unsafe {
638+
std::env::set_var("GOBBY_HOOKS_DISABLED", "");
639+
}
640+
assert!(
641+
!hooks_disabled_by_env(),
642+
"empty string should not short-circuit"
643+
);
644+
645+
unsafe {
646+
std::env::remove_var("GOBBY_HOOKS_DISABLED");
647+
}
648+
}
649+
569650
#[test]
570651
fn is_blocked_matches_dispatcher_patterns() {
571652
assert!(is_blocked(&json!({"continue": false})));

crates/ghook/src/terminal_context.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ pub fn capture() -> Value {
3232
"gobby_agent_run_id": env_or_null("GOBBY_AGENT_RUN_ID"),
3333
"gobby_project_id": env_or_null("GOBBY_PROJECT_ID"),
3434
"gobby_workflow_name": env_or_null("GOBBY_WORKFLOW_NAME"),
35+
// Carried so the daemon's SESSION_START handler can recognize and
36+
// drop registrations from daemon-spawned ACP subprocesses.
37+
"gobby_acp_child": env_or_null("GOBBY_ACP_CHILD"),
3538
})
3639
}
3740

@@ -181,6 +184,7 @@ mod tests {
181184
"gobby_agent_run_id",
182185
"gobby_project_id",
183186
"gobby_workflow_name",
187+
"gobby_acp_child",
184188
] {
185189
assert!(obj.contains_key(key), "missing key: {key}");
186190
}

crates/gsqz/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "gobby-squeeze"
3-
version = "0.4.1"
3+
version = "0.4.2"
44
edition = "2024"
55
rust-version = "1.85"
66
authors = ["Josh Wilhelmi <hello@gobby.ai>"]

crates/gsqz/src/compressor.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ impl CompressionResult {
1616
if self.original_chars == 0 {
1717
return 0.0;
1818
}
19-
(1.0 - self.compressed_chars as f64 / self.original_chars as f64) * 100.0
19+
((1.0 - self.compressed_chars as f64 / self.original_chars as f64) * 100.0).max(0.0)
2020
}
2121

2222
/// True when no useful compression occurred — original output should be

docs/guides/ghook-development-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ Almost always config-only. ghook treats `--type` as opaque. To make a hook criti
282282

283283
## Versioning
284284

285-
ghook is at `0.2.0`. `SCHEMA_VERSION` is also `1`. The two version numbers are independent:
285+
ghook is at `0.2.1`. `SCHEMA_VERSION` is also `1`. The two version numbers are independent:
286286

287287
- **Crate version** bumps for any code change (binary behavior, dependencies, perf, etc.).
288288
- **`SCHEMA_VERSION`** bumps only when the envelope shape changes in a way the daemon must explicitly handle.

0 commit comments

Comments
 (0)