Tells the amask runner what Claude Code hooks do not: a turn the person interrupted

A screensaver runner for long-running AI coding agents. It wraps the agent, covers the terminal with matrix digital rain while it works, and hands the screen back the moment you are needed.
amask claude -p "refactor this"
amask codex exec "make the tests pass"
> Use the Bash tool to run 'sleep 12; echo done', then
summarise in 2 paragraphs
working... 8s
MODEL Opus 5 (1M context)
DIR ~/edu/amask
BUFFERED 5.1KB (831 tokens)
press any key to return
q to skip this turn
claude it does not guess. The agent reports its own state over Claude Code hooks, so even a 10-minute tool call stays covered and the screen returns when the agent says it is done.Python 3.11+ and an xterm-compatible terminal. There is no packaging step -- the clone is the installation.
git clone https://github.com/JeanTracker/agent-attention-mask.git amask && cd amask
./bin/amask claude
To call it from anywhere, symlink the launcher into a directory on your PATH:
ln -s "$PWD/bin/amask" ~/.local/bin/amask
Leave the clone where it is: the launcher resolves the symlink back to it to find both the package and the hook emitter.
If stdout is not a tty, there is no screen to hide, and the runner hands the process straight over with execvp.
amask <agent command> [args...] # run an agent under the rain
amask --top # watch and steer every session on one screen
amask --ls # list the running sessions
amask --config [key=value...] # show or set the stored defaults
amask --ctl <pid|last> <command> # status | get | set | skip | wake
amask --version
Pressing q while covered stops it covering again for that turn -- including any subagent that turn started. Submitting your next prompt lifts it. Any other key just hands the screen back; the key used to wake is not forwarded to the agent, so waking with Enter neither submits an empty prompt nor approves a pending confirmation.
To have every session run covered without typing amask, alias the agent. What the alias should say depends on what the name already is, so ask first with type claude or type codex. Bash, zsh and ksh all take the same line; only the rc file differs -- ~/.zshrc, ~/.bashrc, or ~/.bash_profile for the login shell macOS Terminal starts.
claude. If type answers with a path, the plain alias works:
alias claude='amask claude'
If it answers with an alias of its own, you are on an older Claude Code install that points claude at a path not on PATH. Pass that path through instead, carrying over any flags the old alias had:
alias claude='amask ~/.claude/local/claude'
Either way the argument's basename has to stay claude: that is what the runner matches on before it wires up its hooks, and a name that does not match falls back to timing heuristics.
codex. The plain alias is all there is to it:
alias codex='amask codex'
Codex fires no hooks, so there is nothing for the runner to wire up and no basename to keep: covering is judged from output timing alone (see How it works). The MODEL and TOKENS rows are scraped from claude's status line, so under codex they may stay blank.
Only a session is wrapped. The agents' own commands (claude mcp, claude plugin test, codex login, ...) and --help/--version are handed straight to the agent, so the alias changes nothing about them; the agent's own --help is what tells the runner which words are commands. command claude or command codex still runs the agent unwrapped. Avoid a wrapper script of the same name earlier on PATH: the runner launches the agent with execvp, which resolves the name through PATH again, finds the wrapper and calls back into amask forever.
Typing resets the timer, so the screen never covers mid-input. The same 4 seconds are counted again after you wake the screen by hand.
When wrapping claude, the runner asks rather than infers. It creates one FIFO and wires up hooks with --settings, so bin/amask-hook writes one line per event. UserPromptSubmit, PreToolUse, PostToolUse and SubagentStop read as working; Notification (a permission request, say), Stop and SessionEnd read as waiting and hand the screen back at once. --settings loads in addition to your own settings, so ~/.claude/settings.json is neither read nor written, and the wiring lives only for that one process.
One thing no hook says is that you interrupted a turn: ESC fires nothing, not even Stop, so the runner would believe the agent still working and put the rain back over an idle prompt. For that, an interactive claude 2.1.286 or newer also gets --plugin-dir <clone>/mod, a Claude Code mod that sees the turn end as aborted and says so on the same FIFO. It is left off for claude -p, for older releases (the runner asks claude --version first), and when you pass --settings yourself; there, pressing q after waking is still how you skip the turn.
Any other agent can report the same way by writing one-line JSON to the FIFO named by AMASK_HOOK_FIFO, carrying the AMASK_HOOK_TOKEN value: {"event": "PreToolUse", "token": "<token>"}. Events with the wrong token are ignored, which is what keeps concurrent runners out of each other's way.
Without hooks, silence is read as a stretch rather than a single gap, because silence means opposite things from one agent to the next:
| Situation | Observed output | Reading |
|---|---|---|
claude -p "..." working | 5.9 s of silence, then the answer | silence is work |
| interactive claude working | streaming every ~114 ms, pausing up to 0.8 s | activity is work |
| interactive claude at the prompt | a 60-150 byte repaint every 8-10 s | a repaint is not work |
The rule: if it has not said anything yet it is working; if it spoke and then stopped it is waiting. Idle repaints cannot build up a stretch, so the screen does not flicker.
For a TUI agent, output is a picture being maintained rather than a log. The runner detects that (cursor movement, screen clears, scroll regions) and, instead of replaying hidden output over a stale screen, briefly changes the pty size so the agent redraws itself.
| Variable | Default | Meaning |
|---|---|---|
AMASK_OVERLAY_DELAY | 4.0 | cover once continuous work has lasted this long |
AMASK_IDLE_SILENCE | 1.5 | without hooks, this much silence breaks a stretch of work |
AMASK_HOOK_STALL | 120.0 | if hooks go this quiet while working, fall back to the heuristic |
AMASK_DEBUG | -- | record every cover/uncover judgement to this file |
AMASK_RUN_DIR | ~/.amask/run | control socket and its discovery file |
AMASK_CONFIG | ~/.amask/config.json | the stored defaults |
The first three are what a session starts with; each can then be retuned live per session over the control socket, which is never written to disk.
amask --config is what a new session starts with:
amask --config # stored values, and what a new session gets
amask --config overlay_delay=8 # store one
amask --config overlay_delay= # forget it again
c in --top edits the same file without leaving the screen that shows what the values are doing.
Narrowest wins: the shipped default, then this file, then an env knob, then a session's own set over the socket. Changing the file does not reach sessions that are already running -- d in --top is the deliberate act that does.
Each runner opens a Unix socket, so something other than the terminal it runs in can see what it is doing. amask --top is the view onto it, refreshed twice a second:
amask 3 sessions (2 marked)
PID Screen State Cover Folder Prompt
* 23875 covered working 4s amask add the config feature
24110 open waiting·skip 20s release-notes draft the release notes
* 24777 covered working·idle? 8s agent-attention-mask run the whole suite
defaults overlay_delay=8 -> applied to 2 sessions
mark(space) all(a) open(enter) │ skip(s) wake(w) │ defaults(d) config(c) │ poll(r) help(?) quit(q)
The bar along the bottom reads what it does(key), grouped by what the keys are for, and it is laid out for the window it is in: a wide terminal gets skip turn(s) and push defaults(d), a narrow one drops the keys it can spare and keeps help(?) and quit(q).
? opens the key reference: every key with a sentence on what it does, grouped by screen, and what cover, idle threshold and stored defaults actually mean. The bar is the reminder; ? is the explanation.
Every column is as wide as what is actually in it, and the prompt takes what is left. space marks a session (a marks them all) and every command then applies to the marked ones: s skips their turn, w wakes them, d pushes the stored defaults into them, +/- move their cover delay and [/] their idle threshold. With nothing marked, the highlighted row is the target.
c opens the stored defaults for editing -- see below. enter opens one session on its own screen -- session id, working directory, how long it has been working, how much output is hidden, all three timing values and the prompt in full. The same commands work there and apply only to the session you are looking at.
amask session 23875 [covered]
PID 23875
Command claude --resume
Session id sess-abc
Folder /home/me/work
Started 2026-09-22 10:54:29 (3m ago)
Screen covered (the rain is up)
State working
Working 54.2s
Notes hooks say working, but the output stopped
Model Opus 5
Hidden 43K (43869B, all of it comes back when the cover lifts)
Cover delay 4s
Idle silence 1.5s
Hook stall 120s
Prompt add the config feature
scroll(↑↓) back(esc) │ skip(s) wake(w) │ defaults(d) config(c) │ poll(r) help(?) quit(q)
c opens the stored defaults -- the same file amask --config writes, edited from the screen that shows what those numbers are doing:
amask default settings (unsaved)
The defaults a NEW session starts with. This writes the file only --
running sessions keep their own values, and `d` in the list pushes
these into the marked ones. File: ~/.amask/config.json
idle_silence 1.5s shipped shipped 1.5s step 0.1
hook_stall 120s shipped shipped 120s step 10
> overlay_delay 9s changed shipped 4s step 1
Unsaved. Enter writes it, esc leaves it alone.
field(↑↓) change(+/-) shipped(0) │ save(enter) back(esc) │ help(?) quit(q)
↑/↓ pick a value, +/- move it, 0 forgets it again (the shipped default comes back), enter writes the file and esc leaves without writing. Nothing else writes it, and what it writes governs new sessions only -- d back in the list is still what reaches the running ones.
The same channel answers one question at a time, for scripts:
amask --ls # pid, agent command, session id
amask --ctl last status # covered? working? what was asked?
amask --ctl last set overlay_delay=8 # retune this session, live
amask --ctl last skip # same as pressing q
amask --ctl last wake # same as touching a key
examples/watch_sessions.py is the smallest external client, and its closing comment is the whole protocol: read ~/.amask/run/amask-ctl-<pid>.json for the socket path and token, connect, send one JSON object per line, read one back. The socket is 0600 inside a 0700 directory, skip is refused when there is no turn to skip, and there is deliberately no way to cover the screen or to type at the agent from outside.
Turning off "Save lines to scrollback in alternate screen mode" in the profile is recommended; the default is on, so you have to do it yourself.
The runner does not dirty scrollback either way -- the overlay emits neither a newline nor ESC[2J, so it never creates a line that can be pushed out of the alternate screen. The reason to turn the option off is the agent's side: a full-screen agent such as interactive claude clears the screen on every frame, and with this option on iTerm2 files each of those frames into scrollback (some 80 frames over 9 seconds, in one measurement). That happens with or without amask.
Remove the symlink, remove the alias from your rc file, delete the clone. Nothing is registered anywhere else: hook settings and the mod are passed on Claude Code's command line for that one run, and each runner's FIFO is unlinked when it exits. The one thing that outlives a session is ~/.amask -- the --config defaults and the control sockets -- and rm -rf ~/.amask is safe once nothing is running.
python3 tests/run_fast.py # ~2s, no terminal needed
python3 tests/run_all.py # ~12min, real ptys -- run it before you push
Thirteen phase suites. Most drive real ptys against the fixture agents in tests/fixtures/; test_phase11_judge.py drives the cover/uncover state machine directly, with time as an argument rather than something to wait for, and finishes in milliseconds (D-054). The fast tier is those cases plus everything else that needs no terminal. The mod under mod/ has its own tests for claude plugin test mod.
CI runs the fast tier on every pull request and the full suite on Linux and macOS once something lands on master, which is why the full one is yours to run first. Python floor is 3.11, also checked by hand -- if you are on 3.11 or 3.12, run the suites before opening a pull request.
CONTRIBUTING.md has the procedure for changing the code and the module map. .governance/DECISIONS.md records why the design is the way it is -- every judgement above traces back to a numbered entry there -- and .governance/PROJECT_STATE.md carries the measurements with their dates.
Release notes are in CHANGELOG.md.
MIT -- see LICENSE.
hooks/register.ts 33 lines1import type { Register } from 'claude-code'
2
3// An interrupted turn fires no Claude Code hook -- not Stop, not anything --
4// so the runner kept the rain up until HOOK_STALL ran out (D-036). A mod sees
5// the turn end either way; this hands that one fact to the runner's FIFO
6// through the same emitter the hooks use (D-056).
7//
8// amask passes this folder with --plugin-dir and sets the three variables.
9// Run any other way they are unset and this does nothing.
10//
11// Inline rather than through a helper: before 2.1.260 the engine refuses a
12// module that hands $ to a function of its own.
13export const register: Register = on => {
14 on('turn.complete', async ($, e, next) => {
15 const result = await next(e)
16 // A subagent's turn ending says nothing about the main thread (D-033),
17 // and a finished turn already has its Stop.
18 if (e.agentId === undefined && e.reason === 'aborted') {
19 const emitter = await $.env.get('AMASK_HOOK_EMITTER')
20 const fifo = await $.env.get('AMASK_HOOK_FIFO')
21 const token = await $.env.get('AMASK_HOOK_TOKEN')
22 if (emitter && fifo && token) {
23 try {
24 await $.process.run([emitter, fifo, 'TurnAborted', token], { stdin: '{}', timeoutMs: 5000 })
25 } catch {
26 // The runner may be gone; that is not the agent's problem (P-101).
27 }
28 }
29 }
30 return result
31 })
32}
33