XRDP session teardown can kill your default tmux server

An XRDP/Xfce connection is tied to a desktop and user-manager lifecycle, not only to the RDP window. If that session tears down, the default tmux server, kitty processes, and user services can disappear together without anyone running tmux kill-server.

On XPS, xrdp-sesman logged the window manager exiting with signal 15, then cleaned up the X server. XFCE reported broken PipeWire, D-Bus, and ICE connections; systemd subsequently stopped the user manager and kitty scopes. A simultaneous NVIDIA “fallen off the bus” failure was a strong suspect, but the old logs could not identify the original signal sender.

Keep long-lived agents on an explicit tmux socket supervised outside the desktop session:

sudo systemctl enable --now [email protected]
tmux -L mat list-sessions
tmux -L mat attach -t <session>

The mat-keeper session is only the service sentinel. MAT agent sessions share the mat server and should survive an XRDP switch. The default tmux a command still refers to the desktop-bound default socket, so use -L mat when recovering the fleet.

To make the next teardown attributable, enable auditd with a persistent signal rule and inspect the caller:

sudo ausearch -k process-signal -ts recent -i
sudo ausearch -k service-control -ts recent -i

Look for comm, exe, pid, ppid, and the target in OBJ_PID; these distinguish xrdp-sesman, systemd, and other senders. Auditd cannot reconstruct an earlier incident, and a desktop process can also exit because of D-Bus or GPU failure without a signal, so correlate its records with /var/log/xrdp-sesman.log, .xsession-errors, and journalctl.

`printf: write error: Broken pipe` on a self-hosted GitHub runner? Check SIGPIPE

A bash pipeline that is quiet locally but fails only on one self-hosted runner may be a signal-disposition problem, not a data or code problem. Our required test failed in unrelated files with printf: write error: Broken pipe, and every failure landed on the same Linux runner.

The useful check is the effective job-shell behavior, not only the runner listener:

systemctl show actions.runner.SHUKE-LABS.<runner>.service -p IgnoreSIGPIPE

PATH=/usr/local/libexec/actions-runner:$PATH bash -c '
  set -o pipefail
  printf "%s\n" {1..10000} | head -n1 >/dev/null
  printf "pipeline rc=%s\n" "$?"
'

The normal result is a SIGPIPE exit (usually 141). With SIGPIPE inherited as SIG_IGN, bash's builtin printf receives EPIPE instead, prints the broken-pipe diagnostic, and pipefail turns it into a failure.

The fix has two parts. Add a systemd drop-in:

[Service]
IgnoreSIGPIPE=no

Then put a root-owned launcher first in the runner's .path file:

#!/usr/bin/perl
$SIG{PIPE} = "DEFAULT";
exec "/usr/bin/bash", @ARGV or die "exec bash: $!";

The launcher matters because the Actions Runner's Node/Listener/Worker chain can set SIGPIPE to ignored again after systemd starts the service. Verify with a fresh job shell and the pipeline test; the listener process's SigIgn alone is not a reliable success criterion.

Check the whole Linux fleet, not only the runner named in the first failure. The same defect was present on all three sydney2 runners and on the mbp15-vm Lima guest. After fixing the signal boundary, two independent failures surfaced: a jq 1.6-incompatible test mock and newer-main tmux/test timing regressions. Keeping those layers separate prevented a correct runner diagnosis from being mistaken for a complete CI diagnosis.

The final CI-shaped local run passed 166/166, and the GitHub required jobs passed after the fleet fix.

Baton: the harness beneath the harness

When an AI harness calls an external agent, the visible action is often just one command. The real problem starts immediately afterward: the caller may finish its turn, the tool runner may disappear, the worker may need to outlive its parent, and the reply still has to reach the right session. Pipes and background processes handle the happy path; they do not define a reliable lifecycle.

Baton is a local coordination layer for that boundary. It gives an external-agent call a mailbox and a durable message flow. A request enters an inbox, baton serve claims it, launches the configured agent, and writes the response to an outbox. Its mailbox uses atomic state transitions, single-instance locking, stale-work reclaim, and cooperative stop. Delegation becomes an explicit, inspectable protocol instead of an accidental child-process relationship.

orchestrator / harness → Baton inbox → baton serve → external agent
orchestrator / harness ← Baton outbox ← baton serve ← external agent

That is why Baton is the harness of the harness. The outer harness manages the current model turn, tools, and user interaction. Baton manages the boundary where that harness asks another agent or worker to do something. It does not replace the external model and it does not need to understand the model's reasoning. It provides the durable submission, delivery, retry, and recovery behavior that the outer harness should not have to reinvent for every provider.

The foundation is already useful, but the larger design is a service, not a collection of detached commands. A host-owned Baton supervisor can spawn each session's baton serve, persist its identity and state, and stop or reap it deliberately. That is materially stronger than setsid or disown: detachment changes a process's parent; supervision gives the process a real owner that survives the submitting client.

This is where the companion bg-run layer fits. bg-run is the agent-facing convenience: start long work and end the current turn. Baton can provide the generic task lifecycle underneath it—stable task IDs, isolated process groups, durable results, and immutable milestone or terminal events delivered back to the role mailbox. The agent receives a wake-up when there is something to consume instead of sleeping and polling.

The names should stay in their layers. bg-run describes a useful action in my-ai-team; Baton should expose provider-neutral primitives such as task or job. A future supervisor can then own both session servers and asynchronous tasks without knowing whether the caller was Codex, another harness, or a human-operated CLI.

The opportunity is bigger than a better way to launch subprocesses. If an AI harness is the harness for one agent, Baton can become the infrastructure for a whole population of delegated workers: routing their messages, preserving their work, waking their consumers, and eventually supervising their lifecycles. That is why Baton deserves to be designed as a foundation, not as a thin wrapper around tmux.

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