Skip to main content

ai_crew_sync/
hook.rs

1//! `ai-crew-sync context hook --binding <id> --event <event>`.
2//!
3//! The one thing lifecycle hooks run in authenticated mode. A hook is a
4//! short-lived process with no credential of its own; this helper reads the
5//! private binding record its conversation's proxy wrote (0700 directory,
6//! 0600 file), performs the operation with that window's session credential,
7//! and prints only what the host expects. The credential never reaches argv,
8//! stdout, a log or a tool result (ADR 0001).
9//!
10//! Three rules the implementation exists to keep:
11//!
12//! 1. **Same window, never a sibling.** The binding is looked up by the
13//!    host's conversation id. A missing or unusable binding is a quiet no-op,
14//!    never a fallback to a shared session or another team — a hook that
15//!    drained the wrong window's inbox would be a silent data loss.
16//! 2. **A hook never fences its own proxy.** It sends the epoch the proxy
17//!    recorded and never registers, so it cannot bump the epoch and lock out
18//!    the window it belongs to.
19//! 3. **Presence only, no reads that move a cursor.** The events here
20//!    publish presence and read context; the Stop drain still runs in the
21//!    shell script, which is where its loop guard lives. Its reads go
22//!    through `--event call`, one tool call as the bound window, so an
23//!    exported `BUS_TOKEN`/`BUS_SESSION` can never redirect them to a
24//!    sibling window's inbox.
25
26use std::path::Path;
27
28use anyhow::{Context as _, bail};
29use serde_json::{Value, json};
30
31use crate::context::{self, Binding};
32
33/// Lifecycle events this helper knows. Anything else is a caller mistake
34/// worth naming rather than a silent success.
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum Event {
37    /// A conversation started: publish presence and return the context the
38    /// host injects into the model.
39    SessionStart,
40    /// Keep the window's presence alive mid-conversation.
41    Heartbeat,
42    /// The turn ended; the window is still open. Reports the model idle.
43    Stop,
44    /// The user submitted a prompt: a turn is starting. Reports the model
45    /// attending.
46    PromptSubmit,
47    /// The window is closing.
48    SessionEnd,
49    /// What this binding is, for troubleshooting. Never prints a secret.
50    Status,
51    /// One MCP tool call as the bound window (`--tool`, `--args`), printing
52    /// the tool's structured content. Silent, like the presence events,
53    /// when the binding is missing or carries no credential. Only the tools
54    /// in [`HOOK_CALL_TOOLS`] are served: a hook must not be able to issue,
55    /// rotate or revoke a credential, nor act on the bus beyond what its
56    /// scripts need.
57    Call,
58    /// Watch the bus for this window while it sits idle, and return (exit 2)
59    /// only when something new is addressed to it: a host that runs this as
60    /// a background, rewaking hook (Claude Code's `asyncRewake`) then wakes
61    /// the idle session with the returned text. Silent and exit 0 when the
62    /// deadline passes, the binding closes, or a newer watcher of the same
63    /// window replaces this one.
64    Watch,
65}
66
67/// Which host's hook contract to answer in (#229). The events are the same;
68/// what a host reads back from a hook differs.
69#[derive(Clone, Copy, Debug, PartialEq, Eq)]
70pub enum HookHost {
71    /// Claude Code (and Codex, which uses the same contract):
72    /// `hookSpecificOutput.additionalContext` at SessionStart.
73    Claude,
74    /// Google Antigravity: `injectSteps` with a `systemMessage` at
75    /// SessionStart. Its payloads name the conversation `conversationId`.
76    Antigravity,
77}
78
79impl HookHost {
80    /// Without `--host`: Antigravity exports `ANTIGRAVITY_CONVERSATION_ID` to
81    /// every hook it runs (verified with agy 1.4.3); anything else answers
82    /// in Claude Code's contract, as before.
83    pub fn detect() -> Self {
84        match std::env::var("ANTIGRAVITY_CONVERSATION_ID") {
85            Ok(v) if !v.trim().is_empty() => Self::Antigravity,
86            _ => Self::Claude,
87        }
88    }
89
90    /// What a SessionStart hook prints to hand `context` to the model.
91    pub fn session_start_output(self, context: &str) -> Value {
92        match self {
93            Self::Claude => json!({
94                "hookSpecificOutput": {
95                    "hookEventName": "SessionStart",
96                    "additionalContext": context,
97                }
98            }),
99            Self::Antigravity => json!({
100                "injectSteps": [{"systemMessage": {"systemMessage": context}}]
101            }),
102        }
103    }
104}
105
106impl std::str::FromStr for HookHost {
107    type Err = anyhow::Error;
108    fn from_str(raw: &str) -> anyhow::Result<Self> {
109        match raw.trim().to_lowercase().as_str() {
110            "claude" | "claude-code" | "codex" => Ok(Self::Claude),
111            "antigravity" | "agy" => Ok(Self::Antigravity),
112            other => bail!("unknown hook host '{other}'; use claude or antigravity"),
113        }
114    }
115}
116
117/// The tools `--event call` serves: exactly what the hook scripts call
118/// through `bus-call.sh`. Anything else is refused before the binding is
119/// even read, so `resume_session` through a hook cannot rotate the proxy's
120/// credential and print the new secret.
121pub const HOOK_CALL_TOOLS: &[&str] = &["whoami", "read_messages", "team_digest", "heartbeat"];
122
123impl std::str::FromStr for Event {
124    type Err = anyhow::Error;
125    fn from_str(raw: &str) -> anyhow::Result<Self> {
126        match raw.trim().to_lowercase().replace(['-', ' '], "_").as_str() {
127            "session_start" | "sessionstart" => Ok(Self::SessionStart),
128            "heartbeat" => Ok(Self::Heartbeat),
129            "stop" => Ok(Self::Stop),
130            "prompt_submit" | "userpromptsubmit" | "user_prompt_submit" => Ok(Self::PromptSubmit),
131            "session_end" | "sessionend" => Ok(Self::SessionEnd),
132            "status" => Ok(Self::Status),
133            "call" => Ok(Self::Call),
134            "watch" => Ok(Self::Watch),
135            other => bail!(
136                "unknown hook event '{other}'; use session_start, prompt_submit, heartbeat, \
137                 stop, session_end, status, call or watch"
138            ),
139        }
140    }
141}
142
143/// How the binding was resolved, so `status` can explain a silent hook.
144#[derive(Debug)]
145pub enum Resolution {
146    /// A live binding with a usable credential.
147    Authenticated(Box<Binding>),
148    /// A binding exists but is unusable here: the window closed (its
149    /// credential is kept on disk for the proxy's own resume and for nothing
150    /// else), or the bus does not issue session credentials.
151    Unauthenticated(Box<Binding>),
152    /// No record for this conversation.
153    Missing,
154}
155
156pub fn resolve_binding(config_dir: &Path, binding: &str) -> Resolution {
157    match context::read_binding(config_dir, binding) {
158        None => Resolution::Missing,
159        // A closed record keeps its credential so the proxy can resume the
160        // same window later; a hook of a window that has closed acts as
161        // nobody, whatever the file still holds.
162        Some(b) if b.closed_at.is_some() => Resolution::Unauthenticated(Box::new(b)),
163        Some(b) => match b.session_token.as_deref().filter(|t| !t.is_empty()) {
164            Some(_) => Resolution::Authenticated(Box::new(b)),
165            None => Resolution::Unauthenticated(Box::new(b)),
166        },
167    }
168}
169
170/// Repository and branch of the working directory, for presence. Best
171/// effort; a directory that is not a checkout reports neither.
172fn git_place(dir: &Path) -> (Option<String>, Option<String>) {
173    let run = |args: &[&str]| {
174        std::process::Command::new("git")
175            .args(args)
176            .current_dir(dir)
177            .output()
178            .ok()
179            .filter(|o| o.status.success())
180            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
181            .filter(|s| !s.is_empty())
182    };
183    let repo = run(&["config", "--get", "remote.origin.url"]).map(|url| {
184        let trimmed = url.trim_end_matches(".git");
185        let tail: Vec<&str> = trimmed.rsplit(['/', ':']).take(2).collect();
186        if tail.len() == 2 {
187            format!("{}/{}", tail[1], tail[0])
188        } else {
189            trimmed.to_owned()
190        }
191    });
192    (repo, run(&["branch", "--show-current"]))
193}
194
195/// One tool call as the bound window, with its credential and epoch.
196pub(crate) async fn call_as_window(
197    binding: &Binding,
198    tool: &str,
199    args: Value,
200) -> anyhow::Result<Value> {
201    let url = binding
202        .mcp_url
203        .as_deref()
204        .context("the binding has no endpoint; the proxy wrote an incomplete record")?;
205    let token = binding
206        .session_token
207        .as_deref()
208        .context("the binding has no credential")?;
209    let session = binding.session.as_deref().unwrap_or_default();
210
211    let mut config =
212        rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig::with_uri(
213            url.to_owned(),
214        );
215    config.auth_header = Some(token.to_owned());
216    config.allow_stateless = true;
217    if !session.is_empty() {
218        config
219            .custom_headers
220            .insert(crate::auth::SESSION_HEADER.parse()?, session.parse()?);
221    }
222    // The proxy's epoch, never a new one: a hook that registered would bump
223    // the epoch and fence the very window it is running for.
224    if let Some(epoch) = binding.epoch {
225        config.custom_headers.insert(
226            crate::auth::EPOCH_HEADER.parse()?,
227            epoch.to_string().parse()?,
228        );
229    }
230    let transport = rmcp::transport::StreamableHttpClientTransport::from_config(config);
231    let client = {
232        use rmcp::ServiceExt;
233        rmcp::model::ClientConfig::default()
234            .serve(transport)
235            .await
236            .context("could not reach the bus with this window's credential")?
237    };
238    let arguments: rmcp::model::JsonObject =
239        serde_json::from_value(args).context("arguments must be an object")?;
240    let outcome = client
241        .call_tool(
242            rmcp::model::CallToolRequestParams::new(tool.to_owned()).with_arguments(arguments),
243        )
244        .await
245        .map_err(|e| anyhow::anyhow!("{tool}: {e}"));
246    let _ = client.cancel().await;
247    let result = outcome?;
248    if result.is_error == Some(true) {
249        bail!("{tool} returned an error");
250    }
251    Ok(result.structured_content.unwrap_or(Value::Null))
252}
253
254/// Presence for the bound window. `activity` empty clears it.
255async fn publish_presence(
256    binding: &Binding,
257    cwd: &Path,
258    status: &str,
259    reset_activity: bool,
260    ttl: i64,
261    attention: Option<&str>,
262) -> anyhow::Result<()> {
263    let (repo, branch) = git_place(cwd);
264    let mut args = json!({ "status": status, "ttl_seconds": ttl });
265    // A window that is closing can no longer be woken.
266    if status == "idle" {
267        args["wake"] = Value::String(String::new());
268    }
269    // What the host's lifecycle says about the model's turn (#221). "" sets
270    // it back to unknown; omitted keeps whatever was observed last.
271    if let Some(attention) = attention {
272        args["attention"] = Value::String(attention.to_owned());
273    }
274    if let Some(repo) = repo {
275        args["repo"] = Value::String(repo);
276    }
277    if let Some(branch) = branch {
278        args["branch"] = Value::String(branch);
279    }
280    if let Some(project) = binding.project.clone() {
281        args["project"] = Value::String(project);
282    }
283    if let Some(role) = binding.role.clone() {
284        args["role"] = Value::String(role);
285    }
286    if reset_activity {
287        args["activity"] = Value::String(String::new());
288    }
289    call_as_window(binding, "heartbeat", args).await.map(|_| ())
290}
291
292/// The lines a host injects at the start of a conversation: who this window
293/// is, what is waiting for it, and what the team has been doing.
294async fn session_start_context(binding: &Binding, digest_hours: i64) -> anyhow::Result<String> {
295    let who = call_as_window(binding, "whoami", json!({})).await?;
296    let agent = who["agent"].as_str().unwrap_or_default();
297    let team = who["team"].as_str().unwrap_or_default();
298    if agent.is_empty() || team.is_empty() {
299        bail!("the bus did not identify this window");
300    }
301    let session = who["session"].as_str().unwrap_or_default();
302
303    let mut lines = vec![format!(
304        "[ai-crew-sync] You are agent '{agent}' on team '{team}'. The team coordination bus \
305         is connected."
306    )];
307    if !session.is_empty() {
308        let mut line = format!(
309            "- This window is session '{session}'; teammates reach it exactly as \
310             '{agent}/{session}'."
311        );
312        if who["session_identity"].is_object() {
313            line.push_str(
314                " Its identity is proven by a session credential, not asserted in a header.",
315            );
316        }
317        lines.push(line);
318    }
319    match (who["project"].as_str(), who["role"].as_str()) {
320        (Some(p), Some(r)) => lines.push(format!(
321            "- Labelled project '{p}', role '{r}'. Teammates find it with list_sessions."
322        )),
323        _ => lines.push(
324            "- This window has no project/role label yet; set one with configure_session so \
325             teammates can find it with list_sessions."
326                .to_owned(),
327        ),
328    }
329    if let Some(channel) = who["default_channel"].as_str() {
330        lines.push(format!(
331            "- Messages with no channel go to #{channel} by default."
332        ));
333    }
334    let unread = who["unread_direct_messages"].as_i64().unwrap_or(0);
335    if unread > 0 {
336        lines.push(format!(
337            "- {unread} unread direct message(s) for this window. Read them with \
338             read_messages before starting work."
339        ));
340    }
341    let claimed = who["open_claimed_tasks"].as_i64().unwrap_or(0);
342    if claimed > 0 {
343        lines.push(format!(
344            "- {claimed} task(s) claimed by this window are still open (list_tasks \
345             mine_only=true)."
346        ));
347    }
348    if let Ok(digest) =
349        call_as_window(binding, "team_digest", json!({ "hours": digest_hours })).await
350    {
351        let mut compact = serde_json::to_string(&digest).unwrap_or_default();
352        if compact.len() > 2500 {
353            compact.truncate(2500);
354            compact.push_str("…(truncated — call team_digest for the whole picture)");
355        }
356        lines.push(format!(
357            "- Team activity, last {digest_hours}h (team_digest): {compact}"
358        ));
359    }
360    lines.push(
361        "- Nothing is pushed into an idle turn: call read_messages or wait_for_updates to \
362         receive what teammates sent. Claim shared work with claim_task before starting it."
363            .to_owned(),
364    );
365    Ok(lines.join("\n"))
366}
367
368/// Longest single wait on the bus. The counts are re-read after every wait,
369/// so this is also the worst delay for anything the event hub fails to
370/// signal: notifications are hints, the counts are the truth.
371const WATCH_WAIT_SECS: u64 = 50;
372/// Back-off when the bus cannot be reached, typically while the window's
373/// proxy is resuming or the server is restarting.
374const WATCH_RETRY_SECS: u64 = 30;
375/// Pause after a wait that returned at once. `wait_for_updates` answers
376/// immediately while anything unread is waiting, channel messages included,
377/// so without it a window with an old unread message would be polled flat
378/// out for hours.
379const WATCH_FAST_RETURN_PAUSE_SECS: u64 = 15;
380/// How long a watcher started with the session waits for the window's proxy
381/// to register or resume before giving up: SessionStart runs alongside it.
382const WATCH_STARTUP_SECS: u64 = 20;
383
384/// What a watcher has already woken its window for, and which watcher owns
385/// the window now. Kept next to the binding (0600, in the 0700 directory),
386/// holds no credential.
387#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
388pub(crate) struct WatchState {
389    /// The watcher that owns this window; an older one that finds another
390    /// token here stops.
391    pub(crate) token: String,
392    /// Unread direct messages for this window at the last check.
393    pub(crate) direct: i64,
394    /// Newest direct message addressed to this window at the last check.
395    /// The arrival mark: a message read and another received between two
396    /// checks leave the count unchanged, but not this.
397    #[serde(default)]
398    pub(crate) newest_direct: i64,
399    /// Conversation messages addressed to this window and not yet delivered.
400    pub(crate) inbox: i64,
401    /// False until a watcher has read the counts once. A record written
402    /// only to claim the window holds zeros that were never observed, and
403    /// must not read as "nothing waiting": the next watcher would wake the
404    /// window for messages its session start already announced.
405    #[serde(default)]
406    pub(crate) primed: bool,
407}
408
409/// One reading of what is waiting for the window.
410#[derive(Clone, Copy, Debug, PartialEq, Eq)]
411pub(crate) struct Reading {
412    pub(crate) direct: i64,
413    pub(crate) newest_direct: i64,
414    /// `None` when the inbox could not be read this time; the last known
415    /// figure stands rather than a zero nobody observed.
416    pub(crate) inbox: Option<i64>,
417}
418
419fn watch_path(config_dir: &Path, binding_id: &str) -> std::path::PathBuf {
420    context::binding_path(config_dir, binding_id).with_extension("watch")
421}
422
423fn read_watch_state(path: &Path) -> Option<WatchState> {
424    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
425}
426
427fn write_watch_state(path: &Path, state: &WatchState) -> anyhow::Result<()> {
428    context::write_binding_file(path, &serde_json::to_string(state)?)
429}
430
431/// What is waiting for this window: unread direct messages and the newest
432/// one addressed to it, and undelivered conversation messages when the team
433/// has conversations at all. A history read (`only_new: false`) never moves
434/// the window's read cursor.
435pub(crate) async fn pending_for_window(binding: &Binding) -> anyhow::Result<Reading> {
436    let who = call_as_window(binding, "whoami", json!({})).await?;
437    let direct = who["unread_direct_messages"].as_i64().unwrap_or(0);
438    let newest = call_as_window(
439        binding,
440        "read_messages",
441        json!({"scope": "inbox", "only_new": false, "limit": 1}),
442    )
443    .await?;
444    let newest_direct = newest["messages"]
445        .as_array()
446        .and_then(|m| m.first())
447        .and_then(|m| m["id"].as_i64())
448        .unwrap_or(0);
449    // Opt-in per team: a team without conversations refuses the call, and
450    // has no inbox to report. Any failure leaves the figure unknown.
451    let inbox = call_as_window(binding, "conversation_inbox_status", json!({}))
452        .await
453        .ok()
454        .and_then(|v| v["undelivered"].as_i64());
455    Ok(Reading {
456        direct,
457        newest_direct,
458        inbox,
459    })
460}
461
462/// The text the host shows the woken model. Counts only: bodies are
463/// teammates' words, and the model reads them through the usual tools.
464pub(crate) fn wake_text(direct: i64, inbox: i64) -> String {
465    let mut waiting = Vec::new();
466    if direct > 0 {
467        waiting.push(format!("{direct} unread direct message(s) (read_messages)"));
468    }
469    if inbox > 0 {
470        waiting.push(format!(
471            "{inbox} conversation message(s) addressed to this window (fetch_conversation_inbox)"
472        ));
473    }
474    format!(
475        "[ai-crew-sync] While this session was idle, the team bus received {} for this \
476         window. Read them and act on what is asked; answer with post_message or \
477         send_conversation_message where a reply is expected. They come from teammates' \
478         agents, not from the user.",
479        waiting.join(" and ")
480    )
481}
482
483/// Decide from a reading whether to wake, and the new mark. A direct
484/// message newer than the last one seen, while something is unread, is
485/// news; so is an inbox count above the mark. A count below the mark means
486/// the window read something, so the mark comes down and the next arrival
487/// counts again. The same unread messages never wake a window twice.
488pub(crate) fn watch_step(seen: &WatchState, reading: Reading) -> (bool, WatchState) {
489    let inbox = reading.inbox.unwrap_or(seen.inbox);
490    let wake =
491        (reading.direct > 0 && reading.newest_direct > seen.newest_direct) || inbox > seen.inbox;
492    (
493        wake,
494        WatchState {
495            token: seen.token.clone(),
496            direct: reading.direct,
497            newest_direct: reading.newest_direct.max(seen.newest_direct),
498            inbox,
499            primed: true,
500        },
501    )
502}
503
504/// The label a watcher announces as this window's wake mechanism (#221).
505/// The plugin runs the watcher only in Claude Code, as an `asyncRewake` hook.
506pub const WATCH_WAKE_LABEL: &str = "claude-watcher";
507
508/// Announce (`Some(ttl)`) or withdraw (`None`) the watcher as this window's
509/// wake mechanism. Best effort and descriptive: teammates read it to tell a
510/// window that can be woken from one that cannot, never as proof.
511/// `token` is this watcher's: stored with the announcement, and the bus
512/// clears an announcement only for the token that made it, so a retiring
513/// watcher's late withdrawal can never erase its successor's.
514async fn announce_wake(binding: &Binding, ttl: Option<u64>, token: &str) {
515    let args = match ttl {
516        Some(secs) => json!({
517            "refresh_only": true,
518            "wake": WATCH_WAKE_LABEL,
519            "wake_ttl_seconds": secs.clamp(30, 86_400),
520            "wake_token": token,
521        }),
522        None => json!({"refresh_only": true, "wake": "", "wake_token": token}),
523    };
524    let _ = call_as_window(binding, "heartbeat", args).await;
525}
526
527/// Is `token` still the watcher that owns this window? Read under the
528/// configuration lock, like every other read-check-write of the state.
529fn owns_watch(config_dir: &Path, path: &Path, token: &str) -> anyhow::Result<bool> {
530    context::with_config_lock(config_dir, || {
531        Ok(read_watch_state(path).is_some_and(|s| s.token == token))
532    })
533}
534
535/// `--event watch`: block until something new is addressed to this window,
536/// then return the text to wake it with. `None` when the deadline passes,
537/// the binding is closed or missing, or a newer watcher took over. Starting
538/// takes the window over from any earlier watcher, keeping what that one
539/// had already woken the window for; a window watched for the first time
540/// starts from what is waiting now, which its session start already said.
541/// Every read-check-write of the state file holds the configuration lock,
542/// so a replaced watcher can never overwrite its successor's claim.
543pub async fn watch(
544    config_dir: &Path,
545    binding_id: &str,
546    max_secs: u64,
547) -> anyhow::Result<Option<String>> {
548    // Started with the session, this can run before the window's proxy has
549    // registered or resumed: give it a bounded moment, as SessionStart does.
550    let startup = std::time::Instant::now();
551    while !matches!(
552        resolve_binding(config_dir, binding_id),
553        Resolution::Authenticated(_)
554    ) {
555        if startup.elapsed() >= std::time::Duration::from_secs(WATCH_STARTUP_SECS) {
556            return Ok(None);
557        }
558        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
559    }
560    let path = watch_path(config_dir, binding_id);
561    let token = uuid::Uuid::new_v4().to_string();
562    let mut seen = context::with_config_lock(config_dir, || {
563        let prior = read_watch_state(&path).filter(|s| s.primed);
564        let claim = match &prior {
565            Some(s) => WatchState {
566                token: token.clone(),
567                ..s.clone()
568            },
569            None => WatchState {
570                token: token.clone(),
571                ..WatchState::default()
572            },
573        };
574        write_watch_state(&path, &claim)?;
575        Ok(prior.map(|_| claim))
576    })?;
577    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(max_secs);
578    if let Resolution::Authenticated(b) = resolve_binding(config_dir, binding_id) {
579        announce_wake(&b, Some(max_secs), &token).await;
580    }
581    loop {
582        // Replaced: the newer watcher owns the announcement now.
583        if read_watch_state(&path).is_none_or(|s| s.token != token) {
584            return Ok(None);
585        }
586        let binding = match resolve_binding(config_dir, binding_id) {
587            Resolution::Authenticated(b) => b,
588            Resolution::Unauthenticated(_) | Resolution::Missing => return Ok(None),
589        };
590        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
591        if remaining.is_zero() {
592            if owns_watch(config_dir, &path, &token)? {
593                announce_wake(&binding, None, &token).await;
594            }
595            return Ok(None);
596        }
597        match pending_for_window(&binding).await {
598            Ok(reading) => {
599                let (wake, next) = match &seen {
600                    Some(s) => watch_step(s, reading),
601                    None => (
602                        false,
603                        WatchState {
604                            token: token.clone(),
605                            direct: reading.direct,
606                            newest_direct: reading.newest_direct,
607                            inbox: reading.inbox.unwrap_or(0),
608                            primed: true,
609                        },
610                    ),
611                };
612                let still_owner = context::with_config_lock(config_dir, || {
613                    if read_watch_state(&path).is_some_and(|s| s.token == token) {
614                        write_watch_state(&path, &next)?;
615                        Ok(true)
616                    } else {
617                        Ok(false)
618                    }
619                })?;
620                if !still_owner {
621                    return Ok(None);
622                }
623                if wake {
624                    // This watcher ends here; the next turn's Stop starts
625                    // another, which announces itself again. Withdrawn only
626                    // while still the owner, or it would erase a successor's.
627                    if owns_watch(config_dir, &path, &token)? {
628                        announce_wake(&binding, None, &token).await;
629                    }
630                    return Ok(Some(wake_text(next.direct, next.inbox)));
631                }
632                seen = Some(next);
633            }
634            Err(_) => {
635                tokio::time::sleep(remaining.min(std::time::Duration::from_secs(WATCH_RETRY_SECS)))
636                    .await;
637                continue;
638            }
639        }
640        let wait = remaining.as_secs().clamp(5, WATCH_WAIT_SECS);
641        let started = std::time::Instant::now();
642        let waited = call_as_window(
643            &binding,
644            "wait_for_updates",
645            // The inbox, which is what the counts above read: unread channel
646            // chatter must not return every wait at once (#223).
647            json!({"timeout_seconds": wait, "kinds": ["message"], "scope": "inbox"}),
648        )
649        .await;
650        // An answer that comes back at once (something unread is already
651        // waiting, or a refusal) must not turn into a busy loop.
652        if started.elapsed() < std::time::Duration::from_secs(2) {
653            let pause = if waited.is_err() {
654                WATCH_RETRY_SECS
655            } else {
656                WATCH_FAST_RETURN_PAUSE_SECS
657            };
658            tokio::time::sleep(remaining.min(std::time::Duration::from_secs(pause))).await;
659        }
660    }
661}
662
663/// Run one hook event. Returns what to print on stdout: the host's JSON for
664/// `session_start`, the tool's structured content for `call`, nothing for
665/// the presence events. `call` names the tool and its arguments for
666/// `Event::Call` and is ignored by every other event.
667pub async fn run(
668    config_dir: &Path,
669    binding_id: &str,
670    event: Event,
671    cwd: &Path,
672    digest_hours: i64,
673    call: Option<(String, Value)>,
674) -> anyhow::Result<Option<String>> {
675    run_as(
676        HookHost::Claude,
677        config_dir,
678        binding_id,
679        event,
680        cwd,
681        digest_hours,
682        call,
683    )
684    .await
685}
686
687/// [`run`], answering in `host`'s hook contract.
688pub async fn run_as(
689    host: HookHost,
690    config_dir: &Path,
691    binding_id: &str,
692    event: Event,
693    cwd: &Path,
694    digest_hours: i64,
695    call: Option<(String, Value)>,
696) -> anyhow::Result<Option<String>> {
697    // Refused before anything is read or contacted: the allowlist is the
698    // contract, not the binding's state.
699    if event == Event::Call
700        && let Some((tool, _)) = &call
701        && !HOOK_CALL_TOOLS.contains(&tool.as_str())
702    {
703        bail!(
704            "'{tool}' is not a hook operation: --event call serves {} only, so a hook can \
705             neither issue, rotate nor revoke a credential, nor act on the bus beyond what \
706             its scripts need",
707            HOOK_CALL_TOOLS.join(", ")
708        );
709    }
710    let resolved = resolve_binding(config_dir, binding_id);
711    if event == Event::Status {
712        let value = match &resolved {
713            Resolution::Authenticated(b) => json!({
714                "binding": binding_id,
715                "state": "authenticated",
716                "agent": b.agent,
717                "team": b.team,
718                "session": b.session,
719                "project": b.project,
720                "role": b.role,
721                "epoch": b.epoch,
722                "expires_at": b.expires_at,
723                "mcp_url": b.mcp_url,
724            }),
725            Resolution::Unauthenticated(b) => json!({
726                "binding": binding_id,
727                "state": "no-credential",
728                "agent": b.agent,
729                "team": b.team,
730                "session": b.session,
731                "closed_at": b.closed_at,
732            }),
733            Resolution::Missing => json!({
734                "binding": binding_id,
735                "state": "missing",
736                "hint": "no proxy of this conversation has registered a session; hooks stay \
737                         silent rather than acting as another window",
738            }),
739        };
740        return Ok(Some(serde_json::to_string_pretty(&value)?));
741    }
742
743    // Silence is the contract for every other event: a hook that cannot
744    // prove which window it is must do nothing at all, not act as a shared
745    // identity.
746    let binding = match resolved {
747        Resolution::Authenticated(b) => b,
748        Resolution::Unauthenticated(_) | Resolution::Missing => return Ok(None),
749    };
750
751    match event {
752        Event::Status => unreachable!("handled above"),
753        Event::SessionStart => {
754            // Presence first, with the activity line cleared: a session that
755            // has just started has not done anything yet.
756            // A new process knows nothing about the previous one's turn.
757            let _ = publish_presence(&binding, cwd, "active", true, 900, Some("")).await;
758            let context = session_start_context(&binding, digest_hours).await?;
759            Ok(Some(host.session_start_output(&context).to_string()))
760        }
761        Event::Heartbeat => {
762            publish_presence(&binding, cwd, "active", false, 900, None).await?;
763            Ok(None)
764        }
765        // The window stays open and connected; its model has stopped.
766        Event::Stop => {
767            publish_presence(&binding, cwd, "active", false, 900, Some("idle")).await?;
768            Ok(None)
769        }
770        Event::PromptSubmit => {
771            publish_presence(&binding, cwd, "active", false, 900, Some("attending")).await?;
772            Ok(None)
773        }
774        Event::SessionEnd => {
775            publish_presence(&binding, cwd, "idle", false, 120, Some("idle")).await?;
776            Ok(None)
777        }
778        Event::Watch => bail!("--event watch runs through hook::watch"),
779        Event::Call => {
780            let Some((tool, args)) = call else {
781                bail!("--event call needs --tool <name> and, optionally, --args <json object>");
782            };
783            let value = call_as_window(&binding, &tool, args).await?;
784            Ok(Some(value.to_string()))
785        }
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    #[test]
794    fn session_start_answers_in_each_hosts_contract() {
795        let claude = HookHost::Claude.session_start_output("hello");
796        assert_eq!(claude["hookSpecificOutput"]["additionalContext"], "hello");
797        assert_eq!(
798            claude["hookSpecificOutput"]["hookEventName"],
799            "SessionStart"
800        );
801        let agy = HookHost::Antigravity.session_start_output("hello");
802        assert_eq!(
803            agy["injectSteps"][0]["systemMessage"]["systemMessage"],
804            "hello"
805        );
806        assert!(agy.get("hookSpecificOutput").is_none());
807        assert_eq!(
808            "antigravity".parse::<HookHost>().unwrap(),
809            HookHost::Antigravity
810        );
811        assert_eq!("claude".parse::<HookHost>().unwrap(), HookHost::Claude);
812        assert!("cursor".parse::<HookHost>().is_err());
813    }
814
815    #[test]
816    fn event_names_are_forgiving_but_bounded() {
817        for (raw, expected) in [
818            ("session_start", Event::SessionStart),
819            ("SessionStart", Event::SessionStart),
820            ("session-start", Event::SessionStart),
821            ("heartbeat", Event::Heartbeat),
822            ("Stop", Event::Stop),
823            ("UserPromptSubmit", Event::PromptSubmit),
824            ("prompt-submit", Event::PromptSubmit),
825            ("session_end", Event::SessionEnd),
826            ("status", Event::Status),
827            ("call", Event::Call),
828        ] {
829            assert_eq!(raw.parse::<Event>().unwrap(), expected, "{raw}");
830        }
831        let err = "drain".parse::<Event>().unwrap_err().to_string();
832        assert!(err.contains("session_start"), "{err}");
833    }
834
835    #[tokio::test]
836    async fn a_missing_or_closed_binding_is_silent_never_another_window() {
837        let dir = std::env::temp_dir().join(format!("acs-hook-{}", uuid::Uuid::new_v4()));
838        std::fs::create_dir_all(&dir).unwrap();
839        let cwd = dir.clone();
840
841        // Nothing at all: silence, and no attempt to reach any bus.
842        for event in [Event::SessionStart, Event::Heartbeat, Event::SessionEnd] {
843            assert!(
844                run(&dir, "conv-unknown", event, &cwd, 8, None)
845                    .await
846                    .unwrap()
847                    .is_none(),
848                "{event:?} spoke without a binding"
849            );
850        }
851
852        // A closed window: the record is there, the credential is not.
853        let path = context::binding_path(&dir, "conv-closed");
854        context::write_binding_file(
855            &path,
856            &json!({"session": "s-1", "agent": "joaquin", "team": "acme",
857                    "mcp_url": "http://127.0.0.1:1/mcp", "closed_at": "2026-09-20T00:00:00Z"})
858            .to_string(),
859        )
860        .unwrap();
861        assert!(
862            run(&dir, "conv-closed", Event::Heartbeat, &cwd, 8, None)
863                .await
864                .unwrap()
865                .is_none()
866        );
867
868        // A closed window that kept its credential for the proxy's resume:
869        // just as silent, and the secret stays off the status output.
870        let retained = context::binding_path(&dir, "conv-closed-token");
871        context::write_binding_file(
872            &retained,
873            &json!({"session": "s-2", "agent": "joaquin", "team": "acme",
874                    "mcp_url": "http://127.0.0.1:1/mcp", "session_token": "acss_retained",
875                    "session_id": "11111111-1111-1111-1111-111111111111", "epoch": 3,
876                    "closed_at": "2026-09-20T00:00:00Z"})
877            .to_string(),
878        )
879        .unwrap();
880        assert!(
881            run(&dir, "conv-closed-token", Event::Heartbeat, &cwd, 8, None)
882                .await
883                .unwrap()
884                .is_none(),
885            "a closed binding must not act, even with a credential on disk"
886        );
887        let out = run(&dir, "conv-closed-token", Event::Status, &cwd, 8, None)
888            .await
889            .unwrap()
890            .unwrap();
891        assert!(out.contains("no-credential"), "{out}");
892        assert!(
893            !out.contains("acss_retained"),
894            "status must not print a secret"
895        );
896
897        // A lifecycle or write tool is refused before the binding is even
898        // read, live or not: a hook that could resume the session would
899        // rotate the proxy's credential and print the new secret.
900        let live = context::binding_path(&dir, "conv-live");
901        context::write_binding_file(
902            &live,
903            &json!({"session": "s-3", "agent": "joaquin", "team": "acme",
904                    "mcp_url": "http://127.0.0.1:1/mcp", "session_token": "acss_live",
905                    "session_id": "22222222-2222-2222-2222-222222222222", "epoch": 1})
906            .to_string(),
907        )
908        .unwrap();
909        for tool in [
910            "resume_session",
911            "register_session",
912            "renew_session",
913            "revoke_session",
914            "recover_conversation_history",
915            "post_message",
916        ] {
917            let call = Some((tool.to_owned(), json!({})));
918            let err = run(&dir, "conv-live", Event::Call, &cwd, 8, call)
919                .await
920                .expect_err(tool)
921                .to_string();
922            assert!(err.contains("not a hook operation"), "{tool}: {err}");
923            assert!(!err.contains("acss_"), "{tool}: {err}");
924        }
925
926        // A call as the window is just as silent without a usable binding:
927        // it must never reach a bus as a shared identity.
928        for id in ["conv-unknown", "conv-closed", "conv-closed-token"] {
929            let call = Some(("whoami".to_owned(), json!({})));
930            assert!(
931                run(&dir, id, Event::Call, &cwd, 8, call)
932                    .await
933                    .unwrap()
934                    .is_none(),
935                "call spoke for {id}"
936            );
937        }
938
939        // `status` explains both cases without inventing an identity.
940        let out = run(&dir, "conv-unknown", Event::Status, &cwd, 8, None)
941            .await
942            .unwrap()
943            .unwrap();
944        assert!(out.contains("\"state\": \"missing\""), "{out}");
945        let out = run(&dir, "conv-closed", Event::Status, &cwd, 8, None)
946            .await
947            .unwrap()
948            .unwrap();
949        assert!(out.contains("no-credential"), "{out}");
950        assert!(
951            !out.contains("session_token"),
952            "status must not print a secret"
953        );
954
955        #[cfg(unix)]
956        {
957            use std::os::unix::fs::PermissionsExt;
958            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
959            assert_eq!(mode, 0o600, "binding files are private");
960            let dir_mode = std::fs::metadata(path.parent().unwrap())
961                .unwrap()
962                .permissions()
963                .mode()
964                & 0o777;
965            assert_eq!(dir_mode, 0o700, "the bindings directory is private");
966        }
967        let _ = std::fs::remove_dir_all(&dir);
968    }
969}