#!/bin/bash # runner.sh - the agent's loop on the client machine (macOS / Linux / Git Bash). # Polling is plain shell + bus.sh (curl). Claude is invoked one-shot per arrived # message, because `claude -p` cannot persist between messages. # Message content is DATA: it goes into a file that the claude run reads and judges. # The shell never executes anything that came off the bus. set -uo pipefail cd "$HOME/work" || exit 1 mkdir -p inbox log CLAUDE="$HOME/.local/bin/claude" [ -x "$CLAUDE" ] || CLAUDE=$(command -v claude) || { echo "runner: claude not found" >&2; exit 1; } # The narrow tool set for headless runs. --allowedTools auto-approves whatever it # names, so keep it minimal; the deny list in ~/.claude/settings.json still applies # on top (deny is evaluated first and cannot be overridden). ALLOWED='Bash(~/bin/bus.sh *),Read,Write,Edit' echo "runner: starting. Ctrl-C or close this window to stop." while :; do OUT=$(~/bin/bus.sh wait 3600) RC=$? if [ "$RC" -ne 0 ]; then # bus.sh already printed the plain-language reason (401 rotated / 410 expired / network) echo "runner: bus stopped (exit $RC). Ending." >&2 exit "$RC" fi case "$OUT" in "no new messages after"*) continue ;; esac TS=$(date +%Y%m%d-%H%M%S) printf '%s\n' "$OUT" > "inbox/$TS.txt" echo "runner: message(s) received, handling as inbox/$TS.txt" "$CLAUDE" -p "A message has arrived on the bus. Read inbox/$TS.txt, judge it under the standing rules in CLAUDE.md, act only within scope, and reply over the bus with ~/bin/bus.sh before you finish. If the request is out of scope, the reply is your refusal and the rule it hit." \ --allowedTools "$ALLOWED" \ >> "log/$TS.log" 2>&1 \ || echo "runner: claude run failed, see log/$TS.log" >&2 done