The handoff watcher

A local daemon that runs TTY-bound commands in your shell so Claude never has to stop and ask.

Some commands cannot run in an agent’s non-interactive Bash. Without a mechanism, Claude has to stop and ask you to paste ! cmd, which breaks both its flow and your concentration.

aoforge-watch is a small local daemon that closes that loop.

Quick start

aoforge-watch start --foreground   # in a terminal you keep open: the attended terminal
aoforge-watch status               # verify (from another terminal)
aoforge-watch stop

That is the whole setup. From then on, when Claude tries to run something on the allowlist, the gate-interactive hook queues it instead of failing, and Claude tells you in one line that it is waiting in your aoforge-watch terminal. The daemon runs it there, you answer its prompt there, and the result reaches Claude by itself: after Claude’s next tool call, when Claude ends its turn, or at the latest on your next prompt.

What gets intercepted

TTY-interactive auth flows — cloud and registry CLIs whose login is a browser or prompt flow: DigitalOcean, GitHub, Google Cloud, AWS (including SSO), 1Password, npm, Vault.

Password and passphrase prompts — passwd, ssh-keygen making a key without -N "", and ssh-keygen -y reading a key without -P. Its read-only forms (-l fingerprints, -F and -R on known hosts) run directly.

Shell-flow tools that depend on aliases, sourced rc files or shimmed shell sessions — nvm use, nvm install, pyenv shell, pyenv install, conda activate, direnv exec, direnv allow, mise use, mise install, mise run, asdf shell, asdf install, rbenv shell.

How it flows

Claude runs a gated command (gate-interactive, PreToolUse Bash)
        │
        ▼
gate-interactive denies the Bash call
  → writes .aoforge-handoff/pending/<id>.json with the session and agent that asked
  → Claude tells you the command is waiting in your aoforge-watch terminal
        │
        ▼
aoforge-watch (started with `start --foreground` in a terminal: attended)
  → shows the command's live output there; you answer its prompt there
    (Ctrl+C interrupts the command, the daemon keeps running)
  → writes .aoforge-handoff/done/<id>.json
        │
        ▼
the result reaches Claude, once, with no paste:
  → after Claude's next tool call (inject-handoff-results, PostToolUse), or
  → when Claude ends its turn (inject-handoff-results, Stop; it waits up to
    120 s while the command is still running), or
  → on your next prompt (route-results, the catch-all)

A result goes to the session and agent that queued it, and only once: the hook that claims it first delivers it. In the attended terminal each command is framed by a start line with its id and the terminal bell, and an end line such as [aoforge-watch] <id> finished: done (exit 0). The daemon never types, logs or stores what you type.

Without the daemon

Everything still works, just with one manual step. gate-interactive prints the command for you to run with ! cmd, and Claude picks up the output inline. The value even in this mode is that Claude recognises which commands need a shell and does not waste a turn discovering it by failing.

Configuration

{
  "daemon": {
    "notifications": false,
    "notify_on_start": true,
    "notify_on_complete": true,
    "auto_launch": false,
    "multi_project": false,
    "cross_shell": [],
    "status_line": false
  }
}
VariableEffect
AOFORGE_WATCH_ALLOW_FILEPath to a custom allowlist
AOFORGE_WATCH_UNATTENDED=1Run the daemon unattended even in a terminal (same as --no-attend)
AOFORGE_HANDOFF_PID_FILEOverride the PID file location
AOFORGE_HANDOFF_RESULT_TTL_MSHow long an unconsumed result stays valid (default one hour)
AOFORGE_HANDOFF_STOP_WAIT_MSHow long the Stop hook waits for a running command (default 120000; 0 disables the wait)
AOFORGE_SKIP_HANDOFF_INJECT=1No delivery after a tool call or at the end of a turn; results wait for the next prompt
AOFORGE_SKIP_HANDOFF_RESULTS=1No handoff delivery at all
AOFORGE_SKIP_INTERACTIVE_GATE=1Disable interception entirely

The hook variables are read by Claude Code’s hooks: set them in the environment Claude Code is launched from.

Attended and unattended

aoforge-watch start --foreground run in a terminal (stdin and stdout are both terminals) is attended: a queued command’s live output appears there and the keys you type there go to the command, so you answer its prompt yourself. Ctrl+C during a command ends the command with exit 130 and the daemon keeps serving; between commands Ctrl+C stops the daemon.

The detached aoforge-watch start, a launchd or systemd unit, --no-attend, AOFORGE_WATCH_UNATTENDED=1 or piped stdio run unattended: commands run with no terminal for you to answer in. Use the attended terminal for anything that asks for input. If the daemon’s shell dies, the next command gets a new one (the log says respawned shell).

Allowlist and deny list

The allowlist is curated rather than open — the daemon runs commands in your shell, so what it will accept is a security boundary, not a convenience setting. Extend it with your own patterns via AOFORGE_WATCH_ALLOW_FILE. A deny list takes precedence over the allowlist.

The built-in entries are the hook’s own matcher, so the daemon runs exactly the commands gate-interactive hands off and nothing it lets through: a quoted mention, heredoc data or a read-only key form is refused, and a command the hook gated is never rejected. Entries you add through AOFORGE_WATCH_ALLOW_FILE (by default ~/.claude/aoforge/aoforge-watch-allow.json) are regular expressions tested on the whole command.

Auto-launch

The daemon can install itself as a launchd (macOS) or systemd (Linux) unit so it starts with your session:

aoforge-watch install
aoforge-watch uninstall

Multi-project

One daemon can watch several projects, added and removed while it runs. aoforge-watch status shows all watched projects with per-project pending and done counts.

Known limitations

What gate-interactive matches

Each simple command is matched on its own words. Quoted arguments and heredoc bodies are data, so a grep, an echo, a commit message or a heredoc writing documentation about aws sso login is not intercepted; a heredoc fed to bash, sh or zsh is read as commands, one level deep. Flags count only in their own call, and in a compound command the reason names the call that needs your shell. ssh-keygen -lf key.pub runs directly, and so does a new key made with -N; a key read with -y and no -P is handed off, even when the command passes -N.

What it does not match

These run in Claude’s Bash as before, unintercepted: a command name inside quotes ("npm" login) or with a path (/usr/bin/ssh-keygen); a command behind a prefix such as env, FOO=1, time, command, exec or sudo, or after a shell keyword (then, do, {, !); a $( ) inside double quotes; a backtick substitution; and the string body of bash -c '...'. If one of them hangs on a prompt, run it yourself with ! cmd. To turn interception off, set AOFORGE_SKIP_INTERACTIVE_GATE=1.