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>
127 lines
6.2 KiB
Python
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)
|