Claude Code on launchd, and a 401 That Was 13 Days Old
Forty minutes setting up Claude Code remote-control as a launchd resident on an always-on Mac mini, running into an expired-token 401, a TUI log bomb, and a race in the verification script, one after another.
The Mac mini at home is always on, and keeping claude remote-control running per repo under launchd means I can attach from my phone or the browser at any time and hand it work. The targets were three repos: the game-meta analysis project, the disclosure-summary service, and the legislative-activity tracker. I wrote the likely traps into the prompt in advance, and then the moment I loaded one plist as a pilot, it got stuck on auth.
What I set out to do
The Mac mini at home is always on. If I keep claude remote-control running per repo under launchd, I can attach to any repo from my phone or the browser (claude.ai/code) at any time and hand it work. The picture is lying in bed and saying “fix that bug” from my phone. Three targets to start: the game-meta analysis project, the disclosure-summary service, and the legislative-activity tracker.
How I asked
At 9:37 PM I put in the order with a one-page prompt. I wrote in the trap defenses ahead of time. A LaunchDaemon (root) can’t access the login Keychain and OAuth auth breaks, so it must be a LaunchAgent (user domain); if it misbehaves without a TTY, fall back to tmux; ask first before anything hard to undo, like overwriting an existing plist.
First actually check this environment before proceeding (don’t guess; inspect and decide)
What it found: claude v2.1.201, no API key, OAuth auth. But the credentials weren’t in the Keychain; they were in plaintext at ~/.claude/.credentials.json. At the time I filed that as a curiosity. It was foreshadowing.
In the design, Claude made a counter-proposal. Instead of a wrapper per repo, one single script that takes the repo name as an argument, plus one plist per repo. I accepted, and we agreed to load just one plist as a pilot to verify.
Where it broke
First, the 401 ambush. The moment the pilot loaded, it crash-looped on auth 401. To split “launchd environment problem” from “problem with the command itself,” I ran the same command in the foreground: same 401. launchd was innocent. The real culprit: the access token had expired 13 days ago. There was no refreshToken either, which looked like the trace of a setup-token style login. remote-control doesn’t support that method. All this time I hadn’t even known I was logged out. Claude created the remaining two plists but left them unloaded, and handed the ball back: do the browser OAuth yourself.
I stepped away for 21 minutes here, and on returning left my second and last utterance of the day.
Logged in
This time the credentials were stored properly in the Keychain, and all three repos showed Connected. The tmux fallback wasn’t needed.
Second, the log bomb. Right after startup the out log grew by about 19KB per minute. The TUI was infinitely re-rendering its status line. Left alone, that’s roughly 29MB per repo per day, an estimated 2.6GB a month. I got three options and chose to dump stdout to /dev/null and keep only errors in the stderr log.
Third, the verification script’s self-inflicted drama. The health-check helper falsely reported two repos as “not in the list.” What actually happened was a race: it was repeatedly querying launchctl right at the moment KeepAlive was re-seating the process. I fixed it to snapshot the list once and judge from that.
Result
Wrapped up at 10:17 PM with all three repos running full-time. Of the 40 minutes total, subtracting the 21 I was away, actual work was 19 minutes. The gotcha “on a 401, suspect an expired login first” went into project memory.
Lessons
- When something dies under launchd, don’t start digging into launchd. If the same command reproduces it in the foreground, the daemon is innocent.
- Credentials living somewhere unusual (plaintext file vs Keychain) signal a different login method, and a different method can mean a different support scope.
- Run a TUI program as a daemon and stdout becomes a log bomb. Estimate the growth rate and throw away what should be thrown away.
- Verification scripts have races too. It’s safer to judge state from a single snapshot.
- Write the traps into the prompt in advance and you won’t step on those traps. You’ll step on the ones you didn’t write down instead.