Files
Gabriel Vidal 023f43c811 fix(worker): wait for launchd to unload the old agent before bootstrapping the new one
`launchctl bootout` returns before the agent is gone; an immediate
`bootstrap` then fails with "5: Input/output error" and the worker stays
down (what --update just did on the Mac). Poll until the label is unloaded,
retry the bootstrap with backoff, treat an already-loaded label as success,
and die with the retry command when launchd really refuses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 16:13:50 +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 — your Keychain Claude login, mise, gcloud, op, gh, OrbStack.

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).

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, 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: WORKER_CWD (where new runs start — default ~/projects/orus-monorepo), WORKER_PORT, WORKER_ACCOUNT (the hub's name for this login, default work), WORKER_BIND.

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 off (SIDECAR_REMOTE_CONTROL=0): org-disabled on the work seat, and a work run must not list itself there anyway.
  • 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).