Skip to content
Draft
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
144 changes: 143 additions & 1 deletion src-tauri/src/antigravity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down Expand Up @@ -56,7 +67,7 @@ fn shell_single_quote(s: &str) -> String {

fn command_for(event: &str) -> Result<String, String> {
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) {
Expand Down Expand Up @@ -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)]
{
Expand Down Expand Up @@ -140,6 +193,20 @@ pub fn antigravity_hooks_status() -> Result<bool, String> {
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
Expand Down Expand Up @@ -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!({
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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);
}
Expand Down
8 changes: 7 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>({});
// 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.
Expand Down Expand Up @@ -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}
Expand All @@ -959,7 +965,7 @@ export default function App() {
/>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<AgentStatusBar />
<AgentStatusBar onAntigravityShadowed={setAntigravityShadowed} />
<div className="min-h-0 flex-1">
{tabs.map((tab) => (
<Terminal
Expand Down
43 changes: 41 additions & 2 deletions src/components/AgentStatusBar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
antigravityDetect,
antigravityHooksRemove,
antigravityHooksSetup,
antigravityHooksShadowed,
antigravityHooksStatus,
codexDetect,
codexHooksRemove,
Expand All @@ -19,12 +20,20 @@ import {
import { getExtractorSettings, setExtractorSettings } from "../lib/repo";
import type { ExtractorSettings } from "../types";

interface Props {
/** Report whether a foreign PostToolUse hook can shadow the Antigravity
* adapter. The check is driven from here rather than App because only this
* component knows when the toggle flips — a startup-only check would stay
* stale for the rest of the session the moment the user turns agy on. */
onAntigravityShadowed: (shadowed: boolean) => 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<boolean | null>(null);
const [opencodeAvailable, setOpencodeAvailable] = useState(false);
const [opencodeOn, setOpencodeOn] = useState<boolean | null>(null);
Expand All @@ -35,6 +44,36 @@ export function AgentStatusBar() {
const [extractor, setExtractor] = useState<ExtractorSettings | null>(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);
Expand Down
19 changes: 19 additions & 0 deletions src/components/SidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,6 +90,7 @@ export function SidePanel({
accent,
refreshKey,
blindPaths,
antigravityShadowed,
fanOut,
onSelectTab,
onDismissMember,
Expand Down Expand Up @@ -556,6 +558,23 @@ export function SidePanel({
</span>
</p>
)}
{/* 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 && (
<p
className="flex shrink-0 items-start gap-1 border-b border-red-500/30 bg-red-500/10 px-3 py-1.5 text-[10px] text-red-300"
title={
"Another PostToolUse hook is registered in ~/.gemini/config/hooks.json.\n\n" +
"agy runs only one named hook per event despite documenting that it merges them, so Logic Loop's may never fire — agy tabs would show no tool activity at all.\n\n" +
"Logic Loop will not edit your hook. Remove or merge it by hand if agy rows stay empty."
}
>
<span>⚠ another PostToolUse hook may be shadowing the antigravity adapter</span>
</p>
)}
{childStrip && (
<p
className="flex shrink-0 cursor-pointer items-center gap-1 border-b border-purple-500/20 bg-purple-400/5 px-3 py-1.5 text-[10px] text-purple-300 hover:bg-purple-400/10"
Expand Down
8 changes: 8 additions & 0 deletions src/lib/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,14 @@ export function antigravityHooksStatus(): Promise<boolean> {
return invoke<boolean>("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<boolean> {
return invoke<boolean>("antigravity_hooks_shadowed");
}

export function onHookEvent(cb: (p: HookPayload) => void): Promise<UnlistenFn> {
return listen<HookPayload>("ingest://hook", (e) => {
if (typeof e.payload?.hook_event_name === "string" && typeof e.payload?.session_id === "string") {
Expand Down