# A terminal status light for your agent hooks, with a five-check quick check (free sample of Agent Status Hardware)

Version 1.0, 2026-10-28. By PaperChaseWebb. For Claude Code and Codex hooks, with notes for Hermes and Grok Bot. One Markdown file and one Python 3 script, standard library only. The script was tested on its worked example (5 findings, then 0) and with sample hook payloads for Claude Code and Codex. It was not loaded inside a harness for this release, and it drives no hardware: the light is a block of color in a terminal window.

The common way to miss an agent's question is simple: it asks for a permission at minute two, you are in another window, and you find out at minute twenty. This sample gives you the smallest honest fix. Each hook writes one line of state, a second terminal window shows one colored block for all your sessions, and when any session needs you it turns red, blinks and rings the bell, whatever the other sessions are doing.

## Install in one line

Save this page as FREE-SAMPLE.md, then:

```
python3 -c "import re;t=open('FREE-SAMPLE.md').read();open('status_mini.py','w').write(re.search(r'~~~python\n(.*?)~~~',t,re.S).group(1))"
cp status_mini.py ~/status_mini.py
python3 ~/status_mini.py watch
```

Leave watch running in its own window. The hooks print nothing, because the agent owns its own terminal.

## The event-to-state map

| Hook event (Claude Code; Codex uses the same names) | State | Light |
|---|---|---|
| PermissionRequest, Notification with permission_prompt | waiting | red, blinking, bell, "NEEDS YOU" |
| StopFailure | error | orange |
| Stop, Notification with idle_prompt | done | blue |
| UserPromptSubmit, PreToolUse, PostToolUse | working | green |
| SessionStart | idle | grey |
| SessionEnd | removes the session | |

Priority is waiting, error, done, working, idle. With three sessions open, one waiting session turns the whole light red. The map keeps only the agent name, a hashed session id and the state; never the prompt, tool input or message.

Hooks for Claude Code, in ~/.claude/settings.json (the example command below writes them for you into demo/fixed/settings.json):

```json
{"hooks": {
  "PermissionRequest": [{"hooks": [{"type": "command", "command": "python3 ~/status_mini.py hook --agent claude-code", "timeout": 2}]}],
  "Notification": [{"matcher": "permission_prompt|idle_prompt", "hooks": [{"type": "command", "command": "python3 ~/status_mini.py hook --agent claude-code", "timeout": 2}]}],
  "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "python3 ~/status_mini.py hook --agent claude-code", "timeout": 2}]}],
  "Stop": [{"hooks": [{"type": "command", "command": "python3 ~/status_mini.py hook --agent claude-code", "timeout": 2}]}]}}
```

For Codex, put the same block in ~/.codex/hooks.json with --agent codex --codex (Codex wants {} on stdout from a Stop hook), then trust it in /hooks. Hermes and Grok Bot are covered in the full pack.

## Five rules

1. Waiting is the loudest state. It blinks, in a color nothing else uses, and it always comes first.
2. A status hook must never slow or stop the agent: a timeout of 5 seconds or less, and exit 0 even on bad input.
3. Status is metadata. Never put a prompt, a tool input, a message or a path into it.
4. A light is never an approval. It tells you to look; only your yes at the keyboard counts.
5. Status stays on your machine unless you decide otherwise and write that down.

## The five checks

| Check | What it looks for |
|---|---|
| Q1 | no hook for PermissionRequest or a permission_prompt notification, so waiting never shows |
| Q2 | a status hook without a timeout of 5 seconds or less |
| Q3 | waiting that does not blink, or shares the working color |
| Q4 | content fields (prompt, tool_input, message, transcript_path, cwd) in the map |
| Q5 | waiting not first in the priority |

## Worked example

`python3 status_mini.py example demo` writes demo/fixed, a clean setup, and demo/seeded, with five faults planted the way they happen when a setup is copied from a forum post. Then:

```
python3 status_mini.py check demo/seeded
Q1  settings.json: no hook for PermissionRequest or a permission_prompt notification, so waiting never shows
Q2  settings.json: hook without a timeout of 5 seconds or less: ['PreToolUse']
Q3  status-map.json: waiting must blink, in a color that is not the working color
Q4  status-map.json: fields carry content, not metadata: ['prompt']
Q5  status-map.json: waiting must come first, so another session finishing cannot hide it
5 finding(s)
```

Exit 1. What happened: the PermissionRequest hook was dropped and the Notification matcher only caught idle_prompt, a PreToolUse hook got a 60 second timeout, waiting was set to solid, someone added the prompt "for context", and error was moved above waiting. The fixed copy gives 0 findings, exit 0. The check reads only.

A short run with two sessions, as watch shows it:

```
echo '{"hook_event_name":"UserPromptSubmit","session_id":"a"}' | python3 status_mini.py hook --agent claude-code   ->  green  WORKING (1 session(s))
echo '{"hook_event_name":"PreToolUse","session_id":"b"}' | python3 status_mini.py hook --agent codex --codex       ->  green  WORKING (2 session(s))
echo '{"hook_event_name":"Notification","notification_type":"permission_prompt","session_id":"a"}' | python3 status_mini.py hook --agent claude-code   ->  red, blinking, bell  NEEDS YOU
echo '{"hook_event_name":"Stop","session_id":"b"}' | python3 status_mini.py hook --agent codex --codex             ->  still red  NEEDS YOU
```

## status_mini.py

~~~python
#!/usr/bin/env python3
"""status_mini.py: a status light in your terminal for agent hooks, and a five-check quick check.

  echo '{"hook_event_name":"Stop","session_id":"a"}' | python3 status_mini.py hook --agent claude-code
  python3 status_mini.py watch             keep a light in a second terminal window (Ctrl+C to stop)
  python3 status_mini.py show              print the light once
  python3 status_mini.py check FOLDER      five read-only checks on FOLDER/settings.json and FOLDER/status-map.json
  python3 status_mini.py example DIR       write DIR/fixed and DIR/seeded for the worked example

The hook prints nothing: the agent owns its own terminal, so the light lives in a second
window running watch. Python 3.9+, standard library only. State lives in ~/.agent-status-mini.json. Only the agent
name, a hashed session id and the state are kept; never the prompt, tool input or message.
"""
import hashlib, json, os, sys, time

STATE_FILE = os.path.expanduser(os.environ.get("STATUS_MINI_FILE", "~/.agent-status-mini.json"))
MAP = {
    "colors": {"waiting": [255, 32, 0], "error": [255, 112, 0], "done": [0, 80, 255], "working": [0, 160, 64], "idle": [60, 60, 60]},
    "effects": {"waiting": "blink", "error": "solid", "done": "solid", "working": "solid", "idle": "off"},
    "priority": ["waiting", "error", "done", "working", "idle"],
    "fields": ["agent", "session", "state"],
    "events": {
        "PermissionRequest": "waiting", "Notification:permission_prompt": "waiting", "Notification:idle_prompt": "done",
        "UserPromptSubmit": "working", "PreToolUse": "working", "PostToolUse": "working",
        "Stop": "done", "StopFailure": "error", "SessionStart": "idle", "SessionEnd": "end",
    },
}
CONTENT = {"prompt", "tool_input", "message", "transcript_path", "cwd"}


def to_state(payload):
    ev = payload.get("hook_event_name", "")
    kind = payload.get("notification_type", "")
    return MAP["events"].get(f"{ev}:{kind}") or MAP["events"].get(ev)


def load():
    try:
        return json.load(open(STATE_FILE))
    except (OSError, ValueError):
        return {}


def show(sessions, end="\n"):
    rank = {s: i for i, s in enumerate(MAP["priority"])}
    top = min((v["state"] for v in sessions.values()), key=lambda s: rank.get(s, 9), default="idle")
    r, g, b = MAP["colors"][top]
    blink = "\033[5m" if MAP["effects"][top] == "blink" else ""
    bell = "\a" if top == "waiting" else ""
    label = {"waiting": "NEEDS YOU"}.get(top, top.upper())
    sys.stdout.write(f"\r\033[K{bell}\033[48;2;{r};{g};{b}m    \033[0m {blink}{label}\033[0m ({len(sessions)} session(s)){end}")
    sys.stdout.flush()
    return top


def cmd_hook(args):
    agent = args[args.index("--agent") + 1] if "--agent" in args else "agent"
    try:
        payload = json.loads(sys.stdin.read() or "{}")
        st = to_state(payload)
        if st:
            s = load()
            key = agent + ":" + hashlib.sha256(str(payload.get("session_id")).encode()).hexdigest()[:8]
            if st == "end":
                s.pop(key, None)
            else:
                s[key] = {"agent": agent, "state": st, "ts": round(time.time())}
            json.dump(s, open(STATE_FILE, "w"))
    except Exception as e:  # the light must never stop the agent
        sys.stderr.write(f"status_mini: {e}\n")
    if "--codex" in args:
        print("{}")
    return 0


def check(folder):
    found = []
    try:
        hooks = json.load(open(os.path.join(folder, "settings.json"))).get("hooks", {})
    except (OSError, ValueError):
        hooks = {}
    try:
        m = json.load(open(os.path.join(folder, "status-map.json")))
    except (OSError, ValueError):
        m = MAP
    notif = any("permission_prompt" in g.get("matcher", "") for g in hooks.get("Notification", []))
    if "PermissionRequest" not in hooks and not notif:
        found.append(("Q1", "settings.json", "no hook for PermissionRequest or a permission_prompt notification, so waiting never shows"))
    slow = [ev for ev, gs in hooks.items() for g in gs for h in g.get("hooks", [])
            if "status_mini.py" in h.get("command", "") and (h.get("timeout") is None or h["timeout"] > 5)]
    if slow:
        found.append(("Q2", "settings.json", f"hook without a timeout of 5 seconds or less: {slow}"))
    if m.get("effects", {}).get("waiting") not in ("blink", "flash") or m.get("colors", {}).get("waiting") == m.get("colors", {}).get("working"):
        found.append(("Q3", "status-map.json", "waiting must blink, in a color that is not the working color"))
    leak = sorted(set(m.get("fields", [])) & CONTENT)
    if leak:
        found.append(("Q4", "status-map.json", f"fields carry content, not metadata: {leak}"))
    if (m.get("priority") or ["?"])[0] != "waiting":
        found.append(("Q5", "status-map.json", "waiting must come first, so another session finishing cannot hide it"))
    for f in found:
        print(f"{f[0]}  {f[1]}: {f[2]}")
    print(f"{len(found)} finding(s)")
    return 1 if found else 0


def example(d):
    cmd = "python3 ~/status_mini.py hook --agent claude-code"
    hooks = {ev: [{"hooks": [{"type": "command", "command": cmd, "timeout": 2}]}]
             for ev in ("UserPromptSubmit", "PreToolUse", "PermissionRequest", "Stop", "StopFailure", "SessionEnd")}
    hooks["Notification"] = [{"matcher": "permission_prompt|idle_prompt", "hooks": [{"type": "command", "command": cmd, "timeout": 2}]}]
    for name in ("fixed", "seeded"):
        os.makedirs(os.path.join(d, name), exist_ok=True)
        h, m = json.loads(json.dumps(hooks)), json.loads(json.dumps(MAP))
        if name == "seeded":
            del h["PermissionRequest"]                       # Q1
            h["Notification"][0]["matcher"] = "idle_prompt"
            h["PreToolUse"][0]["hooks"][0]["timeout"] = 60   # Q2
            m["effects"]["waiting"] = "solid"                # Q3
            m["fields"].append("prompt")                     # Q4
            m["priority"] = ["error", "waiting", "done", "working", "idle"]  # Q5
        json.dump({"hooks": h}, open(os.path.join(d, name, "settings.json"), "w"), indent=2)
        json.dump(m, open(os.path.join(d, name, "status-map.json"), "w"), indent=2)
    print(f"wrote {d}/fixed and {d}/seeded")
    return 0


def main(a):
    if a[:1] == ["hook"]:
        return cmd_hook(a[1:])
    if a[:1] == ["watch"]:
        last = None
        try:
            while True:
                s = load()
                sig = json.dumps(s, sort_keys=True)
                if sig != last:
                    show(s, end="")
                    last = sig
                time.sleep(0.5)
        except KeyboardInterrupt:
            print()
            return 0
    if a[:1] == ["show"]:
        show(load())
        return 0
    if a[:1] == ["check"] and len(a) == 2:
        if not os.path.isdir(a[1]):
            print("folder not found")
            return 2
        return check(a[1])
    if a[:1] == ["example"] and len(a) == 2:
        return example(a[1])
    print(__doc__)
    return 2


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
~~~

## What the full pack adds

Agent Status Hardware ($14) adds hooks for Claude Code, Codex and Hermes and a rules block for Grok Bot, a bridge with seven adapters (terminal, simulated light, a 250 x 122 e-ink frame, USB busylights, blink(1), an ESP32 over serial, Home Assistant), a stale state so a dead session cannot look like progress, firmware for an ESP32 light and a Raspberry Pi e-ink panel, owner approvals for flashing, network exposure and Home Assistant tokens, plain notes on what was and was not tested on real hardware, and a fifteen-check read-only checker tested on a seeded fixture. If you want it set up across your team's harnesses, there is a $5,000 Install sprint and $5,000/mo Manage. Book a call from paperchasewebb.ai.

Credit: the idea owes a lot to A. Lottes's Tokibot (https://github.com/a-lottes/tokibot) and u/Fincut's Claude Code busylight post on r/ClaudeAI. Nothing here is copied from them.

PaperChaseWebb 💸
