Research & Learning · feature summary

How ainb-hooks, ainb-notifyd, and the Inbox screen work in agents-in-a-box

TL;DR — A single bash script (notify.sh) handles every Claude Code and Codex CLI hook event, builds a JSON envelope, and writes it to a Unix domain socket. A small Rust daemon (ainb-notifyd) listens on that socket, persists each envelope to a SQLite database with WAL, replays a fallback file at startup, and emits OS notifications for user-facing events. The ainb-tui Inbox screen polls the same DB on every render tick, shows two-pane list + detail, and uses cwd-based correlation to render per-session row badges and jump straight to the matching tmux pane. Three PRs shipped to main.

Architecture in one picture

Two host agents fire hooks at distinct lifecycle moments. The same script absorbs both shapes, hands off to a daemon that owns the SQLite file, and the TUI polls that SQLite for live display. Every box below is a real file on disk; arrows are the actual transport.

Claude Code session stdin JSON · hook_event_name Codex CLI session argv[1] JSON · type plugins/ainb-hooks/hooks/notify.sh universal · detects stdin vs argv builds JSON envelope · exits 0 always notify.sock ~/.agents-in-a-box · 0600 notify.fallback.jsonl written if socket missing corrupt lines → .corrupt.jsonl daemon down ainb-notifyd tokio · UnixListener replays fallback on start SIGTERM → drop sock + pid replay notifications.db rusqlite · WAL · 5 indexes osascript / notify-send debounced 60s · user-facing only ainb-tui 📥 Inbox screen (Shift+I) · two-pane list + detail · keys: d / C / a / p / r Sessions row badges · ●N matched by workspace_path Menu-bar I inbox hint · always shown · ●N when unread poll · SQLite WAL tmux attach Enter on inbox row · cwd match → session
solid arrows = hot path · dashed = failure / async · olive = startup replay

The seven components

Every box in the diagram is a real artifact in the repo. Each card below is one file (or one tight module) and what it owns.

component · bash

notify.sh — universal hook
plugins/ainb-hooks/hooks/notify.sh

One script for both agents. Detects whether JSON arrives on stdin (Claude) or as argv[1] (Codex), pulls hook_event_name or type, builds a normalised envelope, and pipes it to the socket. Exits 0 unconditionally so a delivery failure never blocks the host agent.

component · json

plugin.json — Claude manifest
plugins/ainb-hooks/.claude-plugin/plugin.json

Standard Claude Code plugin marketplace manifest. Wires every event (SessionStart, UserPromptSubmit, PostToolUse, Notification, Stop, PreCompact) to ${CLAUDE_PLUGIN_ROOT}/hooks/notify.sh with AINB_AGENT=claude prepended.

component · json

codex/hooks.json — merge template
plugins/ainb-hooks/codex/hooks.json

Codex doesn't have a plugin marketplace, so we merge a managed block into the user's ~/.codex/hooks.json. Each entry carries _ainb_managed: true so uninstall strips only what we added; pre-existing user hooks survive untouched.

component · rust

ainb-notifyd — daemon binary
crates/ainb-plugin-notifyd/

Tokio UnixListener + per-connection task. install, uninstall, status, run, stop verbs from one binary. Embeds the bash script + manifests via include_str! so the install verb has no repo dependency at runtime.

component · sqlite

notifications.db — store
~/.agents-in-a-box/notifications.db

WAL mode + synchronous=NORMAL. Five indexes (ts, session_id, project, agent, partial on unread). Retention sweep on every insert: deletes rows older than 7 days or beyond the 10k-row cap. Notifyd-owned — does not share the usage.db file that session-reader plugin owns.

component · ratatui

Inbox screen + badges
crates/ainb-core/src/components/inbox.rs

Two-pane list + detail. Eager Store open on TUI startup so the global menu-bar badge works from frame one. session_list.rs renders a per-row ● N using unread_for_workspace_path. Sidebar tile 📥 Inbox [I] closes the discoverability gap.

component · bash

live-validate-ainb-hooks.sh
scripts/live-validate-ainb-hooks.sh

One-command host smoke. Installs for both agents, starts the daemon, fires synthetic Claude + Codex hooks (or pauses for a real CLI session with --real-session), and prints the rows that landed in SQLite. The only thing that proves the live-host seam end-to-end.

The event path, step by step

From hook fire to a visible row in the TUI — five steps, end to end in single-digit milliseconds when the daemon is up. Each step's file:line citation is on the right.

1 · The agent fires a hook claude/codex internals

When the host agent hits a lifecycle moment — turn complete, idle prompt, permission request — it dispatches the registered hook command. Claude pipes a JSON object on stdin with a hook_event_name field; Codex passes the JSON as the first argv argument with a type field. Same wire shape, two different delivery channels.

2 · notify.sh builds + sends the envelope plugins/ainb-hooks/hooks/notify.sh

The script auto-detects the delivery channel ($# > 0 means argv-mode Codex; else read stdin). It extracts session_id, cwd, the raw event name, optionally a matcher (concatenated as EventName:matcher), then assembles a normalised envelope with jq. The envelope is piped to nc -U notify.sock; if the socket doesn't exist the script writes the envelope to notify.fallback.jsonl for the daemon to replay later. exit 0 always.

3 · ainb-notifyd parses + persists crates/ainb-plugin-notifyd/src/listener.rs

Each accepted connection spawns a tokio task that reads newline-terminated JSON. Parsing (Envelope::from_bytes) validates protocol_version and required fields; malformed lines are routed to .corrupt.jsonl for forensics. The blocking SQLite insert + retention prune runs on the blocking pool via spawn_blocking. After every successful persist, osnotify::notify decides whether to fire a native notification (gated by is_user_facing + a per-(session, raw_event) 60-second debounce).

4 · SQLite stores the row crates/ainb-plugin-notifyd/src/store.rs

One row per envelope. The retention sweep runs in the same transaction: rows older than retention_days are deleted, then the oldest rows beyond max_rows are deleted (ORDER BY ts ASC). WAL mode means the TUI reader never blocks the daemon writer. unread_by_cwd() and unread_by_session() are grouped-count queries that use the partial unread index, so they stay sub-millisecond even with 10k rows.

5 · ainb-tui renders crates/ainb-core/src/components/inbox.rs

Three render surfaces share the same store handle:

· The Inbox screen calls Store::list on every render tick and shows the rows in a two-pane list + detail. Keys drive mark_read, dismiss, dismiss_visible, plus filter cycling.
· The menu bar (layout.rs) queries unread_count once per frame and renders ● N · I inbox. The hint is always visible; the ● N only when unread > 0.
· The session list (session_list.rs) queries unread_by_cwd once per render and looks up each session's workspace_path in the resulting map to render the per-row ● N.

Wire format & install

One envelope shape, three install surfaces. The placeholders below are exactly what's on the wire and on disk after a clean ainb-notifyd install --all.

// one JSON line per envelope on the socket / in fallback.jsonl
{
  "protocol_version": 1,
  "agent":            "claude",        // or "codex"
  "raw_event":        "Notification:idle_prompt",
  "session_id":       "7f2a-...",
  "cwd":              "/Users/stevie/d/git/ai-coder-rules",
  "project":          "ai-coder-rules",  // basename(cwd)
  "ts":               1717000000000,
  "payload":          { /* verbatim original hook JSON */ }
}
// ~/.claude/plugins/ainb-hooks/.claude-plugin/plugin.json
{
  "name":    "ainb-hooks",
  "version": "0.1.0",
  "hooks": {
    "Stop": [{
      "matcher": "",
      "hooks": [{
        "type":    "command",
        "command": "AINB_AGENT=claude ${CLAUDE_PLUGIN_ROOT}/hooks/notify.sh",
        "timeout": 5
      }]
    }]
    // + SessionStart, UserPromptSubmit, PostToolUse,
    //   Notification, PreCompact — same shape
  }
}
// ~/.codex/hooks.json — managed block merged in-place
{
  "hooks": {
    "Stop": [
      { /* user's pre-existing hook — untouched */ },
      {
        "_ainb_managed": true,        // uninstall strips only these
        "matcher": "",
        "hooks": [{
          "type":    "command",
          "command": "AINB_AGENT=codex /Users/stevie/.agents-in-a-box/hooks/notify.sh",
          "timeout": 5
        }]
      }
    ]
  }
}
// ~/.agents-in-a-box/install.json — written by install verb
{
  "agents": ["claude", "codex"],
  "hook_script":       "/Users/stevie/.agents-in-a-box/hooks/notify.sh",
  "claude_plugin_dir": "/Users/stevie/.claude/plugins/ainb-hooks",
  "codex_hooks_json":  "/Users/stevie/.codex/hooks.json"
}
The canonical hook script lives at ~/.agents-in-a-box/hooks/notify.sh. Claude's plugin dir symlinks to it (with a copy fallback on platforms where symlink fails); Codex's hooks.json references it by absolute path. One source of truth, no drift between agents.

cwd-based correlation — bridging two ID spaces

ainb internally identifies a session by a Uuid it generates when spawning a worktree. The host agents' hooks emit their own session_id strings — Claude's session ID, Codex's session ID — which sit in a different namespace. There's no shared key.

The workaround is cwd: every envelope already carries the agent's working directory at hook-fire time, and every ainb Session already carries a workspace_path (the repo root). Match the two and the bridge is done:

Notification (envelope) session_id "abc-claude-uuid" cwd "/repo/branch-a" ainb Session id <Uuid> workspace_path "/repo" match on cwd cwd == workspace_path OR cwd starts_with(path + "/") trailing slash guard prevents "/repo" matching "/repository" → powers session_list ●N badge AND Enter-attaches-tmux jump

Lifecycle, retention & gotchas

How it was validated

Five test layers in order of cost-to-confidence. CI runs the bottom three on every push across macOS + Linux.

// crates/ainb-plugin-notifyd/src/{envelope,store,fallback,pid,osnotify}.rs
// crates/ainb-core/src/components/{inbox,sidebar}.rs
$ cargo test -p ainb-plugin-notifyd --lib
test result: ok. 44 passed; 0 failed
$ cargo test -p ainb --lib components::{inbox,sidebar}
test result: ok. 15 passed; 0 failed
// crates/ainb-plugin-notifyd/tests/end_to_end.rs
// real bash → real socket → real notifyd → real SQLite
$ cargo test -p ainb-plugin-notifyd --test end_to_end
hook_script_into_real_daemon_persists_both_agents .. ok
hook_falls_back_when_daemon_down_then_daemon_replays .. ok
// crates/ainb-core/tests/tripwire_inbox_opens_and_renders.rs
// real ainb binary in a tmux pane · capture-pane assertions
$ cargo test -p ainb --test tripwire_inbox_opens_and_renders
inbox_opens_and_renders_seeded_notifications .. ok
  ✓ HomeScreen sidebar shows 📥 Inbox [I]    (discoverability)
  ✓ Shift+I opens 📥 Inbox screen
  ✓ Both seeded events render with detail pane
  ✓ Help bar visible
# Driven by hand against an isolated $HOME via tmux capture
$ HOME=/tmp/ainb-test ./target/debug/ainb tui
→ HomeScreen sidebar shows 📥 Inbox tile between Sessions and Recovery
→ Shift+I opens Inbox with both seeded events
→ Detail pane shows event, agent, session, cwd, payload JSON
→ Menu-bar shows "I inbox" hint always; "● N · I inbox" when unread
# THE one unproven seam — requires real Claude or Codex CLI session
$ scripts/live-validate-ainb-hooks.sh --real-session
==> install + daemon + pause for real session ...
# requires Stevie's machine: a real claude/codex CLI process
# firing its own hook with its real payload shape
==> rows that landed:
   agent  raw_event                 session_id  project
   ?????  ???????????               ??????????  ???????
The layers below "live host" exercise real components — the actual bash script, socket, daemon, SQLite, TUI binary — but with synthetic JSON envelopes hand-authored to match the agents' documented shapes. The only thing the suite cannot prove is that current Claude / Codex emit the exact hook_event_name / type field names and event values I expect. Run live-validate-ainb-hooks.sh --real-session to close that gap.

FAQ

Where is the SQLite file? Can I poke around with sqlite3?
~/.agents-in-a-box/notifications.db. Yes — WAL means the daemon can keep writing while you read. sqlite3 -header -column ~/.agents-in-a-box/notifications.db "SELECT agent, raw_event, session_id, project FROM notifications ORDER BY ts DESC LIMIT 20".
Why Shift+I for the Inbox? Why not n?
n already opens NewSession; lowercase i already opens Stats. Shift+I was the next free letter that still mnemonically ties to "inbox". The home-screen sidebar tile and the menu-bar hint both advertise it as [I]. On Linux tmux Shift+i may arrive as KeyCode::Char('i') + SHIFT rather than Char('I'); the handler accepts both forms (PR #165).
Why not just identify sessions by session_id?
The host agent's session_id and ainb's Session.id live in different namespaces — Claude generates its own UUID, Codex generates its own, ainb generates its own. There is no shared key. The cheapest correlation that already exists at both ends is the working directory: every envelope has cwd, every ainb session has workspace_path. Exact match or prefix-with-trailing-slash for worktree subdirs.
What happens if I install once, run, then install again?
Idempotent. install writes/overwrites the canonical hook script, re-creates the Claude plugin dir, re-merges the Codex managed block (replacing any prior _ainb_managed entries — never duplicating). Running twice produces the same on-disk state as running once.
How do I confirm it works on my real Claude Code or Codex sessions?
cd ainb-tui && cargo install --path crates/ainb-core --force (your existing ~/.cargo/bin/ainb is the pre-merge build), then scripts/live-validate-ainb-hooks.sh --real-session. The script pauses after starting the daemon — launch a real claude or codex session, do something, then come back. It prints the rows that actually landed.
Why does the discoverability test exist if the keystroke test already passes?
Because the original tripwire only proved pressing I opens the inbox. It never asserted there was a visible cue that I was a shortcut. The follow-up regression: an undiscoverable feature can pass green CI. The new pre-assertion checks for 📥, Inbox, and [I] in the home-screen capture before firing the keystroke.