diff --git a/README.md b/README.md index 0a9d545..b6ad9ad 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,8 @@ from this side (full derivation in [docs/TESTING.md](docs/TESTING.md) §21): - `agy` doesn't merge multiple named `PostToolUse` hooks despite documenting that it does. If you already have your own `PostToolUse` hook in `~/.gemini/config/hooks.json`, Logic Loop's may never fire — the toggle - will still read "on". Check for a foreign hook first if no rows appear. + will still read "on". Logic Loop detects that case and says so in the side + panel; it never edits the foreign hook — merging or removing it is yours. ## Status diff --git a/src-tauri/src/antigravity.rs b/src-tauri/src/antigravity.rs index 9e9915a..56c8c60 100644 --- a/src-tauri/src/antigravity.rs +++ b/src-tauri/src/antigravity.rs @@ -17,6 +17,17 @@ const HOOK_NAME: &str = "logic-loop"; /// on a Post* event can only delay, never block or deny. const ANTIGRAVITY_HOOK_EVENTS: [&str; 3] = ["PostToolUse", "PostInvocation", "Stop"]; +/// Command-string fingerprint of a Logic Loop entry, the way `ingest::MARKER` +/// fingerprints ours inside Claude's flat per-event arrays. `HOOK_NAME` is the +/// primary identity; this exists so the shadow check below cannot mistake a +/// Logic Loop registration filed under some *other* key (hand-copied, or a key +/// the user renamed) for a stranger and warn about ourselves. +/// Plain `pub`, not `pub(crate)`: `main.rs` is the binary crate (`app`), this +/// module lives in the library crate (`app_lib`), and `pub(crate)` doesn't +/// cross that boundary. `main.rs`'s headless-hook dispatch matches against +/// this same constant so the two can't drift. +pub const HOOK_FLAG: &str = "--antigravity-hook"; + fn home() -> String { std::env::var("HOME").unwrap_or_else(|_| "/tmp".into()) } @@ -56,7 +67,7 @@ fn shell_single_quote(s: &str) -> String { fn command_for(event: &str) -> Result { let exe = std::env::current_exe().map_err(|e| e.to_string())?; - Ok(format!("{} --antigravity-hook {event}", shell_single_quote(&exe.to_string_lossy()))) + Ok(format!("{} {HOOK_FLAG} {event}", shell_single_quote(&exe.to_string_lossy()))) } fn strip_ours(settings: &mut serde_json::Value) { @@ -89,6 +100,48 @@ fn hooks_status_from(settings: &serde_json::Value) -> bool { settings.get(HOOK_NAME).is_some() } +/// Does a hook *other* than ours claim `PostToolUse`? +/// +/// `agy` dispatches a single named hook per event rather than merging them, +/// despite its own docs promising a merge (README caveats, docs/TESTING.md +/// §21). A pre-existing foreign `PostToolUse` registration can therefore win +/// the dispatch outright: our entry is installed, `antigravity_hooks_status` +/// reads true, the toggle reads "on", and not one row ever lands. Detection +/// only — the foreign entry is never rewritten or dropped, because removal has +/// to stay byte-identically reversible (`remove_restores_original`). +/// +/// Both entry shapes are probed: grouped (`matcher` + `hooks` wrapper, what +/// `apply_setup` writes for `PostToolUse`) and flat (a bare handler). A foreign +/// hook may use either, and assuming the shape we happen to write would miss +/// half the shadowing cases. Anything else — a non-object root, a +/// `PostToolUse` that is not an array — reads as "nothing to say": this runs +/// against arbitrary third-party config, so an unrecognized shape must stay +/// silent rather than guess. +fn shadowing_post_tool_use(settings: &serde_json::Value) -> bool { + fn handler_is_ours(handler: &serde_json::Value) -> bool { + handler + .get("command") + .and_then(|c| c.as_str()) + .is_some_and(|c| c.contains(HOOK_FLAG)) + } + fn entry_is_ours(entry: &serde_json::Value) -> bool { + handler_is_ours(entry) + || entry + .get("hooks") + .and_then(|h| h.as_array()) + .is_some_and(|hs| hs.iter().any(handler_is_ours)) + } + settings.as_object().is_some_and(|obj| { + obj.iter().any(|(name, hook)| { + name.as_str() != HOOK_NAME + && hook + .get("PostToolUse") + .and_then(|v| v.as_array()) + .is_some_and(|entries| entries.iter().any(|e| !entry_is_ours(e))) + }) + }) +} + fn is_executable(candidate: &std::path::Path) -> bool { #[cfg(unix)] { @@ -140,6 +193,20 @@ pub fn antigravity_hooks_status() -> Result { Ok(hooks_status_from(&settings)) } +/// Whether our installed `PostToolUse` hook is being shadowed by a foreign +/// one — the failure the side panel's warning strip exists for. Deliberately +/// `bool`, not `Result`: a missing, unreadable or malformed `hooks.json` means +/// "say nothing and carry on", not an error the caller has to render. Reports +/// only while our hook is actually installed, so the warning cannot fire at a +/// user who never turned the adapter on. +#[tauri::command] +pub fn antigravity_hooks_shadowed() -> bool { + let Ok(settings) = read_settings() else { + return false; + }; + hooks_status_from(&settings) && shadowing_post_tool_use(&settings) +} + /// Remap Antigravity's native camelCase hook payload into the canonical /// snake_case wire shape every other adapter already produces. Pure and /// total — never panics, never fails; a field it can't find is just absent @@ -296,6 +363,81 @@ mod tests { assert_eq!(s, serde_json::json!({})); } + #[test] + fn shadow_check_silent_when_there_is_no_hooks_file() { + // `read_settings` maps NotFound to `{}`, so this is the exact value the + // command sees for a user who has never configured agy at all. + assert!(!shadowing_post_tool_use(&serde_json::json!({}))); + } + + #[test] + fn shadow_check_silent_when_only_our_hook_is_installed() { + let mut s = serde_json::json!({}); + apply_setup(&mut s).unwrap(); + assert!(hooks_status_from(&s)); + assert!(!shadowing_post_tool_use(&s), "our own entry must never read as foreign"); + } + + #[test] + fn shadow_check_sees_a_foreign_post_tool_use_hook() { + let s = foreign_settings(); + assert!(shadowing_post_tool_use(&s)); + // Nothing of ours to shadow yet — `antigravity_hooks_shadowed` gates on + // this before it ever asks, so an uninstalled adapter stays quiet. + assert!(!hooks_status_from(&s)); + } + + #[test] + fn shadow_check_fires_when_ours_and_a_foreign_hook_coexist() { + // The silent failure this whole check exists for: agy dispatches only + // lint-checker, the toggle still reads "on", zero rows ever land. + let mut s = foreign_settings(); + apply_setup(&mut s).unwrap(); + assert!(hooks_status_from(&s)); + assert!(shadowing_post_tool_use(&s)); + } + + #[test] + fn shadow_check_ignores_foreign_hooks_on_other_events() { + // Only PostToolUse carries our tool rows; a stranger on Stop shadows + // less than the whole adapter and must not cry wolf. + let s = serde_json::json!({ "greeter": { "Stop": [{ "type": "command", "command": "say done" }] } }); + assert!(!shadowing_post_tool_use(&s)); + } + + #[test] + fn shadow_check_sees_the_flat_foreign_entry_shape_too() { + // agy accepts a bare handler as well as the grouped matcher wrapper we + // write; a foreign hook in that shape shadows ours just as hard. + let s = serde_json::json!({ "linter": { "PostToolUse": [{ "type": "command", "command": "./lint.sh" }] } }); + assert!(shadowing_post_tool_use(&s)); + } + + #[test] + fn shadow_check_does_not_warn_about_our_own_command_under_another_key() { + // A hand-copied or renamed Logic Loop block is still ours — warning + // about it would send the user hunting for a hook that isn't there. + let s = serde_json::json!({ + "logic-loop-old": { + "PostToolUse": [{ + "matcher": "*", + "hooks": [{ "type": "command", "command": "'/Applications/Logic Loop.app/x' --antigravity-hook PostToolUse" }] + }] + } + }); + assert!(!shadowing_post_tool_use(&s)); + } + + #[test] + fn shadow_check_stays_silent_on_shapes_it_does_not_recognize() { + // hooks.json is arbitrary third-party content. A malformed root or a + // PostToolUse that isn't an array must read as "nothing to say" rather + // than as a warning nobody can act on. + assert!(!shadowing_post_tool_use(&serde_json::json!("nonsense"))); + assert!(!shadowing_post_tool_use(&serde_json::json!({ "linter": { "PostToolUse": "./lint.sh" } }))); + assert!(!shadowing_post_tool_use(&serde_json::json!({ "linter": 7 }))); + } + #[test] fn translate_maps_common_fields() { let input = serde_json::json!({ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3e73048..b1ed36a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -249,6 +249,7 @@ pub fn run() { antigravity::antigravity_hooks_setup, antigravity::antigravity_hooks_remove, antigravity::antigravity_hooks_status, + antigravity::antigravity_hooks_shadowed, extractor::run_extractor, clipboard::clipboard_text, clipboard::clipboard_image_path diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7912c03..c950189 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -5,7 +5,7 @@ fn main() { // Antigravity spawns this exact binary as its hook `command`; intercept // before booting the GUI so `agy` gets a headless process, not a window. let args: Vec = std::env::args().collect(); - if let Some(pos) = args.iter().position(|a| a == "--antigravity-hook") { + if let Some(pos) = args.iter().position(|a| a == app_lib::antigravity::HOOK_FLAG) { if let Some(event) = args.get(pos + 1) { app_lib::antigravity::run_hook_mode(event); } diff --git a/src/App.tsx b/src/App.tsx index c0d29e8..f5817e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -71,6 +71,11 @@ export default function App() { // Sessions whose transcript file could not be opened — they emit hooks but no // transcript, so decisions never extract for them. Silent until surfaced. const [blindSessions, setBlindSessions] = useState>({}); + // Same silence, one layer earlier: the agy adapter is installed but a foreign + // PostToolUse hook can win agy's single-hook dispatch, so no event ever + // arrives. Owned here because the toggle (AgentStatusBar) and the warning + // strip (SidePanel) are siblings. + const [antigravityShadowed, setAntigravityShadowed] = useState(false); // Nudges (Phase 6): muted project keys, cached so the hot ingestion path // never blocks on a DB read before deciding whether to notify. @@ -949,6 +954,7 @@ export default function App() { accent={activeTab.color === PALETTE[7] ? null : activeTab.color} refreshKey={panelRefresh} blindPaths={Object.values(blindSessions)} + antigravityShadowed={antigravityShadowed} fanOut={fanOutRollups} onSelectTab={setActiveId} onDismissMember={dismissSpawnMember} @@ -959,7 +965,7 @@ export default function App() { /> )}
- +
{tabs.map((tab) => ( void; +} + /** Header row above the terminal pane, lined up with SidePanel's own * "project:/notify" header on the left. Was previously crammed into * BookmarksBar alongside bookmarks — grows with every adapter (Phase 8 * added "opencode", more coming per ROADMAP.md v2 Adapters), and bookmarks * grow without bound too, so the two don't belong on the same row. */ -export function AgentStatusBar() { +export function AgentStatusBar({ onAntigravityShadowed }: Props) { const [hooksOn, setHooksOn] = useState(null); const [opencodeAvailable, setOpencodeAvailable] = useState(false); const [opencodeOn, setOpencodeOn] = useState(null); @@ -35,6 +44,36 @@ export function AgentStatusBar() { const [extractor, setExtractor] = useState(null); const [showSettings, setShowSettings] = useState(false); + // A user who fixes the foreign hook by hand — exactly what the warning + // strip's own text tells them to do — never touches the toggle, so a + // mount/toggle-only check would never clear. Recheck on window focus (the + // moment they're most likely to come back from editing hooks.json) plus a + // slow interval as a backstop for a session that never loses focus. The + // sequence ref drops a stale in-flight result from a check that started + // before the toggle flipped again, closing the rapid on/off race. + const shadowSeq = useRef(0); + useEffect(() => { + if (!antigravityOn) { + onAntigravityShadowed(false); + return; + } + const check = () => { + const seq = ++shadowSeq.current; + void antigravityHooksShadowed() + .catch(() => false) + .then((shadowed) => { + if (seq === shadowSeq.current) onAntigravityShadowed(shadowed); + }); + }; + check(); + window.addEventListener("focus", check); + const interval = setInterval(check, 15_000); + return () => { + window.removeEventListener("focus", check); + clearInterval(interval); + }; + }, [antigravityOn]); + useEffect(() => { void hooksStatus().then(setHooksOn).catch(() => setHooksOn(null)); void getExtractorSettings().then(setExtractor).catch(() => undefined); diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx index fb5954e..18a93a0 100644 --- a/src/components/SidePanel.tsx +++ b/src/components/SidePanel.tsx @@ -37,6 +37,7 @@ interface Props { accent: string | null; // matching bookmark's color, if the project is bookmarked refreshKey: number; // bump to force reload (new events / blocker changes) blindPaths: string[]; // transcripts that failed to open — panels are incomplete + antigravityShadowed: boolean; // a foreign PostToolUse hook can swallow the agy adapter's events fanOut: FanOutRollup[]; // every fan-out group the active tab belongs to (as parent, possibly several; as child, at most one), oldest first onSelectTab: (id: string) => void; // jump to a fan-out child/parent tab onDismissMember: (groupId: string, childTabId: string) => void; // drop a lingering row from the fan-out rollup @@ -89,6 +90,7 @@ export function SidePanel({ accent, refreshKey, blindPaths, + antigravityShadowed, fanOut, onSelectTab, onDismissMember, @@ -556,6 +558,23 @@ export function SidePanel({

)} + {/* Same strip, same failure class: the adapter is installed and the + toggle reads "on", but `agy` dispatches a single named PostToolUse + hook instead of merging them, so a foreign hook can swallow every + event and the panels just look like a quiet day. Detection only — + the user's own hook is never touched. */} + {antigravityShadowed && ( +

+ ⚠ another PostToolUse hook may be shadowing the antigravity adapter +

+ )} {childStrip && (

{ return invoke("antigravity_hooks_status"); } +/** True when our hook is installed but a foreign `PostToolUse` hook in + * ~/.gemini/config/hooks.json can shadow it — `agy` dispatches one named hook + * per event instead of merging, so the adapter reads "on" and never fires. + * Detection only, and false for any unreadable/malformed config. */ +export function antigravityHooksShadowed(): Promise { + return invoke("antigravity_hooks_shadowed"); +} + export function onHookEvent(cb: (p: HookPayload) => void): Promise { return listen("ingest://hook", (e) => { if (typeof e.payload?.hook_event_name === "string" && typeof e.payload?.session_id === "string") {