Files
Gabriel Vidal 4975833352 fix(worker): installer prompts died with "reply: unbound variable"
ask() kept a local named like the caller's variable, so `printf -v reply`
filled the shadowing local and the caller's own `local reply` stayed unset —
fatal under set -u right after the first `y`. The helper now uses a private
name and the callers initialise their variable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 16:56:54 +02:00
..

ai-agent worker (macOS)

Run a Mac as a worker for the ai-agent hub: the hub spawns, resumes, forks and interrupts claude -p sessions on this machine, and shows its terminal sessions live. A session here is exactly a terminal session — this Mac's Keychain Claude login and whatever its shell has (mise, gcloud, op, gh, OrbStack…). Any Mac works: a personal laptop paired on the personal account, or a work seat paired on work.

The worker is the same sidecar/sidecar.py the homelab runs on its own host, as a launchd user agent. The hub always calls in; the worker never contacts the hub — it binds the Mac's Tailscale address only and holds one bearer token minted at pairing.

Install

Prerequisites: Claude Code installed and logged in (claude auth status), Tailscale up, git, and uv (or python3).

cd to the directory new runs should start in (your projects dir, a repo…), then:

curl -fsSL https://git.gabvdl.xyz/gabrielvidal/ai-agent/raw/branch/main/worker/install-macos.sh | bash

It clones this repo into ~/.local/share/ai-agent-worker/src, asks you to confirm the current directory as the default working dir (y, or type another path — without a terminal it is taken as is), builds a venv, writes ~/Library/LaunchAgents/xyz.gabvdl.ai-agent-worker.plist bound to tailscale ip -4 on port 8790, waits for /health, and prints the pairing string:

    100.80.162.92:8790/K7QMX4PJ2R/you@company.com

Paste it in the hub → Settings → Workers → Add worker. The login at the end picks the hub account automatically; the code works once.

Overrides (each skips its prompt or default): WORKER_CWD (where new runs start), WORKER_PORT, WORKER_ACCOUNT (the hub's name for this login — default personal; a work seat passes work), WORKER_REMOTE_CONTROL (1/0 — default on, off for a work account), WORKER_BIND. --update keeps the cwd, account and Remote Control setting the installed agent already has.

Note that the hub sends every new run on an account to that account's online worker unless the composer picks a runner explicitly: a personal Mac paired on personal takes the lab's personal spawns while it is awake.

Day to day

S=~/.local/share/ai-agent-worker/src/worker/install-macos.sh
$S --status      # launchd state + /health
$S --pair        # a fresh pairing string (re-pair, or a new hub) — rotates the token on use
$S --update      # git pull + deps + restart (keeps the pairing)
$S --uninstall   # stop + remove the agent (state in ~/.config/ai-agent-worker kept)

Logs: ~/Library/Logs/ai-agent-worker/worker.log (the worker) and runs/<session>.log (each claude -p). State: ~/.config/ai-agent-worker/ (token, worker.json, a pending pairing-code) — delete it to forget the hub.

Notes

  • Keychain. A launchd agent runs in your GUI session and can read the login keychain; the first run may pop a Keychain access prompt for claude — pick Always Allow. If it can't, claude setup-token and add CLAUDE_CODE_OAUTH_TOKEN to the plist's EnvironmentVariables.
  • Asleep = offline. A closed lid drops the Mac off the tailnet; the hub marks the worker offline and new runs on its account go to the lab (the composer chip turns amber and says so). In-flight runs survive a worker restart (they're detached; launchd's AbandonProcessGroup), not a sleep.
  • Binding. The listener is on the Tailscale IP only — nothing on the office LAN. If the App-Store Tailscale client ever refuses the bind, set WORKER_BIND=127.0.0.1, re-run the installer, and expose it with tailscale serve --bg --tcp 8790 tcp://127.0.0.1:8790.
  • Remote Control is on for a personal account and off for work (SIDECAR_REMOTE_CONTROL): org-disabled on a work seat, and a work run must not list itself there anyway. WORKER_REMOTE_CONTROL=0|1 forces it.
  • Notifications, asks and forms work: the notify CLI (cli/notify.py, on every run's PATH; notify-done / notify-ask / ask-form are its aliases) queues them in this worker's outbox (/outbox, in ~/.config/ai-agent-worker/outbox.json) and the hub collects them on its 2 s poll, records and forwards them, and pushes the tapped / submitted answer back — the worker still never calls the hub. The installer links the skill's SKILL.md into ~/.claude/skills/notify. A notify send --final marks the session finished in the hub; the exit watcher still covers runs that end without one.
  • Not yet: the other lab skills that call the hub back (conv-meta, complete).