The Daemon Is Alive, Only the Login Is Dead

Resident daemons dying silently when OAuth expired every month — after a token detour, I laid down a safety net of health checks and a Telegram bot.

A Claude Code remote-control daemon runs per repo on the Mac mini under launchd, and I connect to them from my phone or iPad. One day the list on the iPad was empty. Every process was alive thanks to KeepAlive, but the auth had expired, so nothing had died and nothing was usable either.

What I set out to do: daemons that quietly die once a month

Since July I’ve had a Claude Code remote-control daemon per repo running permanently on the Mac mini under launchd, and I connect to them from my phone or iPad. Then one day the iPad list was empty. Every process was alive thanks to KeepAlive, but the auth had expired, so only the service registration was dead — a stale state. If they’d died I would at least have gotten a restart. Instead they were alive and silently useless.

“Because it always uses the login flow, all the logins drop once a month and it’s annoying.”

Two goals. Either keep the login from expiring or know immediately when it does, and be able to recover from my phone.

How I asked: a token detour and finding the real culprit

I asked to have the daemons use the CLAUDE_CODE_OAUTH_TOKEN I’d put in .zshrc. Claude checked and it was hopeless from the start. There was no export, so child processes couldn’t see it, and launchd doesn’t read .zshrc anyway.

Still, a fixed token would be the root fix, so I had it verified. Feeding a fake token produced a 401 even with a healthy Keychain — confirming the environment variable takes precedence over the Keychain. But feeding the real token produced a 403. Not expiry, but a missing scope. A token created with setup-token has no session scope at all, which Remote Control needs, so this approach was a dead end by design. Claude called it a footgun that would break everything with 403s on restart, pulled the token file immediately, and even added an unset to the wrapper.

Instead, the real culprit turned up. The old login had no refreshToken, so the moment the access token expired, it was simply dead. The newly logged-in credentials have a refreshToken, so auto-refresh should work — though since this was the first cycle, whether it really holds wouldn’t be known until a month had passed.

Where it broke: it says it’s alive but doesn’t show up

So I laid down the safety net first. A watchdog agent that checks the connection every 15 minutes and sends a Telegram message only when the state changes. But even after adding it, “healthy” and “visible” kept disagreeing.

First. The disclosure-summary service repo was Connected locally but didn’t show up in the app list. Digging through the logs turned up a 404 on worker registration. The culprit was the bridge-pointer file. The pointer was still holding on to a dead environment that had already been deleted on the server, and every resume attempt hit a 404. After backing it up and deleting the pointer, it came up immediately in a new environment. As a bonus, I found uncommitted work in a leftover worktree from an earlier forced shutdown and preserved it.

The second came a few days later. The bot said everything was ✅ but the app showed only some of them, so I pushed back asking if it was a bug — and it turned out the two screens were measuring different things. The bot’s ✅ means the local process and its connection socket to the server. The app list only shows “environments with a session.” A repo that’s registered but has never created a session gets no card at all. App list ≠ registration list. Not a bug, just different things being measured.

Result: a control room in my phone

I asked whether status checks, restarts, and re-login could all be done over Telegram. The first two were possible; only re-login was ruled out because it’s browser OAuth. So I had a dedicated bot built in Python. A single script using only the standard library, running as a launchd resident with long polling, with /status, /restart, /logs, and a /fix command that auto-recovers from that day’s pointer incident. It responds only to my chat_id, and target repos are auto-detected from plists, so adding repos doesn’t mean touching bot code. Because it reused the existing bot token, the old notification agent polling the same bot had to be taken down (two pollers means a 409 conflict).

Meanwhile the resident repos grew from 5 to 11. Now I check the state of all 11 with /status from bed and revive them with /restart. Whether the refreshToken really lasts a month is still being watched.

Lessons