Skip to content

Commit 1fdbc66

Browse files
committed
Add tmux pane context to ghook envelopes
Task: #313
1 parent 7ff5e3e commit 1fdbc66

12 files changed

Lines changed: 249 additions & 142 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12+
## [0.4.4] — gobby-hooks
13+
14+
### Added
15+
16+
#### gobby-hooks
17+
18+
- **Tmux pane terminal context**`ghook` now injects
19+
`input_data.terminal_context.tmux_pane` for any dispatch path when `TMUX` is
20+
set and `TMUX_PANE` matches the daemon's `^%\d+$` contract. Missing, empty,
21+
or invalid pane IDs leave `terminal_context` absent, so the daemon only sees
22+
pane metadata it can validate and use for tmux window titles.
23+
1224
## [0.9.4] — gcode
1325

1426
### Changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
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.4.3"
3+
version = "0.4.4"
44
edition = "2024"
55
rust-version = "1.88"
66
authors = ["Josh Wilhelmi <hello@gobby.ai>"]

crates/ghook/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ ghook --diagnose --cli=<c> --type=<t>
1616
ghook --version
1717
```
1818

19+
## Terminal context
20+
21+
When `TMUX` is set and `TMUX_PANE` matches `^%\d+$`, `ghook` injects
22+
`input_data.terminal_context.tmux_pane` into the envelope for any `--cli` value.
23+
The pane ID is passed through verbatim. If the pane variable is missing, empty,
24+
or invalid, `terminal_context` is omitted.
25+
1926
Exit codes:
2027

2128
- `0` — success, including non-Stop deny/block responses that are returned as JSON

crates/ghook/schemas/inbox-envelope.v1.schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
"description": "Host-CLI-specific hook name (e.g. session-start, SessionStart, PreToolUse)."
3636
},
3737
"input_data": {
38-
"description": "Original stdin payload from the host CLI, optionally enriched with a terminal_context object for hooks that request it."
38+
"description": "Original stdin payload from the host CLI, optionally enriched with a terminal_context object when valid tmux pane context is available."
3939
},
4040
"source": {
4141
"type": "string",

crates/ghook/src/cli_config.rs

Lines changed: 3 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
//!
33
//! Mirrors the `CLIConfig` registry in `hook_dispatcher.py` — the set of
44
//! host CLIs Gobby dispatches for and, per CLI, which hooks are "critical"
5-
//! (block on failure, exit 2) and which should carry enriched terminal
6-
//! context.
5+
//! (block on failure, exit 2).
76
87
use std::collections::HashSet;
98

@@ -14,8 +13,6 @@ pub struct CliConfig {
1413
pub source: &'static str,
1514
/// Hooks where failure should fail-closed (exit 2).
1615
pub critical_hooks: HashSet<&'static str>,
17-
/// Hooks that should carry enriched terminal context in `input_data`.
18-
pub terminal_context_hooks: HashSet<&'static str>,
1916
/// Exit code to use for malformed JSON input, matching the Python dispatcher.
2017
pub json_error_exit_code: u8,
2118
}
@@ -28,39 +25,26 @@ impl CliConfig {
2825
critical_hooks: ["session-start", "session-end", "pre-compact"]
2926
.into_iter()
3027
.collect(),
31-
terminal_context_hooks: ["session-start"].into_iter().collect(),
3228
json_error_exit_code: 2,
3329
}),
3430
"gemini" => Some(Self {
3531
source: "gemini",
3632
critical_hooks: ["SessionStart"].into_iter().collect(),
37-
terminal_context_hooks: ["SessionStart"].into_iter().collect(),
3833
json_error_exit_code: 1,
3934
}),
4035
"qwen" => Some(Self {
4136
source: "qwen",
4237
critical_hooks: ["SessionStart"].into_iter().collect(),
43-
terminal_context_hooks: ["SessionStart"].into_iter().collect(),
4438
json_error_exit_code: 1,
4539
}),
4640
"codex" => Some(Self {
4741
source: "codex",
4842
critical_hooks: ["SessionStart", "Stop"].into_iter().collect(),
49-
terminal_context_hooks: [
50-
"SessionStart",
51-
"UserPromptSubmit",
52-
"PreToolUse",
53-
"PostToolUse",
54-
"Stop",
55-
]
56-
.into_iter()
57-
.collect(),
5843
json_error_exit_code: 2,
5944
}),
6045
"droid" => Some(Self {
6146
source: "droid",
6247
critical_hooks: HashSet::new(),
63-
terminal_context_hooks: HashSet::new(),
6448
json_error_exit_code: 1,
6549
}),
6650
_ => None,
@@ -71,10 +55,6 @@ impl CliConfig {
7155
Self::for_cli(cli).unwrap_or_else(|| Self::for_cli("claude").expect("claude config"))
7256
}
7357

74-
pub fn wants_terminal_context(&self, hook_type: &str) -> bool {
75-
self.terminal_context_hooks.contains(hook_type)
76-
}
77-
7858
pub fn is_critical_hook(&self, hook_type: &str) -> bool {
7959
self.critical_hooks.contains(hook_type)
8060
}
@@ -94,18 +74,8 @@ mod tests {
9474
}
9575

9676
#[test]
97-
fn codex_terminal_context_broad() {
98-
let c = CliConfig::for_cli("codex").unwrap();
99-
assert!(c.wants_terminal_context("PreToolUse"));
100-
assert!(c.wants_terminal_context("Stop"));
101-
assert!(!c.wants_terminal_context("session-start"));
102-
}
103-
104-
#[test]
105-
fn gemini_session_only_terminal_context() {
77+
fn gemini_json_parse_errors_exit_one() {
10678
let c = CliConfig::for_cli("gemini").unwrap();
107-
assert!(c.wants_terminal_context("SessionStart"));
108-
assert!(!c.wants_terminal_context("PreToolUse"));
10979
assert_eq!(c.json_error_exit_code, 1);
11080
}
11181

@@ -118,12 +88,10 @@ mod tests {
11888
}
11989

12090
#[test]
121-
fn droid_recognized_with_no_terminal_context_or_critical_hooks() {
91+
fn droid_recognized_with_no_critical_hooks() {
12292
let c = CliConfig::for_cli("droid").unwrap();
12393
assert_eq!(c.source, "droid");
12494
assert!(c.critical_hooks.is_empty());
125-
assert!(!c.wants_terminal_context("SessionStart"));
126-
assert!(!c.wants_terminal_context("PreToolUse"));
12795
assert_eq!(c.json_error_exit_code, 1);
12896
}
12997

crates/ghook/src/diagnose.rs

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,8 @@ pub fn diagnose(cli: &str, hook_type: &str) -> DiagnoseOutput {
8585
let (source, critical, terminal_context_enabled, terminal_context_preview) = match cfg {
8686
Some(c) => {
8787
let critical = c.critical_hooks.contains(hook_type);
88-
let wants_ctx = c.wants_terminal_context(hook_type);
89-
let preview = if wants_ctx {
90-
Some(crate::terminal_context::capture())
91-
} else {
92-
None
93-
};
94-
(Some(c.source.to_string()), critical, wants_ctx, preview)
88+
let preview = crate::terminal_context::capture();
89+
(Some(c.source.to_string()), critical, true, preview)
9590
}
9691
None => (None, false, false, None),
9792
};
@@ -139,7 +134,6 @@ mod tests {
139134
assert_eq!(d.source.as_deref(), Some("claude"));
140135
assert!(d.critical);
141136
assert!(d.terminal_context_enabled);
142-
assert!(d.terminal_context_preview.is_some());
143137
}
144138

145139
#[test]
@@ -151,13 +145,12 @@ mod tests {
151145
}
152146

153147
#[test]
154-
fn droid_session_start_is_recognized_noncritical_without_terminal_context() {
148+
fn droid_session_start_is_recognized_noncritical_with_terminal_context_enabled() {
155149
let d = diagnose("droid", "SessionStart");
156150
assert!(d.cli_recognized);
157151
assert_eq!(d.source.as_deref(), Some("droid"));
158152
assert!(!d.critical);
159-
assert!(!d.terminal_context_enabled);
160-
assert!(d.terminal_context_preview.is_none());
153+
assert!(d.terminal_context_enabled);
161154
}
162155

163156
fn compile_v2_schema() -> jsonschema::JSONSchema {

crates/ghook/src/envelope.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pub const SCHEMA_VERSION: u32 = 1;
1919
///
2020
/// Field order follows the schema. `headers` is serialized as a plain
2121
/// object; absent headers are not keys. `input_data` is the original stdin
22-
/// payload verbatim (with `terminal_context` injected when applicable).
22+
/// payload verbatim (with valid tmux `terminal_context` injected when present).
2323
#[derive(Debug, Serialize)]
2424
pub struct Envelope {
2525
pub schema_version: u32,

crates/ghook/src/main.rs

Lines changed: 124 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ fn run_gobby_owned(args: &Args) -> ExitCode {
179179
)))
180180
};
181181

182-
let mut input_data = match parsed {
182+
let input_data = match parsed {
183183
Ok(v) => v,
184184
Err(e) => {
185185
let _ = transport::quarantine_malformed(&stdin_raw, &e.to_string(), is_critical);
@@ -188,29 +188,7 @@ fn run_gobby_owned(args: &Args) -> ExitCode {
188188
}
189189
};
190190

191-
// Terminal-context enrichment, gated by per-CLI set.
192-
if cfg.wants_terminal_context(hook_type) {
193-
terminal_context::inject(&mut input_data);
194-
}
195-
196-
// Headers: omit on missing (never empty string).
197-
let mut headers: BTreeMap<String, String> = BTreeMap::new();
198-
if let Some(pid) = project_id {
199-
headers.insert("X-Gobby-Project-Id".into(), pid);
200-
}
201-
if let Some(sid) = input_data.get("session_id").and_then(|v| v.as_str())
202-
&& !sid.is_empty()
203-
{
204-
headers.insert("X-Gobby-Session-Id".into(), sid.to_string());
205-
}
206-
207-
let env = Envelope::new(
208-
is_critical,
209-
hook_type.to_string(),
210-
input_data,
211-
detect_source(&cfg),
212-
headers,
213-
);
191+
let env = build_dispatch_envelope(&cfg, hook_type, input_data, project_id.as_deref());
214192

215193
// Enqueue first (atomic write to ~/.gobby/hooks/inbox/).
216194
let inbox = match transport::inbox_dir() {
@@ -297,6 +275,34 @@ fn hooks_disabled_by_env() -> bool {
297275
std::env::var_os("GOBBY_HOOKS_DISABLED").is_some_and(|v| v == "1")
298276
}
299277

278+
fn build_dispatch_envelope(
279+
cfg: &CliConfig,
280+
hook_type: &str,
281+
mut input_data: Value,
282+
project_id: Option<&str>,
283+
) -> Envelope {
284+
terminal_context::inject(&mut input_data);
285+
286+
// Headers: omit on missing (never empty string).
287+
let mut headers: BTreeMap<String, String> = BTreeMap::new();
288+
if let Some(pid) = project_id {
289+
headers.insert("X-Gobby-Project-Id".into(), pid.to_string());
290+
}
291+
if let Some(sid) = input_data.get("session_id").and_then(|v| v.as_str())
292+
&& !sid.is_empty()
293+
{
294+
headers.insert("X-Gobby-Session-Id".into(), sid.to_string());
295+
}
296+
297+
Envelope::new(
298+
cfg.is_critical_hook(hook_type),
299+
hook_type.to_string(),
300+
input_data,
301+
detect_source(cfg),
302+
headers,
303+
)
304+
}
305+
300306
fn detect_source(cfg: &CliConfig) -> String {
301307
if cfg.source != "claude" {
302308
return cfg.source.to_string();
@@ -571,6 +577,100 @@ mod tests {
571577
use super::*;
572578
use crate::transport::DeliveryFailureKind;
573579
use serde_json::json;
580+
use std::ffi::OsString;
581+
use std::sync::{Mutex, MutexGuard};
582+
583+
static ENV_LOCK: Mutex<()> = Mutex::new(());
584+
585+
fn with_tmux_env<T>(tmux: Option<&str>, tmux_pane: Option<&str>, f: impl FnOnce() -> T) -> T {
586+
let _env = TmuxEnv::set(tmux, tmux_pane);
587+
f()
588+
}
589+
590+
struct TmuxEnv {
591+
_guard: MutexGuard<'static, ()>,
592+
original_tmux: Option<OsString>,
593+
original_tmux_pane: Option<OsString>,
594+
}
595+
596+
impl TmuxEnv {
597+
fn set(tmux: Option<&str>, tmux_pane: Option<&str>) -> Self {
598+
let guard = ENV_LOCK.lock().unwrap();
599+
let original_tmux = std::env::var_os("TMUX");
600+
let original_tmux_pane = std::env::var_os("TMUX_PANE");
601+
602+
set_env_var("TMUX", tmux.map(OsString::from));
603+
set_env_var("TMUX_PANE", tmux_pane.map(OsString::from));
604+
605+
Self {
606+
_guard: guard,
607+
original_tmux,
608+
original_tmux_pane,
609+
}
610+
}
611+
}
612+
613+
impl Drop for TmuxEnv {
614+
fn drop(&mut self) {
615+
set_env_var("TMUX", self.original_tmux.take());
616+
set_env_var("TMUX_PANE", self.original_tmux_pane.take());
617+
}
618+
}
619+
620+
fn set_env_var(key: &str, value: Option<OsString>) {
621+
// SAFETY: tests that mutate TMUX/TMUX_PANE serialize those mutations
622+
// with ENV_LOCK and restore the original values before releasing it.
623+
unsafe {
624+
match value {
625+
Some(value) => std::env::set_var(key, value),
626+
None => std::env::remove_var(key),
627+
}
628+
}
629+
}
630+
631+
#[test]
632+
fn dispatch_envelope_injects_valid_tmux_pane_for_any_cli() {
633+
with_tmux_env(Some("/tmp/tmux-501/default,12345,0"), Some("%17"), || {
634+
let cfg = CliConfig::for_dispatch("grok");
635+
let envelope = build_dispatch_envelope(
636+
&cfg,
637+
"SessionStart",
638+
json!({"session_id": "sess-1"}),
639+
None,
640+
);
641+
642+
assert_eq!(envelope.input_data["terminal_context"]["tmux_pane"], "%17");
643+
});
644+
}
645+
646+
#[test]
647+
fn dispatch_envelope_omits_terminal_context_for_missing_or_invalid_tmux_pane() {
648+
for pane in [None, Some(""), Some("17"), Some("%"), Some("%x")] {
649+
with_tmux_env(Some("/tmp/tmux-501/default,12345,0"), pane, || {
650+
let cfg = CliConfig::for_dispatch("gemini");
651+
let envelope = build_dispatch_envelope(
652+
&cfg,
653+
"SessionStart",
654+
json!({"session_id": "sess-1"}),
655+
None,
656+
);
657+
658+
assert!(envelope.input_data.get("terminal_context").is_none());
659+
});
660+
}
661+
662+
with_tmux_env(None, Some("%17"), || {
663+
let cfg = CliConfig::for_dispatch("gemini");
664+
let envelope = build_dispatch_envelope(
665+
&cfg,
666+
"SessionStart",
667+
json!({"session_id": "sess-1"}),
668+
None,
669+
);
670+
671+
assert!(envelope.input_data.get("terminal_context").is_none());
672+
});
673+
}
574674

575675
#[test]
576676
fn action_from_success_forwards_sessionstart_context_json() {

0 commit comments

Comments
 (0)