Files
ai-agent/sidecar/sidecar.py
Gabriel Vidal cf01bc00f4 refactor(sidecar): split sidecar.py into focused modules
config / pids / validate / claude_accounts / launch / fork / routes_runs /
routes_accounts, mounted by a thin sidecar.py (same `sidecar:app`, same 24
routes, same startup hook). Mutable state keeps one owner module
(pids._procs, claude_accounts._account_status_cache). Includes the
_claude_args builder from 1de9426 and its test; the Dockerfile's explicit
COPY list gains the new modules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:55:47 +02:00

127 lines
6.2 KiB
Python

"""
claude-sidecar — a tiny host-side FastAPI wrapper that launches `claude -p`.
The ai-agent viewer runs in a Docker container and therefore can't reach the
host's `claude` CLI (auth, hooks, skills all live in the host user's
`~/.claude`). This sidecar runs natively on the host as the same user, so a
session it spawns is *exactly* like one started from a terminal — same
credentials, same RTK/vault hooks, same CLAUDE.md/skills.
Flow:
ai-agent backend ──POST /spawn {prompt, sessionId}──▶ this sidecar
◀──────── {sessionId, pid} ─────────
The sidecar fires `claude -p <prompt> --session-id <uuid> --output-format json`
as a detached background process (its own session/process group) and returns as
soon as the run is known to have *started* (it watches the child for a couple of
seconds and reports a 502 if it dies on the spot — a resume whose transcript
can't be found, a bad cwd, a broken CLI). Claude writes its transcript to
`~/.claude/projects/-home-gabrielvidal-homelab/<uuid>.jsonl`, which the ai-agent
container already watches read-only — so the new conversation shows up in the
viewer within a couple of seconds and the UI redirects to it.
Restart-proofing (why the runs survive the sidecar restarting).
Spawned runs are *daemons*: `start_new_session=True` detaches them into their
own session/process group, and the systemd unit runs with `KillMode=process`
so a `systemctl restart claude-sidecar` (i.e. what happens when Claude edits
*this very sidecar* and redeploys it) only signals uvicorn — the in-flight
`claude -p` children keep running. Their identity is persisted to disk in a
per-session pidfile (`logs/<uuid>.pid`, JSON with pid + `/proc` start-time),
so a freshly restarted sidecar re-discovers the survivors on startup and can
still list and interrupt them. Startup also GCs pidfiles whose process is gone
or whose PID has been recycled (start-time mismatch).
Auth is a shared bearer token (SIDECAR_TOKEN) so nothing on the LAN can drive
`claude` on the host but the ai-agent backend.
Accounts. A claude run launches on one Claude login: the default account (the
CLI's own `~/.claude`) or one declared in SIDECAR_ACCOUNTS (`work=~/.claude-work`),
whose dir is exported as CLAUDE_CONFIG_DIR for that run. Resume and fork must
name the account the session started on — its transcript only exists under
that account's `projects/`. A non-default account with no login is a 409, never
a fallback, and ANTHROPIC_API_KEY is stripped once accounts are declared.
"""
import logging
import os
import pathlib
from fastapi import FastAPI
import claude_accounts
import feed
import fork
import outbox
import pairing
import pids
import routes_accounts
import routes_runs
import skills_list
from config import ACCOUNTS, DEFAULT_ACCOUNT, DEFAULT_CWD, PERMISSION_MODE, TOKEN
from validate import _auth
class _QuietSessionsPoll(logging.Filter):
"""Drop ``/sessions`` access-log lines: the ai-agent backend polls that
endpoint every ~2s for run-exit detection, which would otherwise flood the
journal with tens of thousands of identical lines a day."""
def filter(self, record: logging.LogRecord) -> bool:
return "/sessions" not in record.getMessage()
logging.getLogger("uvicorn.access").addFilter(_QuietSessionsPoll())
app = FastAPI(title="claude-sidecar", docs_url=None, redoc_url=None)
# On (re)start, GC pidfiles whose process is gone and count the survivors —
# see pids.py.
app.add_event_handler("startup", pids._reconcile_pidfiles)
# ---- the runner's own endpoints ----------------------------------------------
# /health + the account routes (routes_accounts.py), the run routes
# /sessions /spawn /resume /interrupt /message (routes_runs.py), and /fork
# with its transcript surgery (fork.py).
app.include_router(routes_accounts.router)
app.include_router(routes_runs.router)
app.include_router(fork.router)
# A paired remote worker has no hub URL or credential, so its runs reach the
# hub through *this* sidecar's outbox (outbox.py): the hub pulls it. The host
# sidecar (SIDECAR_TOKEN set) is next to the hub — its runs talk to it directly.
OUTBOX = outbox.Outbox(pairing.state_dir() / "outbox.json")
# ---- worker endpoints --------------------------------------------------------
# Mounted on every runner (host sidecar, in-container runner, remote worker) —
# they're only *used* when a hub pairs with this runner as a remote worker:
# /feed is how the hub pulls transcripts it has no mount for, /pair how it gets
# the bearer in the first place. See feed.py / pairing.py.
app.include_router(feed.make_router(
_auth, lambda: claude_accounts._account_projects(DEFAULT_ACCOUNT)))
app.include_router(pairing.make_router(
_auth, lambda: claude_accounts._auth_status(DEFAULT_ACCOUNT),
lambda: {"cwd": DEFAULT_CWD, "defaultAccount": DEFAULT_ACCOUNT,
"permissionMode": PERMISSION_MODE,
"projectsDir": str(claude_accounts._account_projects(DEFAULT_ACCOUNT))}))
# /outbox: a worker session's notifications / asks / forms, queued for the hub
# to collect (outbox.py). Runs authenticate with the per-worker run token.
app.include_router(outbox.make_router(
OUTBOX, _auth, lambda: outbox.run_token(pairing.state_dir())))
# /skills: the skills a run here can use (the default account's user-level
# skills + the default cwd's project skills) — what the hub's composer lists
# when a prompt is sent to this worker (skills_list.py).
app.include_router(skills_list.make_router(
_auth, lambda: skills_list.skill_roots(ACCOUNTS.get(DEFAULT_ACCOUNT),
pathlib.Path(DEFAULT_CWD))))
if __name__ == "__main__":
# `python sidecar.py` — how the macOS worker's launchd agent starts it:
# SIDECAR_BIND is the Mac's Tailscale address, so the listener is reachable
# over the tailnet only (nothing on the office LAN, nothing on loopback).
import uvicorn
bind = os.environ.get("SIDECAR_BIND", "127.0.0.1")
port = int(os.environ.get("SIDECAR_PORT", "8790"))
if not (TOKEN or pairing.token()) and not pairing._read("pairing-code"):
print(f"[worker] not paired — pairing code: {pairing.new_code()}",
flush=True)
uvicorn.run(app, host=bind, port=port)