`bg-run` needs a real owner, not a detached process

bg-run sounds like a small shell convenience: start a command and let the agent continue. Its real contract is much larger. The command must survive the end of the current agent turn, record its result, report meaningful milestones, and wake the right session later. That is a lifecycle and delivery problem, not an ampersand problem.

A reliable implementation needs a durable owner, an isolated process group, cancellation and reaping, durable result and event records, and a callback path into the session that started the work. It also needs at-least-once delivery, because a wake-up can be retried without creating a second task.

The tmux implementation works because tmux happens to provide most of this machinery. A helper session owns the process after the caller returns, and a pane gives the result a place to wake. bg-run can write immutable milestone and final events, then the agent can consume them on its next turn. On a tmux host, this is a perfectly useful adapter.

But tmux is a hosting-specific workaround, not the underlying abstraction. A Baton driver has no pane and no TMAT_PANE, so the tmux implementation correctly refuses to run. Replacing it with setsid, disown, or another backgrounding trick would not fix the ownership problem: an external tool runner can still clean up the caller's descendant process tree, leaving a state file that names a dead worker. Detaching a process is not the same as giving it a supervisor.

Baton already has the right foundation. baton serve is a resident mailbox responder with atomic pending/claimed/done delivery, single-instance locking, stale-work reclaim, and cooperative stop. The missing piece is making that residency real for the whole integration: a host-owned baton service process, run in the foreground under something like a systemd user service, should spawn and own each session's baton serve and each asynchronous task. The client that submits work must not be its owner.

On top of that service, a generic baton task start API can return a stable task ID immediately, persist the command specification and state, run the task in its own process group, capture its output, and emit immutable milestone and terminal events to the requested Baton mailbox. task status and task cancel complete the lifecycle; session teardown cancels and reaps its tasks. The agent starts the task, ends its turn, and is woken by the mailbox when there is something worth reading. No sleep 50, PID loop, or result-file polling is needed.

That is why the Baton design is the general solution: it treats ownership and notification as a protocol rather than an accidental property of a terminal multiplexer. bg-run remains a good agent-facing name in my-ai-team because it describes the user action. Baton itself should expose a provider-neutral task or job primitive, with my-ai-team's bg-run as one adapter. Tmux can remain a useful adapter where it exists; Baton supplies the real owner where it does not.

Routing Claude Code through a gateway that's behind HTTP Basic Auth

Say you expose an Anthropic-compatible gateway on the public internet and put nginx Basic Auth in front of it. Point Claude Code at it and every request 401s — or worse, the gateway rejects it with a cryptic error. The reason is a header collision, and it's easy to untangle once you see it.

Basic Auth lives in the Authorization header (Authorization: Basic <base64>). Claude Code's own auth also wants a header — but which one depends on how you authenticate:

  • ANTHROPIC_API_KEY → sent as X-Api-Key
  • ANTHROPIC_AUTH_TOKEN (and subscription/OAuth login) → sent as Authorization: Bearer <token>

So the trick is to stay in API-key mode. Then Authorization is free for Basic Auth and your key rides in the separate X-Api-Key header — no collision:

export ANTHROPIC_BASE_URL="https://gateway.example.com"
export ANTHROPIC_CUSTOM_HEADERS="Authorization: Basic $(echo -n 'user:pass' | base64)"
export ANTHROPIC_API_KEY="<your-key>"
unset ANTHROPIC_AUTH_TOKEN   # Bearer would fight Basic for the Authorization header

On the nginx side, strip the client's Authorization after Basic Auth passes, so the Basic credential never leaks upstream to the gateway:

location / {
    auth_basic           "gateway";
    auth_basic_user_file /etc/nginx/.htpasswd;

    proxy_pass http://backend:9949;
    proxy_set_header Authorization "";   # consumed by auth_basic; don't forward
}

Two gotchas worth knowing. If you're logged into Claude Code with a subscription, it sends Authorization: Bearer <oauth> and ignores ANTHROPIC_API_KEY — so it collides with Basic and never sends X-Api-Key. Log out (or use a config dir with no login) to force API-key mode. And if your gateway reads a pool/backend selector from X-Api-Key (some proxies do), the value must be the selector name, not a real sk-ant-... key — a real key or a dummy matches no pool and fails closed.

The failure mode that eats the most time isn't any of the above, though: a typo in the variable name. ANTROPIC_API_KEY (missing the H) sets nothing, Claude Code silently falls back to whatever else it can find, and you'll chase the server for an hour before spotting the letter. Echo env | grep -i anthropic before blaming the proxy.

Moving a Guacamole (or any stateful Docker stack) to a new host? Copy the volume, don't re-init

When you move a Guacamole stack between machines, the temptation is to spin up a fresh stack on the new host and let initdb.sql build the database. Don't — that gives you an empty install. Every saved connection, every user, and (critically) every TOTP/MFA enrollment lives inside the MariaDB data volume. Re-running the init script wipes all of it, and your users have to re-scan their authenticator QR codes.

The fix is a cold, byte-exact copy of the DB volume. Stop the stack first so the copy is consistent:

# on the OLD host
cd ~/path/to/guacamole && docker compose down
docker run --rm -v guacamole_db-data:/v -v /tmp:/out alpine \
  tar czf /out/guacdb.tgz --numeric-owner -C /v .

--numeric-owner matters: MariaDB's files are owned by uid 999 inside the container, and you want that uid preserved, not remapped to whatever user happens to exist on the new box.

Ship the tarball over, then restore it into a fresh named volume before the first up:

# on the NEW host
docker volume create guacamole_db-data
docker run --rm -v guacamole_db-data:/v -v /tmp:/in alpine \
  tar xzf /in/guacdb.tgz --numeric-owner -C /v
cd ~/path/to/guacamole && docker compose up -d

MariaDB's entrypoint checks whether the data directory is empty. Since you just populated it, it skips initialization entirely and comes up with all your data intact — confirm with docker logs guacamoledb | grep "ready for connections" and no Initializing database line.

One more thing worth doing while you're at it: if a reverse proxy fronts the app (e.g. nginx terminating TLS on a separate edge box), point its proxy_pass at a DNS name that tracks the new host's IP rather than the raw IP. Then a future move is a one-line edge change — or zero, if the name already follows the host. A quick sanity check that the whole path works, without needing to log in:

curl -s -o /dev/null -w "%{http_code}\n" -X POST https://your.guac.example/api/tokens \
  -H "Content-Type: application/x-www-form-urlencoded" --data "username=x&password=y"

A 403 here is success — it means the webapp reached the database and rejected bad credentials. (A 500 usually just means you forgot the application/x-www-form-urlencoded content type, not that anything's broken.)

`gh` suddenly 401s over SSH after a reboot? Your token is locked in the keyring

After a reboot, every gh command returned HTTP 401: Requires authentication — even though you ran gh auth login ages ago. It looks like "the reboot wiped the auth."

The usual culprit: the token was saved in the system keyring (libsecret / gnome-keyring), and you log in over plain SSH (publickey). On an SSH login PAM never sees your password, so pam_gnome_keyring doesn't unlock the keyring — only a desktop/GUI login does that as a side effect. After a reboot the keyring stays locked, gh can't read the token, and you get a 401. It's not really about the reboot; it's that after the reboot no desktop login ever unlocked the keyring.

On a headless / SSH-only box, don't hand your credentials to the keyring. The simplest fix is an environment variable — gh reads it first and never touches the keyring or hosts.yml:

# ~/.bashrc.secret (sourced by login shells)
export GH_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx

Verify without printing the token — just check which account it resolves to:

gh api user -q .login      # prints your username on success

GH_TOKEN loads with every login shell, so reboots and pure SSH both stay stable. Clear the now-redundant credential in the keyring / hosts.yml (gh auth logout) to keep a single source of truth.

Broader lesson: on a headless machine, anything that assumes an interactive desktop session — keyring unlocking, a resident user-level systemd service — will bite you. Prefer session-independent mechanisms (environment variables, loginctl enable-linger).

A `--user` systemd service restarts every 5 minutes? Check logins, not cron

A systemctl --user service kept starting, then stopping ~12s later, every 5 minutes — flooding a notification channel each time. But crontab -l was empty and systemctl --user list-timers had no matching timer. So who was cycling it?

Usually it's not a scheduled job. A user-level service's lifetime is tied to the per-user systemd manager (user@<uid>.service), and without linger that manager only runs while the user has at least one login session:

  • Someone logs in (even a 2-second SSH/rsync) → the manager starts → it reaches default.target → your WantedBy=default.target service gets pulled up.
  • ~10s after the last session exits → the manager tears down → your service stops with it.
  • Next login repeats the whole dance.

So the service's "restart cadence" is really the login cadence. The system journal makes it obvious:

journalctl --since "-15min" | grep -iE "Accepted publickey|New session|Removed session|Reached target exit.target"

In my case the culprit was another box running */5 * * * * rsync … host:/backup/… — a short SSH connection every 5 minutes that lit up the entire user manager and dropped it again. The moment the host had a persistent session (a lingering tmux), the symptom vanished — which is the strongest tell: it only happens when nobody is logged in.

The fix is to detach the manager from login so it stays resident:

sudo loginctl enable-linger <user>
# verify
loginctl show-user <user> --property=Linger   # Linger=yes

With linger on, the manager starts at boot and no longer stops when sessions end, so the service becomes a real background daemon. For a user-level service that must run while no one is logged in, enable --now is not enough — enable-linger is the missing prerequisite.